From d7ba8a06de4173631d4c7b094af34f12f91b28f7 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Tue, 8 Sep 2026 20:50:52 +0200 Subject: [PATCH 1/4] v1.7.0: gewogen OV-families-filter vervangt match_filter-regex Filter is nu vast in code (MATCH_FILTER_FAMILIES + MATCH_FILTER_THRESHOLD): doorlaten als 2x(titelfamiliescore) + 1x(samenvattingscore) >= 3. Sterk(3): vervoerders, spoorinfra, station, voertuig-samenstellingen, trein. Medium(2): tram, metro, dienst, vervoersbewijs/staking/pont. Zwak(1): losse bus/kaartje/rails - moet combineren met ander OV-woord. Weert 'VW-bus'/'schoolbus', woningbrand-leads met 'bussen rijden om' en figuratief 'op de rails', zonder bushalte/overweg/NS/trein-berichten te raken. Gewogen per familie ipv per cluster dicht ook het 'bus x3 in lead'-gat. !rss set-filter en !rss filter zijn read-only geworden (tonen het vaste filter); config-keys match_filter/allow_filter verwijderd uit do_update. V1.7.0 gedeployed op nextcloud (instance rss, enabled+started, run-log actief). --- base-config.yaml | 13 ++- buzzrss/bot.py | 236 ++++++++++++++++++++++++------------------ buzzrss/db.py | 7 ++ buzzrss/migrations.py | 12 +++ maubot.yaml | 2 +- 5 files changed, 164 insertions(+), 106 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index a0f595f..9f4b52b 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -16,12 +16,17 @@ notification_template: "New post in $feed_title: [$title]($link)" # Absoluut pad in de container aanraden: /data is de persistente maubot-mount (host: /matrix/maubot/data). # Leeg = uitgeschakeld. run_log_file: /data/buzzrss-runs.log +# Doorsturen naar WhatsApp (wwebjs-api op nextcloud :3001). Leeg whatsapp_api/whatsapp_chat_id = uit. Alleen de link wordt gepost (geen titel. + +whatsapp_api: http://192.168.5.252:3001 +whatsapp_session: EdGpt +whatsapp_chat_id: 120363429643087130@g.us # Exclude paywalled articles (Telegraaf/NHD markeren dit met true) exclude_paywalled: true -# Global match filter (whitelist): applies to ALL feeds in this instance. -# A post is ONLY sent when the regex matches the title OR the (HTML-stripped) summary. -# Set it live with `!rss set-filter `. Empty = send everything. -match_filter: "(?i)\\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|\\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking|sen)?\\b|\\btrein(?:en|verkeer|stel(?:len)?|staking|ongeluk|reizigers?|station|stations)?\\b|\\b(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking)\\b|\\b(?:openbaar vervoer|dienstregeling|reizigers?|inchecken|uitchecken|pont|ponten|lightrail|pendelbus|shuttlebus)\\b|\\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs|opwaarderen)\\b|\\b(?:ov-staking|conducteursstaking)\\b" +# OV-matchfilter: VAST in code (gewogen families), niet via config te wijzigen. +# Doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= 3. Zie de families in +# buzzrss/bot.py (MATCH_FILTER_FAMILIES). `!rss set-filter` en `!rss filter` +# tonen alleen de huidige (vaste) filter. # Users who can bypass room permission checks admins: - "@user:example.com" \ No newline at end of file diff --git a/buzzrss/bot.py b/buzzrss/bot.py index 8022205..dd38b0b 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -49,16 +49,42 @@ from .migrations import upgrade_table rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE) -DEFAULT_MATCH_FILTER = ( - r"(?i)" - 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" - r"|\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking|sen)?\b" - r"|\btrein(?:en|verkeer|stel(?:len)?|staking|ongeluk|reizigers?|station|stations)?\b" - r"|\b(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking)\b" - r"|\b(?:openbaar vervoer|dienstregeling|reizigers?|inchecken|uitchecken|pont|ponten|lightrail|pendelbus|shuttlebus)\b" - r"|\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs|opwaarderen)\b" - r"|\b(?:ov-staking|conducteursstaking)\b" -) +# Gewogen OV-trefwoord-families voor het match-filter. Elke tuple = (regex, gewicht). +# Per familie wordt 1× geteld per tekst (niet per cluster/herhaling). Drempel (3): +# doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= MATCH_FILTER_THRESHOLD +# 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. +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"\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/...) + # 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-staking|conducteursstaking)\b", 2), + (r"\b(?:pont|ponten)\b", 2), # waterbus/veerdienst + # zwak (1) — dubbelzinnig, moet combineren + (r"\bbus(?:sen)?\b", 1), + (r"\bkaart(?:je|jes)\b", 1), + (r"\brails\b", 1), +] + +# Drempel voor doorlaten (score = 2×titel + 1×samenvatting). +MATCH_FILTER_THRESHOLD = 3 + +_compiled_match_families: list[tuple[re.Pattern, int]] = [ + (re.compile(pat, re.IGNORECASE), w) for pat, w in MATCH_FILTER_FAMILIES +] class Config(BaseProxyConfig): @@ -69,10 +95,11 @@ class Config(BaseProxyConfig): helper.copy("spam_sleep") helper.copy("command_prefix") helper.copy("notification_template") - helper.copy("allow_filter") helper.copy("exclude_paywalled") - helper.copy("match_filter") helper.copy("run_log_file") + helper.copy("whatsapp_api") + helper.copy("whatsapp_session") + helper.copy("whatsapp_chat_id") helper.copy("admins") @@ -162,47 +189,42 @@ class RSSBot(Plugin): self.log.exception("Fatal error while polling feeds") @staticmethod - def _matches_in(text: str, match_filter: str) -> int: - """Aantal aaneengesloten match-clusters in text (0 als geen match). - Aaneengesloten matches (bv 'NS-trein', 'openbaar vervoer') tellen als 1.""" - spans = sorted((m.start(), m.end()) for m in re.finditer(match_filter, text)) - if not spans: - return 0 - clusters, prev_end = 1, spans[0][1] - for s, e in spans[1:]: - if s - prev_end <= 1: - prev_end = max(prev_end, e) - else: - clusters += 1 - prev_end = e - return clusters + def _fam_score(text: str) -> int: + """Som van gewichten over de OV-families die in text voorkomen (1× per familie).""" + return sum(weight for family, weight in _compiled_match_families if family.search(text)) @staticmethod - def _filter_score(title: str, summary: str, match_filter: str) -> int: - """Score = 2× hits in titel + 1× hits in summary.""" - return 2 * RSSBot._matches_in(title, match_filter) + RSSBot._matches_in(summary, match_filter) + def _filter_passes(title: str, summary: str) -> bool: + """Gewogen familiescore: doorlaten bij 2×(titelscore) + 1×(samenvattingscore) >= drempel. + + 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'. + 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 @staticmethod - def _filter_passes(title: str, summary: str, match_filter: str) -> bool: - """Regel C: doorlaten bij (titel-match >= 1) of (totaalscore >= 3). - - Een OV-trefwoord in de titel is een sterke intentieverklaring (score 2 - volstaat). Treffers alleen in de samenvatting moeten dubbel bevestigd - worden (>= 3): zo houdt 'bussen rijden om' in de lead van een - woningbrand-bericht het artikel niet meer binnen.""" - if RSSBot._matches_in(title, match_filter) >= 1: - return True - return RSSBot._filter_score(title, summary, match_filter) >= 3 + 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), " + "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" + "Doorlaten: `2×(titel-score) + 1×(samenvatting-score) ≥ 3`" + ) async def _send( self, feed: Feed, entry: Entry, sub: Subscription, *, check_filter: bool = True ) -> EventID | None: if check_filter: - match_filter = self.config["match_filter"] - if match_filter: - summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary)) - if not self._filter_passes(entry.title, summary, match_filter): - return None + summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary)) + if not self._filter_passes(entry.title, summary): + return None message = sub.notification_template.safe_substitute( { @@ -222,21 +244,77 @@ class RSSBot(Plugin): self.log.warning(f"Failed to send {entry.id} of {feed.id} to {sub.room_id}: {e}") return None + async def _send_whatsapp(self, feed: Feed, entry: Entry) -> bool: + """Post entry.link naar de WhatsApp-groep via wwebjs-api (met dedup-check). True = nieuw geplaatst.""" + api = self.config.get("whatsapp_api", "") + chat_id = self.config.get("whatsapp_chat_id", "") + if not api or not chat_id: + self.log.debug("WhatsApp forwarding disabled") + return False + link = entry.link + if not link: + self.log.warning(f"No link for {entry.id}, skipping WhatsApp") + return False + if await self.dbm.is_whatsapp_posted(link): + self.log.info(f"{entry.id} already posted to WhatsApp before, skipping") + return False + session = self.config.get("whatsapp_session", "EdGpt") + base = api.rstrip("/") + # Dedup: sla over als de URL al in de doelgroep staat. Eerst de lokale + # tabel (eigen posts; daarna de globale search (vreemde posts), gefilterd + # op chat-id van het gevonden bericht. + + try: + resp = await self.http.post( + f"{base}/client/searchMessages/{session}", + json={"query": link, "options": {"limit": 1}}, + 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 + except Exception as e: + self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {e}") + return False + try: + resp = await self.http.post( + f"{base}/client/sendMessage/{session}", + json={"chatId": chat_id, "content": link, "contentType": "string"}, + timeout=aiohttp.ClientTimeout(total=20), + ) + data = await resp.json() + ok = bool(data.get("success")) + if ok: + try: + await self.dbm.mark_whatsapp_posted(link) + except Exception as e: + self.log.warning(f"Failed to record WhatsApp post {entry.id}: {e}") + self.log.info(f"WhatsApp post {entry.id} to {chat_id}: {'ok' if ok else 'failed: ' + str(data)}") + return ok + except Exception as e: + self.log.warning(f"Failed to send {entry.id} to WhatsApp {chat_id}: {e}") + return False + async def _broadcast( self, feed: Feed, entry: Entry, subscriptions: list[Subscription] ) -> tuple[int, int, int]: """Send entry to all subs. Returns (placed, filtered, failed). - placed = 1 when at least one room received the message + placed = 1 when the entry was posted to at least one channel (Matrix room or newly posted WhatsApp link) filtered = 1 when the global match filter rejected the entry failed = number of subscriptions where sending failed """ self.log.debug(f"Broadcasting {entry.id} of {feed.id}") - match_filter = self.config["match_filter"] - if match_filter: - summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary)) - if not self._filter_passes(entry.title, summary, match_filter): - return 0, 1, 0 + summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary)) + if not self._filter_passes(entry.title, summary): + return 0, 1, 0 spam_sleep = self.config["spam_sleep"] if spam_sleep >= 0: results: list[EventID | None] = [] @@ -248,7 +326,8 @@ class RSSBot(Plugin): *(self._send(feed, entry, sub, check_filter=False) for sub in subscriptions) ) sent = sum(1 for result in results if result is not None) - return (1 if sent else 0, 0, len(results) - sent) + whatsapp_ok = await self._send_whatsapp(feed, entry) + return (1 if (sent or whatsapp_ok) else 0, 0, len(results) - sent) async def _poll_once(self) -> None: subs = await self.dbm.get_feeds() @@ -583,56 +662,16 @@ class RSSBot(Plugin): await evt.reply(f"Subscribed to {feed_info}") @rss.subcommand( - "set-filter", aliases=("f",), help="Set the global match filter: !rss set-filter | !rss set-filter clear" + "set-filter", aliases=("f",), help="Show the fixed global OV match filter." ) - @command.argument("match_filter", "match filter", pass_raw=True, required=False) - async def set_filter(self, evt: MessageEvent, match_filter: str = "") -> None: - if evt.sender in self.config["admins"]: - pass - elif not self.config["allow_filter"]: - await evt.reply("This bot does not allow non-admin users to set the match filter") - return - elif not await self.can_manage(evt): - return - if not match_filter: - # Lege aanroep: nooit het filter wissen. Toon usage + huidige filter. - f = self.config["match_filter"] - if f: - await evt.reply( - f"Usage: `!rss set-filter ` or `!rss set-filter clear`\n\n" - f"**Current filter:**\n```\n{f}\n```" - ) - else: - await evt.reply( - "Usage: `!rss set-filter ` or `!rss set-filter clear`\n\n" - "No filter is currently set (all posts are sent)" - ) - return - if match_filter.strip().lower() in ("clear", "none", "off", "remove"): - self.config["match_filter"] = "" - self.config.save() - await evt.reply("Global filter removed (all posts will be sent)") - return - try: - re.compile(match_filter) - except re.error: - await evt.reply("Filter is not a valid regular expression") - return - self.config["match_filter"] = match_filter - self.config.save() - await evt.reply( - f"Global filter updated: only posts matching `{match_filter}` will be sent" - ) + async def set_filter(self, evt: MessageEvent) -> None: + await evt.reply(self._filter_summary()) @rss.subcommand( "filter", aliases=("show-filter", "get-filter"), help="Show the current global match filter." ) async def command_show_filter(self, evt: MessageEvent) -> None: - f = self.config["match_filter"] - if f: - await evt.reply(f"**Current global filter:**\n```\n{f}\n```") - else: - await evt.reply("No global filter is set (all posts are sent)") + await evt.reply(self._filter_summary()) @rss.subcommand( "unsubscribe", aliases=("u", "unsub"), help="Unsubscribe this room from a feed." @@ -723,15 +762,10 @@ class RSSBot(Plugin): ) async def command_subscriptions(self, evt: MessageEvent) -> None: subscriptions = await self.dbm.get_feeds_by_room(evt.room_id) - match_filter = self.config["match_filter"] if len(subscriptions) == 0: await evt.reply("There are no RSS subscriptions in this room") return - filter_line = ( - f"**Global filter:** `{match_filter}`" - if match_filter - else "**Global filter:** none (all posts are sent)" - ) + filter_line = self._filter_summary().replace("\n", " \n") await evt.reply( "**Subscriptions in this room:**\n\n" + "\n".join( diff --git a/buzzrss/db.py b/buzzrss/db.py index b043edb..1c10433 100644 --- a/buzzrss/db.py +++ b/buzzrss/db.py @@ -232,3 +232,10 @@ class DBManager: async def set_send_notice(self, feed_id: int, room_id: RoomID, send_notice: bool) -> None: q = "UPDATE subscription SET send_notice=$3 WHERE feed_id=$1 AND room_id=$2" await self.db.execute(q, feed_id, room_id, send_notice) + + async def is_whatsapp_posted(self, link: str) -> bool: + row = await self.db.fetchrow("SELECT 1 FROM whatsapp_posted WHERE link = $1", link) + return row is not None + + async def mark_whatsapp_posted(self, link: str) -> None: + await self.db.execute("INSERT INTO whatsapp_posted (link) VALUES ($1) ON CONFLICT DO NOTHING", link) diff --git a/buzzrss/migrations.py b/buzzrss/migrations.py index 9432cf9..a31a8da 100644 --- a/buzzrss/migrations.py +++ b/buzzrss/migrations.py @@ -105,3 +105,15 @@ async def upgrade_v8(conn: Connection) -> None: async def upgrade_v9(conn: Connection) -> None: await conn.execute("ALTER TABLE feed ADD COLUMN etag TEXT DEFAULT ''") await conn.execute("ALTER TABLE feed ADD COLUMN last_modified TEXT DEFAULT ''") + + +@upgrade_table.register(description="Add WhatsApp posted-link dedup table") +async def upgrade_v10(conn: Connection, scheme: Scheme) -> None: + default = "now()" if scheme == Scheme.POSTGRES else "CURRENT_TIMESTAMP" + await conn.execute( + f"CREATE TABLE IF NOT EXISTS whatsapp_posted (" + f"link TEXT PRIMARY KEY, " + f"posted_at {'TIMESTAMP WITH TIME ZONE' if scheme == Scheme.POSTGRES else 'TIMESTAMP'} " + f"NOT NULL DEFAULT {default}" + f")" + ) diff --git a/maubot.yaml b/maubot.yaml index 677eca2..9ebea5d 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.5.4 +version: 1.7.0 modules: - buzzrss main_class: RSSBot From 6264e73441a87525ce749b7eaa9d6d2172e99f30 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Wed, 9 Sep 2026 23:39:05 +0200 Subject: [PATCH 2/4] v1.8.0: titel-dedup over feeds (zelfde artikel, andere url) --- .gitignore | 2 ++ base-config.yaml | 4 +++ buzzrss/__pycache__/bot.cpython-314.pyc | Bin 54431 -> 0 bytes buzzrss/bot.py | 34 +++++++++++++++++++++--- buzzrss/db.py | 30 +++++++++++++++++++++ buzzrss/migrations.py | 10 +++++++ maubot.yaml | 2 +- 7 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 .gitignore delete mode 100644 buzzrss/__pycache__/bot.cpython-314.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/base-config.yaml b/base-config.yaml index 9f4b52b..a45943d 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -23,6 +23,10 @@ whatsapp_session: EdGpt whatsapp_chat_id: 120363429643087130@g.us # Exclude paywalled articles (Telegraaf/NHD markeren dit met true) exclude_paywalled: true +# Titel-dedup over feeds: skip een artikel waarvan de genormaliseerde titel al is gepost +# binnen X dagen, ook als de feed/URL anders is (zelfde uitgever, meerdere sites). +# 0 = uit. +title_dedup_days: 30 # OV-matchfilter: VAST in code (gewogen families), niet via config te wijzigen. # Doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= 3. Zie de families in # buzzrss/bot.py (MATCH_FILTER_FAMILIES). `!rss set-filter` en `!rss filter` diff --git a/buzzrss/__pycache__/bot.cpython-314.pyc b/buzzrss/__pycache__/bot.cpython-314.pyc deleted file mode 100644 index 19c4c36eb526d118910ec23ed4afb3e8ed0aa93b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54431 zcmd443v?UTl_2;ERrmu02#^4OAVKj9ekF<$CF*B>MM@M!6)DKFM8P0YBw>R9TvgDA zVJ8|V-Ag5zv6Q4Ex;wj~yWL}ICUZ>pWXAGLW+T~gqHKDn0f92$YW2jO&7PUw+1;UR zpYdmVPw%;}Pz8W0N_NtH&ORPiJygAVpQ`un|K8VJU^XCx&_i-l%59@~Xyne{Q z8-{ee4qwv@8+qe7L~Djkya}JpycwSh_yT;k@D_Zw@>YB<xN5*%J{OOa=sk@)(=+!5C0{vI#a9ivdG}B?Up=&q--hoS zhHHju`C6Pe4%ZFU^Yudwd_xsFN6@C>#vu>y8EWF2hMM{2p%%UcUpEi84z=-ZRcNwu z?40zvvZ)%;1(TIy&t|VkX>3Z$WfN`bQi;F#4%*7^EQaR`ortzg)_0PbJDvEd-HGU; z$+EHMvQJA%F1Z_*EOsK=G3k)6cF~#|}Kin@EvbQ{hU^*F{W&HtW!ao&aJSxG` z>*ts;HgfS&UnD#eoE2>1`D7GEJj8^e4C|o;eShyUf7n0Eu!4FZ%(IsSYLH<9f@wT9 z#Z9xpD7>$mgkX+|)%$p;f1%*wrG>V6e{3qkx3L_&Pc8484*5CGUri#^cqr&OJJooo zJIpQmSvGjyzxcvbY++$>IuZ^u7cT}Q;l=5T7Z-WfALatg`Nij%NGQlHvJAgCdwyz> znc^1b`1wWdQkai%i&HEUo?VQ!M$Rw#L!sHo;!G%baSCpP{ILsRhF@e`;dtI33NgV@ z@VW5f1wYTQP=txG5svq>i~O8FbctJJ{ow$|`va^$7+Rb?A6tx^_ne(t#Jz>`JnNrd zoM(78;%Pk8J?9Vc%pyK=i@0JqFyTc`oJrD|TRhLO&od0W2>sxA|MS7{>>|g6J%@1R zJj(>bjfc9Kuw0PinNZ`Q?hurbpNNEKnNaNcMV1LJ1ZNqR!?)lY$Sq}RY&_J>MI#aR z0yDdaGgE$lSj^#m;%ic;q>_>I3_I(Gp24FWA7P=JQHEtB;mnC!ghEhj<_v>nf?=uf zg&@ZjO zIl~Tyr{|dI=b7+gEXYgQXe7+z0~1~h1!w1Y{1u~2IKYJ9<8gB_o)0loF)rKW`PS*V zVDx#vpXC>`xybpb|I%XQd@Gz-CdftENFX*1-!2wlV4>w+%ygHVVlD)q!`~WupZOJu zUhwdS`{>N7!a@}_y^1IF*YnvqmeLk6JVxhCc@79c@EmxCKSLDW;w1MR(FO zh>@_D7?Z}BlW5eLxmvYV3F3(*R8K5HKCuM##1iT!me4%0g!YLgbWbdye_{zkwghdg zK^~J}iD7)<3x;`yJ?{?*#(Dom-;{s)`N+(S;D|;-Az#$b0^kh=xp`kGI3MH%0~htr z`?wIpM1_J3`twCuW+r%1C=N&X;7o8DLp2|tL9~L&9|}b-_-2A3o?(ST=HhfH7GQi) z|D_91c|b7D`}yfPsep-%g?*vOEc|8!jXy9S40EF%LZGH2Q9$ez$Ao6sT9|+^x9aOz z56p3WAmS5;Bv>;OR=$E~>$mW1P3sC?GF~ypi|W>O^_d@6kFD#r$v0Zob*<8kk#$|Q ze4}<D#kOM&xb1( zn}D~&R!fJc;Juj;LVtihQArC|EC^-tz?V!j4Ry@bYPl*NaJD$#vz!M+bz8QN2Av6wOr!4bzbn3c?a9Fl)z-jdm{w4M_7cD_gjewSZC{DH~ zprv!D1zO0YG3k@$q|Qt}N_t6Jy%!-Apa5CZM3g#K>?LVU&NWq(I%YhZS!)Po5z`#u zCCPg&a};OrSCVkinyySX={(0+S3-> z_jX?8zq|M9zPP1o#U8iR#&xwKC= z)r;Sf_~@Q`6GT17leLH@CMjA4z)2(tCn;Kue`zKuT8neKNs89voPpQS#z~4cWqxUC z^CU$V;9r(Winiih;Uq=dAUCNKIc|W-(){H0lRA;R&bw-$of(*3BEoduNS8VhU54Sg zi7s~{x&r6Tv@27xGE=gEu5u#Ujqh3}t#q{$(cAKBFPyZ}HBLm==3TW-TIo6`qU-an z+UW+mv7MkjPDD4sosfzzqMMzFZh>EXG2QA!bQ{h)=yoTfx8uB%-r+=a2hNw!U!!+A z5#0&DLIht*e~sSdMD*^=@3P5qy32{^J@8w8GZpk++CX>HhISRb&xz>$xU`CQP3q_a zPDCHfD_uFMqYpU|{Z!u7s!1K)<3x0C-c|RcmF{yQxn7{R0EBN!)rdakMD!@#NFVRYKr4F8iRcq~wKh&R zj=>7ulxUj1OpntO(vy?*V-uT-AOvl6(_l`#sZVm> zwr|n5DE$ZY3%^C*c2pyprMb=w4yJi0qGONkLC2((KJP^Ih1^p7&Mo?Nk^Ud(OWA(W z3r<9TCGW}3$z5Y1rCB`LH5PsJy2L!ui%vxUUS3Um>96wLn7Z9pgFH)uh8`d9jqq$K zU=}hkH9yD>1Nztnu|47*S!l^G0%m?3{S5Xu%b;evDG#f4&j zFfzyU(YC%&kO}kS3eo9wz#vB%gIThFbed9 zF9gy~-?SfOJ-GxTe10s~s1V66MY%>h0rV~=4n>l%n7P?BX}cBjbaB z(%4 zO=C%|NOlX(Om{ZlfO6jYSO?5Mpoq32oMQ-~d1%}&zYiC@ z2iMA3e9Cr2N>6$ntP9$_Akk5oS5X8b4ovy-B+f#4&gM8@9YAC7UN$8OPy-$e!q zaaCE^V*p3hAm`_W(n#3HT;v%x><>vxN)-8s;1*Oq{wY7lurQki%`^sU0_l$i1uZ8+ zHod&qi%^FtGkI|b z@qB?P`|01J%jiLY{mSf>`ENy1I#*ohO6yE1U424VzpiV1V6Rx7xH0+iWYXTeM15PA zE-JZx^xDzu$FChv6*VV{;G#ZVU9&{3>nb0Vx>Kbs_extD~phi5j5dN zVY&QSqf}nGue2(c`3%jzOQ8zUB9hwPs;1^fH;iM&P3XjUiBg8u9Z&fsbSOSX815Jef%hEr^p?WY*cQ!*lM; zB;Im);ZF_|`!Y)FOdvl^>1q;C}UBTvz$Q-s5q4e0}fuw-2XG zu6rg|ys~rMv@3pka)U&A^S28=)}h9h)&AFy-aH!LbM$`8NV4JBx2dIxZyDBgwRn+~ zCv@eKfb<`8BEtffQJziR^$-p#6h1vVkpZ6Ki5yqKd&p7%V;M3wH(}@}PpNEDnv5ny zqYhbLvE@`$fc8poU7CDWG}wkLk5*Bt9!Ln4opTDGz5i}W9c~wBMK$^vRE?6Qt?_9<`*;I|ZSQ;!DAmm~$FB-FD zOG;@Bi1bYa8AB`I9|QUK zrldUfZ0|Af(Jg4(*jP9enH^=j04AW%)iHjibr0A~FyZM)AQ+xqaMm^YxoNPG@Nn+B z#(B|3!ov-EY=Sn!b_n`eh8Nirfeb}vMd>r3Mu7?iIbP6Z*bh!Hv&?+tyu_mj>giB~ zW7rn}p1lO+jX4~QVB#2l5GyS~jik?JbsWQEg9?Ef9vwU`=%Z{T>Yw%Vj9~Bw0vQg- zLt->8Xfv(jQF0&+sm};5+3!M|#Ti(b4?i)e_P_7nx;M>l zm{-4cYc}3Gm@GdGD7)l=vwXQfRnd~DXh~JuE6&$)Z)aN1-|nJN;dij=7-VQO0K zS~u-Xo9r(gyK?NG9e=1sC0!dDsS1OVkuU<$s?77#lC%%_XHjJ<9 zCh+}|gsvo|t4`>uUu}4;<&~CoU30|dQZ1I3l_gL z`TfO+i|^;cWJ+EC5Dv@z_>^s2O7QtGy&cCIl;4WpR)W$p;?AZlvQ}Ps60{1qGH^}_ zBFLmgHf8Gt>Ni6Hk7g-gjYt7g93P2ddQ{L%`@_>rNL(i?uvkE|nMay}9z!t7q-i9I zUn`K(Ih1MWTj7)rcq%~Y1y>4EI#)vHTGv&jjh2+LB4wS&A`lYiNI85(X$t2g>1#a7nPt9=#*NO(_+Dx5Xi&fnGoT7hC}R6D8Czyku04Yb zw{%wKvYuF2mvRxp;GI6iL}j;vTaM)wk2(~DO2sbsrJ(c7Dvib zm$1}*j0k;O+GKrc=*rMbqgO`bjO&(+~_4TcT%RxgsC!K^;F8#vq8d*zvDnf z^CHo6i1>!xjjj`w)H3BpKT!1?BK{}BjaIhz>?iM7$(}>R9lH+l6%}yYgRkr(Aip2- zd2}jp99XagMhcg=HrT=BN;_GDJerK+nrANZqWW_;ENsw$3S=>peBmBCidE&JlaM5egJwmQRM- zrv{}yMX94z2zkkKL`%*(iINkVD0Q?&q+z}A0my|bJ^nB(0lz=YFyUE-3q^P)?8b`A z*6C1;;~AE72g7doM()P#Zhwe#&oWXGPn)4f;Yo&bH%^^*H;j(AVoSk>7I(vDTe$|0 zn`c5HCXB1x(T3-elmI>L1miC-z!ibl1RAVG=!L?^0WdG|IG%c^>LR+Vu0luaGh{rf zL0Oce5=9#%6ApM(f~sx1K+NYdje>svEE8rfM%jZxokT9-_r!3%V7P66C^GF2aR=Mv zVyn{Zgw-5(oJ{PRSR-y|U)KJyer3mNyI$G#r(O4*&B|X&k9DZfnQ)x^5&hEztoeqSpVtb@(b%0_k;4v<%`Mk z=I`h}u$C+#T;+ch3cRaYYAtCgg5*@5s59c~fm36$^q7_J0LYAjtxu$tI*ZC-M)H1)4cy z!sg?k4WUSyP@?6Vc=bo{CY$l#H#Epv_`TgJdtJg_x56at-S;isKlY{$98VlL9v?fE zJTP`5mbH<&(mOF*tJ{JmB8paYts{jeLan`$PD{!quIgHiKb+3A>Mb0j|$n0Ly4V z4gtn3&ZZ#e2HXHx<9WsnMwVed$bi{)$Pc#J?nbe)4#W9Kh~xc!-qYruV40a2Kr>(% z$%Hc%a{hTHeBRIV@YsBWfvyH(Q&UXHJ;j`7I6gQVFkFbR0VeEj#GUJQFGSpPkywCt zH%!GijtRTj;B#<4GT)G?HN^M>?(_aIbklGl5)OuEr@(~1b&6qw({sFgj^W*YmJdG9 zgxujE!@K7hhILN`!(k@eHsK+&>w;Z}1(AfA0Si)I5CziI2O~9x%ktKl6(L@J3!du` zp{x~|Y$>BFVRS7sNn^*Ff8E&izTNr2Qk=5XBrG*618XBmOV8~S3CmzyH;8AIV8TY^ znMx<}C@~-vFysVS@EA%gV?;>nXgzIcSJxnqF+-67^~-`DLr^o|MZtnpNFce`6boug zR)7OuIoM#WAmad%(ab(^IlT*m2d)F?wlbjG%G4$4qLK{~&eiqexpl929vQ>u5A@E5 z>N0hPl#>`(z!tz@VupBxl&A$SC9|gIGpwn!I&#t`X}a30mY+IX6iHz`TWQzjZUUZlj-cvVU&t5b~hywu-}xR9(tUQ$V+H3mw~O^K$w zgp%NrR(lDhR%wlwP!d7XS}&ob3~Q&(%EH7km^Padh%Tk^_sN=xsMbp=i3z2V1`6?) z0j~g@#=y&9Q#xkJxex5frWDm-Ru~M#Hl;DJ8{U+>glOXgvBe$Jgkpb9h&E5? zO{gQsu-=7eGO9nMhj}CBf;x!f;rufU^n>Gre2ixV5_+nW zJ4QjD1PbiN1&yBrgAc(7?fbxzf0|)IW-e$(k53Hu4G7f4z|#}#@53cCE;R3FpASSX zgas`diOl)Q3{~F;Aa7)2&!PfV-Pi{58&=!!vXggLr%=*Lf-{#Mw-n2?@;0| z04@Tws0Z`p9GYG2gSKV8EB=Ln=wU(>7JQ;Xrjm4d)s1~G?_2rm{qo&QBfqkgKPaob z(fUWNKUBT0`4^higZxa*p0} zj=o=B^?|K8Wou5@np3uY3ERF8tYypnH-=swN?R+I12>*~`MI>UYNhBk*DJ2HwS0Nv z#_5+&Z|F7Vf{zWz?!4aoo#tgGX{+5ZYRm;6kvh9!NxNY~o|e}wuUl3v-)m0UYVX-< ze|#o&;8^0ou_f(>N~14Hm%470zg(Uw?MRe%q^lbfcTz&2qb!*}$r`|esYxwrW-I~8_ z{zda$SKRw_{PZ*Nr_aP)XV*=We{FaEKO1V5zU1#XFd}@hX0Vp{=Y6KZTH>9Z?!j8( zr$x;J8uZgc!~jLTi^u^DdY2>yYl(L$0`eN;U^V$}VUK#Sg8Z37GgwRftU^6lP5!K! z9IVZkk9<*nM#jpRpV5m(KzJ4bLKdeIM#pBDkC0RcF=8*p@Y21j>iaCCRNYZ!2I_Xm0x;O1_T;FWMQB$&-#N67q< ziUiwVKjBx2{QUSA)``=^xJn8Cnh>oTCzS7W{5Q^$1v}54zuqllzDh@s#3 z<=0V#jvg1~HOCrrYEe)m0zKg+yl5v$8^A8xOJ;?0)rfke=BV~8$PRI$jZWm%X3I-G zkLta8={<|Chv$f6#5r^hot2f2UfG$=tIrkF?IbZ3@C_{*324#CUMz+-FZ|bWz;&3c z21Mr%h1{@^+uSsESLED{(U5=^S}@Eg=H`v$KrF`VZ(yCmyP%X(|!)KaqNEv2!^rW2ap4Df(5!~@FsyV<7l1~ z5LO_T0s>^-Ps(z@GEni4Q%aJ+adKScC32mIGBUr4jLc6Z%KSX!APbt!Q4hg>4n~4% z_9xI8jH5BO#t;`Ai9}Bi_vOnO3#dDQw`(Oa$CNZa1p(lv@H8+;SpO6+Jo}6J+uYUK z>rL01mM2zrt=8NcxZ0Gm^~PLau)xyR^GesUg zOU7n^4xjq|OusTZA2`ac&wpqB)!x?z|738rdyT!d>n8`^I*_a#PCAY(9ZuVwDSLgwUcVB1 z?JKW*W$kdve&nA0NZMI>wIE$wdi~6`GbNYc@svbNu|wtuKbC3PR` zkgN8_=pT*#Czy)K!m|%al?$jUXTj28Ag1gI_~=PnPtww}ME$C`^cu1J!bSy_=P zwI^dLRn~@2x)&wAgcY5nVyZS2Qw=sC^g_Rq`IJ|+$0nH^9W4g)S4C4PidJ=Hr1PVy zqaG2~MAcEst7t^4D03jsg8CdMuk1)}LNwJOd-_2fm#F3(ifYfHsO}t!>d&F5;faQ~ za&1;LKn}Jb6g7H{n^oFaoi?&Xe-#XOX|iAtsQAP)cvvglL`DM57}_ zJ{Rsa%{-6ig<+Gz&Y;{v#^UpMW-iIbQQ0&m(>2*d>$e(7)(meet5Zb-D+*>SZIHZt zHoxgAbo5<>ye4_>{~Y&M@tvI`WHAXsC~Ed1C8-T<%%x+`?a$SYc+I)Lq6KHAX3?n+ z6zei=U6-Uui55I=-FXXg`z)_Q%NBD>>5G`R`XY3J*No?p#e_O!%J|ECom0oW7Ku-I z^vetoN56~E7T-d&C;tEa>?%iIlNFt`##HS9+pGvhtzN6rTJ>6|R7Y(YXjK@sPAGGp z;N89{O(?GYi9%_HJo;uQlp&!KWU(sOZbEcn7Di4KN;AuZXj{&8Q_gjJhs=Rgp$Uyr zsXRjf%~@I2I8itTLbFXt!XHu_QQL%V41BpZCA!Fo=wb;cVB?=nB_NG~Y2~IgVUy}s zc!xGC8axc&a{`Tl744=pK~2EB1KNC3+G@sMAQ6h%qeao;3GIYE?_FpobQ48+S9BBl ziQ>E~`lG|*>=_`Bwu`w(ajlok<^7@#Wh|flHd%b-)p)gDomcNQcpdq8UyBB)k!>+m z6^f~%R1kiXBhy+mpc;8Yiw201|BR1InG)6@a-*}LF2_lO0&M_u@pn$Zw9pssL4k}(yL)0+$g4#Xj z54aZ!-C^beW`%=b+5!c%ZcvVfJb4T&5U_SITM-p|%EY5r(RLmJ8;IUNDuIHKW|)t< zNnmC)$8=24?GO3qrvm*jPo@Xw3Dn;KRFk%*+fCMX!MgtC(lJx|(nE-Jf3p6NeClC?}c=i&+%i$RO zm?#COg5jA6=GjD!&gAEO(~k+^L074x$0Rr|`xKP_7^tq#T= zJJ)rc>9X=AOWIkoWJ;HoEft74xc0zama;e8vp1}&Q}(u{zVx=b8^c$QE%oOZMy74$ z%l$VFzkGOk@5<3NDz*Ko#P+A+?LD{cwkk$FkuYF*xTG?^mT6@)kIro}`y=KL~Zf^iLwxMf7*GI37#_PIob^fI5 zt*%?1cuoI$(LlPa@L|NM^f4lJR3-PkHWZ8*S(TRA`iTAA~zp|I5E4QU8 z+Y*&+YlZhKJFXhjH4U%rdu3m|{i*vkJ-2)B*9_h*zh5&Ex4IwH)W;jTep3Ec`R&R3 zd&lG6r|<85I$3iXZn@mA7X7Is-qd}+x;yFGch!)#7G2j}(}AEMUfn6lDsD~OZocih zI~}Lb#!pPftIn-keQ8J8^|@$#+3IAtA%5#w+_bbf7zGyw;eqc@j3yYVGR{HyhUWC2faNrbBVlp}gf*U(?s@1FW)#odCqcQS50_c5t57i?&dx#9=hO4lDR z-ZwQb9e>c&w)&M+drzXhC)wVYZ0cVcO`FP6rmBRgDp5NWH&v~hhSN5O*p>Q(t$yXJ zN!#v}X?NVT`>#qHuBy{JcD-5kMpe3~WVvpoC{?{PQN8mcQq^Jm1gTnV32Wn3&9ZK# zenW!_T`QXV*7~caw6$`j@xHa`YC+msfv%03_itpR& zmvq=+0qoKrm@HS--#U^um8VT5X;T?KmZwcc|J|a~8$P^5AZycSADU3%8REktvPk$4F%^&3wI-bz8!^EnauvR^TUdZ_V9$CSEhN zZXNzBYsrRMWiI{vLk%)NMLaA(=B|g7s>Svh2h!RXD`^+8LYz=huNY|;@vpjR7jdV- zc0x_vIp{c{rrs{Zzt(#4nA+3A^T(CJOR})QNJ{FN+-~9GYJ?t03R%pd9_iK2j9!lI}>k2j$KV`eZuRM8q*MQdr5XiP&J+jS6cE@O-cs&Eb?P(d$a8F1kV zVL>~)6Yvxe#DZSNBkV5tt)2-*{JdzX2IdM+G_C0eg#S#&w5Cwq_z(^sSZo{WLUrSY z1{FCsw2;#wyJJHSIRoU3kTapeq75_T3ZR??a#mz7*(ijZ4H*g^+6&c zuMT?&!aTW*XyheK;3cO56`hwfA)543Ams6?O(0UyVDX8T)|wHm>##(1%9N6ln2pxs zx(%79jb7a&EOM^;F_R=|K%g=sG*Kv{)S5TPSOOGm@b57%*x6XvEs-=LEqh?W=$1e2 zfdzwG`nZsn@b-ZPom<4*P+b1hP((uH2NuY-jv4lsK%N#32E##ajtR(K2~ZczB_LLD z8xW0?>voTNNKpdsf|&$vXCA$1ey|rVbi-jE9ORkC?uVavj)6f0!vRqTW(SZmZnEVt z1EshPo9RJ3(ZIvzGTLw;%eIxe73ZpPZFp_pt>^BR+%;?Np z-#UHk=msK&@Z}Zz)vvCc0D#koePlNbIru+b;#sX*b*>q1?YZ4|tL?5KK7J}rKOJ|R zUe`T?J$1@c#`3taJjc5X6DGCqnHs=X=XVL2@WrIdr(mH;4mk$(0PzqXS31S>qta!V za#*_js_c;SC8oX1i&vp?(UC{N@KfU34?7n!8VEXOV71`2l0xlTTF`3^%>GOsH&kdu^b>#{NDs*nqfd`Gf; z0(~v!Vbu#c9z%Yx#vf(hfQ4C#J%5xuhA`Xd(dVeccEdUY$07D#!vPx$VU9)(H^AeQ zeG?QF@H)kaA?%+6?;u;|XWfJFlmp*4&`N>-T1F=ocQ&r;Jm~`4hDu|8iU2w+axIc{ zG^DHzacjfJCS)(W-uj)^73wwPE5>-sf&1=*w{7>`eMwt?%G7_))c??o3L8Jgq_iht z_W-fr$R+ivwy#@vq^-r6kRYH9k+{rI5;X_kvmV+Y;l}5GW9t8m1JlqKU42$!#op6_ z{x#9Fo%#{g(}8a7=(7@cJVajsb*D)K`Rznc2fEX-x6eqv-BQtKB;V1g`mDq|1_JU% zs?VB_m|+!R#Qa6V0^bzlD`Gb9b0cQy#qdHHp?+Q^8pgo&7B{w@!6CyZGliaPBLgIZ z91mEn?tBP`)$$EE#h>!wpCTXt$$*q#DPTa*@dQA?3}>*F^kh!jNBGE?Dn|NL@}egc z;;JYtp{&RIX3<-WjHze@f3hG=wYkn309s$FR%`?@q(vh;v}k0T8#NkPo1}7nR(ZH3fkUX}np+RMp zsnXU&Y3phrS-LZ2-x)XUe5gew_3z8Ypo-5QYEV(0dc8BzF~rs*do+~%1bC=pv*H0_SrzPcH{<_R>UA)f~F+t z_hCew2YY#k0C|l^1P7HxRMcg%@;d!qpnDXj`3Lt9`TmjrRDC-rLn6 z^%)=}u}@j$jjETcRs+jb^3qP&cYcaJY*lhoVt9jiRaV9_e!J-BC3i}08}4qqd*W_i z+%>jtI+3k(t{~Nhm4o=CO#EUF5iD1ML!^nU?-Ou|9i$lZZozPpW7yW7StiUcXvHl# z+83(a%@CfC>uztK4f1obskZ6Je0%1uxM4?u_)FTVWHV7E6k-Xr4KBT>BV7+vlga#o&e#CdP8Gt zU+fJndM=994Z`|?Iu{HaF9kdlr2t^@{Y1$86&Qf%hy4kvQSW*PhqQCs1_|fw-T3^0 zzVV^DM!hTlYr*W1DU=W;8^RDX)tDMl&6wbv>n|;_0&~P!nKGaY!JirbkzE;9B}yE# zcvZ~i=VepfIf&b%z?p*?ixObZsH*#LzZ3zy%=cTjE(5`;LA2r}ln}uqH7Fnp2yKmP zP>fBaXi7sUDdfE5Svg*hlId?XI#)z%C4@ukwlci{V`fE19sQoC5elfqcR^#Flb2F* z-BIc@ZJ=ex)F_z6D8#>0e9oBj9Jmn4PE*;G`I7y(XR;I4tRDZ?$lst7exLHjR9+$* zy39*tqyAw+LUuaJCZ(uEr;5EAna+JQCZXc^Xmc(@JO;L%n-X0ht%iJW|1oJ0IZ2}2 zqgt<)w!pWU)!`9qX)FFxlDU|IQ_`uTt&9*bg|495ecTgRf$P$uk*~VZS=qE&g90tk zAIzZ34p-TvWEMo5wjxV%&5T@Vz$3v?9|dxj;YIbSHWT?)FpHsYC&bYR8rVuB#<5RB zZ8(Ue71Xg9D`q;Lb#fAod?82)1gK<#Q3#QOHNenCP&48mfRuGP9#EJLga!nPWwPXITNl^Y=6F1@qi{=(kRofCF=G25~v9K6!02Lwcr*${!S!r;0rHiacwo^`e~{TI8tt2vH8hCy25dmZ%K_GFe~RcV*vl z&AQR`f!(=WbhSHfazXoxR~A#2s)VI#MYF0(TAG)rbVaI5 z!1mJFD`%J6?wdU+b9=(vzD6d^oogrVo4Zox0}1nir1{X2Ds42!3v1SmH7jhqwST># zKV9ldm3k7Tp4Hl9>8_N0SKPGggHjhJl#7z3ohf@~+|>D@hq4+TmZ8Fh-37!;=6hvj z{i`KpCt=aq&GqFHktD6oMbml20;v>^47SpdTJREKnLyd1v@Wl-Y@2=Bp5OZ%%j-&n zUU*80zxNVKAsW4u)SFS&QMYJu1{PpoB&0N-;Rx^cyTxr(vZWhELn!$^$VNRmcozJ21oK>E9yYX^ zWxy5KqgtqxSTsNcfIQkF>}-3c<@Oy5?#JJ6+u< zNf&ge)OADuvi^^aDO(NT4cpbd_l>R%MAjQVMx@E`u@2c?ao7H&?Lgdg;DM!dxnZT@ zep%BG`%{)3_bfXw0-}?)@wjO`U0ktz>V9$E67_9Ex}@rc@nz%6ku_DaWakp~ZF9P) z96}?nSk`RGqD~wa*2BJ)5+pz62qr&t5c5Vgrj-2|MkN+53ikk&#^Btd^%D5 zOx*HJT=&dFC$iUm_Mrng&Jdq*;35BFQJ;Mfp-0a?xy6_0&Fc6`Wr0=tG)AzGybbR^&%RiBl^LU>jAZldz&HCyykHaUK@)d@{h zn>BPjXa8LIhJN!Cuv<*29VZ(5vwc($fAIqDh&!Wifzgt#1V=B({=(twWMSt`EeoPC@j@}= zveA=(KN0lu9)KPb3xG&4&#;mCAQusLrxc-|?kICfj4#*#bpT2d$QVB@0>A)V!_kbe zC%DZQmbU{(HFl#WrP zzU_g%JZ1OXvwK$S;@h9P-_(<|_b&B=^^@be{+fQdVWlH!tzS}qU@ClR_{#9rVAAAH znVyZCo?RJuZTOYp)o`-*fOz3{@y|=|l-@mC{(P$RwdV+|@O zyjpyf|HJa6*|nrfTZ-_8TE$6ARmxHqx74k4EKv_EMd_mQ)uP*n(~i=Vqb1>JS)E8a zIzCpDAh{#;hEG2(M1|!)7>Z|tC_b>4U*G?o{jcg?GreL;+FQTb50MhvKF6lBFS`1i z=pE;-ehc~20;1nS{%(+xeI7m@5HgR`t-J#-S(4ycVP^`f+J$L3I0GqXRl6`HXH{pm zs*s7ROmsu6V=T&s#QBnh98Gu$B}my$cop68`|y6O?L;3DrLwE`w^^UnIorN@DVmaE zTSI^zCG!P|C=7G~bT})kQM_s;;wqPJ;a%Q6l= zx~TqCiAX395F<>6RMrMjCZ`WdMDqgdmsEO1u8!NDUF&>v{~P<`wrAr#OBS_a(tKbnHSqMkfv1zU(}{tnZw;jE4GDY0D)qYdrgp9PJ$qMT z;OQl8+;%!`EJzvMDWf}XbicpNlXM(-rD6HRo8|we{Q3)5UwEZq>2Ta}AZ@WP{a##G z@nJcp;fs*{8RD~FI}Uut!OpiY77gl%?-K+2sOvpdgF50)S8v^*j`(Rs?Z83mT~sot zBi=2s50cco+cj|9t$`~C4THLT0{=_d(Rea;G%%$_vIEobH~<_h$HyVE_uzwzTE9;a z39-#db^VHc15T?&_*cFH3KF0)OuUO$J~Wz8ATwzKoWp96N3}q;N4a)!*=72JDVrRd zs>?7POC^QhBjnP3=p}e2Uq)c=?^v$A(X`yOvMph$i|aC*r$TKRqqi?F3zF1(m>$Ef zG?*xZ&+sJ}q6g4Ti6JTnhNxU^Sm8F{w6+tUZtcPsA7zMSBi-+dTX{%BK@&M@J1-aj z5t3RYhqRAo2ydCl!0J7vk~n%!RSpTSr0}UmG^rF6Jg(Pxu15?a4!2<3jg zk<8bV*}c5l4ZmaUIQT#IPF9E=l-E+uNTqSm#w_FKF=4i3R0K4z;bsmC_6LA&mI2pl zqbxHYjLoyKtFE96M5Df_KNw&^Ip8sgERlAW;cd;2G(boDp(GT=|rMNV)v z`b|a6%D`&ZT5qzV>jBOmxV0@=abQDDmX>`)s*4?;7?9I-ZD#q@cOxH}wMDj13Xr|x zn&-RC9~5T1uU1Yb3!5LX-<`7WPuTY-?FVnyC+tI?sIx^kbg0Og!gi-WY*?$i<+yE2HuWd%10U}w z19q(}V`z~Fe}60VwF%+^?=7JVrR~sh7_`rG(U)G`1V(L5%Q((&)5=Bs>rwQT(G=)E z^5XCPWZ6@W2z*meQfZk;XDl6&1=QIfW!b$Gc9NRR#@k>;d5n_e8(*du6DVJZFSRb6 zfBRnKNN!~QCY!2J)?7}>LL%F~Mmb0*514!e551&IofGjv`g_d31(Bx-O7HqsPBany`$`X+u)~^-E zyN2)YIFf8SnzWC^O(UN^u$KJ5^XfBk%XZkNq40f^6=V&CpFjHK+V9=pF_3H;Oxh2} zO^3I9@HZCcON-xJ{K3$Tk(Wo37EfH~!8F2)b&Y7%(L<1T5CKPZT~9N*)9mP_$hS$N zm&%u8{8lRp_D*1di^ep}LYt_(`l5TKK5_A>RS0q7KB=D|le+vN9MVqL25C~4f1vMr zs5Ys~#pRdFO2hKN!jJ!1)?#gCVd~A(RY9Bpwg!Z`S*(>^ni6)ECLzSk5qG#h)d>tI zZbQM(L5VW)5|57`C^7a5`ZYz7$`jjA@c#z^_SPmuU*rwYWNxT{Q=L0WWsepw0p?NS zTgvqc$pus+HzYu%MI$jS8tKEjEozhn!ZpKT1X}>b>+w#OIS%Qtp2Zlg7O>(2a|Xu8 zoCoh~5C#c_^zq}zd}GH?4bZ-k0q?-bxM0MW`sjh4i2;vVG*p1uENGaE(I9(CAfq8p z(1?3vX7ok!77BQG+njBF^LMw+y@6-&+mZ%Jqt+Lux7DY%?MZCgbIW;uTTjANwKTZw zNLz}3K>ncrj|czQxNfOQJIk(qC2rleK_YAIh7J{%f-)#o)Sf76U(^1$G_`jkv3DX_ zbTUnol~j8sU^*W=C&X5|ML95+;_`y zzxmK@d%WowY<*Rk&{Zy9Naz~U7TeNS;<~a=KQ356&ppECEf=7CF)}`p&O?CVV^h_S)!J` zgy%JE+9JBI6ui`ZrF*#uLMg92eZRDA-PrcN-I=bcNmX?wsyf%k-#qiinPgQ@sh6<_yU^Q5h(KO$9j2q$RH1ZmcwqSE&*_J_r&te1FbQke@r!{IPr z>>+y(5O>r>?_TPTrl+a*0P*&AqIVzl_6|Md_Yu7ZL}`_j0J~#(khB+!(h|&HY_RtU z3vpOHgtC{N6F!I~BN39~rPKhB0O9y$v|M|m`Q_$#{qFb5#EnrG?8l_}6y#v{Soc(f z_mFt2M}eASLQ%oSGB3n}EDk?D9hsl^hp~5&EH=uFgZSu4o40#B0 zhyoiRVwEYDU49{F`W6^#R@~MNlC|!Y{UB%SUYo|JTLFAgeA}{cELQ_(QcG`JdH21o z{qgdlDts$tETiNKoE2LskdUNYGZyP%!c{h7$|kRhCM0X~&9z|6GcchT%ScJcHjxYM z^Y(v_l3UawW1MU{J+4T|${1~PFRRe8r@W++7BFLRz8&kGlt-Z;U^}%_xh|U8ijW3e zro1Hwj)6cb+3DlZf25LqJ%o_#&|+;`F2j>aN@3=d+x8Mlf$}75zWFWUZ+*q9W3Xt~ zt5eb#dI=>9acspdQat)&6|_P5S=i2E%R9Obb=2S`l;{+%!Gvg&S8GDq;GvuQE(TED z;tR_mytC=cyf5W3D{DIQJqJ30>P1MQx5^`>3-BmqQMB?XabUO#Y|H;KKCZiSBt#k% z@W5=3f=i(S)kmu)G`V5ka2t7Ex*$)YQB-qC1F%TUWnBnyi~j{5{{udL4IkZFH1bm| z8i{Dp$Vp7cS%8AW&P_RS8HoLWp11_SKBh2nSN03iZA#w-yjVlu3G#?d}xHEc#fi=(eW9CLy22?iM< zvmAVL$<9Y&WpNXNHG|&e!cCYAQf0KX8ssRu?z`q&85e_ry=U>fZ?efz!der>t*g_? z;*ON11IG!2NKejmebZ}^S0cA+RwBvzgDLmHYv!xeh7NoxOI|L?cbQ)5&yTls;pfgf z&bX~UX&X+Nh7+dYw7D>4b|uWN6=L07onM$w?Hx|+9gcfX;U}I>nVycDo=!U|m-!nP zUcRs#T;Wzbfwo<3S)005^pldeN^Ti$SI3L`m-N6pU%YlP<=mcdZeJ^U)A@$;R?SbE z-)c@e2UCTES2Rmx+F0<-LpX5G^?ld&t<=2M^h(pZz2zZ^3LPIfOP1|GX)l}Ob^C7h z{p9FdM{gatOT>$hY-o|$iDT;kalE{+9Evv`kB^Fftmn5o)? ztrpDVwb!&_jVsfyJ@?9UYqf7SztJ2&c=CRScN2BJp+R=B?(pjA)uZu>JxSZ%lxgoh z)7}qvA#2mGEgc_fQ1u}x_>(j3D7_xM7L0G}x<#yUareHYV}HuJKVjYf*H#Dkq?(-& z`KhQ=Fb)6O^z^R_2O$E~B=L}f4OcYC-2E8`5#3)bK0*=KiJ_g;vi=A~{E7PrMch#l zL%XRvq!IGg;-UTMP8~6{m%3B0fqYBl5sG-*Kn!TatR z^1BH*?j?ryqr3Z!!|miRw9ers@)woFaJA+aRcgrBYK~CEFPhYFx1ECgE_mt}d+>1| zJ|2YQ|AO#Q1;+&g$MCIU@ScT{afaV&%Y<%sHOq0}os=o<-p?|#%*BK5#YgT;hZsML z!`3gD#QpZf20SWE_Oku|2(&K_lK&-X-($J7Z!M^rYt;r2m(;2aw@G}uJAp4gO8d%E zlb>a+&we+l362YK?4&Xt&(~GRw5=q2fZl9MS@(m@8+TP9yonl8q=#is*UeAKZHlxG zY{RCKuX(qoG4liLfb)d(l(?U^mr(4z8pqVqou_3%4;l*S`#azgu#3}LFR088Ik7~8_3380&%BD^~gVKPo6p-|+4I~)nO`UCU9 zup8(Y4)}d812%JE-KYrvPM+lavrM=9*)5>wv*7;u$W07DpEVeon)+fa%Y^w10PSvS zGCce2vxd(pI4y*Jr^-ho(r!3G&OI%*6S{;4(+E5EL?ax}x&7jfH@pX%9xb#T7I)JT z0lJ%I<|D8RPtF4uf+5(x6t>CXJqwM4IG$_P&qnmaVVT)j$j`cAuW1I$G8bC%YmP-h z>lo;EN5Iup>B(oSc( z7%+BbI@9@m`?xz;Db~cDX&L6X-+lY~zTN$P@5kUbrHXqHLNhiwG8#xQBmbmWzfhb3jfl5yS6;?>Til*`IrmcTTz|~IXT`op zRyaoTo{u{EV|;&v?+11?EBEquE`4V{7|p7T*(?9qUU{2$+_mQ3vpGp6xx*jXDyKbn ztu~nt5-FTj6R)YgqPwzB)Zxs=HJvWYv59nchX`QRfy^1A0cGU?*CbLnvkh-OghS?x zxG77j-5NCrg2rjn?sEK=FAV(N%j?3q4rv3;g}&a%vxL3C*QOuLjVWQEUHr{Rw} zI%0fBgzvb`J4A{$WZdB`7Z1I5X!ctxJlKEA>MojQOtXiVxnjurWtuU?xa?&vdxgu1 zTkID{W=7(c#>M8vs+*Pwe|$|xS&f^JpW;&j_9OEE{={Cn%EPRT0Bp#77=OObA)Djk zxtViNLN7xdPFvLBi}Ai?-k0DVP#Q1ex2s0VVEiKZH^BWh`wH=UPG2G9Cu-Y8FFA9d zU4`3R^is6}+D`zyri1=#`Pl%!hTDqiYxU4`-HzKz=<5zosjm?KyaBhB(LaAe?<>Ub zqaAH-^!^SH@)hD6JFu^SzLCX3+l2uZ6IeoE8G&xMFHiS@1N$=dA7n7l&er(~@dvpW z+Ih6E5GV99_g2z1JaLw=Z}MW+s(dBYpjZ6j9?3AJWExBA8^64k@td#;q--Obh++H~ z@`m~GLLcN2^JCC3!uBD?slQhhy4v>Fbj|oAm1Is-HX~JaDgP-6JbxFN>cN_4rz-q3 zm=hNH3t8mn2ytN~f>4me>2hH*>Gdh7T>$^b!{I%LC(`!=1_e5kf@xLM?52{)O-VFF z-V(u9Fl{B#P$ENC*$)JVC(bHZD@T@%HTFS*u^?C?WM5{pU0UoP@nzHau8CKcRQ{7! z@nZ05NhI&?%ANx>&QsGn;1o>F`K7#_ypHqfctp13CY=_h?$zx*QjE<8(M4Iz4&WBTwa zf%APL*)Dpe?bQQP|8|)J@|P0p26$}AmRh70x}3(vr)i=U-dpD4A4%VSyim){^`RQo zy-99gl~rICTmCvzrH-`mK2*17Wr?p$p4ot^8XQw{2eUvTO=#-;op`M_LP)DltY-+l|giRq2a+0 z(KUv1QWnjSv{pd92P#J47r+hu69CXLx(!J^ps)hH#P^A?O#cZ#iKb80?d(_ep;~FAdgUnV$a?*2}ivZOytC~k?iN9mfjfGdyDH`wIW-^edodZyzf5OaGUG>BKSPf7kQiT zW$dk?=M3H^{G$qQ6JFx6w~k&i7eTukd-u^xk3ZpU!q?o`TTfrB@E~s!zFvX7PteyZ zS!mZ`fcprnC-4b?2`&^En;abs1;S5xNdX2z=gD&N9nXk=m> z@+hm;$uJL9K9r-=kRo_>k*uSN{rO=?5dyPPYxim)#!umsc|?uO>+nH9C2~NKg-q=# zfQ3CGY_4XIP2^~ov^~*B_DX0{Ha3ty6-~aCRL%U~Mw6|{%Dlf9np9OQr`w4_<^gCd z=}4>XpxUE}%(T#$B4m?Xk}|=3h?(%g|MLUStqNegroZklv!zypp)xUQ%`9H92TNJHLy@CTu^xnorX?h zr>T>#mz}3cDl#o{Yt|kIk2)=BobVP>+=DdJboKPm4}SU*A#26{zamPW^8`LippixX zWfu9XSmfU!o6Q6fj9>?vCtDQFhk~rq@K|GW^<+!)q!4InJ~=TIaE%U*pPm{#9oS!_ z)UGdT37ea%VP{KoH5}O8=B9*SfxOQt$G91ZZV@sE;ZejqCVUD9>AO4~NByl~zH1Q5 z`bb5B!}WyhjsyY6ru2gnXecl?F&+#FgQ0-y{KS+%>M1FM4?DnU*H~R0I7dzcR@<$Y z?oO!cmdJGo?Fwgh%Xpt)b%kh$^A+7ll}jF5Dk*4mXFiyUgKZ%qdS+6HW)QAQGVv<~ zYi)}Bp^QTfG}*f2kAX};C=8KGY+oVYaMzk8GZb-K&z+2{%T2F0%>`ox4a)@$u>#L> z0Yv^s9NxICKx8PpOVp!0SA1u_XhIozYe)~yVR~>5)0-_|Tgp%wk3rzNXMcG1 zitZiL+or`6QA=BlYm0DgcPu$jVaIpL2hlI>m)hmcouZ0`hG@~=cy{ia);Fx{CcV`- zty{OC>|M(7)~q}BotN`o&-=mfo1<@xM(vexhb!*vh}%on&1|-Dnip-z>Hq~wB&Yfc z{SNy!yKp|z&=bi%9<`i^aVH|&2^iyr^AOo3YPAU*G@qZl4q1D8xP+eWPe6w41QcLEmU*eNOxVg8^~`nh3NI zXd}=8FrkMva54~X1QXWT;Q&dmHzAPZKTrf56uH6SiK)>cQombXLP6JH&^1;iott3g z@@rvZNSF##pBfwu{x7pg{|z{YgqVJLDe`CV?L*NwV37<&iUhd8Lr&^Gyh!49`tZ$e z>35{(If&rtlf}tYJ_!~e5hs83|3#eqs`w;LvyG-W$!Kv%kIsW6IWyT+i0Z(dx(Cu{ zTKm4q!ZNjLLY(dVjx0KU$Rhs_Smb|+MgFH%(UFxYET??+Vf|PYWOoTWpCKCeU^=nc zA*tENC(bH5eONLtB=8Gv_JMRJVFmp+xEl{hON$Vc?c{-c{z>v+AZLWmPX@Nr28%)h z`%@v zj6~`?ZjRjS-6~DmQ>4k(`yfq18TbpC-Wrf5%Rri}0co;^NRw_NP1X=;vXWkUywO{O zuT^00WAwF36SV8y-Wq(}g}v4E^+FcfZVa%Jz{d!z2AC%JJ&HDrCTX1Yq~??4pF?u~ z#nVMSP%=F%Uj7EWCdKEJWinO)%h@s>D?2rnl&Bh`*LwC%r;=dkNiG@N~MW*f}62Yf5wB zO61B5PiyD<@LmP-UPL{z+Gm}Yvo2-L8s?mHLvNmWK zYT65NJ#wCMDDfDs^p6B3Hx~Jk5;X{^Cj}2jF^+)l?Dw#S2)}`E{&#)pEAT{=fE)*S zm^c{Y!nZ`&T&2mc?Vq?xm*+4-gR-KxOY<|=vPx*zj!eBqg(;ZGGb$zNF znRL7Emz-Y}Cln2JY?>3w7HN`b15}O05KCMtbj0n0uGMwmPG*vRfUBs!lhtxc=s|V3 zlgmP*Tn}YaT@;j=r^fL?rFd`zP3=>T+xmng6*P3Vu7O& za?`?lAR7|LO+pi(fVE^Uce!4clIARd3|hT~e5XCKloG$E1LNQ#&~hKv5hQ;7oJf2~ zmu~g!ueGpVBfhT306BnZ zyvZXOsFf5xy9^ z^#p$1piWbYRKdGQEmD;*geJyb42Hl-M{&tkdwv5i;VGGYzh`w^d}-#Tn6)xyt&CW~ z)kS5(OO`Dq^Cy4&jURsFO5Hn6Z#Tv2jx5(5iCT`vxT7oF(JzAFF?PX|=PAQW`PfrJ zFBPz!GVErBKY-5yibs;6lkD-N%9bz=eL(=j((s`?B@s^CnAp+Xn0P?Bsjvxb=Zep) z@wDm@>!uQTD!~x@mYWiwLTN%5njCdw;W%7Vm!L+ag5E+YV3=lMKis1Qz>@%A>Iv(> zz~K1!L`bT6Juo0;F(X5U!6!^7M+bw!u|Q~eVn{d-r+pj11pu!A_!9tc0eB6L=OOHJ z*}@!ZU2n7x}81CuB2KzhqMrDKe6 zVT%YY(Fg@}O59Q*o&BCoD5xVI!emB}Q(c}CG)0%(OU}28l!YHVq*u--FnK%$V0qv|cYl3!c5<)&B4H&$ch*MVk9w?vFCh#~D7xWZq&jX9IK3UD>_D)UQ*y zOe5T-0YE<%>0G7}?oZB*bI!R_k;?O;4(Xk1h|=rfvS#@j17| z@EQw8al09#=*Q}1Y>}M$NJ}7jopn8UI!OnUrf5+Q&Yb(h$leplV^i-rGQaam9T_*c zjz|NS?g+m{r+N`b+?E$N9}!tHa`IG?BUi4Hd>&`!s}}d(ELmZWu2Y3fEnN7Re0lOn zErm?2*p@P(xzb44$%l{eHe;W4&-F!_iggNfk8sh-lp!0T6TUsiI>j;>u*V@nOLVBy z$co!a2#`i3=yDM`A>|9%ahlOO^&-{uyZ!dQDclyCGp5 z7&tW*ni2v70|GcjCG?^5lOyA&r3`h%ssXwXs1+hWH!*ua7_gL42LvFg%ITjGWCcH8 zgVy(<^*(?X;HOBA1AM2K>UBxMxsVD-08eV6lh|COeO1GQ<3pnXAxLnggxSnvgHuvu z7pTGn$V5Oeod8r#086Qqj|NzrM5c1^RA^+hYA|?y{N%vUivm>bO=P6)J2^3aYUH#4 z)=&ZJ8YQTq7lobh3j|b2kpniLgmG--v>=fm0a&60eQIQUD8YDv@FbY7Zclfs@GeZk zd7!n;^NhcHpu^Mc{rW)LL4S8^rvRFr1m{ukJHg-t>jJwYy%AvL6I}Dy#L(1epheh6 zN%7-BAX}miW4z%)xcJ}Dj=QMrmnioxDqlwBA0rF-H+_r@q+$3M=rMha?7v2B%c$)x zs@SAhOl=|-QyVsftM4N3uhHHp+WSjXaxWwAa?9&2k)qnggYO-A_lT53WF@0Bf@~tk yGF74u;mq}H9_LRFtRo)hujli)WVSw*QM{Z{3_T_5`G<}87@qF=43P$z&Hn(?o0$#( diff --git a/buzzrss/bot.py b/buzzrss/bot.py index dd38b0b..248ebac 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -16,7 +16,7 @@ from __future__ import annotations from typing import Any -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from string import Template from time import mktime, monotonic, time import asyncio @@ -43,7 +43,7 @@ from mautrix.types import ( from mautrix.util.async_db import UpgradeTable from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper -from .db import DBManager, Entry, Feed, Subscription +from .db import DBManager, Entry, Feed, Subscription, normalize_title from .migrations import upgrade_table rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE) @@ -96,6 +96,7 @@ class Config(BaseProxyConfig): helper.copy("command_prefix") helper.copy("notification_template") helper.copy("exclude_paywalled") + helper.copy("title_dedup_days") helper.copy("run_log_file") helper.copy("whatsapp_api") helper.copy("whatsapp_session") @@ -332,10 +333,15 @@ class RSSBot(Plugin): async def _poll_once(self) -> None: subs = await self.dbm.get_feeds() if not subs: - self._log_run_summary(0, 0, 0, 0, 0, 0, 0.0) + self._log_run_summary(0, 0, 0, 0, 0, 0, 0, 0.0) return now = int(time()) start = monotonic() + title_dedup_days = int(self.config.get("title_dedup_days", 30)) + posted_titles: set[str] = set() + if title_dedup_days > 0: + cutoff = datetime.now(timezone.utc) - timedelta(days=title_dedup_days) + posted_titles = await self.dbm.get_recent_posted_titles(cutoff) tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now] feed: Feed entries: list[Entry] | None @@ -343,6 +349,7 @@ class RSSBot(Plugin): evaluated_total = 0 placed_total = 0 filtered_total = 0 + deduped_total = 0 failed_total = 0 unchanged_total = 0 for res in asyncio.as_completed(tasks): @@ -383,18 +390,36 @@ class RSSBot(Plugin): new_entry_list.sort(key=lambda entry: (entry.date, entry.id)) await self.dbm.add_entries(new_entry_list) for entry in new_entry_list: + norm = normalize_title(entry.title) + if title_dedup_days > 0 and norm and norm in posted_titles: + deduped_total += 1 + self.log.info( + f"{entry.id} of feed {feed.id} title already posted ({norm[:60]}), skipping" + ) + continue placed, filtered, failed = await self._broadcast( feed, entry, feed.subscriptions ) placed_total += placed filtered_total += filtered failed_total += failed + if placed and norm: + # In-memory set: vangt ook duplicaten binnen dezelfde poll-run + # (twee sites van dezelfde uitgever in één run). + posted_titles.add(norm) + try: + await self.dbm.mark_entry_posted(feed.id, entry.id) + except Exception: + self.log.warning( + f"Failed to mark {entry.id} of feed {feed.id} as posted" + ) duration = monotonic() - start self._log_run_summary( len(tasks), evaluated_total, placed_total, filtered_total, + deduped_total, failed_total, unchanged_total, duration, @@ -406,13 +431,14 @@ class RSSBot(Plugin): evaluated: int, selected: int, filtered: int, + deduped: int, failed: int, unchanged: int, duration: float, ) -> None: line = ( f"run complete feeds={feeds} evaluated={evaluated} selected={selected} " - f"filtered={filtered} failed={failed} unchanged={unchanged} " + f"filtered={filtered} deduped={deduped} failed={failed} unchanged={unchanged} " f"duration={duration:.2f}s" ) self.log.info(f"Finished polling {feeds} feeds in {duration:.2f} seconds: {line}") diff --git a/buzzrss/db.py b/buzzrss/db.py index 1c10433..3e81e4d 100644 --- a/buzzrss/db.py +++ b/buzzrss/db.py @@ -15,6 +15,8 @@ # along with this program. If not, see . from __future__ import annotations +import html +import re from datetime import datetime, timezone from string import Template @@ -85,6 +87,19 @@ date_fmt = "%Y-%m-%d %H:%M:%S" 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 "")) + text = re.sub(r"\s+", " ", text) + return text.strip(" \t\n.,;:!?…-–—").lower() + + @dataclass class Entry: feed_id: int @@ -151,6 +166,21 @@ class DBManager: for row in existing: entries.pop(row["id"], None) + async def get_recent_posted_titles(self, since: datetime) -> set[str]: + """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 + ) + 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", + feed_id, + entry_id, + ) + async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None: if not entries: return diff --git a/buzzrss/migrations.py b/buzzrss/migrations.py index a31a8da..c7e0077 100644 --- a/buzzrss/migrations.py +++ b/buzzrss/migrations.py @@ -117,3 +117,13 @@ async def upgrade_v10(conn: Connection, scheme: Scheme) -> None: f"NOT NULL DEFAULT {default}" f")" ) + + +@upgrade_table.register(description="Add posted flag to entries for cross-feed title dedup") +async def upgrade_v11(conn: Connection, scheme: Scheme) -> None: + # posted = deze entry is daadwerkelijk gebroadcast (Matrix en/of WhatsApp). + # Nieuwe entries die alleen 'gezien' zijn (bv. feed-historis bij subscribe) en + # filter-afgewezen entries blijven false en blokkeren geen latere titel-match. + 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)") diff --git a/maubot.yaml b/maubot.yaml index 9ebea5d..f5fa9ce 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.7.0 +version: 1.8.0 modules: - buzzrss main_class: RSSBot From c13ec813799fc8adf5571ec5e6da0aef2b21cd14 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Wed, 9 Sep 2026 23:46:22 +0200 Subject: [PATCH 3/4] =?UTF-8?q?v1.8.1:=20idempotente=20start()=20=E2=80=94?= =?UTF-8?q?=20guard=20voorkomt=20dubbele=20poll-loop=20bij=20maubot=20doub?= =?UTF-8?q?le-start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WHATSAPP_DOORSTUREN_PLAN.md | 170 ++++++++++++++++++++++++++++++++++++ buzzrss/bot.py | 11 +++ maubot.yaml | 2 +- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 WHATSAPP_DOORSTUREN_PLAN.md diff --git a/WHATSAPP_DOORSTUREN_PLAN.md b/WHATSAPP_DOORSTUREN_PLAN.md new file mode 100644 index 0000000..8439d45 --- /dev/null +++ b/WHATSAPP_DOORSTUREN_PLAN.md @@ -0,0 +1,170 @@ +# buzz-rss: nieuwsfeed-URLs doorsturen naar WhatsApp-groep "OV in de media" + +## Context + +De maubot-plugin `nl.eddydevink.rss` (bron: `/Users/eddy/src/buzz-rss`, draait als instance `rss` in `matrix-bot-maubot` op nextcloud) filtert nieuwsfeeds op OV-trefwoorden (`match_filter`) en plaatst ze per feed-subscription in Matrix-rooms. De wens: **alleen de link** van dezelfde geplaatste berichten óók plaatsen in de WhatsApp-groep **"OV in de media"** in de community "EBS Klets en Diensten opvraag", via de bestaande wwebjs-api op nextcloud (`:3001`). + +Doelgroep-ID (vastgesteld via de DevTools-chatcollectie van de Eddy-sessie-browser): **`120363429643087130@g.us`** (type `LINKED_SUBGROUP`, titel "OV in de media"). De Verkeersberichten-groep (`120363427013472489@g.us`) is een andere groep en blijft voor EBS-verkeersberichten. + + + +**Read-only-route voor de dedup-check:** `fetchMessages`, `getChats` en `getChatById` falen in de aangepaste container met `{"success":false,"error":"r"}` (fout uit de browser-evaluate. De werkende read-route is **`POST /client/searchMessages/{session}`** — getest tegen de doelgroep: substring-query op de bericht-body; de response bevat `messages[]` met `_data.body` (berichttekst) en `_data.id.remote` (chat-id van het bericht. Alleen deze read-route en `sendMessage` worden gebruikt. Een `searchMessages`-query zonder match retourneert `{"success":true,"messages":[]}`. + +. Deze foutsymptomen en workaround zijn gedocumenteerd in de Obsidian-vault: `[[Whatsapp rest-api]]` → sectie `Troubleshooting: error r op read-endpoints`. Raadpleeg die notitie bij het tegenkomen van deze fout (of `client/searchMessages` ineens faalt). + + + +**Hard verbod tijdens uitvoering:** de wwebjs-api container NOOIT herstarten en geen WhatsApp-sessie beëindigen/restarten. Alleen de maubot-instance `rss` mag herladen (eigen container, geen WhatsApp-sessies.) WhatsApp-kanaal = uitsluitend de wwebjs-api HTTP-API op `http://192.168.5.252:3001` (bereikbaarheid vanuit de maubot-container is geverifieerd: `/ping` → pong). Read-only container-inspectie (docker exec cat/logs, curl GET/POST tegen de API) is toegestaan; sessie-/container-management niet. + + + +## Approach + +Alle wijzigingen in `/Users/eddy/src/buzz-rss`. Er is geen test-suite in de repo; er is geen bestaande WhatsApp-forward-functie (de EBS-verkeersberichten-bot is een los Node-script, geen herbruikbare bibliotheek — NIET importeren, alleen als voorbeeld van de wwebjs-API-call). + + + +1. **Config-keys toevoegen** — `buzzrss/bot.py`, class `Config.do_update` (rond regel 44-56): vóór `helper.copy("admins")` toevoegen: + ```python + helper.copy("whatsapp_api") + helper.copy("whatsapp_session") + helper.copy("whatsapp_chat_id") + ``` + Koppel daaraan in `base-config.yaml` (achter `run_log_file`, vóór `exclude_paywalled` — volgorde in YAML maakt niet uit, wel alle drie toevoegen): + ```yaml + # Doorsturen naar WhatsApp (wwebjs-api op nextcloud :3001). Leeg whatsapp_api/whatsapp_chat_id = uit. Alleen de link wordt gepost (geen titel. + whatsapp_api: http://192.168.5.252:3001 + whatsapp_session: EdGpt + whatsapp_chat_id: 120363429643087130@g.us + ``` + Geen `whatsapp_notification_template`: het WhatsApp-bericht is uitsluitend de onbewerkte `entry.link` (WhatsApp maakt daar automatisch link-preview van; geen Markdown-links. Een ander formaat later = code-wijziging nodig. + + + +2. **Nieuwe methode `_send_whatsapp`** in `RSSBot` (`buzzrss/bot.py`, naast `_send`, rond regel 200). **Dedup-check is verplicht vóór elke post; faalt de check, dan wordt niet gepost.** `self.http` is de bestaande aiohttp-sessie (`self.client.api.session`, al gebruikt voor feed-fetches met absolute URL's). Foutafhandeling: API-error of netwerkfout → log + `False`, geen crash; niet-geconfigureerd → `False` zonder fout: + ```python + async def _send_whatsapp(self, feed: Feed, entry: Entry) -> bool: + """Post entry.link naar de WhatsApp-groep via wwebjs-api (met dedup-check). True = nieuw geplaatst.""" + api = self.config.get("whatsapp_api", "") + chat_id = self.config.get("whatsapp_chat_id", "") + if not api or not chat_id: + self.log.debug("WhatsApp forwarding disabled") + return False + link = entry.link + if not link: + self.log.warning(f"No link for {entry.id}, skipping WhatsApp") + return False + session = self.config.get("whatsapp_session", "EdGpt") + base = api.rstrip("/") + # Dedup: sla over als de URL al in de doelgroep staat. Search is globaal + # over alle chats; filter op chat-id van het gevonden bericht. + + try: + resp = await self.http.post( + f"{base}/client/searchMessages/{session}", + json={"query": link, "options": {"limit": 1}}, + timeout=aiohttp.ClientTimeout(total=20), + ) + data = await resp.json() + if data.get("success"): + for msg in data.get("messages", []): + msg_data = msg.get("_data", {}).get("id", {}) + if ( + msg_data.get("remote") == chat_id + and link in msg.get("_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 + try: + resp = await self.http.post( + f"{base}/client/sendMessage/{session}", + json={"chatId": chat_id, "content": link, "contentType": "string"}, + timeout=aiohttp.ClientTimeout(total=20), + ) + data = await resp.json() + ok = bool(data.get("success")) + self.log.info(f"WhatsApp post {entry.id} to {chat_id}: {'ok' if ok else 'failed: ' + str(data)}") + return ok + except Exception as e: + self.log.warning(f"Failed to send {entry.id} to WhatsApp {chat_id}: {e}") + return False + ``` + + + +3. **`_broadcast` uitbreiden** (regel ~235): ná de bestaande filter-check en ná de matrix-verzendlus, vlak vóór de `return`, één WhatsApp-send per entry (niet per subscription): + ```python + sent = sum(1 for resultin results if result is not None) + whatsapp_ok = await self._send_whatsapp(feed, entry) + return (1 if (sent or whatsapp_ok) else 0, 0, len(results) - sent) + ``` + Concreet:de huidige regel `return (1 if sent else 0, 0, len(results) - sent)` vervangen door de drie regels hierboven. Docstring-regel "placed = 1 when at least one room received the message" aanpassen naar "placed = 1 when the entry was posted to at least one channel (Matrix room or newly posted WhatsApp link)". De `failed`-component blijft het aantal gefaalde Matrix-subscriptions. WhatsApp-skip (URL al aanwezig) en WhatsApp-fouten loggen zelf; ze mogen de bestaande telling niet vertekenen. Filter-semantiek ongewijzigd: alleen entries die de filter-pas halen komen in `_send_whatsapp` terecht. + + + +4. **Versie bump** — `maubot.yaml`: `version: 1.5.4` → `version: 1.6.0`. + + + +## Critical files & anchors + +- `buzzrss/bot.py` — `Config.do_update` (regel ~44), `_send` (~200), `_broadcast` (~225-250). Imports reeds aanwezig (`Template`, `aiohttp`, `attr`, `Feed`, `Entry`; `Template` blijft voor de Matrix-notification-template. Implementer herleest de regio's vóór het editen. + +- `base-config.yaml` — extra_files van het mbp; ontbreken = `BaseMissingError` bij start. + + + +- `maubot.yaml` — versie voor de mbp-naam en uploadlaar. + + + + + +## Verification + +Volgorde na elke stap doorvoeren; wacht NIET op project-brede test (geen suite}). Werk: `/Users/eddy/src/buzz-rss`. Nooit wwebjs/sessie-acties — alleen maubot-management-API en de wwebjs HTTP-API (searchMessages/sendMessage). + + + +1. **Compile-check** na de code-wijziging: `python3 -m py_compile buzzrss/bot.py` → exit 0. + + + +2. **Deploy volgens maubot-skill-recept** (skill `maubot` lezen vóór uitvoeren; `MAUBOT_ADMIN_PASSWORD` uit vault-notitie "Matrix Synapse en MAS.md" → secrets-tabel, NOOIT in chat/plan): + - Bronmap (met `buzzrss/`, `maubot.yaml`, `base-config.yaml`) via tar+base64 naar de container brengen in `/data/deploytmp/`; `mbc build` in die werkmap (géén `-o`); verifieer `.mbp`-inhoud: `unzip -l … | grep base-config`. + - Upload (`POST /plugins/upload?allow_override=true`) → `POST /plugin/nl.eddydevink.rss/reload` → `PUT /instance/rss` met `{"enabled": true, "started": true}` → `GET /plugin/nl.eddydevink.rss` toont versie 1.6.0 en beide `true`. + + + +3. **Poll draait**: na reload binnen ~60 s één verse regel `run complete …` in `/matrix/maubot/data/buzzrss-runs.log` (`ssh aiaiaiaiagent@nextcloud 'sudo -A tail …'`); géén verdubbelde regels met dezelfde timestamp. Bewijst dat de nieuwe code startte (start-fout zou de instance op `enabled=False` zetten + geen run-log.). + + + +4. **End-to-end WhatsApp-kanaal** (nieuw gedrag, concrete input → observable output): throwaway-script (lokaal, daarna verwijderen) dat EXACT dezelfde volgorde doet als `_send_whatsapp`, tegen dezelfde API en sessie: + + - **4a — dedup-check (lege verwachting):** `POST http://192.168.5.252:3001/client/searchMessages/EdGpt` met body `{"query": "https://www.ovpro.nl/", "options": {"limit": 1}}}`. Verwacht: `{"success":true,"messages":[]}` (URL nog niet in de groep.) Als die er WEL al instaat: stap 4b slaat het posten over en is de dedup-check daarmee bewezen — ga door naar stap 5. + - **4b — post alleen de link:** `POST …/client/sendMessage/EdGpt` met body `{"chatId": "120363429643087130@g.us", "content": "https://www.ovpro.nl/", "contentType": "string"}`. Verwacht: `{"success":true}`; het bericht (alleen de URL) is zichtbaar in de groep (gebruiker kan bevestigen. Geen titel in het bericht. +. + - **4c — dedup-check (verse post vindbaar):** herhaal 4a → verwacht exact 1 match met `_data.id.remote == "120363429643087130@g.us"` en `_data.body` die de URL bevat. Bewijst dat de zoekfunctie een net geposte link direct vindt (geen index-vertraging. **Vindt 4c de verse post niet direct, dan is de dedup-check onbetrouwbaar → stoppen en overleggen met de gebruiker vóór verdere uitvoering** (geen post zonder werkende check.). + - **Contingentie (pre-gedecideerd):** als de call faalt met een lidmaatschapsfout (EdGpt kan niet posten in die subgroep), zet dan in de maubot-instance-config `whatsapp_session: Eddy` (in de web-UI config-editor of `PUT /config/…`), herstart daarna alleen de instance `rss`, en herhaal deze stap. De sessie-Eddy heeft de groep in zijn chatcollectie (bewezen tijdens onderzoek). + + + +5. **Plugin-pad zelf**: de eerstvolgende poll die een nieuw OV-match-item vindt produceert in maubot-logs de regel `WhatsApp post … ok` (of `… already in WhatsApp group, skipping` bij een al geposte URL) én in de Matrix-room. Komt er (nog) geen nieuw item, dan is stap 4 het bewijs van de koppeling; de plugin-code is door stap 1-3 geverifieerd. Verwijder hierna het throwaway-script. + + + +## Assumptions & contingencies + +- **Berichtformaat**: uitsluitend de onbewerkte `entry.link`; geen titel, geen template-config. WhatsApp maakt automatisch link-preview van de URL. Een ander formaat later = code-wijziging nodig (bewuste keuze volgend uit de eis "Alleen de link moet worden gepost, niet de titel". +- **Dedup-check**: verplicht vóór elke post. De search (`client/searchMessages`) doorzoekt globaal alle chats en matcht **substring** op de bericht-body; de filter op `_data.id.remote == chat_id` (plus `entry.link` in body als extra beveiliging) beperkt tot de doelgroep. Exacte-string-semantiek: een eerdere post met een **variant** van dezelfde link (bijv. andere scheme, `www`, trailing slash, utm-params) wordt niet als duplicaat herkend, omdat de body die variant bevat en de query (`entry.link`) geen substring is van die variant. Acceptabel: we posten exact dezelfde string die we zoeken; een eerdere post van dezelfde entry heeft dezelfde link. +. + +- **Check-fout → geen post**: faalt de dedup-search (netwerk/API-fout), dan wordt de entry **niet** naar WhatsApp gepost (`return False`, warning-log). Dit volgt uit de vereiste "alvorens te posten moet worden gecheckt of de url nog niet in de whatsappgroep geplaatst is". +- **Sessie**: default `EdGpt` (de bot-sessie, zelfde als andere EBS-bots). Valt de test-send in stap 4 uit op lidmaatschap van de subgroep → zie stap 4-contingentie (sessie `Eddy`). +- **Filter**: WhatsApp-forward krijgt exact dezelfde filter-pas als Matrix — er wordt niets extra's gefilterd of doorgelaten; geen aparte whatsapp-filter. + +- **Herstarten/beëindigen van de wwebjs-container of WhatsApp-sessies is ten strengste verboden**; alles verloopt via HTTP-API (`searchMessages` + `sendMessage`) en de maubot-management-API. \ No newline at end of file diff --git a/buzzrss/bot.py b/buzzrss/bot.py index 248ebac..86c26cb 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -123,6 +123,7 @@ class RSSBot(Plugin): dbm: DBManager exclude_paywalled: bool = False poll_task: asyncio.Future + _poll_started: bool = False poll_sema: asyncio.Semaphore | None _run_logger: logging.Logger | None = None _run_handler: logging.FileHandler | None = None @@ -138,6 +139,15 @@ class RSSBot(Plugin): return upgrade_table async def start(self) -> None: + # Idempotentie-guard: maubot kan internal_start() (her)aanroepen via + # boot/client/upload/update-paden; Instance.start() zet zijn eigen + # started-flag pas ná plugin.start(). Zonder guard ontstaat bij een + # tweede (concurrente) aanroep een tweede poll-loop. Check+set is + # synchronous (geen await ertussen) → atoom in de event-loop. + if self._poll_started: + self.log.warning("start() called twice, ignoring duplicate poll task") + return + self._poll_started = True await super().start() self.on_external_config_update() self.dbm = DBManager(self.database) @@ -177,6 +187,7 @@ class RSSBot(Plugin): async def stop(self) -> None: await super().stop() + self._poll_started = False self.poll_task.cancel() if self._run_handler: self._run_handler.close() diff --git a/maubot.yaml b/maubot.yaml index f5fa9ce..5866a4c 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.8.0 +version: 1.8.1 modules: - buzzrss main_class: RSSBot From f4ea969f3342b31fa692d04e56b2434b946ecaac Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Thu, 10 Sep 2026 08:42:33 +0200 Subject: [PATCH 4/4] v1.8.2: blacklist straatnamen rond stations in OV-matchfilter 'de Stationsstraat afsluiten voor een festival' is geen OV-nieuws, maar scored wel (familie \bstation\w*\b, gewicht 3; via `stations`+suffix dingt `station\w*\b` op 'stationsstraat'). Regex-uitbreiding faalt op de dubbele s (stations+straat): elke naamvorm vraagt opnieuw regex-herrie. Daarom MATCH_FILTER_BLACKLIST: exact gematchte woorden die geen OV-waarde hebben (straatnamen rond stations: stationsstraat/weg/laan/plein/... plus treinstraat/treinweg) tellen niet meer mee. De genitief-s-vorm dekt zowel 'Stationsstraat' als 'Stationsweg' etc. Zwartgelijste woorden blokkeren de familie niet: een echt station-/trein-woord ernaast scoort gewoon. --- base-config.yaml | 8 ++++---- buzzrss/bot.py | 35 +++++++++++++++++++++++++++++++++-- maubot.yaml | 2 +- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index a45943d..fc7fa59 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -27,10 +27,10 @@ exclude_paywalled: true # binnen X dagen, ook als de feed/URL anders is (zelfde uitgever, meerdere sites). # 0 = uit. title_dedup_days: 30 -# OV-matchfilter: VAST in code (gewogen families), niet via config te wijzigen. -# Doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= 3. Zie de families in -# buzzrss/bot.py (MATCH_FILTER_FAMILIES). `!rss set-filter` en `!rss filter` -# tonen alleen de huidige (vaste) filter. +# OV-matchfilter: VAST in code (gewogen families + blacklist), niet via config te wijzigen. +# Doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= 3. Zie de families en de +# blacklist in buzzrss/bot.py (MATCH_FILTER_FAMILIES / MATCH_FILTER_BLACKLIST). +# `!rss set-filter` en `!rss filter` tonen alleen de huidige (vaste) filter. # Users who can bypass room permission checks admins: - "@user:example.com" \ No newline at end of file diff --git a/buzzrss/bot.py b/buzzrss/bot.py index 86c26cb..a796285 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -59,6 +59,10 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # 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. +# 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 @@ -82,6 +86,20 @@ MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [ # Drempel voor doorlaten (score = 2×titel + 1×samenvatting). MATCH_FILTER_THRESHOLD = 3 +# Woorden die een familie wél matcht maar die geen OV-waarde hebben. Een hit dat +# hierin staat telt niet mee voor de score (en blokkeert de familie niet). Vorm: +# exact woord (lowercase), zoals door de familie-regex wordt gematcht. +MATCH_FILTER_BLACKLIST: frozenset[str] = frozenset({ + # Straatnamen rond stations (genitief-s-vorm van "\bstation\w*\b"): een straat + # die toevallig bij een station ligt maakt een bericht nog geen OV-bericht. + "stationsstraat", "stationsweg", "stationslaan", "stationsplein", + "stationssingel", "stationskade", "stationsdreef", "stationshof", + "stationspad", "stationsboulevard", "stationsgracht", "stationsdijk", + "stationssteeg", "stationswal", "stationsbuurt", "stationswijk", "stationspark", + # Idem voor "\btrein\w*\b". + "treinstraat", "treinweg", +}) + _compiled_match_families: list[tuple[re.Pattern, int]] = [ (re.compile(pat, re.IGNORECASE), w) for pat, w in MATCH_FILTER_FAMILIES ] @@ -202,8 +220,19 @@ class RSSBot(Plugin): @staticmethod def _fam_score(text: str) -> int: - """Som van gewichten over de OV-families die in text voorkomen (1× per familie).""" - return sum(weight for family, weight in _compiled_match_families if family.search(text)) + """Som van gewichten over de OV-families die in text voorkomen (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: + for m in family.finditer(text): + if m.group(0).lower() in MATCH_FILTER_BLACKLIST: + continue + total += weight + break + return total @staticmethod def _filter_passes(title: str, summary: str) -> bool: @@ -227,6 +256,8 @@ class RSSBot(Plugin): "- 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" + "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 5866a4c..5b89aa4 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.8.1 +version: 1.8.2 modules: - buzzrss main_class: RSSBot