v1.8.0: titel-dedup over feeds (zelfde artikel, andere url)

This commit is contained in:
Eddy de Vink 2026-09-09 23:39:05 +02:00
commit 6264e73441
7 changed files with 77 additions and 5 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View file

@ -23,6 +23,10 @@ whatsapp_session: EdGpt
whatsapp_chat_id: 120363429643087130@g.us whatsapp_chat_id: 120363429643087130@g.us
# Exclude paywalled articles (Telegraaf/NHD markeren dit met <premium>true</premium>) # Exclude paywalled articles (Telegraaf/NHD markeren dit met <premium>true</premium>)
exclude_paywalled: 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. # OV-matchfilter: VAST in code (gewogen families), niet via config te wijzigen.
# Doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= 3. Zie de families in # Doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= 3. Zie de families in
# buzzrss/bot.py (MATCH_FILTER_FAMILIES). `!rss set-filter` en `!rss filter` # buzzrss/bot.py (MATCH_FILTER_FAMILIES). `!rss set-filter` en `!rss filter`

View file

@ -16,7 +16,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from string import Template from string import Template
from time import mktime, monotonic, time from time import mktime, monotonic, time
import asyncio import asyncio
@ -43,7 +43,7 @@ from mautrix.types import (
from mautrix.util.async_db import UpgradeTable from mautrix.util.async_db import UpgradeTable
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper 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 from .migrations import upgrade_table
rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE) 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("command_prefix")
helper.copy("notification_template") helper.copy("notification_template")
helper.copy("exclude_paywalled") helper.copy("exclude_paywalled")
helper.copy("title_dedup_days")
helper.copy("run_log_file") helper.copy("run_log_file")
helper.copy("whatsapp_api") helper.copy("whatsapp_api")
helper.copy("whatsapp_session") helper.copy("whatsapp_session")
@ -332,10 +333,15 @@ class RSSBot(Plugin):
async def _poll_once(self) -> None: async def _poll_once(self) -> None:
subs = await self.dbm.get_feeds() subs = await self.dbm.get_feeds()
if not subs: 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 return
now = int(time()) now = int(time())
start = monotonic() 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] tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now]
feed: Feed feed: Feed
entries: list[Entry] | None entries: list[Entry] | None
@ -343,6 +349,7 @@ class RSSBot(Plugin):
evaluated_total = 0 evaluated_total = 0
placed_total = 0 placed_total = 0
filtered_total = 0 filtered_total = 0
deduped_total = 0
failed_total = 0 failed_total = 0
unchanged_total = 0 unchanged_total = 0
for res in asyncio.as_completed(tasks): 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)) new_entry_list.sort(key=lambda entry: (entry.date, entry.id))
await self.dbm.add_entries(new_entry_list) await self.dbm.add_entries(new_entry_list)
for entry in 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( placed, filtered, failed = await self._broadcast(
feed, entry, feed.subscriptions feed, entry, feed.subscriptions
) )
placed_total += placed placed_total += placed
filtered_total += filtered filtered_total += filtered
failed_total += failed 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 duration = monotonic() - start
self._log_run_summary( self._log_run_summary(
len(tasks), len(tasks),
evaluated_total, evaluated_total,
placed_total, placed_total,
filtered_total, filtered_total,
deduped_total,
failed_total, failed_total,
unchanged_total, unchanged_total,
duration, duration,
@ -406,13 +431,14 @@ class RSSBot(Plugin):
evaluated: int, evaluated: int,
selected: int, selected: int,
filtered: int, filtered: int,
deduped: int,
failed: int, failed: int,
unchanged: int, unchanged: int,
duration: float, duration: float,
) -> None: ) -> None:
line = ( line = (
f"run complete feeds={feeds} evaluated={evaluated} selected={selected} " 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" f"duration={duration:.2f}s"
) )
self.log.info(f"Finished polling {feeds} feeds in {duration:.2f} seconds: {line}") self.log.info(f"Finished polling {feeds} feeds in {duration:.2f} seconds: {line}")

View file

@ -15,6 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations from __future__ import annotations
import html
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from string import Template 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" 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 @dataclass
class Entry: class Entry:
feed_id: int feed_id: int
@ -151,6 +166,21 @@ class DBManager:
for row in existing: for row in existing:
entries.pop(row["id"], None) 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: async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None:
if not entries: if not entries:
return return

View file

@ -117,3 +117,13 @@ async def upgrade_v10(conn: Connection, scheme: Scheme) -> None:
f"NOT NULL DEFAULT {default}" f"NOT NULL DEFAULT {default}"
f")" 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)")

View file

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