# buzz-rss — RSS/Atom → Matrix, gefilterd op OV-relevantie Maubot-plugin `nl.eddydevink.rss` (fork van [`xyz.maubot.rss`](https://github.com/maubot/rss)) die nieuwsfeeds pollt en **alleen openbaar-vervoer-gerelateerde berichten** doorplaatst naar Matrix-rooms (optioneel ook alleen de link naar een WhatsApp-groep). De filterlogica is **bewust vast in code** (niet via config) en volgt één businessregel: > **Precision boven recall** — liever een relevant OV-artikel missen dan één > niet-OV-artikel ten onrechte doorlaten. (Gebruikersbesluit 2026-09-10) ## Functionaliteit - Abonneren op feeds per room, met per-feed meldingssjabloon en `m.notice`-optie. - **Conditional fetch** (`ETag`/`Last-Modified`) en backoff bij faalende feeds. - **HTML-scrape-modus**: bronnen zonder RSS/Atom/JSON worden als pagina gepolled via per-feed `feed.scrape_config` (zie `buzzrss/html_scrape.py`; eerste productiefeed: CNV). - **Paywall-uitsluiting** (`exclude_paywalled`) en **titel-dedup over feeds** (`title_dedup_days`: zelfde titel van een andere site van dezelfde uitgever = niet dubbel posten). - Per-run logfile met aantallen (`run_log_file`). - **WhatsApp-forward**: alleen `entry.link` wordt gepost (vooruit-links, zonder titel), met dedup-check tegen de doelgroep. Uit zolang `whatsapp_api`/`whatsapp_chat_id` leeg zijn. ## Commando's Prefix uit config (`command_prefix`, default `rss`), eventueel zonder `!`: | Commando | Aliassen | Werking | |---|---|---| | `!rss subscribe ` | `s`, `sub` | Deze room op een feed abonneren (nieuwe feed wordt direct geïndexeerd) | | `!rss unsubscribe ` | `u`, `unsub` | Abonnement van deze room opzeggen | | `!rss subscriptions` | `ls`, `list`, `subs` | Abonnementen in deze room + huidige filter | | `!rss template [sjabloon]` | `t`, `tpl` | Meldingssjabloon bekijken/wijzigen (variabelen: `$title`, `$link`, `$summary`, `$feed_title`, …) | | `!rss notice ` | `n` | Als `m.notice` versturen i.p.v. `m.text` | | `!rss filter` | `f`, `set-filter`, `show-filter`, `get-filter` | Toont het vaste OV-matchfilter | Bestuurders: admins uit de config, of gebruikers met een powerlevel ≥ dat van het RSS-state-event in die room (default 50 als geen level is ingesteld). Bij een room-tombstone worden abonnementen automatisch naar de vervangende room verplaatst. ## Het OV-matchfilter (v1.15) De volledige classificatie + de werkwijze voor nieuwe woorden staat in `buzzrss/bot.py`, boven `MATCH_FILTER_STRONG` — **dat blok is leidend** bij elke wijziging. Samengevat, in drie regels: ```text 1. staat er een STERK signaal in de TITEL? -> PLAATSEN 2. score = som unieke groeps-gewichten ≥ 3 én de titel bevat een signaal? -> PLAATSEN 3. anders -> NIET PLAATSEN ``` - **Sterk (2 punten, titel = zelfstandig)**: vervoerders (NS, ProRail, Arriva, RET, GVB, Qbuzz, Deutsche Bahn, TESO, ...), spoorjargon (spoorstoring, spoorlijn, wisselstoring, treinstoring, ertms, perron, ...), bus-samenstellingen (bushalte, buslijn, busbaan, buschauffeur), tramverkeer/tramlijn, metrolijn, intercity/streekvervoer/veerdienst/buurtbus, conducteur, ov-chipkaart/ovpay, openbaar vervoer, lightrail, 'lijn 12', ICE-frasen, rangeer... én de dienststoring-zin ('sprinters rijden niet', 'metro rijdt niet'). - **Ambugue (1 punt; halte/tram/metro/spoorbrug 2)** — contextafhankelijk, nooit zelfstandig voldoende, alleen als combinatie: `bus`, `trein`, `station(s)`, `reiziger`/`passagier`, `vervoer(der)`, `sprinter`, `pendelbus`/`shuttlebus`, `dienstregeling`, `ontsporen`, `spoor`. - **Semantische groepen tellen één keer per artikel**: 'bus' + 'buslijn' + 'busvervoer' + 'busstation' is één bus-signaal (bus-samenstelling, bus, station — nooit vier onafhankelijke signalen). - **Geen whitelist, geen blacklist**: woordgrenzen en koppelteken-lookbehinds weren structureel 'schoolbus'/'VW-bus', 'stationsstraat'/'stationair'/ 'stationcar'/'treinstraat'/'treinweg'. Bekende false positives blijven geweerd: krant 'De Metro', café 'De Tram', atleet-'sprinter', werkwoord 'brengen', idioom 'goed met elkaar overweg kunnen', 'een wijk opwaarderen', brandweerjargon 'sein brandmeester', figuratief 'weer op de rails', achternaam 'Hermes', buitenlandse veerboot-/busrampen, 'restaurant/woning naast busstation'. ## Tests ```bash python3 validate_filter.py ``` Vaste invarianten + regressie (momenteel **110 cases**): 1. gewichten geldig (sterk 2; ambigu in {1, 2}), drempel 3; 2. trefwoorden matchen hooguit één groep per categorie (geen overlappende regexes); 3. semantische varianten ('bus' + 'buslijn' + 'busvervoer', 'station'-reeks) leveren samen één signaal op; 4. regressie: bekende positive/negative/combinatie-cases (precision-first). **Verplicht vóór elke filterwijziging** (zie `AGENTS.md`): het classificatiebeleid in `buzzrss/bot.py` lezen, `validate_filter.py` groen, en de **compleetheids-audit**: OV-gap-scan over de afgewezen artikelen van de specialist-feeds (SpoorPro, Treinreiziger, Mobiliteit; query op `entry` in PostgreSQL-schema `mbp_rss`), nieuwe woordkandidaten klasseer je volgens het beleid (minimaal één extra taalmodel; enige stem 'TWIJFEL'/'NIET-OV' ⇒ zwak). ## Configuratie Zie `base-config.yaml` (wordt in het gebouwde `.mbp` meegeleverd): | Sleutel | Default | Werking | |---|---|---| | `update_interval` | 60 | Poll-interval in minuten | | `max_backoff` | 7200 | Maximale backoff (minuten) bij faalende feeds | | `poll_parallelism_limit` | 10 | Maximaal aantal feeds tegelijk pollend | | `spam_sleep` | 2 | Pauze tussen berichten bij broadcast (‑1 = parallel) | | `command_prefix` | `rss` | Commando-prefix | | `notification_template` | `New post in $feed_title: [$title]($link)` | Standaard meldingssjabloon | | `run_log_file` | `/data/buzzrss-runs.log` | Per-run samenvattingsregel (leeg = uit) | | `whatsapp_api` / `whatsapp_session` / `whatsapp_chat_id` | – | WhatsApp-forward (leeg adapter = uit) | | `exclude_paywalled` | true | Betaalmuur-artikelen overslaan | | `title_dedup_days` | 30 | Cross-feed titel-dedup-venster (0 = uit) | | `admins` | – | Gebruikers die room-permissies mogen omzeilen | ## Deploy Productie draait het **geïnstalleerde `.mbp`** (maubot), niet deze werkdirectory — een commit is pas actief na build + upload + restart. Exacte recepten staan in de maubot-beheerskill (`skill://maubot`) en de Obsidian-vault (Matrix Synapse en MAS.md). In het kort: ```text 1. mbc build uit de VOLLEDIGE bron (buzzrss/ + maubot.yaml + base-config.yaml — zonder base-config.yaml faalt de instance met BaseMissingError) 2. POST /_matrix/maubot/v1/plugins/upload?allow_override=true (admin-login) 3. docker restart matrix-bot-maubot ``` **NOOIT maubot `reload` gebruiken** — die laat zombie-commandhandlers achter (één commando → N antwoorden). Alleen een proces-herstart wist de handlers. ## Architectuur in het kort - Poll-loop → per feed conditional-fetch of HTML-scrape → nieuwe entries (`feed_id` + `id` PK-dedup) → cross-feed titel-dedup → `_filter_passes` → broadcast naar alle subscriptions + optioneel WhatsApp (`_send_whatsapp`). - Persistente tabel `whatsapp_posted` voor WhatsApp-dedup. - DB: PostgreSQL-schema `mbp_rss` (`feed`, `entry`, `subscription`, `whatsapp_posted`); migraties via maubot's `UpgradeTable` (`buzzrss/migrations.py`). ## Licentie AGPL-3.0-or-later (upstream: maubot/rss, © Tulir Asokan).