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

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:
Maciej Pienczyn 2026-03-19 10:03:11 +01:00
parent 5682e1622f
commit e0e145c70f
4 changed files with 339 additions and 0 deletions

View File

@ -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

View 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')

View 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

View File

@ -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">