From 6264e73441a87525ce749b7eaa9d6d2172e99f30 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Wed, 9 Sep 2026 23:39:05 +0200 Subject: [PATCH] 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 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 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