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