From eab249ea57f214baef5d75363ec2d6625de9842b Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Thu, 10 Sep 2026 12:27:17 +0200 Subject: [PATCH 1/2] v1.8.3: OV-matchfilter weert gewone-woord-botsingen (breng/overweg/sein/opwaarderen) 'NOS | Oldambt weigert Leninbeeld' kwam door het filter via het werkwoord 'breng je het gesprek op gang' (familie vervoerders, gewicht 3). Audit over 30 dagen productiedata toonde dezelfde klasse: 'sein brandmeester' (brandweer) 3x, 'overwegen' (werkwoord) 2x, idioom 'overweg kunnen' 1x. - breng/overweg/overwegen/sein/opwaarderen van sterk(3)/medium(2) naar zwak(1): dubbelzinnige woorden combineren nu met een ander OV-woord (bushalte/trein/spoor/NS blijven gewoon scoren) - _filter_passes eist sindsdien: alleen zwakke hits -> minimaal twee verschillende families (zelfde zwakke familie in titel+samenvatting is geen combinatie); voorkomt 'overwegen' in beide velden alsnog doorlaten - trein-familie matst nu ook samenstellingen (stoptrein/goederentrein/ sprinttrein) zodat overweg-demotie geen echte spoorincidenten verliest - blacklist + stationair/stationeren/stationeel (geen OV-waarde, matchen wel via \bstation\w*\b) - docs: module-commentaar en _filter_summary/_filter_passes bijgewerkt Verificatie: exacte storingscode-herhaling op de gemelde NOS-entry (nu gefilterd), 12 echte 30-dagen-entries (9 FP's eruit, 3 echte OV-berichten blijven), 10/10 echte OV-regressiegevallen doorlaten, 11/11 FP-vormen geweerd. --- buzzrss/bot.py | 66 +++++++++++++++++++++++++++++++++++--------------- maubot.yaml | 2 +- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/buzzrss/bot.py b/buzzrss/bot.py index a796285..19f493e 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -55,31 +55,41 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # Gewicht 3 (sterk, onmiskenbaar OV): één woord in titel óf samenvatting volstaat. # Gewicht 2 (medium): één woord in de titel volstaat; in de samenvatting moet er # een tweede OV-woord bijkomen. -# Gewicht 1 (zwak/dubbelzinnig: los "bus", "kaartje", "rails"): moet met een ander -# OV-woord combineren. Weert zo "VW-bus"/"schoolbus", "weer op de rails" en -# 112-berichten met "bussen rijden om" in de lead, zonder eenduidige -# bushalte/overweg/NS/trein-berichten te raken. +# Gewicht 1 (zwak/dubbelzinnig: los "bus", "breng", "overweg", "overwegen", "sein", +# "kaartje", "opwaarderen", "rails"): moet met een ander OV-woord combineren (een +# tweede, andere familie — dezelfde zwakke familie in titel én samenvatting telt +# niet als combinatie). Weert zo "VW-bus"/"schoolbus", "weer op de rails", het +# werkwoord "brengen" ("hoe breng je ... in beeld"), het idioom "goed met elkaar +# overweg kunnen", het werkwoord "overwegen" ("gratieverlening overwegen"), het +# werkwoord "opwaarderen" ("een wijk opwaarderen") en brandweerjargon +# ("sein brandmeester"), zonder eenduidige bushalte/overweg/NS/trein-berichten +# te raken. # MATCH_FILTER_BLACKLIST: gematchte woorden die tóch geen OV-waarde hebben (o.a. # straatnamen rond stations — "de Stationsstraat afsluiten voor een festival" # is geen OV-nieuws). Zwartgelijste woorden leveren geen score op, maar # blokkeren de familie niet: een écht "station"-woord ernaast scoort gewoon. MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [ # sterk (3) - (r"\b(?:ns|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|ebs|htm|syntus|breng|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|ov)\b", 3), # vervoerders - (r"\b(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking)\b", 3), # spoorinfra + (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(?: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"\bstation\w*\b", 3), # station(s) (r"\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking)\b", 3), # voertuig-samenstelling - (r"\btrein\w*\b", 3), # trein(verkeer/staking/ongeluk/...) + (r"\b\w*trein\w*\b", 3), # trein(verkeer/staking/…), ook samenstellingen: stoptrein, goederentrein, sprinttrein # medium (2) (r"\btram\b", 2), (r"\bmetro\b", 2), (r"\b(?:openbaar vervoer|dienstregeling|inchecken|uitchecken|lightrail|pendelbus|shuttlebus)\b", 2), - (r"\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs|opwaarderen)\b", 2), + (r"\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs)\b", 2), (r"\b(?:ov-staking|conducteursstaking)\b", 2), (r"\b(?:pont|ponten)\b", 2), # waterbus/veerdienst # zwak (1) — dubbelzinnig, moet combineren (r"\bbus(?:sen)?\b", 1), + (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"\boverwegen\b", 1), # meervoud van overweg vs werkwoord ("een gratieverlening overwegen") + (r"\bsein\b", 1), # spoorsein vs brandweerjargon ("sein brandmeester") en figuurlijk gebruik (r"\bkaart(?:je|jes)\b", 1), + (r"\bopwaarderen\b", 1), # ov-chipkaart opladen vs werkwoord "een wijk/park opwaarderen" (r"\brails\b", 1), ] @@ -98,6 +108,10 @@ MATCH_FILTER_BLACKLIST: frozenset[str] = frozenset({ "stationssteeg", "stationswal", "stationsbuurt", "stationswijk", "stationspark", # Idem voor "\btrein\w*\b". "treinstraat", "treinweg", + # Idem voor "\bstation\w*\b": werkwoords-/bijvoeglijke vormen zonder OV-waarde + # ("motor stationair laten draaien", "auto's hier stationeren"). + "stationair", "stationeel", "stationeren", "stationeert", "stationeerde", + "stationeerden", "stationerend", }) _compiled_match_families: list[tuple[re.Pattern, int]] = [ @@ -219,20 +233,20 @@ class RSSBot(Plugin): self.log.exception("Fatal error while polling feeds") @staticmethod - def _fam_score(text: str) -> int: - """Som van gewichten over de OV-families die in text voorkomen (1× per familie). + def _fam_hits(text: str) -> list[tuple[int, int]]: + """(familie-index, gewicht) voor elke OV-familie die in text voorkomt (1× per familie). Gematchte woorden uit MATCH_FILTER_BLACKLIST (o.a. 'stationsstraat') tellen niet mee en blokkeren de familie niet: een écht OV-woord in dezelfde tekst scoort gewoon verder.""" - total = 0 - for family, weight in _compiled_match_families: + hits: list[tuple[int, int]] = [] + for idx, (family, weight) in enumerate(_compiled_match_families): for m in family.finditer(text): if m.group(0).lower() in MATCH_FILTER_BLACKLIST: continue - total += weight + hits.append((idx, weight)) break - return total + return hits @staticmethod def _filter_passes(title: str, summary: str) -> bool: @@ -240,22 +254,34 @@ class RSSBot(Plugin): Sterke families (gewicht 3) halen de drempel al met één woord in titel óf samenvatting. Medium (2) in de titel ook; in de samenvatting moet een tweede - OV-woord bijkomen. Zwakke woorden (losse 'bus', 'kaartje', 'rails') moeten - combineren met een ander OV-woord. Weert oa 'VW-bus', 'schoolbus', - 'bussen rijden om' in een woningbrand-lead en figuratief 'weer op de rails'. + OV-woord bijkomen. Zwakke woorden (losse 'bus', 'breng', 'overweg', 'overwegen', + 'sein', 'opwaarderen', 'kaartje', 'rails') moeten combineren met een ander + 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, + het werkwoord 'brengen', het idioom 'goed met elkaar overweg kunnen', + 'een wijk opwaarderen', brandweerjargon 'sein brandmeester' en figuratief + 'weer op de rails'. Bewust doorgelaten: krant 'De Metro'/café 'De Tram' in de titel (medium).""" - return 2 * RSSBot._fam_score(title) + RSSBot._fam_score(summary) >= MATCH_FILTER_THRESHOLD + t_hits = RSSBot._fam_hits(title) + s_hits = RSSBot._fam_hits(summary) + score = 2 * sum(w for _, w in t_hits) + sum(w for _, w in s_hits) + if score < MATCH_FILTER_THRESHOLD: + return False + # Alleen zwakke families? Dan moet een tweede (verschillende) familie meetellen. + if max(w for _, w in t_hits + s_hits) == 1 and len({i for i, _ in t_hits + s_hits}) < 2: + return False + return True @staticmethod def _filter_summary() -> str: """Mensvriendelijke weergave van het vaste gewogen match-filter.""" return ( "**OV-matchfilter (vast in code, gewogen families):**\n" - "- Sterk (3): vervoerders (NS, Arriva, Qbuzz, ...), spoorinfra (perron, overweg, sein), " + "- Sterk (3): vervoerders (NS, Arriva, Qbuzz, ...), spoorinfra (perron, spoorwegovergang, seinstoring), " "station(s), bushalte/buslijn/treinstel, trein\n" "- Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, " "ov-chipkaart/ovpay/reisproduct, ov-staking, pont\n" - "- Zwak (1): losse 'bus', 'kaartje', 'rails' — moet combineren met een ander OV-woord\n\n" + "- Zwak (1): losse 'bus', '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, " "o.a. straatnamen rond stations ('stationsstraat', 'stationsweg', ...)\n\n" "Doorlaten: `2×(titel-score) + 1×(samenvatting-score) ≥ 3`" diff --git a/maubot.yaml b/maubot.yaml index 5b89aa4..e87e25b 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.8.2 +version: 1.8.3 modules: - buzzrss main_class: RSSBot From 59f927b0384bd13dbbf4b3538d8fff0596c70bfd Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Thu, 10 Sep 2026 19:04:05 +0200 Subject: [PATCH 2/2] 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) --- buzzrss/bot.py | 125 +++++++++++++++++++++++++++--------------- buzzrss/db.py | 17 +++--- buzzrss/migrations.py | 17 ++++++ maubot.yaml | 2 +- 4 files changed, 107 insertions(+), 54 deletions(-) diff --git a/buzzrss/bot.py b/buzzrss/bot.py index 19f493e..a3e2165 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -64,26 +64,34 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # werkwoord "opwaarderen" ("een wijk opwaarderen") en brandweerjargon # ("sein brandmeester"), zonder eenduidige bushalte/overweg/NS/trein-berichten # 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. # straatnamen rond stations — "de Stationsstraat afsluiten voor een festival" # is geen OV-nieuws). Zwartgelijste woorden leveren geen score op, maar # 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]] = [ # 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(?: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"\bstation\w*\b", 3), # station(s) - (r"\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking)\b", 3), # voertuig-samenstelling + (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|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"(? None: self.config.load_and_update() @@ -232,6 +248,13 @@ class RSSBot(Plugin): except Exception: 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 def _fam_hits(text: str) -> list[tuple[int, int]]: """(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', 'sein', 'opwaarderen', 'kaartje', 'rails') moeten combineren met een ander 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, - het werkwoord 'brengen', het idioom 'goed met elkaar overweg kunnen', - 'een wijk opwaarderen', brandweerjargon 'sein brandmeester' en figuratief - 'weer op de rails'. - Bewust doorgelaten: krant 'De Metro'/café 'De Tram' in de titel (medium).""" + Weert oa 'VW-bus' (bus matcht niet ná een koppelteken), 'schoolbus', 'bussen + rijden om' in een woningbrand-lead, het werkwoord 'brengen', het idioom 'goed + met elkaar overweg kunnen', 'een wijk opwaarderen', brandweerjargon + 'sein brandmeester' en figuratief 'weer op de rails'. + Bewust doorgelaten: krant 'De Metro'/café 'De Tram' (medium), film-'treinramp' + en atleet-'sprinter' (sterk).""" t_hits = RSSBot._fam_hits(title) s_hits = RSSBot._fam_hits(summary) 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.""" return ( "**OV-matchfilter (vast in code, gewogen families):**\n" - "- Sterk (3): vervoerders (NS, Arriva, Qbuzz, ...), spoorinfra (perron, spoorwegovergang, seinstoring), " - "station(s), bushalte/buslijn/treinstel, trein\n" - "- Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, " + "- Sterk (3): vervoerders (NS, Arriva, ProRail, Qbuzz, ...), spoorinfra (spoorlijn, spoorwegovergang, seinstoring, perron), " + "station(s), bushalte/buslijn/bushokje/busstation, trein, intercity/sprinter/streekvervoer/veerdienst/buurtbus, 'lijn 12'\n" + "- Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, halte, bushokje, busbaan, vervoerder, reiziger, " "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, " - "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`" ) @@ -291,7 +315,7 @@ class RSSBot(Plugin): self, feed: Feed, entry: Entry, sub: Subscription, *, check_filter: bool = True ) -> EventID | None: 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): return None @@ -340,15 +364,19 @@ class RSSBot(Plugin): timeout=aiohttp.ClientTimeout(total=20), ) data = await resp.json() - if data.get("success"): - for msg in data.get("messages", []): - msg_data = msg.get("_data", {}) - if ( - 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 + if not data.get("success"): + # Checkfout (wwebjs antwoordt ook met HTTP 200 + success:false) = + # niet posten: anders omzeilt de fout de dedup → dubbele link. + self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {data}") + return False + for msg in data.get("messages", []): + msg_data = msg.get("_data", {}) + if ( + 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: self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {e}") return False @@ -381,7 +409,7 @@ class RSSBot(Plugin): failed = number of subscriptions where sending failed """ 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): return 0, 1, 0 spam_sleep = self.config["spam_sleep"] @@ -574,17 +602,21 @@ class RSSBot(Plugin): return feed, None etag = resp.headers.get("ETag", "") 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): feed.etag, feed.last_modified = etag, last_modified try: await self.dbm.set_feed_headers(feed, etag, last_modified) except Exception: self.log.debug(f"Failed to store fetch headers of {feed.id}") - ct = resp.headers["Content-Type"].split(";")[0].strip() - if ct == "application/json" or ct == "application/feed+json": - return await self._parse_json(feed, resp) - else: - return await self._parse_rss(feed, resp) + return feed, entries @classmethod async def _parse_json( @@ -601,7 +633,10 @@ class RSSBot(Plugin): feed.title = content["title"] feed.subtitle = content.get("subtitle", "") 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 def _parse_json_entry(cls, feed_id: int, entry: dict[str, Any]) -> Entry: diff --git a/buzzrss/db.py b/buzzrss/db.py index 3e81e4d..728fdf3 100644 --- a/buzzrss/db.py +++ b/buzzrss/db.py @@ -90,12 +90,12 @@ date_fmt_microseconds = "%Y-%m-%d %H:%M:%S.%f" def normalize_title(title: str) -> str: """Genormaliseerde titel-sleutel voor cross-feed duplicate-detectie. - Stript HTML, lowercaset, vouwt whitespace samen en verwijdert rand-interpunctie, - zodat hetzelfde artikel dat door één uitgever onder een andere URL/feed wordt - gepubliceerd (vrijwel) identieke titels matcht. Bewust exacte matching: - terugkerende koppen ("treinstoring", "staking") met écht andere titels - mogen niet als duplicaat worden weggefilterd.""" - text = html.unescape(re.sub(r"<[^>]+>", "", title or "")) + Decodeert eerst HTML-entities en stript dan de tags, lowercaset, vouwt whitespace + samen en verwijdert rand-interpunctie, zodat hetzelfde artikel dat door één + uitgever onder een andere URL/feed wordt gepubliceerd (vrijwel) identieke titels + matcht. Bewust exacte matching: terugkerende koppen ("treinstoring", "staking") + met écht andere titels mogen niet als duplicaat worden weggefilterd.""" + text = re.sub(r"<[^>]+>", "", html.unescape(title or "")) text = re.sub(r"\s+", " ", text) return text.strip(" \t\n.,;:!?…-–—").lower() @@ -170,15 +170,16 @@ class DBManager: """Genormaliseerde titels van entries die sinds `since` daadwerkelijk zijn gebroadcast (posted=true). Dit is de bron voor cross-feed titel-dedup.""" 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"]} async def mark_entry_posted(self, feed_id: int, entry_id: str) -> None: 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, entry_id, + datetime.now(timezone.utc), ) async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None: diff --git a/buzzrss/migrations.py b/buzzrss/migrations.py index c7e0077..635fcd7 100644 --- a/buzzrss/migrations.py +++ b/buzzrss/migrations.py @@ -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") # Venster-query voor de titel-dedup: recent gepostede titels ophalen. 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)") diff --git a/maubot.yaml b/maubot.yaml index e87e25b..84467fb 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.8.3 +version: 1.9.0 modules: - buzzrss main_class: RSSBot