Compare commits

..
Author SHA1 Message Date
Eddy de Vink
59f927b038 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) 2026-09-10 19:04:05 +02:00
Eddy de Vink
eab249ea57 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.
2026-09-10 12:27:17 +02:00
4 changed files with 142 additions and 63 deletions

View file

@ -55,31 +55,49 @@ 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 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 # Gewicht 2 (medium): één woord in de titel volstaat; in de samenvatting moet er
# een tweede OV-woord bijkomen. # een tweede OV-woord bijkomen.
# Gewicht 1 (zwak/dubbelzinnig: los "bus", "kaartje", "rails"): moet met een ander # Gewicht 1 (zwak/dubbelzinnig: los "bus", "breng", "overweg", "overwegen", "sein",
# OV-woord combineren. Weert zo "VW-bus"/"schoolbus", "weer op de rails" en # "kaartje", "opwaarderen", "rails"): moet met een ander OV-woord combineren (een
# 112-berichten met "bussen rijden om" in de lead, zonder eenduidige # tweede, andere familie — dezelfde zwakke familie in titel én samenvatting telt
# bushalte/overweg/NS/trein-berichten te raken. # 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.
# 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|breng|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|ov)\b", 3), # vervoerders (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|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking)\b", 3), # spoorinfra (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"\btrein\w*\b", 3), # trein(verkeer/staking/ongeluk/...) (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|opwaarderen)\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"\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"\bkaart(?:je|jes)\b", 1),
(r"\bopwaarderen\b", 1), # ov-chipkaart opladen vs werkwoord "een wijk/park opwaarderen"
(r"\brails\b", 1), (r"\brails\b", 1),
] ]
@ -98,6 +116,12 @@ MATCH_FILTER_BLACKLIST: frozenset[str] = frozenset({
"stationssteeg", "stationswal", "stationsbuurt", "stationswijk", "stationspark", "stationssteeg", "stationswal", "stationsbuurt", "stationswijk", "stationspark",
# 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
# ("motor stationair laten draaien", "auto's hier stationeren") en
# samenstellingen met een station in de naam ("stationcar", "stationwagen").
"stationair", "stationeel", "stationeren", "stationeert", "stationeerde",
"stationeerden", "stationerend", "stationcar", "stationwagen",
"stationnement", "stationering",
}) })
_compiled_match_families: list[tuple[re.Pattern, int]] = [ _compiled_match_families: list[tuple[re.Pattern, int]] = [
@ -166,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
try:
await super().start() await super().start()
self.on_external_config_update() self.on_external_config_update()
self.dbm = DBManager(self.database) self.dbm = DBManager(self.database)
self.http = self.client.api.session self.http = self.client.api.session
self.power_level_cache = {} self.power_level_cache = {}
self.poll_task = asyncio.create_task(self.poll_feeds()) 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()
@ -219,20 +249,27 @@ class RSSBot(Plugin):
self.log.exception("Fatal error while polling feeds") self.log.exception("Fatal error while polling feeds")
@staticmethod @staticmethod
def _fam_score(text: str) -> int: def _filter_text(raw: str) -> str:
"""Som van gewichten over de OV-families die in text voorkomen (1× per familie). """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).
Gematchte woorden uit MATCH_FILTER_BLACKLIST (o.a. 'stationsstraat') Gematchte woorden uit MATCH_FILTER_BLACKLIST (o.a. 'stationsstraat')
tellen niet mee en blokkeren de familie niet: een écht OV-woord in tellen niet mee en blokkeren de familie niet: een écht OV-woord in
dezelfde tekst scoort gewoon verder.""" dezelfde tekst scoort gewoon verder."""
total = 0 hits: list[tuple[int, int]] = []
for family, weight in _compiled_match_families: for idx, (family, weight) in enumerate(_compiled_match_families):
for m in family.finditer(text): for m in family.finditer(text):
if m.group(0).lower() in MATCH_FILTER_BLACKLIST: if m.group(0).lower() in MATCH_FILTER_BLACKLIST:
continue continue
total += weight hits.append((idx, weight))
break break
return total return hits
@staticmethod @staticmethod
def _filter_passes(title: str, summary: str) -> bool: def _filter_passes(title: str, summary: str) -> bool:
@ -240,24 +277,37 @@ class RSSBot(Plugin):
Sterke families (gewicht 3) halen de drempel al met één woord in titel óf 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 samenvatting. Medium (2) in de titel ook; in de samenvatting moet een tweede
OV-woord bijkomen. Zwakke woorden (losse 'bus', 'kaartje', 'rails') moeten OV-woord bijkomen. Zwakke woorden (losse 'bus', 'breng', 'overweg', 'overwegen',
combineren met een ander OV-woord. Weert oa 'VW-bus', 'schoolbus', 'sein', 'opwaarderen', 'kaartje', 'rails') moeten combineren met een ander
'bussen rijden om' in een woningbrand-lead en figuratief 'weer op de rails'. OV-woord; dezelfde zwakke familie in titel én samenvatting is géén combinatie.
Bewust doorgelaten: krant 'De Metro'/café 'De Tram' in de titel (medium).""" Weert oa 'VW-bus' (bus matcht niet ná een koppelteken), 'schoolbus', 'bussen
return 2 * RSSBot._fam_score(title) + RSSBot._fam_score(summary) >= MATCH_FILTER_THRESHOLD 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)
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 @staticmethod
def _filter_summary() -> str: def _filter_summary() -> str:
"""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, overweg, sein), " "- 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', 'kaartje', '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`"
) )
@ -265,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
@ -314,7 +364,11 @@ 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"):
# 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", []): for msg in data.get("messages", []):
msg_data = msg.get("_data", {}) msg_data = msg.get("_data", {})
if ( if (
@ -355,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"]
@ -548,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(
@ -575,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.2 version: 1.9.0
modules: modules:
- buzzrss - buzzrss
main_class: RSSBot main_class: RSSBot