270 lines
9.9 KiB
Python
270 lines
9.9 KiB
Python
"""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, 0–100) 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."
|
||
)
|