Compare commits
4 changed files with 63 additions and 142 deletions
169
buzzrss/bot.py
169
buzzrss/bot.py
|
|
@ -55,49 +55,31 @@ 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", "breng", "overweg", "overwegen", "sein",
|
# Gewicht 1 (zwak/dubbelzinnig: los "bus", "kaartje", "rails"): moet met een ander
|
||||||
# "kaartje", "opwaarderen", "rails"): moet met een ander OV-woord combineren (een
|
# OV-woord combineren. Weert zo "VW-bus"/"schoolbus", "weer op de rails" en
|
||||||
# tweede, andere familie — dezelfde zwakke familie in titel én samenvatting telt
|
# 112-berichten met "bussen rijden om" in de lead, zonder eenduidige
|
||||||
# niet als combinatie). Weert zo "VW-bus"/"schoolbus", "weer op de rails", het
|
# bushalte/overweg/NS/trein-berichten te raken.
|
||||||
# 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|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(?: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|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"\b(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking)\b", 3), # spoorinfra
|
||||||
(r"(?<![\w-])\bstation\w*\b", 3), # station(s); niet ná een koppelteken ('radio-station'); niet-OV-vormen staan in de blacklist
|
(r"\bstation\w*\b", 3), # station(s)
|
||||||
(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"\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking)\b", 3), # voertuig-samenstelling
|
||||||
(r"\b\w*trein\w*\b", 3), # trein(verkeer/staking/…), ook samenstellingen: stoptrein, goederentrein, sprinttrein
|
(r"\btrein\w*\b", 3), # trein(verkeer/staking/ongeluk/...)
|
||||||
(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"\btrams?\b", 2),
|
(r"\btram\b", 2),
|
||||||
(r"\bmetro(?:'?s)?\b", 2),
|
(r"\bmetro\b", 2),
|
||||||
(r"\b(?:openbaar vervoer|dienstregeling|inchecken|uitchecken|lightrail|pendelbus|shuttlebus|halte|bushokje|busbaan|vervoerder|reiziger)\w*\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)\w*\b", 2),
|
(r"\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs|opwaarderen)\b", 2),
|
||||||
(r"\b(?:ov-staking|conducteursstaking)\w*\b", 2),
|
(r"\b(?:ov-staking|conducteursstaking)\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), # ná een koppelteken geen OV: 'VW-bus', 'school-bus'
|
(r"\bbus(?:sen)?\b", 1),
|
||||||
(r"\bbreng\b", 1), # vervoerder Breng vs werkwoord "brengen" ("hoe breng je ... in beeld")
|
|
||||||
(r"\boverweg\b", 1), # spoorwegovergang vs idioom "goed met elkaar overweg kunnen"
|
|
||||||
(r"\boverwegen\b", 1), # meervoud van overweg vs werkwoord ("een gratieverlening overwegen")
|
|
||||||
(r"\bsein\b", 1), # spoorsein vs brandweerjargon ("sein brandmeester") en figuurlijk gebruik
|
|
||||||
(r"\bkaart(?:je|jes)\b", 1),
|
(r"\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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -116,12 +98,6 @@ 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]] = [
|
||||||
|
|
@ -190,18 +166,12 @@ 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()
|
||||||
|
|
@ -249,27 +219,20 @@ class RSSBot(Plugin):
|
||||||
self.log.exception("Fatal error while polling feeds")
|
self.log.exception("Fatal error while polling feeds")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _filter_text(raw: str) -> str:
|
def _fam_score(text: str) -> int:
|
||||||
"""Platte tekst voor het match-filter: eerst entities decoderen, dán tags
|
"""Som van gewichten over de OV-families die in text voorkomen (1× per familie).
|
||||||
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."""
|
||||||
hits: list[tuple[int, int]] = []
|
total = 0
|
||||||
for idx, (family, weight) in enumerate(_compiled_match_families):
|
for family, weight in _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
|
||||||
hits.append((idx, weight))
|
total += weight
|
||||||
break
|
break
|
||||||
return hits
|
return total
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _filter_passes(title: str, summary: str) -> bool:
|
def _filter_passes(title: str, summary: str) -> bool:
|
||||||
|
|
@ -277,37 +240,24 @@ 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', 'breng', 'overweg', 'overwegen',
|
OV-woord bijkomen. Zwakke woorden (losse 'bus', 'kaartje', 'rails') moeten
|
||||||
'sein', 'opwaarderen', 'kaartje', 'rails') moeten combineren met een ander
|
combineren met een ander OV-woord. Weert oa 'VW-bus', 'schoolbus',
|
||||||
OV-woord; dezelfde zwakke familie in titel én samenvatting is géén combinatie.
|
'bussen rijden om' in een woningbrand-lead en figuratief 'weer op de rails'.
|
||||||
Weert oa 'VW-bus' (bus matcht niet ná een koppelteken), 'schoolbus', 'bussen
|
Bewust doorgelaten: krant 'De Metro'/café 'De Tram' in de titel (medium)."""
|
||||||
rijden om' in een woningbrand-lead, het werkwoord 'brengen', het idioom 'goed
|
return 2 * RSSBot._fam_score(title) + RSSBot._fam_score(summary) >= MATCH_FILTER_THRESHOLD
|
||||||
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, ProRail, Qbuzz, ...), spoorinfra (spoorlijn, spoorwegovergang, seinstoring, perron), "
|
"- Sterk (3): vervoerders (NS, Arriva, Qbuzz, ...), spoorinfra (perron, overweg, sein), "
|
||||||
"station(s), bushalte/buslijn/bushokje/busstation, trein, intercity/sprinter/streekvervoer/veerdienst/buurtbus, 'lijn 12'\n"
|
"station(s), bushalte/buslijn/treinstel, trein\n"
|
||||||
"- Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, halte, bushokje, busbaan, vervoerder, reiziger, "
|
"- Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, "
|
||||||
"ov-chipkaart/ovpay/reisproduct, ov-staking, pont\n"
|
"ov-chipkaart/ovpay/reisproduct, ov-staking, pont\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"
|
"- 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, "
|
"Uitgesloten (blacklist): woorden die wél matchen maar geen OV-waarde hebben, "
|
||||||
"o.a. straatnamen rond stations ('stationsstraat', 'stationsweg', ...) en niet-OV-vormen als 'stationcar' en 'stationair'\n\n"
|
"o.a. straatnamen rond stations ('stationsstraat', 'stationsweg', ...)\n\n"
|
||||||
"Doorlaten: `2×(titel-score) + 1×(samenvatting-score) ≥ 3`"
|
"Doorlaten: `2×(titel-score) + 1×(samenvatting-score) ≥ 3`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -315,7 +265,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 = self._filter_text(entry.summary)
|
summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary))
|
||||||
if not self._filter_passes(entry.title, summary):
|
if not self._filter_passes(entry.title, summary):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -364,19 +314,15 @@ class RSSBot(Plugin):
|
||||||
timeout=aiohttp.ClientTimeout(total=20),
|
timeout=aiohttp.ClientTimeout(total=20),
|
||||||
)
|
)
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
if not data.get("success"):
|
if data.get("success"):
|
||||||
# Checkfout (wwebjs antwoordt ook met HTTP 200 + success:false) =
|
for msg in data.get("messages", []):
|
||||||
# niet posten: anders omzeilt de fout de dedup → dubbele link.
|
msg_data = msg.get("_data", {})
|
||||||
self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {data}")
|
if (
|
||||||
return False
|
msg_data.get("id", {}).get("remote") == chat_id
|
||||||
for msg in data.get("messages", []):
|
and link in msg_data.get("body", "")
|
||||||
msg_data = msg.get("_data", {})
|
):
|
||||||
if (
|
self.log.info(f"{entry.id} already in WhatsApp group, skipping")
|
||||||
msg_data.get("id", {}).get("remote") == chat_id
|
return False
|
||||||
and link in msg_data.get("body", "")
|
|
||||||
):
|
|
||||||
self.log.info(f"{entry.id} already in WhatsApp group, skipping")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {e}")
|
self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
@ -409,7 +355,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 = self._filter_text(entry.summary)
|
summary = html.unescape(re.sub(r"<[^>]+>", "", 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"]
|
||||||
|
|
@ -602,21 +548,17 @@ 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}")
|
||||||
return feed, entries
|
ct = resp.headers["Content-Type"].split(";")[0].strip()
|
||||||
|
if ct == "application/json" or ct == "application/feed+json":
|
||||||
|
return await self._parse_json(feed, resp)
|
||||||
|
else:
|
||||||
|
return await self._parse_rss(feed, resp)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _parse_json(
|
async def _parse_json(
|
||||||
|
|
@ -633,10 +575,7 @@ 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", "")
|
||||||
entries = [cls._parse_json_entry(feed.id, entry) for entry in content["items"]]
|
return feed, [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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
Decodeert eerst HTML-entities en stript dan de tags, lowercaset, vouwt whitespace
|
Stript HTML, lowercaset, vouwt whitespace samen en verwijdert rand-interpunctie,
|
||||||
samen en verwijdert rand-interpunctie, zodat hetzelfde artikel dat door één
|
zodat hetzelfde artikel dat door één uitgever onder een andere URL/feed wordt
|
||||||
uitgever onder een andere URL/feed wordt gepubliceerd (vrijwel) identieke titels
|
gepubliceerd (vrijwel) identieke titels matcht. Bewust exacte matching:
|
||||||
matcht. Bewust exacte matching: terugkerende koppen ("treinstoring", "staking")
|
terugkerende koppen ("treinstoring", "staking") met écht andere titels
|
||||||
met écht andere titels mogen niet als duplicaat worden weggefilterd."""
|
mogen niet als duplicaat worden weggefilterd."""
|
||||||
text = re.sub(r"<[^>]+>", "", html.unescape(title or ""))
|
text = html.unescape(re.sub(r"<[^>]+>", "", 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,16 +170,15 @@ 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 posted_at >= $1", since
|
"SELECT title FROM entry WHERE posted AND date >= $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, posted_at = $3 WHERE feed_id = $1 AND id = $2",
|
"UPDATE entry SET posted = true 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:
|
||||||
|
|
|
||||||
|
|
@ -127,20 +127,3 @@ 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)")
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
id: nl.eddydevink.rss
|
id: nl.eddydevink.rss
|
||||||
version: 1.9.0
|
version: 1.8.2
|
||||||
modules:
|
modules:
|
||||||
- buzzrss
|
- buzzrss
|
||||||
main_class: RSSBot
|
main_class: RSSBot
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue