Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI)
This commit is contained in:
314
plugins/neuheiten/quellen_sync.py
Normal file
314
plugins/neuheiten/quellen_sync.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Sync-Orchestrierung für die Web-Quellen des Neuheiten-Plugins.
|
||||
|
||||
Pro Quelle:
|
||||
- Env-Schalter SPIELE_NEUHEITEN_QUELLE_<NAME>_AKTIV (Standard „1“ = an).
|
||||
- Adapter-Lauf mit konditionalen Requests (ETag/Last-Modified werden in
|
||||
der Statuszeile persistiert; 304 → Quelle übersprungen, kein Fehler).
|
||||
- Fehler-Isolation: eine kaputte Quelle bricht den Gesamtdurchlauf nicht
|
||||
ab; ihr Ergebnis wird als Fehler protokolliert, die anderen Quellen
|
||||
laufen normal weiter.
|
||||
- Duplikatvermeidung: Web-Quellen haben keine BGG-ID, daher wird jeder
|
||||
Treffer vor dem Speichern fuzzy gegen die bestehende Liste geprüft
|
||||
(Titel-Match, rapidfuzz) — Treffer gegen BGG-Einträge werden
|
||||
übersprungen. Zusätzlich läuft jeder neue Treffer durch die öffentliche
|
||||
dedup-Prüfung (via Plugin-Registry, sofern das Plugin geladen ist).
|
||||
- Upsert: bereits vorhandene Treffer derselben Quelle (gleicher Titel)
|
||||
werden aktualisiert statt doppelt angelegt; neue Einträge bekommen
|
||||
quelle = '<name>' statt 'boardgamegeek' und keine BGG-ID.
|
||||
|
||||
Das Ergebnis jedes Laufes wird pro Quelle in `neuheiten_quellen_status`
|
||||
protokolliert und im Plugin-UI unter /neuheiten angezeigt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from .models import STATUS_NEUHEIT, Neuheit, QuellenStatus
|
||||
from .quellen import (
|
||||
ADAPTER_KLASSEN,
|
||||
QuellenAdapter,
|
||||
QuellenTreffer,
|
||||
WebQuellenClient,
|
||||
jahr_aus_datumsangabe,
|
||||
titel_aehnlichkeit,
|
||||
titel_normalisieren,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("plugins.neuheiten")
|
||||
|
||||
#: Schwelle für den Fuzzy-Titel-Match gegen bestehende Einträge (0–100).
|
||||
TITEL_FUZZY_SCHWELLE = 92.0
|
||||
#: Zweite Regel: etwas lockererer Titel-Match, dafür gleicher/ähnlicher Verlag.
|
||||
TITEL_FUZZY_MIT_VERLAG_SCHWELLE = 85.0
|
||||
VERLAG_FUZZY_SCHWELLE = 85.0
|
||||
|
||||
ENV_SCHABLONE_AKTIV = "SPIELE_NEUHEITEN_QUELLE_{name}_AKTIV"
|
||||
|
||||
|
||||
def quelle_aktiv(name: str, umgebung=None) -> bool:
|
||||
"""Liest den Env-Schalter einer Quelle (Standard: an)."""
|
||||
env = umgebung if umgebung is not None else os.environ
|
||||
wert = env.get(ENV_SCHABLONE_AKTIV.format(name=name.upper()), "1")
|
||||
return str(wert).strip() != "0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuellenLaufZeile:
|
||||
"""Ergebnis einer Quelle in einem Sync-Lauf (Grundlage der UI-Übersicht)."""
|
||||
|
||||
quelle: str
|
||||
anzeigename: str
|
||||
aktiv: bool = True
|
||||
neu: int = 0
|
||||
aktualisiert: int = 0
|
||||
gefiltert: int = 0
|
||||
fehleranzahl: int = 0
|
||||
fehlermeldung: str | None = None
|
||||
unveraendert: bool = False
|
||||
seiten: int = 0
|
||||
dauer_sekunden: float = 0.0
|
||||
url: str | None = None
|
||||
|
||||
def als_text(self) -> str:
|
||||
if not self.aktiv:
|
||||
return f"{self.anzeigename}: ausgeschaltet"
|
||||
if self.fehlermeldung:
|
||||
return f"{self.anzeigename}: Fehler — {self.fehlermeldung}"
|
||||
if self.unveraendert:
|
||||
return f"{self.anzeigename}: unverändert (304) — übersprungen"
|
||||
return (
|
||||
f"{self.anzeigename}: {self.neu} neu, {self.aktualisiert} aktualisiert, "
|
||||
f"{self.gefiltert} gefiltert (Duplikate)"
|
||||
)
|
||||
|
||||
|
||||
class WebQuellenSyncService:
|
||||
"""Führt einen Sync-Lauf über alle Web-Quellen aus.
|
||||
|
||||
`dedup` ist die öffentliche API des dedup-Plugins (via Plugin-Registry)
|
||||
oder None, wenn das Plugin nicht geladen ist — die Prüfung ist dann
|
||||
optional und wird übersprungen. `client_fabrik` baut den HTTP-Client
|
||||
(in Tests mit Fake-Transport überschrieben).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: sessionmaker,
|
||||
*,
|
||||
adapter_klassen: tuple[type[QuellenAdapter], ...] = ADAPTER_KLASSEN,
|
||||
dedup=None,
|
||||
client_fabrik: Callable[[dict], WebQuellenClient] | None = None,
|
||||
umgebung=None,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.adapter_klassen = adapter_klassen
|
||||
self.dedup = dedup
|
||||
self.umgebung = umgebung if umgebung is not None else os.environ
|
||||
self._client_fabrik = client_fabrik or self._standard_client_fabrik
|
||||
|
||||
# ---------- Gesamtdurchlauf ----------
|
||||
|
||||
def laufe(self) -> list[QuellenLaufZeile]:
|
||||
zeilen: list[QuellenLaufZeile] = []
|
||||
for klasse in self.adapter_klassen:
|
||||
try:
|
||||
zeile = self._eine_quelle(klasse)
|
||||
except Exception as exc: # letzte Verteidigungslinie pro Quelle
|
||||
_logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name)
|
||||
zeile = QuellenLaufZeile(
|
||||
klasse.name,
|
||||
klasse.anzeigename,
|
||||
fehleranzahl=1,
|
||||
fehlermeldung=str(exc)[:300],
|
||||
)
|
||||
self._status_speichern(zeile)
|
||||
zeilen.append(zeile)
|
||||
return zeilen
|
||||
|
||||
def gesamt_text(self, zeilen: list[QuellenLaufZeile]) -> str:
|
||||
"""Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll."""
|
||||
teile = [zeile.als_text() for zeile in zeilen]
|
||||
return " · ".join(teile) if teile else "Keine Web-Quellen konfiguriert."
|
||||
|
||||
# ---------- Eine Quelle ----------
|
||||
|
||||
def _eine_quelle(self, klasse: type[QuellenAdapter]) -> QuellenLaufZeile:
|
||||
zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename)
|
||||
zeile.url = klasse.start_urls[0] if klasse.start_urls else None
|
||||
if not quelle_aktiv(klasse.name, self.umgebung):
|
||||
zeile.aktiv = False
|
||||
return zeile
|
||||
|
||||
start = time.monotonic()
|
||||
client = self._client_fabrik(self._validatoren_laden(klasse.name))
|
||||
try:
|
||||
adapter = klasse(client)
|
||||
try:
|
||||
ergebnis = adapter.sammle()
|
||||
except Exception as exc:
|
||||
_logger.warning("Quelle %s fehlgeschlagen: %s", klasse.name, exc)
|
||||
zeile.fehleranzahl = 1
|
||||
zeile.fehlermeldung = str(exc)[:300]
|
||||
return zeile
|
||||
zeile.seiten = ergebnis.seiten
|
||||
if ergebnis.unveraendert:
|
||||
zeile.unveraendert = True
|
||||
else:
|
||||
self._treffer_verarbeiten(zeile, ergebnis.treffer, klasse.name)
|
||||
finally:
|
||||
client.schliessen()
|
||||
zeile.dauer_sekunden = round(time.monotonic() - start, 2)
|
||||
self._validatoren_speichern(klasse.name, zeile.url, client)
|
||||
return zeile
|
||||
|
||||
def _treffer_verarbeiten(
|
||||
self, zeile: QuellenLaufZeile, treffer: list[QuellenTreffer], quelle: str
|
||||
) -> None:
|
||||
asyncio.run(self._verarbeite_async(zeile, treffer, quelle))
|
||||
|
||||
async def _verarbeite_async(
|
||||
self, zeile: QuellenLaufZeile, treffer: list[QuellenTreffer], quelle: str
|
||||
) -> None:
|
||||
with self.session_factory() as db:
|
||||
try:
|
||||
for einzel in treffer:
|
||||
if not einzel.titel or not einzel.titel.strip():
|
||||
zeile.gefiltert += 1
|
||||
continue
|
||||
if self._eigenen_eintrag_aktualisieren(db, einzel, quelle):
|
||||
zeile.aktualisiert += 1
|
||||
continue
|
||||
if self._ist_duplikat(db, einzel):
|
||||
zeile.gefiltert += 1
|
||||
continue
|
||||
if not await self._dedup_erklaert_kein_konflikt(einzel):
|
||||
zeile.gefiltert += 1
|
||||
continue
|
||||
db.add(
|
||||
Neuheit(
|
||||
titel=einzel.titel.strip()[:300],
|
||||
verlag=einzel.verlag,
|
||||
autor=einzel.autor,
|
||||
erscheinungsjahr=jahr_aus_datumsangabe(
|
||||
einzel.erscheinungsdatum_oder_quartal
|
||||
),
|
||||
bgg_id=None,
|
||||
status=STATUS_NEUHEIT,
|
||||
quelle=quelle,
|
||||
)
|
||||
)
|
||||
zeile.neu += 1
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
# ---------- Duplikate / Upsert ----------
|
||||
|
||||
def _eigenen_eintrag_aktualisieren(
|
||||
self, db, treffer: QuellenTreffer, quelle: str
|
||||
) -> bool:
|
||||
"""Update statt Duplikat: gleicher Titel + gleiche Quelle."""
|
||||
schluessel = titel_normalisieren(treffer.titel)
|
||||
vorhanden = db.scalars(select(Neuheit).where(Neuheit.quelle == quelle)).all()
|
||||
for eintrag in vorhanden:
|
||||
if titel_normalisieren(eintrag.titel) == schluessel:
|
||||
eintrag.verlag = treffer.verlag
|
||||
eintrag.autor = treffer.autor
|
||||
eintrag.erscheinungsjahr = jahr_aus_datumsangabe(
|
||||
treffer.erscheinungsdatum_oder_quartal
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _ist_duplikat(self, db, treffer: QuellenTreffer) -> bool:
|
||||
"""Titel-Match gegen bestehende Einträge (z. B. aus BGG).
|
||||
|
||||
BGG-IDs haben Web-Quellen nicht — gematcht wird fuzzy auf den
|
||||
Titel; bei Grenzfällen entscheidet ein ähnlicher Verlag mit.
|
||||
"""
|
||||
for eintrag in db.scalars(select(Neuheit)).all():
|
||||
if titel_aehnlichkeit(treffer.titel, eintrag.titel) >= TITEL_FUZZY_SCHWELLE:
|
||||
return True
|
||||
if (
|
||||
treffer.verlag
|
||||
and eintrag.verlag
|
||||
and titel_aehnlichkeit(treffer.titel, eintrag.titel)
|
||||
>= TITEL_FUZZY_MIT_VERLAG_SCHWELLE
|
||||
and titel_aehnlichkeit(treffer.verlag, eintrag.verlag)
|
||||
>= VERLAG_FUZZY_SCHWELLE
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _dedup_erklaert_kein_konflikt(self, treffer: QuellenTreffer) -> bool:
|
||||
"""Öffentliche dedup-Prüfung (sofern Plugin geladen); Fehler tolerieren."""
|
||||
if self.dedup is None:
|
||||
return True
|
||||
try:
|
||||
ergebnis = await self.dedup.check_titel(treffer.titel, treffer.verlag, None)
|
||||
except Exception as exc:
|
||||
_logger.warning("dedup-Prüfung fehlgeschlagen (%s): %s", treffer.titel, exc)
|
||||
return True
|
||||
if ergebnis is None:
|
||||
return True
|
||||
return not ergebnis.hat_konflikte
|
||||
|
||||
# ---------- Statusprotokoll ----------
|
||||
|
||||
def _status_speichern(self, zeile: QuellenLaufZeile) -> None:
|
||||
with self.session_factory() as db:
|
||||
eintrag = db.scalar(
|
||||
select(QuellenStatus).where(QuellenStatus.quelle == zeile.quelle)
|
||||
)
|
||||
if eintrag is None:
|
||||
eintrag = QuellenStatus(quelle=zeile.quelle, url=zeile.url or "")
|
||||
db.add(eintrag)
|
||||
if zeile.url:
|
||||
eintrag.url = zeile.url
|
||||
eintrag.letzte_laufzeit = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
eintrag.dauer_sekunden = zeile.dauer_sekunden
|
||||
eintrag.anzahl_neu = zeile.neu
|
||||
eintrag.anzahl_aktualisiert = zeile.aktualisiert
|
||||
eintrag.anzahl_gefiltert = zeile.gefiltert
|
||||
eintrag.anzahl_fehler = zeile.fehleranzahl
|
||||
eintrag.fehlermeldung = zeile.fehlermeldung
|
||||
eintrag.unveraendert = zeile.unveraendert
|
||||
db.commit()
|
||||
|
||||
def _validatoren_laden(self, quelle: str) -> dict:
|
||||
with self.session_factory() as db:
|
||||
eintrag = db.scalar(
|
||||
select(QuellenStatus).where(QuellenStatus.quelle == quelle)
|
||||
)
|
||||
return dict(eintrag.validatoren) if eintrag is not None else {}
|
||||
|
||||
def _validatoren_speichern(
|
||||
self, quelle: str, url: str | None, client: WebQuellenClient
|
||||
) -> None:
|
||||
if not client.validatoren:
|
||||
return
|
||||
with self.session_factory() as db:
|
||||
eintrag = db.scalar(
|
||||
select(QuellenStatus).where(QuellenStatus.quelle == quelle)
|
||||
)
|
||||
if eintrag is None:
|
||||
eintrag = QuellenStatus(quelle=quelle, url=url or "")
|
||||
db.add(eintrag)
|
||||
eintrag.validatoren = client.validatoren
|
||||
db.commit()
|
||||
|
||||
# ---------- Hilfen ----------
|
||||
|
||||
@staticmethod
|
||||
def _standard_client_fabrik(validatoren) -> WebQuellenClient:
|
||||
return WebQuellenClient(validatoren=validatoren)
|
||||
Reference in New Issue
Block a user