@ -55,31 +55,49 @@ 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", "kaartje", "rails"): moet met een ander
# Gewicht 1 (zwak/dubbelzinnig: los "bus", "breng", "overweg", "overwegen", "sein",
# OV-woord combineren. Weert zo "VW-bus"/"schoolbus", "weer op de rails" en
# "kaartje", "opwaarderen", "rails"): moet met een ander OV-woord combineren (een
# 112-berichten met "bussen rijden om" in de lead, zonder eenduidige
# tweede, andere familie — dezelfde zwakke familie in titel én samenvatting telt
# bushalte/overweg/NS/trein-berichten te raken.
# niet als combinatie). Weert zo "VW-bus"/"schoolbus", "weer op de rails", het
# 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|breng|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|ov) \ b " , 3 ) , # vervoerders
( 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(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking) \ b " , 3 ) , # spoorinfra
( 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 " \ bstation \ w* \ b " , 3 ) , # station(s)
( r " (?<![ \ w-]) \ bstation \ w* \ b " , 3 ) , # station(s); niet ná een koppelteken ('radio-station'); niet-OV-vormen staan in de blacklist
( r " \ b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking) \ b " , 3 ) , # voertuig-samenstelling
( 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 " \ btrein \ w* \ b " , 3 ) , # trein(verkeer/staking/ongeluk/...)
( r " \ b \ w*trein \ w* \ b " , 3 ) , # trein(verkeer/staking/…), ook samenstellingen: stoptrein, goederentrein, sprinttrein
( 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 " \ btram \ b " , 2 ) ,
( r " \ btram s? \ b " , 2 ) ,
( r " \ bmetro \ b " , 2 ) ,
( r " \ bmetro (?:' ?s)? \ b " , 2 ) ,
( r " \ b(?:openbaar vervoer|dienstregeling|inchecken|uitchecken|lightrail|pendelbus|shuttlebus ) \ b " , 2 ) ,
( r " \ b(?:openbaar vervoer|dienstregeling|inchecken|uitchecken|lightrail|pendelbus|shuttlebus |halte|bushokje|busbaan|vervoerder|reiziger)\ w* \ b " , 2 ) ,
( r " \ b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs |opwaarderen) \ b " , 2 ) ,
( r " \ b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs )\ w* \ b " , 2 ) ,
( r " \ b(?:ov-staking|conducteursstaking) \ b" , 2 ) ,
( r " \ b(?:ov-staking|conducteursstaking) \ w*\ 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 ) ,
( r " (?<!-) \ bbus(?:sen)? \ b " , 1 ) , # ná een koppelteken geen OV: 'VW-bus', 'school-bus'
( 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 ) ,
]
]
@ -98,6 +116,12 @@ 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 ] ] = [
@ -166,12 +190,18 @@ 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 ( )
@ -219,20 +249,27 @@ class RSSBot(Plugin):
self . log . exception ( " Fatal error while polling feeds " )
self . log . exception ( " Fatal error while polling feeds " )
@staticmethod
@staticmethod
def _fam_score ( text : str ) - > int :
def _filter_text ( raw : str ) - > str :
""" Som van gewichten over de OV-families die in text voorkomen (1× per familie).
""" Platte tekst voor het match-filter: eerst entities decoderen, dán tags
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 . """
total = 0
hits: list [ tuple [ int , int ] ] = [ ]
for family , weight in _compiled_match_families :
for idx, ( family, weight ) in enumerate ( _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
total + = weight
hits. append ( ( idx , weight ) )
break
break
return total
return hits
@staticmethod
@staticmethod
def _filter_passes ( title : str , summary : str ) - > bool :
def _filter_passes ( title : str , summary : str ) - > bool :
@ -240,24 +277,37 @@ 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 ' , ' kaartje ' , ' rails ' ) moeten
OV - woord bijkomen . Zwakke woorden ( losse ' bus ' , ' breng ' , ' overweg ' , ' overwegen ' ,
combineren met een ander OV - woord . Weert oa ' VW-bus ' , ' schoolbus ' ,
' sein ' , ' opwaarderen ' , ' kaartje ' , ' rails ' ) moeten combineren met een ander
' bussen rijden om ' in een woningbrand - lead en figuratief ' weer op de rails ' .
OV - woord ; dezelfde zwakke familie in titel én samenvatting is géén combinatie .
Bewust doorgelaten : krant ' De Metro ' / café ' De Tram ' in de titel ( medium ) . """
Weert oa ' VW-bus ' ( bus matcht niet ná een koppelteken ) , ' schoolbus ' , ' bussen
return 2 * RSSBot . _fam_score ( title ) + RSSBot . _fam_score ( summary ) > = MATCH_FILTER_THRESHOLD
rijden om ' in een woningbrand-lead, het werkwoord ' brengen ' , het idioom ' goed
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, Qbuzz, ...), spoorinfra (perron, overweg, sein), "
" - Sterk (3): vervoerders (NS, Arriva, ProRail, Qbuzz, ...), spoorinfra (spoorlijn, spoorwegovergang, seinstoring, perro n), "
" station(s), bushalte/buslijn/treinstel, trein \n "
" station(s), bushalte/buslijn/ bushokje/busstation, trein, intercity/sprinter/streekvervoer/veerdienst/buurtbus, ' lijn 12 ' \n "
" - Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, "
" - Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, halte, bushokje, busbaan, vervoerder, reiziger, "
" ov-chipkaart/ovpay/reisproduct, ov-staking, pont \n "
" ov-chipkaart/ovpay/reisproduct, ov-staking, pont \n "
" - Zwak (1): losse ' bus ' , ' kaartje ' , ' rails ' — moet combineren met een ander OV-woord \n \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 "
" 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 ' , ...) \n \n "
" o.a. straatnamen rond stations ( ' stationsstraat ' , ' stationsweg ' , ...) en niet-OV-vormen als ' stationcar ' en ' stationair ' \n \n "
" Doorlaten: `2× (titel-score) + 1× (samenvatting-score) ≥ 3` "
" Doorlaten: `2× (titel-score) + 1× (samenvatting-score) ≥ 3` "
)
)
@ -265,7 +315,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 = html . unescape ( re . sub ( r " <[^>]+> " , " " , entry . summary ) )
summary = self . _filter_text ( entry . summary )
if not self . _filter_passes ( entry . title , summary ) :
if not self . _filter_passes ( entry . title , summary ) :
return None
return None
@ -314,7 +364,11 @@ class RSSBot(Plugin):
timeout = aiohttp . ClientTimeout ( total = 20 ) ,
timeout = aiohttp . ClientTimeout ( total = 20 ) ,
)
)
data = await resp . json ( )
data = await resp . json ( )
if data . get ( " success " ) :
if not 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 (
@ -355,7 +409,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 = html . unescape ( re . sub ( r " <[^>]+> " , " " , entry . summary ) )
summary = self . _filter_text ( 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 " ]
@ -548,17 +602,21 @@ 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 } " )
ct = resp . headers [ " Content-Type " ] . split ( " ; " ) [ 0 ] . strip ( )
return feed , entries
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 (
@ -575,7 +633,10 @@ 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 " , " " )
return feed , [ cls . _parse_json_entry ( feed . id , entry ) for entry in content [ " items " ] ]
entries = [ 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 :