Compare commits

..
4 changed files with 63 additions and 142 deletions

View file

@ -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,11 +314,7 @@ 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) =
# niet posten: anders omzeilt de fout de dedup → dubbele link.
self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {data}")
return False
for msg in data.get("messages", []): for msg in data.get("messages", []):
msg_data = msg.get("_data", {}) msg_data = msg.get("_data", {})
if ( if (
@ -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:

View file

@ -90,12 +90,12 @@ date_fmt_microseconds = "%Y-%m-%d %H:%M:%S.%f"
def normalize_title(title: str) -> str: def normalize_title(title: str) -> str:
"""Genormaliseerde titel-sleutel voor cross-feed duplicate-detectie. """Genormaliseerde titel-sleutel voor cross-feed duplicate-detectie.
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:

View file

@ -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)")

View file

@ -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