Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI)
This commit is contained in:
269
plugins/neuheiten/quellen/basis.py
Normal file
269
plugins/neuheiten/quellen/basis.py
Normal 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, 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."
|
||||
)
|
||||
Reference in New Issue
Block a user