feat(rss): add public RSS feeds for KIG integration
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Three public feeds: /feed/events.xml (upcoming events), /feed/news.xml (announcements), /feed/pej.xml (nuclear news). RSS 2.0 format with KIG custom fields (thumbnail, datawydarzenia). RSS discovery links added to base.html head. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5682e1622f
commit
e0e145c70f
@ -14,3 +14,4 @@ from . import routes_zopk # noqa: E402, F401
|
|||||||
from . import routes_pej # noqa: E402, F401
|
from . import routes_pej # noqa: E402, F401
|
||||||
from . import routes_announcements # noqa: E402, F401
|
from . import routes_announcements # noqa: E402, F401
|
||||||
from . import routes_company_edit # noqa: E402, F401
|
from . import routes_company_edit # noqa: E402, F401
|
||||||
|
from . import routes_rss # noqa: E402, F401
|
||||||
|
|||||||
166
blueprints/public/routes_rss.py
Normal file
166
blueprints/public/routes_rss.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
RSS Feed Routes
|
||||||
|
===============
|
||||||
|
|
||||||
|
Public RSS feeds for KIG (Krajowa Izba Gospodarcza) integration.
|
||||||
|
Format: RSS 2.0 with custom KIG fields (thumbnail, datawydarzenia, dc:creator, content).
|
||||||
|
|
||||||
|
Feeds:
|
||||||
|
- /feed/events.xml — upcoming Norda events
|
||||||
|
- /feed/news.xml — published announcements
|
||||||
|
- /feed/pej.xml — approved PEJ/nuclear news
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
from flask import Response, request
|
||||||
|
|
||||||
|
from . import bp
|
||||||
|
from database import SessionLocal, NordaEvent, Announcement, ZOPKNews
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SITE_URL = 'https://nordabiznes.pl'
|
||||||
|
ORG_NAME = 'Izba Gospodarcza Norda Biznes'
|
||||||
|
|
||||||
|
|
||||||
|
def _rss_date(dt):
|
||||||
|
"""Format datetime to RFC 822 for RSS pubDate."""
|
||||||
|
if isinstance(dt, date) and not isinstance(dt, datetime):
|
||||||
|
dt = datetime(dt.year, dt.month, dt.day)
|
||||||
|
if dt is None:
|
||||||
|
return ''
|
||||||
|
return dt.strftime('%a, %d %b %Y %H:%M:%S +0100')
|
||||||
|
|
||||||
|
|
||||||
|
def _kig_date(dt):
|
||||||
|
"""Format date to DD/MM/YYYY for KIG <datawydarzenia> field."""
|
||||||
|
if dt is None:
|
||||||
|
return ''
|
||||||
|
return dt.strftime('%d/%m/%Y')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rss(title, description, link, items):
|
||||||
|
"""Build RSS 2.0 XML string with KIG custom fields."""
|
||||||
|
xml_parts = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">',
|
||||||
|
'<channel>',
|
||||||
|
f'<title>{escape(title)}</title>',
|
||||||
|
f'<link>{escape(link)}</link>',
|
||||||
|
f'<description>{escape(description)}</description>',
|
||||||
|
f'<language>pl</language>',
|
||||||
|
f'<lastBuildDate>{_rss_date(datetime.now())}</lastBuildDate>',
|
||||||
|
]
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
xml_parts.append('<item>')
|
||||||
|
if item.get('thumbnail'):
|
||||||
|
xml_parts.append(f'<thumbnail>{escape(item["thumbnail"])}</thumbnail>')
|
||||||
|
xml_parts.append(f'<title>{escape(item["title"])}</title>')
|
||||||
|
xml_parts.append(f'<link>{escape(item["link"])}</link>')
|
||||||
|
xml_parts.append(f'<dc:creator>{escape(item.get("creator", ORG_NAME))}</dc:creator>')
|
||||||
|
xml_parts.append(f'<content>{escape(item.get("content", ""))}</content>')
|
||||||
|
if item.get('datawydarzenia'):
|
||||||
|
xml_parts.append(f'<datawydarzenia>{escape(item["datawydarzenia"])}</datawydarzenia>')
|
||||||
|
xml_parts.append(f'<pubDate>{_rss_date(item.get("pub_date"))}</pubDate>')
|
||||||
|
xml_parts.append(f'<guid>{escape(item["link"])}</guid>')
|
||||||
|
xml_parts.append('</item>')
|
||||||
|
|
||||||
|
xml_parts.append('</channel>')
|
||||||
|
xml_parts.append('</rss>')
|
||||||
|
|
||||||
|
return '\n'.join(xml_parts)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/feed/events.xml')
|
||||||
|
def feed_events():
|
||||||
|
"""RSS feed of upcoming Norda events for KIG aggregation."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
events = db.query(NordaEvent).filter(
|
||||||
|
NordaEvent.event_date >= date.today(),
|
||||||
|
NordaEvent.access_level.in_(['public', 'members_only']),
|
||||||
|
).order_by(NordaEvent.event_date.asc()).limit(20).all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for e in events:
|
||||||
|
items.append({
|
||||||
|
'title': e.title,
|
||||||
|
'link': f'{SITE_URL}/kalendarz/{e.id}',
|
||||||
|
'thumbnail': e.image_url or '',
|
||||||
|
'creator': e.organizer_name or ORG_NAME,
|
||||||
|
'content': e.description or '',
|
||||||
|
'datawydarzenia': _kig_date(e.event_date),
|
||||||
|
'pub_date': e.created_at or e.event_date,
|
||||||
|
})
|
||||||
|
|
||||||
|
xml = _build_rss(
|
||||||
|
title=ORG_NAME,
|
||||||
|
description='Nadchodzące wydarzenia Izby Gospodarczej Norda Biznes',
|
||||||
|
link=f'{SITE_URL}/kalendarz/',
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
return Response(xml, mimetype='application/rss+xml; charset=utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/feed/news.xml')
|
||||||
|
def feed_news():
|
||||||
|
"""RSS feed of published announcements for KIG aggregation."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
announcements = db.query(Announcement).filter(
|
||||||
|
Announcement.status == 'published',
|
||||||
|
).order_by(Announcement.published_at.desc()).limit(20).all()
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
items = []
|
||||||
|
for a in announcements:
|
||||||
|
if a.expires_at and a.expires_at < now:
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
'title': a.title,
|
||||||
|
'link': f'{SITE_URL}/ogloszenia/{a.slug}',
|
||||||
|
'thumbnail': a.image_url or '',
|
||||||
|
'creator': ORG_NAME,
|
||||||
|
'content': a.excerpt or a.content or '',
|
||||||
|
'datawydarzenia': '',
|
||||||
|
'pub_date': a.published_at or a.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
xml = _build_rss(
|
||||||
|
title=ORG_NAME,
|
||||||
|
description='Aktualności i ogłoszenia Izby Gospodarczej Norda Biznes',
|
||||||
|
link=f'{SITE_URL}/ogloszenia',
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
return Response(xml, mimetype='application/rss+xml; charset=utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/feed/pej.xml')
|
||||||
|
def feed_pej():
|
||||||
|
"""RSS feed of approved PEJ/nuclear news for KIG aggregation."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
news = db.query(ZOPKNews).filter(
|
||||||
|
ZOPKNews.status.in_(['approved', 'auto_approved']),
|
||||||
|
).order_by(ZOPKNews.published_at.desc()).limit(20).all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for n in news:
|
||||||
|
items.append({
|
||||||
|
'title': n.title,
|
||||||
|
'link': n.url or f'{SITE_URL}/pej/aktualnosci',
|
||||||
|
'thumbnail': n.image_url or '',
|
||||||
|
'creator': n.source_name or ORG_NAME,
|
||||||
|
'content': n.ai_summary or n.description or '',
|
||||||
|
'datawydarzenia': '',
|
||||||
|
'pub_date': n.published_at or n.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
xml = _build_rss(
|
||||||
|
title=f'{ORG_NAME} — Polska Elektrownia Jądrowa',
|
||||||
|
description='Aktualności o Polskiej Elektrowni Jądrowej z portalu Norda Biznes',
|
||||||
|
link=f'{SITE_URL}/pej/aktualnosci',
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
return Response(xml, mimetype='application/rss+xml; charset=utf-8')
|
||||||
167
docs/PLAN_EXTERNAL_EVENTS_AND_AI_MATCHING.md
Normal file
167
docs/PLAN_EXTERNAL_EVENTS_AND_AI_MATCHING.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# Plan: Wydarzenia zewnętrzne w kalendarzu + AI matching
|
||||||
|
|
||||||
|
**Status:** KONCEPT (do zatwierdzenia)
|
||||||
|
**Data:** 2026-03-19
|
||||||
|
**Kontekst:** Mail od ARP (Broker Eksportowy) z wydarzeniami dla MŚP, przekazany przez biuro Nordy (Magda Klóska). Współpraca z KIG (feedy RSS).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Biuro Nordy regularnie dostaje maile z wartościowymi wydarzeniami od partnerów (ARP, KIG, urzędy, inne izby). Targi, seminaria, szkolenia, webinary — treści istotne dla firm członkowskich. Dziś giną w skrzynkach mailowych.
|
||||||
|
|
||||||
|
## Rozwiązanie
|
||||||
|
|
||||||
|
Jeden kalendarz z wyraźnym podziałem na wydarzenia Nordy i zewnętrzne, z filtrowaniem, systemem zainteresowań i AI-dopasowaniem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faza 1: Wydarzenia zewnętrzne w kalendarzu
|
||||||
|
|
||||||
|
### Model danych
|
||||||
|
|
||||||
|
Rozszerzenie `NordaEvent` o 3 pola:
|
||||||
|
|
||||||
|
| Pole | Typ | Opis |
|
||||||
|
|------|-----|------|
|
||||||
|
| `is_external` | Boolean (default False) | Czy wydarzenie pochodzi z zewnątrz |
|
||||||
|
| `external_url` | String(1000) | Link do rejestracji u organizatora |
|
||||||
|
| `external_source` | String(255) | Nazwa źródła (np. "Agencja Rozwoju Pomorza") |
|
||||||
|
|
||||||
|
### Wyróżnienie wizualne
|
||||||
|
|
||||||
|
**Widok siatki (grid):**
|
||||||
|
- Nowy kolor dla zewnętrznych — szary lub jasnopomarańczowy, wyraźnie "cichszy" od kolorów wydarzeń Nordy
|
||||||
|
|
||||||
|
**Widok listy:**
|
||||||
|
- Badge "ZEWNĘTRZNE" (szary) przy tytule
|
||||||
|
- Nazwa źródła pod tytułem (np. "Źródło: Agencja Rozwoju Pomorza")
|
||||||
|
- Lekko wyciszona oprawa wizualna
|
||||||
|
|
||||||
|
**Legenda kalendarza** — rozszerzona o nowy typ
|
||||||
|
|
||||||
|
### Filtrowanie
|
||||||
|
|
||||||
|
Toggle u góry strony kalendarza: "Pokaż wydarzenia zewnętrzne" (domyślnie włączony).
|
||||||
|
Jedno kliknięcie — zewnętrzne znikają, zostają tylko wydarzenia Nordy.
|
||||||
|
Ustawienie zapamiętywane w localStorage.
|
||||||
|
|
||||||
|
### "Jestem zainteresowany" zamiast "Zapisz się"
|
||||||
|
|
||||||
|
| Aspekt | Wydarzenia Nordy | Wydarzenia zewnętrzne |
|
||||||
|
|--------|-----------------|----------------------|
|
||||||
|
| Przycisk | "Zapisz się" (zielony) | "Jestem zainteresowany/a" (szary/niebieski) |
|
||||||
|
| Znaczenie | Deklaracja udziału | Wyrażenie zainteresowania, bez zobowiązań |
|
||||||
|
| Lista osób | "Uczestnicy (12)" | "Zainteresowani (3)" |
|
||||||
|
| Limit miejsc | Tak (max_attendees) | Nie |
|
||||||
|
| Rejestracja | Na portalu | Link zewnętrzny |
|
||||||
|
| Wartość | Wiem kto idze | Widzę kto się interesuje — mogę się dogadać |
|
||||||
|
|
||||||
|
Technicznie: ta sama tabela `event_attendees`. Dla zewnętrznych eventów `status='confirmed'` oznacza "zainteresowany". Rozróżnienie na poziomie UI.
|
||||||
|
|
||||||
|
### Strona szczegółów wydarzenia zewnętrznego
|
||||||
|
|
||||||
|
Podobna do obecnej, z kluczowymi różnicami:
|
||||||
|
|
||||||
|
1. **Banner "Wydarzenie zewnętrzne"** z nazwą źródła/organizatora
|
||||||
|
2. **Lokalizacja z linkiem do Google Maps** — obecny kalendarz już to robi automatycznie (auto-link jeśli adres nie jest "Online" ani "do ustalenia"). Dla zewnętrznych wydarzeń to szczególnie ważne — użytkownik od razu widzi jak daleko to jest.
|
||||||
|
3. **Prominentny przycisk "Przejdź do rejestracji →"** — link do strony organizatora, wyróżniony wizualnie (duży, kolorowy), widoczny bez scrollowania
|
||||||
|
4. **Dane kontaktowe organizatora** — email, telefon (jeśli dostępne) — wyświetlane wprost na stronie wydarzenia, bez konieczności przechodzenia na zewnętrzną stronę
|
||||||
|
5. **Koszt uczestnictwa** — jeśli znany (np. "bezpłatne", "35 000 PLN brutto z dofinansowaniem 85%")
|
||||||
|
6. **Sekcja "Zainteresowani"** zamiast "Uczestnicy"
|
||||||
|
7. **Eksport ICS** — opcjonalny (data i miejsce są znane)
|
||||||
|
|
||||||
|
### Automatyczne wyciąganie danych ze stron zewnętrznych
|
||||||
|
|
||||||
|
**Zweryfikowano (2026-03-19):** Strony brokereksportowy.pl pozwalają na automatyczną ekstrakcję:
|
||||||
|
- Tytuł, data, godzina
|
||||||
|
- Pełny adres (np. "Olivia Centre, Al. Grunwaldzka 472D, 80-309 Gdańsk")
|
||||||
|
- Sposób rejestracji (formularz, email, telefon, deadline)
|
||||||
|
- Koszt / dofinansowanie
|
||||||
|
- Organizator
|
||||||
|
|
||||||
|
**Możliwy workflow admina:**
|
||||||
|
1. Admin wkleja URL zewnętrznego wydarzenia
|
||||||
|
2. System (AI) automatycznie wyciąga: tytuł, datę, lokalizację, opis, dane rejestracji, koszt
|
||||||
|
3. Admin weryfikuje i zatwierdza — ewentualnie poprawia
|
||||||
|
4. Oszczędność: z 5-10 minut ręcznego przepisywania → 30 sekund
|
||||||
|
|
||||||
|
### Formularz admina
|
||||||
|
|
||||||
|
Rozszerzenie istniejącego formularza o:
|
||||||
|
- Checkbox "Wydarzenie zewnętrzne" — po zaznaczeniu:
|
||||||
|
- Pole "URL źródła" (link do strony wydarzenia) + przycisk "Pobierz dane" (AI)
|
||||||
|
- Pole "Link do rejestracji" (może być inny niż URL źródła)
|
||||||
|
- Pole "Źródło / Organizator zewnętrzny"
|
||||||
|
- Pole "Koszt uczestnictwa" (opcjonalne, tekst)
|
||||||
|
- Pole "Kontakt do organizatora" (email, telefon)
|
||||||
|
- Ukryty limit miejsc (nieistotny dla zewnętrznych)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faza 2: AI matching wydarzeń do profili firm
|
||||||
|
|
||||||
|
### Dane do matchingu (już w systemie)
|
||||||
|
|
||||||
|
| Dane w profilu firmy | Użycie |
|
||||||
|
|---------------------|--------|
|
||||||
|
| Kategoria (IT, Construction, Services...) | Główny filtr branżowy |
|
||||||
|
| Opis firmy | Szczegółowe dopasowanie tematyczne |
|
||||||
|
| Usługi | Dopasowanie do typu wydarzenia |
|
||||||
|
| Słowa kluczowe | Precyzyjny matching |
|
||||||
|
| Zainteresowania PEJ (widoczność ZOPK) | Dopasowanie do wydarzeń obronnych/energetycznych |
|
||||||
|
|
||||||
|
### Mechanizm
|
||||||
|
|
||||||
|
1. **Przy dodaniu wydarzenia** — Gemini generuje "profil wydarzenia": branże, słowa kluczowe, typ firmy docelowej
|
||||||
|
2. **Matching** — porównanie profilu wydarzenia z profilami firm (kategoria + opis + usługi)
|
||||||
|
3. **Wynik** — lista par (wydarzenie → firma, score) przechowywana w bazie
|
||||||
|
4. **Koszt** — jedno wywołanie Gemini per wydarzenie (nie per logowanie)
|
||||||
|
|
||||||
|
### Gdzie pokazywać sugestie
|
||||||
|
|
||||||
|
| Miejsce | Co widać |
|
||||||
|
|---------|---------|
|
||||||
|
| Kalendarz — sekcja "Sugerowane dla Ciebie" u góry | 2-3 najbardziej dopasowane wydarzenia |
|
||||||
|
| Profil firmy (widok właściciela) | "Nadchodzące wydarzenia powiązane z Twoją branżą" |
|
||||||
|
| Powiadomienie (bell icon) | "Nowe wydarzenie może Cię zainteresować" |
|
||||||
|
|
||||||
|
### Wyjaśnienie dopasowania
|
||||||
|
|
||||||
|
Przy każdej sugestii krótki tekst: "Pasuje do: Construction, Local Content" — użytkownik rozumie logikę.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faza 3: Sugestie i powiadomienia
|
||||||
|
|
||||||
|
- Powiadomienia przy nowych wydarzeniach dopasowanych do profilu
|
||||||
|
- "3 firmy z Twojej branży są zainteresowane tym wydarzeniem"
|
||||||
|
- Agregacja zainteresowań w profilu użytkownika/firmy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Powiązanie z KIG
|
||||||
|
|
||||||
|
Dwukierunkowy przepływ:
|
||||||
|
- **Nordabiznes → KIG:** Feedy RSS z wydarzeniami Nordy (Faza osobna, patrz PLAN_KIG_RSS)
|
||||||
|
- **KIG → Nordabiznes:** Gdy KIG i inne izby uruchomią RSS, możemy automatycznie importować ich wydarzenia jako zewnętrzne
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kolejność wdrożenia
|
||||||
|
|
||||||
|
1. **Faza 1** — wydarzenia zewnętrzne + filtr + "jestem zainteresowany" + auto-ekstrakcja z URL
|
||||||
|
2. **Faza 2** — AI matching do profili firm
|
||||||
|
3. **Faza 3** — powiadomienia i sugestie w UI
|
||||||
|
|
||||||
|
Każda faza jest niezależna i daje wartość sama w sobie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Przykładowe źródła wydarzeń zewnętrznych
|
||||||
|
|
||||||
|
- Agencja Rozwoju Pomorza (brokereksportowy.pl)
|
||||||
|
- Krajowa Izba Gospodarcza (kig.pl)
|
||||||
|
- Urząd Miasta Wejherowo
|
||||||
|
- Inne izby gospodarcze (via RSS w przyszłości)
|
||||||
|
- PARP, NCBiR, inne agencje rządowe
|
||||||
@ -26,6 +26,11 @@
|
|||||||
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
||||||
<meta name="theme-color" content="#233e6d">
|
<meta name="theme-color" content="#233e6d">
|
||||||
|
|
||||||
|
<!-- RSS Feeds -->
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Norda Biznes — Wydarzenia" href="/feed/events.xml">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Norda Biznes — Aktualności" href="/feed/news.xml">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Norda Biznes — PEJ" href="/feed/pej.xml">
|
||||||
|
|
||||||
<!-- Preload critical resources for LCP optimization -->
|
<!-- Preload critical resources for LCP optimization -->
|
||||||
<link rel="preload" href="{{ url_for('static', filename='img/favicon-512.png') }}" as="image">
|
<link rel="preload" href="{{ url_for('static', filename='img/favicon-512.png') }}" as="image">
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user