v1.9.0: filter-recall (meervoud/vervoeging, ProRail/intercity/veerdienst/buurtbus) + vijf fixes (ETag na parse, WhatsApp-check, paywall JSON, start-guard, dedup op posted_at)

This commit is contained in:
Eddy de Vink 2026-09-10 19:04:05 +02:00
commit 59f927b038
4 changed files with 107 additions and 54 deletions

View file

@ -64,26 +64,34 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT
# werkwoord "opwaarderen" ("een wijk opwaarderen") en brandweerjargon # werkwoord "opwaarderen" ("een wijk opwaarderen") en brandweerjargon
# ("sein brandmeester"), zonder eenduidige bushalte/overweg/NS/trein-berichten # ("sein brandmeester"), zonder eenduidige bushalte/overweg/NS/trein-berichten
# te raken. # te raken.
# Inflectie: families matchen met \w* aan het eind, zodat meervoud en vervoeging
# meegenomen worden ("spoorlijnen", "seinstoringen", "ontspoorde", "chipkaarten").
# Een koppelteken vóór een match is géén OV-signaal: "VW-bus" en "radio-station"
# vallen af (lookbehind bij 'bus' resp. 'station').
# MATCH_FILTER_BLACKLIST: gematchte woorden die tóch geen OV-waarde hebben (o.a. # MATCH_FILTER_BLACKLIST: gematchte woorden die tóch geen OV-waarde hebben (o.a.
# straatnamen rond stations — "de Stationsstraat afsluiten voor een festival" # straatnamen rond stations — "de Stationsstraat afsluiten voor een festival"
# is geen OV-nieuws). Zwartgelijste woorden leveren geen score op, maar # is geen OV-nieuws). Zwartgelijste woorden leveren geen score op, maar
# blokkeren de familie niet: een écht "station"-woord ernaast scoort gewoon. # blokkeren de familie niet: een écht "station"-woord ernaast scoort gewoon.
# Bewust geaccepteerde botsingen: krant "De Metro"/café "De Tram" (medium),
# film-"treinramp" en atleet-"sprinter" (sterk).
MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [ MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [
# sterk (3) # sterk (3)
(r"\b(?:ns|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|ebs|htm|syntus|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|ov)\b", 3), # vervoerders ('Breng' staat in de zwakke groep: botst met het werkwoord "brengen") (r"\b(?:ns|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|ebs|htm|syntus|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|prorail|hermes|rrreis|qliner|ov)\b", 3), # vervoerders ('Breng' staat in de zwakke groep: botst met het werkwoord "brengen")
(r"\b(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|seinstoring|wisselstoring|ontsporing|ontspoord|spoorstaking)\b", 3), # spoorinfra ('overweg', 'overwegen' en 'sein' in de zwakke groep: botsen met idioom/werkwoord resp. brandweerjargon) (r"\b(?:spoorweg|spoorba(?:an|nen)|spoorlijn|spoorverkeer|spoorwegovergang|spoorbrug|spoorstoring|perron|seinstoring|wisselstoring|ontspor|ontspoor|spoorstaking)\w*\b", 3), # spoorinfra; let op de klinkerwisseling: 'ontsporing' (open lettergreep, één o) vs 'ontspoord'/'ontspoorde' (dicht, dubbel o) — vandaar beide stammen
(r"\bstation\w*\b", 3), # station(s) (r"(?<![\w-])\bstation\w*\b", 3), # station(s); niet ná een koppelteken ('radio-station'); niet-OV-vormen staan in de blacklist
(r"\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking)\b", 3), # voertuig-samenstelling (r"\b(?:bus|tram|metro)-?(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking|ba(?:an|nen)|hok(?:je|jes)|chauffeur)\w*\b", 3), # voertuig-samenstelling (busstation/metrostation/bushokje/busbanen/…)
(r"\b\w*trein\w*\b", 3), # trein(verkeer/staking/…), ook samenstellingen: stoptrein, goederentrein, sprinttrein (r"\b\w*trein\w*\b", 3), # trein(verkeer/staking/…), ook samenstellingen: stoptrein, goederentrein, sprinttrein
(r"\b(?:intercity|sprinter|streekvervoer|stadsvervoer|veerdienst|veerboot|veerboten|veerpont|voetveer|veerverbinding|buurtbus|belbus)\w*\b", 3), # treintypes, vervoerssectoren en veren ('sprinter' is ook een atleet: geaccepteerde botsing)
(r"\blijn\s?\d{1,3}\b", 3), # 'lijn 12' — vervoerslijn met nummer
# medium (2) # medium (2)
(r"\btram\b", 2), (r"\btrams?\b", 2),
(r"\bmetro\b", 2), (r"\bmetro(?:'?s)?\b", 2),
(r"\b(?:openbaar vervoer|dienstregeling|inchecken|uitchecken|lightrail|pendelbus|shuttlebus)\b", 2), (r"\b(?:openbaar vervoer|dienstregeling|inchecken|uitchecken|lightrail|pendelbus|shuttlebus|halte|bushokje|busbaan|vervoerder|reiziger)\w*\b", 2),
(r"\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs)\b", 2), (r"\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs)\w*\b", 2),
(r"\b(?:ov-staking|conducteursstaking)\b", 2), (r"\b(?:ov-staking|conducteursstaking)\w*\b", 2),
(r"\b(?:pont|ponten)\b", 2), # waterbus/veerdienst (r"\b(?:pont|ponten)\b", 2), # waterbus/veerdienst
# zwak (1) — dubbelzinnig, moet combineren # zwak (1) — dubbelzinnig, moet combineren
(r"\bbus(?:sen)?\b", 1), (r"(?<!-)\bbus(?:sen)?\b", 1), # ná een koppelteken geen OV: 'VW-bus', 'school-bus'
(r"\bbreng\b", 1), # vervoerder Breng vs werkwoord "brengen" ("hoe breng je ... in beeld") (r"\bbreng\b", 1), # vervoerder Breng vs werkwoord "brengen" ("hoe breng je ... in beeld")
(r"\boverweg\b", 1), # spoorwegovergang vs idioom "goed met elkaar overweg kunnen" (r"\boverweg\b", 1), # spoorwegovergang vs idioom "goed met elkaar overweg kunnen"
(r"\boverwegen\b", 1), # meervoud van overweg vs werkwoord ("een gratieverlening overwegen") (r"\boverwegen\b", 1), # meervoud van overweg vs werkwoord ("een gratieverlening overwegen")
@ -109,9 +117,11 @@ MATCH_FILTER_BLACKLIST: frozenset[str] = frozenset({
# Idem voor "\btrein\w*\b". # Idem voor "\btrein\w*\b".
"treinstraat", "treinweg", "treinstraat", "treinweg",
# Idem voor "\bstation\w*\b": werkwoords-/bijvoeglijke vormen zonder OV-waarde # Idem voor "\bstation\w*\b": werkwoords-/bijvoeglijke vormen zonder OV-waarde
# ("motor stationair laten draaien", "auto's hier stationeren"). # ("motor stationair laten draaien", "auto's hier stationeren") en
# samenstellingen met een station in de naam ("stationcar", "stationwagen").
"stationair", "stationeel", "stationeren", "stationeert", "stationeerde", "stationair", "stationeel", "stationeren", "stationeert", "stationeerde",
"stationeerden", "stationerend", "stationeerden", "stationerend", "stationcar", "stationwagen",
"stationnement", "stationering",
}) })
_compiled_match_families: list[tuple[re.Pattern, int]] = [ _compiled_match_families: list[tuple[re.Pattern, int]] = [
@ -180,12 +190,18 @@ class RSSBot(Plugin):
self.log.warning("start() called twice, ignoring duplicate poll task") self.log.warning("start() called twice, ignoring duplicate poll task")
return return
self._poll_started = True self._poll_started = True
await super().start() try:
self.on_external_config_update() await super().start()
self.dbm = DBManager(self.database) self.on_external_config_update()
self.http = self.client.api.session self.dbm = DBManager(self.database)
self.power_level_cache = {} self.http = self.client.api.session
self.poll_task = asyncio.create_task(self.poll_feeds()) self.power_level_cache = {}
self.poll_task = asyncio.create_task(self.poll_feeds())
except Exception:
# Niets geïnitialiseerd (geen dbm/http/poll_task): guard terugdraaien,
# anders wordt een geldige herstart genegeerd en blijft de instance dood.
self._poll_started = False
raise
def on_external_config_update(self) -> None: def on_external_config_update(self) -> None:
self.config.load_and_update() self.config.load_and_update()
@ -232,6 +248,13 @@ class RSSBot(Plugin):
except Exception: except Exception:
self.log.exception("Fatal error while polling feeds") self.log.exception("Fatal error while polling feeds")
@staticmethod
def _filter_text(raw: str) -> str:
"""Platte tekst voor het match-filter: eerst entities decoderen, dán tags
strippen. Andersom (strip → unescape) laat dubbel-geëscapete feeds hun HTML
als tekst houden — inclusief URL's en attributen, die dan vals scoren."""
return re.sub(r"<[^>]+>", "", html.unescape(raw))
@staticmethod @staticmethod
def _fam_hits(text: str) -> list[tuple[int, int]]: def _fam_hits(text: str) -> list[tuple[int, int]]:
"""(familie-index, gewicht) voor elke OV-familie die in text voorkomt (1× per familie). """(familie-index, gewicht) voor elke OV-familie die in text voorkomt (1× per familie).
@ -257,11 +280,12 @@ class RSSBot(Plugin):
OV-woord bijkomen. Zwakke woorden (losse 'bus', 'breng', 'overweg', 'overwegen', OV-woord bijkomen. Zwakke woorden (losse 'bus', 'breng', 'overweg', 'overwegen',
'sein', 'opwaarderen', 'kaartje', 'rails') moeten combineren met een ander 'sein', 'opwaarderen', 'kaartje', 'rails') moeten combineren met een ander
OV-woord; dezelfde zwakke familie in titel én samenvatting is géén combinatie. OV-woord; dezelfde zwakke familie in titel én samenvatting is géén combinatie.
Weert oa 'VW-bus', 'schoolbus', 'bussen rijden om' in een woningbrand-lead, Weert oa 'VW-bus' (bus matcht niet ná een koppelteken), 'schoolbus', 'bussen
het werkwoord 'brengen', het idioom 'goed met elkaar overweg kunnen', rijden om' in een woningbrand-lead, het werkwoord 'brengen', het idioom 'goed
'een wijk opwaarderen', brandweerjargon 'sein brandmeester' en figuratief met elkaar overweg kunnen', 'een wijk opwaarderen', brandweerjargon
'weer op de rails'. 'sein brandmeester' en figuratief 'weer op de rails'.
Bewust doorgelaten: krant 'De Metro'/café 'De Tram' in de titel (medium).""" Bewust doorgelaten: krant 'De Metro'/café 'De Tram' (medium), film-'treinramp'
en atleet-'sprinter' (sterk)."""
t_hits = RSSBot._fam_hits(title) t_hits = RSSBot._fam_hits(title)
s_hits = RSSBot._fam_hits(summary) s_hits = RSSBot._fam_hits(summary)
score = 2 * sum(w for _, w in t_hits) + sum(w for _, w in s_hits) score = 2 * sum(w for _, w in t_hits) + sum(w for _, w in s_hits)
@ -277,13 +301,13 @@ class RSSBot(Plugin):
"""Mensvriendelijke weergave van het vaste gewogen match-filter.""" """Mensvriendelijke weergave van het vaste gewogen match-filter."""
return ( return (
"**OV-matchfilter (vast in code, gewogen families):**\n" "**OV-matchfilter (vast in code, gewogen families):**\n"
"- Sterk (3): vervoerders (NS, Arriva, Qbuzz, ...), spoorinfra (perron, spoorwegovergang, seinstoring), " "- Sterk (3): vervoerders (NS, Arriva, ProRail, Qbuzz, ...), spoorinfra (spoorlijn, spoorwegovergang, seinstoring, perron), "
"station(s), bushalte/buslijn/treinstel, trein\n" "station(s), bushalte/buslijn/bushokje/busstation, trein, intercity/sprinter/streekvervoer/veerdienst/buurtbus, 'lijn 12'\n"
"- Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, " "- Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, halte, bushokje, busbaan, vervoerder, reiziger, "
"ov-chipkaart/ovpay/reisproduct, ov-staking, pont\n" "ov-chipkaart/ovpay/reisproduct, ov-staking, pont\n"
"- Zwak (1): losse 'bus', 'breng', 'overweg', 'overwegen', 'sein', 'kaartje', 'opwaarderen', 'rails' — moet combineren met een ander OV-woord\n\n" "- Zwak (1): losse 'bus' (niet ná een koppelteken), 'breng', 'overweg', 'overwegen', 'sein', 'kaartje', 'opwaarderen', 'rails' — moet combineren met een ander OV-woord\n\n"
"Uitgesloten (blacklist): woorden die wél matchen maar geen OV-waarde hebben, " "Uitgesloten (blacklist): woorden die wél matchen maar geen OV-waarde hebben, "
"o.a. straatnamen rond stations ('stationsstraat', 'stationsweg', ...)\n\n" "o.a. straatnamen rond stations ('stationsstraat', 'stationsweg', ...) en niet-OV-vormen als 'stationcar' en 'stationair'\n\n"
"Doorlaten: `2×(titel-score) + 1×(samenvatting-score) ≥ 3`" "Doorlaten: `2×(titel-score) + 1×(samenvatting-score) ≥ 3`"
) )
@ -291,7 +315,7 @@ class RSSBot(Plugin):
self, feed: Feed, entry: Entry, sub: Subscription, *, check_filter: bool = True self, feed: Feed, entry: Entry, sub: Subscription, *, check_filter: bool = True
) -> EventID | None: ) -> EventID | None:
if check_filter: if check_filter:
summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary)) summary = self._filter_text(entry.summary)
if not self._filter_passes(entry.title, summary): if not self._filter_passes(entry.title, summary):
return None return None
@ -340,15 +364,19 @@ class RSSBot(Plugin):
timeout=aiohttp.ClientTimeout(total=20), timeout=aiohttp.ClientTimeout(total=20),
) )
data = await resp.json() data = await resp.json()
if data.get("success"): if not data.get("success"):
for msg in data.get("messages", []): # Checkfout (wwebjs antwoordt ook met HTTP 200 + success:false) =
msg_data = msg.get("_data", {}) # niet posten: anders omzeilt de fout de dedup → dubbele link.
if ( self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {data}")
msg_data.get("id", {}).get("remote") == chat_id return False
and link in msg_data.get("body", "") for msg in data.get("messages", []):
): msg_data = msg.get("_data", {})
self.log.info(f"{entry.id} already in WhatsApp group, skipping") if (
return False msg_data.get("id", {}).get("remote") == chat_id
and link in msg_data.get("body", "")
):
self.log.info(f"{entry.id} already in WhatsApp group, skipping")
return False
except Exception as e: except Exception as e:
self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {e}") self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {e}")
return False return False
@ -381,7 +409,7 @@ class RSSBot(Plugin):
failed = number of subscriptions where sending failed failed = number of subscriptions where sending failed
""" """
self.log.debug(f"Broadcasting {entry.id} of {feed.id}") self.log.debug(f"Broadcasting {entry.id} of {feed.id}")
summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary)) summary = self._filter_text(entry.summary)
if not self._filter_passes(entry.title, summary): if not self._filter_passes(entry.title, summary):
return 0, 1, 0 return 0, 1, 0
spam_sleep = self.config["spam_sleep"] spam_sleep = self.config["spam_sleep"]
@ -574,17 +602,21 @@ class RSSBot(Plugin):
return feed, None return feed, None
etag = resp.headers.get("ETag", "") etag = resp.headers.get("ETag", "")
last_modified = resp.headers.get("Last-Modified", "") last_modified = resp.headers.get("Last-Modified", "")
ct = resp.headers["Content-Type"].split(";")[0].strip()
if ct == "application/json" or ct == "application/feed+json":
feed, entries = await self._parse_json(feed, resp)
else:
feed, entries = await self._parse_rss(feed, resp)
# Validators pas bewaren als de body volledig gelezen én geparseerd is:
# anders stuurt de volgende poll If-None-Match op een respons die nooit
# verwerkt is (304 → 'unchanged', entries stil weg).
if feed.id > 0 and (etag or last_modified): if feed.id > 0 and (etag or last_modified):
feed.etag, feed.last_modified = etag, last_modified feed.etag, feed.last_modified = etag, last_modified
try: try:
await self.dbm.set_feed_headers(feed, etag, last_modified) await self.dbm.set_feed_headers(feed, etag, last_modified)
except Exception: except Exception:
self.log.debug(f"Failed to store fetch headers of {feed.id}") self.log.debug(f"Failed to store fetch headers of {feed.id}")
ct = resp.headers["Content-Type"].split(";")[0].strip() return feed, entries
if ct == "application/json" or ct == "application/feed+json":
return await self._parse_json(feed, resp)
else:
return await self._parse_rss(feed, resp)
@classmethod @classmethod
async def _parse_json( async def _parse_json(
@ -601,7 +633,10 @@ class RSSBot(Plugin):
feed.title = content["title"] feed.title = content["title"]
feed.subtitle = content.get("subtitle", "") feed.subtitle = content.get("subtitle", "")
feed.link = content.get("home_page_url", "") feed.link = content.get("home_page_url", "")
return feed, [cls._parse_json_entry(feed.id, entry) for entry in content["items"]] entries = [cls._parse_json_entry(feed.id, entry) for entry in content["items"]]
if cls.exclude_paywalled:
entries = [e for e in entries if not e.paywalled]
return feed, entries
@classmethod @classmethod
def _parse_json_entry(cls, feed_id: int, entry: dict[str, Any]) -> Entry: def _parse_json_entry(cls, feed_id: int, entry: dict[str, Any]) -> Entry:

View file

@ -90,12 +90,12 @@ date_fmt_microseconds = "%Y-%m-%d %H:%M:%S.%f"
def normalize_title(title: str) -> str: def normalize_title(title: str) -> str:
"""Genormaliseerde titel-sleutel voor cross-feed duplicate-detectie. """Genormaliseerde titel-sleutel voor cross-feed duplicate-detectie.
Stript HTML, lowercaset, vouwt whitespace samen en verwijdert rand-interpunctie, Decodeert eerst HTML-entities en stript dan de tags, lowercaset, vouwt whitespace
zodat hetzelfde artikel dat door één uitgever onder een andere URL/feed wordt samen en verwijdert rand-interpunctie, zodat hetzelfde artikel dat door één
gepubliceerd (vrijwel) identieke titels matcht. Bewust exacte matching: uitgever onder een andere URL/feed wordt gepubliceerd (vrijwel) identieke titels
terugkerende koppen ("treinstoring", "staking") met écht andere titels matcht. Bewust exacte matching: terugkerende koppen ("treinstoring", "staking")
mogen niet als duplicaat worden weggefilterd.""" met écht andere titels mogen niet als duplicaat worden weggefilterd."""
text = html.unescape(re.sub(r"<[^>]+>", "", title or "")) text = re.sub(r"<[^>]+>", "", html.unescape(title or ""))
text = re.sub(r"\s+", " ", text) text = re.sub(r"\s+", " ", text)
return text.strip(" \t\n.,;:!?…-–—").lower() return text.strip(" \t\n.,;:!?…-–—").lower()
@ -170,15 +170,16 @@ class DBManager:
"""Genormaliseerde titels van entries die sinds `since` daadwerkelijk zijn """Genormaliseerde titels van entries die sinds `since` daadwerkelijk zijn
gebroadcast (posted=true). Dit is de bron voor cross-feed titel-dedup.""" gebroadcast (posted=true). Dit is de bron voor cross-feed titel-dedup."""
rows = await self.db.fetch( rows = await self.db.fetch(
"SELECT title FROM entry WHERE posted AND date >= $1", since "SELECT title FROM entry WHERE posted AND posted_at >= $1", since
) )
return {normalize_title(row["title"]) for row in rows if row["title"]} return {normalize_title(row["title"]) for row in rows if row["title"]}
async def mark_entry_posted(self, feed_id: int, entry_id: str) -> None: async def mark_entry_posted(self, feed_id: int, entry_id: str) -> None:
await self.db.execute( await self.db.execute(
"UPDATE entry SET posted = true WHERE feed_id = $1 AND id = $2", "UPDATE entry SET posted = true, posted_at = $3 WHERE feed_id = $1 AND id = $2",
feed_id, feed_id,
entry_id, entry_id,
datetime.now(timezone.utc),
) )
async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None: async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None:

View file

@ -127,3 +127,20 @@ async def upgrade_v11(conn: Connection, scheme: Scheme) -> None:
await conn.execute("ALTER TABLE entry ADD COLUMN posted BOOLEAN NOT NULL DEFAULT false") await conn.execute("ALTER TABLE entry ADD COLUMN posted BOOLEAN NOT NULL DEFAULT false")
# Venster-query voor de titel-dedup: recent gepostede titels ophalen. # Venster-query voor de titel-dedup: recent gepostede titels ophalen.
await conn.execute("CREATE INDEX entry_date_idx ON entry (date)") await conn.execute("CREATE INDEX entry_date_idx ON entry (date)")
@upgrade_table.register(description="Measure the title-dedup window on post time, not publication date")
async def upgrade_v12(conn: Connection, scheme: Scheme) -> None:
# posted_at = moment waarop de entry gebroadcast is. v11 meette het venster op
# de publicatiedatum: een backdated item of een na een storing ingehaalde
# batch viel daardoor buiten het venster en werd alsnog dubbel gepost.
await conn.execute(
"ALTER TABLE entry ADD COLUMN posted_at TIMESTAMP WITH TIME ZONE"
if scheme == Scheme.POSTGRES
else "ALTER TABLE entry ADD COLUMN posted_at TIMESTAMP"
)
# Bestaande gepostede entries: publicatiedatum is de beste benadering.
await conn.execute("UPDATE entry SET posted_at = date WHERE posted AND posted_at IS NULL")
# entry_date_idx diende alleen de oude dedup-query.
await conn.execute("DROP INDEX IF EXISTS entry_date_idx")
await conn.execute("CREATE INDEX entry_posted_at_idx ON entry (posted_at)")

View file

@ -1,5 +1,5 @@
id: nl.eddydevink.rss id: nl.eddydevink.rss
version: 1.8.3 version: 1.9.0
modules: modules:
- buzzrss - buzzrss
main_class: RSSBot main_class: RSSBot