Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI)

This commit is contained in:
Flo Hartmann
2026-08-23 00:41:02 +00:00
parent 8c9f542b8c
commit 3b0626c774
18 changed files with 2247 additions and 18 deletions

View File

@@ -0,0 +1,269 @@
"""Gemeinsame Basis für die Web-Quellen-Adapter des Neuheiten-Plugins.
Enthält:
- `USER_AGENT`: der freundliche Bot-User-Agent, den alle Quellen senden.
- `WebQuellenClient`: HTTP-Client mit Rate-Limit (max. 1 Request/Sekunde
je Domain), Retry/Backoff bei 5xx/429 und konditionalen Requests
(If-None-Match/If-Modified-Since; HTTP 304 → Quelle unverändert).
- `QuellenTreffer`: das gemeinsame Zwischenformat aller Parser-Adapter
(titel, verlag, autor, erscheinungsdatum_oder_quartal, quellen_url).
- `QuellenAdapter`: Basisklasse für einen Quellen-Adapter. Der Standard-
Ablauf crawlt alle Seiten einer Quelle (Pagination wird über die
Folge-Links jeder Seite entdeckt) und übergibt das Parsing an die
Unterklasse (`seite_verarbeiten`). JS-lastige Quellen überschreiben
`sammle()` und nutzen stattdessen ihren Daten-Endpunkt direkt.
- Hilfsfunktionen: Jahr aus einer Datums-/Quartalsangabe, Titel-
Normalisierung und Fuzzy-Titelähnlichkeit (rapidfuzz) für den
Duplikatsvergleich gegen bestehende Einträge (z. B. aus BGG).
Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar —
die Testsuite führt keine echten Netzwerkaufrufe durch.
"""
from __future__ import annotations
import re
import time
import xml.etree.ElementTree # noqa: F401 (dokumentiert: keine XML-Nutzung hier)
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import ClassVar
import httpx
from rapidfuzz import fuzz
#: Freundlicher User-Agent für alle Redaktions-Crawler (Courtesy-Regeln).
USER_AGENT = "SpieleRedaktionBot/1.0 (Redaktions-Tool; Kontakt siehe Repo)"
JAHR_MUSTER = re.compile(r"\b(19|20)(\d{2})\b")
class QuellenFehler(Exception):
"""Fehler beim Abrufen oder Parsen einer Web-Quelle."""
@dataclass(frozen=True)
class QuellenTreffer:
"""Ein geparster Spieleintrag einer Web-Quelle (Zwischenformat).
`erscheinungsdatum_oder_quartal` ist die rohe Angabe der Quelle
(z. B. „24.07.2026“, „10/2026“, „Q3 2026“, „Herbst 2026“);
das Erscheinungsjahr für die Datenbank wird daraus abgeleitet.
"""
titel: str
quellen_url: str
verlag: str | None = None
autor: str | None = None
erscheinungsdatum_oder_quartal: str | None = None
@dataclass
class QuellenAntwort:
"""Ergebnis eines konditionalen GETs."""
status_code: int
content: bytes = b""
etag: str | None = None
last_modified: str | None = None
@dataclass
class SammelErgebnis:
"""Ergebnis eines Adapter-Laufs über eine Quelle."""
treffer: list[QuellenTreffer] = field(default_factory=list)
seiten: int = 0
#: True = mindestens eine Seite kam per 304 als unverändert zurück;
#: der Lauf wurde dann abgekürzt (Quelle übersprungen, kein Fehler).
unveraendert: bool = False
def jahr_aus_datumsangabe(angabe: str | None) -> int | None:
"""Leitet das Erscheinungsjahr aus einer Datums-/Quartalsangabe ab."""
if not angabe:
return None
fund = JAHR_MUSTER.search(angabe)
if fund:
return int(fund.group(0))
return None
def titel_normalisieren(titel: str) -> str:
"""Vergleichsschlüssel für Titel: kleingeschrieben, ohne Satzzeichen."""
geklart = re.sub(r"[^\wäöüß ]+", " ", titel.lower(), flags=re.UNICODE)
return " ".join(geklart.split())
def titel_aehnlichkeit(a: str, b: str) -> float:
"""Fuzzy-Titelähnlichkeit (token_set_ratio, 0100) für Duplikatsprüfung."""
if not a or not b:
return 0.0
return float(fuzz.token_set_ratio(a, b))
class WebQuellenClient:
"""HTTP-Client für Web-Quellen mit Rate-Limit und konditionalen Requests.
- Mindestens `mindestabstand_sekunden` (Standard 1 s) zwischen zwei
Requests (Courtesy-Rate-Limit je Domain).
- Sendet bekannte Validatoren als If-None-Match/If-Modified-Since;
HTTP 304 wird als „unverändert“ gemeldet (kein Retry, kein Fehler).
- 5xx/429 werden mit exponentiellem Backoff erneut versucht,
sonstige Statuscodes erzeugen eine klare `QuellenFehler`.
"""
def __init__(
self,
*,
transport: httpx.BaseTransport | None = None,
mindestabstand_sekunden: float = 1.0,
max_versuche: int = 4,
backoff_basis_sekunden: float = 1.0,
backoff_maximum_sekunden: float = 8.0,
timeout_sekunden: float = 60.0,
schlaf: Callable[[float], None] = time.sleep,
uhr: Callable[[], float] = time.monotonic,
validatoren: dict[str, tuple[str | None, str | None]] | None = None,
) -> None:
self.mindestabstand_sekunden = mindestabstand_sekunden
self.max_versuche = max_versuche
self.backoff_basis_sekunden = backoff_basis_sekunden
self.backoff_maximum_sekunden = backoff_maximum_sekunden
self._schlaf = schlaf
self._uhr = uhr
self._letzter_request_um: float | None = None
#: URL → (etag, last_modified); persistierbar über die Sync-Läufe.
self.validatoren: dict[str, tuple[str | None, str | None]] = dict(
validatoren or {}
)
self._http = httpx.Client(
timeout=timeout_sekunden,
transport=transport,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
)
def schliessen(self) -> None:
self._http.close()
def _rate_limit_abwarten(self) -> None:
if self._letzter_request_um is None:
return
vergangen = self._uhr() - self._letzter_request_um
rest = self.mindestabstand_sekunden - vergangen
if rest > 0:
self._schlaf(rest)
def _konditionale_header(self, url: str) -> dict[str, str]:
header: dict[str, str] = {}
etag, last_modified = self.validatoren.get(url, (None, None))
if etag:
header["If-None-Match"] = etag
if last_modified:
header["If-Modified-Since"] = last_modified
return header
def hole(self, url: str) -> QuellenAntwort:
"""GET mit Rate-Limit, Backoff und konditionalen Headern."""
letzte_fehler: Exception | None = None
for versuch in range(self.max_versuche):
self._rate_limit_abwarten()
try:
antwort = self._http.get(url, headers=self._konditionale_header(url))
except httpx.HTTPError as exc:
letzte_fehler = exc
self._schlaf(self._backoff(versuch))
continue
self._letzter_request_um = self._uhr()
if antwort.status_code == 304:
return QuellenAntwort(304)
if antwort.status_code == 200:
etag = antwort.headers.get("ETag")
last_modified = antwort.headers.get("Last-Modified")
if etag or last_modified:
self.validatoren[url] = (etag, last_modified)
return QuellenAntwort(
200, antwort.content, etag=etag, last_modified=last_modified
)
if antwort.status_code == 429 or antwort.status_code >= 500:
retry_after = antwort.headers.get("Retry-After")
try:
wartezeit = float(retry_after) if retry_after else None
except ValueError:
wartezeit = None
self._schlaf(wartezeit if wartezeit is not None else self._backoff(versuch))
letzte_fehler = QuellenFehler(
f"HTTP {antwort.status_code} von {url} (Versuch {versuch + 1})."
)
continue
raise QuellenFehler(
f"Unerwartete HTTP-Antwort {antwort.status_code} für {url}."
)
raise QuellenFehler(
f"Quelle nach {self.max_versuche} Versuchen nicht erreichbar ({url}):"
f" {letzte_fehler}"
)
def _backoff(self, versuch: int) -> float:
return min(
self.backoff_basis_sekunden * (2**versuch),
self.backoff_maximum_sekunden,
)
class QuellenAdapter:
"""Basisklasse eines Quellen-Adapters.
Klassenattribute:
- `name`: stabiler Kurzname (= Env-Suffix, z. B. „spielbox“ für
SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV) und Wert der Spalte `quelle`.
- `anzeigename`: deutscher Name für die Sync-Übersicht im UI.
- `start_urls`: Einstiegspunkte des Crawls.
- `max_seiten`: Sicherheitsgrenze gegen Endlos-Pagination.
HTML-Quellen implementieren `seite_verarbeiten`; JS-lastige Quellen
mit eigenem Daten-Endpunkt überschreiben stattdessen `sammle()`.
"""
name: ClassVar[str]
anzeigename: ClassVar[str]
start_urls: ClassVar[tuple[str, ...]] = ()
max_seiten: int = 100
def __init__(self, client: WebQuellenClient) -> None:
self.client = client
def sammle(self) -> SammelErgebnis:
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS)."""
ergebnis = SammelErgebnis()
warteschlange = list(self.start_urls)
besucht: set[str] = set()
while warteschlange and len(besucht) < self.max_seiten:
url = warteschlange.pop(0)
if url in besucht:
continue
besucht.add(url)
antwort = self.client.hole(url)
if antwort.status_code == 304:
# Unverändert → Quelle überspringen (kein Fehler).
ergebnis.unveraendert = True
break
treffer, folge_links = self.seite_verarbeiten(antwort.content, url)
ergebnis.treffer.extend(treffer)
ergebnis.seiten += 1
for link in folge_links:
if link not in besucht:
warteschlange.append(link)
return ergebnis
def seite_verarbeiten(
self, inhalt: bytes, url: str
) -> tuple[list[QuellenTreffer], list[str]]:
"""Parst eine Seite: liefert Treffer und Folge-Links (Pagination)."""
raise NotImplementedError(
f"{type(self).__name__} muss seite_verarbeiten oder sammle überschreiben."
)