Teil 1 — Coverbilder: - Migration 0004_bild_url (neuheiten), 0002_bild_url (planung, archiv mit beiden Spiegel-Tabellen); nullable VARCHAR(500), SQLite↔Postgres portabel, bestehende Zeilen bleiben erhalten - BGG-Client extrahiert Cover aus der Thing-Antwort (bevorzugt <image>, sonst <thumbnail>; protokoll-relative URLs → https) - Sync-Upsert pflegt bild_url beim Update; fehlendes Bild löscht keines - Web-Quellen übernehmen img-URLs aus den Listenelementen (src/data-src, relative Pfade gegen die Seiten-URL aufgelöst); SPIEL-Essen nutzt das BILDER-Feld des JSON-Endpunkts - Verschiebung Neuheiten→Planung und Archivierung/Wiederherstellen schreiben bild_url mit - Anzeige: kleine Vorschaubilder (40x56) in den Listen, groß (max 200px) in Planung-Bearbeiten/Prüf-Dialog; Platzhalter-Div ohne Bild; Hotlinking mit loading=lazy + decoding=async Teil 2 — Responsives Layout: - Basislayout: Burger-Menü (Alpine.js) auf Mobil, Desktop-Leiste ab md: - Listen kollabieren <768px zu Karten-Ansichten (Cover links, Titel + Metadaten rechts, Status-Badge oben); Desktop behält Tabellenform - Touch-Ziele >= 44px auf Mobil, Formulare vollbreit mit Labels oben - Sync-Übersicht/Audit-Log bleiben horizontal scrollbare Tabellen Tests: 15 neue Fälle (tests/test_covers.py) — Migration inkl. Daten-Erhalt, BGG-Parsing gemockt, Quellen-Übernahme, Template-Render; 316 Tests grün. README aktualisiert.
389 lines
15 KiB
Python
389 lines
15 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 logging
|
||
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
|
||
|
||
from .ki_hilfe import KiHilfe
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
#: 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
|
||
#: Bild-URL der Quelle (img im Listenelement), sofern vorhanden; wird
|
||
#: direkt eingebettet (Hotlinking), nicht heruntergeladen.
|
||
bild_url: str | None = None
|
||
|
||
|
||
def _bild_url_normalisieren(url: str, basis_url: str | None) -> str | None:
|
||
"""Macht aus einer rohen Bildangabe eine absolute http(s)-URL.
|
||
|
||
Protokoll-relative URLs (`//host/bild.jpg`) erhalten https voran,
|
||
pfadrelative Angaben werden gegen die Seiten-URL aufgelöst; alles
|
||
andere (data:-URIs etc.) wird verworfen.
|
||
"""
|
||
url = url.strip()
|
||
if url.startswith("//"):
|
||
url = f"https:{url}"
|
||
elif not url.startswith(("http://", "https://")):
|
||
if basis_url is None:
|
||
return None
|
||
from urllib.parse import urljoin
|
||
|
||
url = urljoin(basis_url, url)
|
||
return url if url.startswith(("http://", "https://")) else None
|
||
|
||
|
||
def bild_aus_element(element, basis_url: str | None = None) -> str | None:
|
||
"""Liest die erste sinnvolle Bild-URL aus einem HTML-Element.
|
||
|
||
Akzeptiert `src` und gängige Lazy-Loading-Attribute (`data-src`,
|
||
`data-srcset`-Erster); protokoll- und pfadrelative URLs werden gegen
|
||
`basis_url` (die Seiten-URL) aufgelöst. Kein Bild → None.
|
||
"""
|
||
if element is None:
|
||
return None
|
||
bild = element.find("img") if hasattr(element, "find") else None
|
||
if bild is None:
|
||
return None
|
||
for attribut in ("src", "data-src"):
|
||
url = bild.get(attribut)
|
||
if url and url.strip():
|
||
return _bild_url_normalisieren(url, basis_url)
|
||
srcset = bild.get("data-srcset") or bild.get("srcset")
|
||
if srcset:
|
||
erster = srcset.split(",")[0].strip().split(" ")[0]
|
||
if erster:
|
||
return _bild_url_normalisieren(erster, basis_url)
|
||
return 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
|
||
#: KI-Feldkorrekturen dieses Laufs (Protokoll fürs Audit-Log); leer bei
|
||
#: rein regelbasiertem Lauf.
|
||
ki_eingriffe: list[dict] = field(default_factory=list)
|
||
#: Von der KI vorgeschlagene und tatsächlich verfolgte Folge-URLs
|
||
#: (Struktur-Erkennung); leer bei rein regelbasiertem Lauf.
|
||
ki_folge_urls: list[str] = field(default_factory=list)
|
||
|
||
|
||
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
|
||
#: Optionale LLM-Hilfsstufe (`ki_hilfe.KiHilfe` oder gleiches Protokoll).
|
||
#: None (Standard) = rein regelbasiert; KI-Fehler blockieren den Lauf nie.
|
||
self.ki_hilfe: KiHilfe | None = None
|
||
|
||
def sammle(self) -> SammelErgebnis:
|
||
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS).
|
||
|
||
Mit gesetzter `ki_hilfe`: Bei verdächtig leer geparsten Seiten darf
|
||
das LLM Pagination-/Filter-Folge-URLs vorschlagen (nur gleiche
|
||
Domain, max. 20), unsichere Treffer werden per LLM nachgebessert
|
||
(nur titel/verlag/autor, nie die URL). Jeder KI-Fehler fällt auf den
|
||
klassischen Befund zurück.
|
||
"""
|
||
ergebnis = SammelErgebnis()
|
||
warteschlange = list(self.start_urls)
|
||
besucht: set[str] = set()
|
||
ki_vorschlaege: set[str] = set()
|
||
while warteschlange and len(besucht) < self.max_seiten:
|
||
url = warteschlange.pop(0)
|
||
if url in besucht:
|
||
continue
|
||
besucht.add(url)
|
||
try:
|
||
antwort = self.client.hole(url)
|
||
except Exception:
|
||
if url in ki_vorschlaege:
|
||
# Eine nicht erreichbare KI-Suggestion darf den klassischen
|
||
# Lauf nie abbrechen — überspringen statt Fehler.
|
||
_logger.warning(
|
||
"KI-vorgeschlagene URL nicht erreichbar (%s) — übersprungen.",
|
||
url,
|
||
)
|
||
continue
|
||
raise
|
||
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)
|
||
if self.ki_hilfe is not None:
|
||
for link in self._ki_folge_urls(treffer, antwort.content, url, besucht):
|
||
if link not in besucht and link not in warteschlange:
|
||
warteschlange.append(link)
|
||
ki_vorschlaege.add(link)
|
||
ergebnis.ki_folge_urls.append(link)
|
||
if self.ki_hilfe is not None:
|
||
self._ki_nachbessern(ergebnis)
|
||
return ergebnis
|
||
|
||
def _ki_folge_urls(
|
||
self,
|
||
treffer: list[QuellenTreffer],
|
||
inhalt: bytes,
|
||
url: str,
|
||
besucht: set[str],
|
||
) -> list[str]:
|
||
"""Struktur-Erkennung der Hilfsstufe (best effort — wirft nicht)."""
|
||
try:
|
||
return self.ki_hilfe.folge_urls_bei_bedarf(
|
||
len(treffer), inhalt, url, besucht
|
||
)
|
||
except Exception:
|
||
_logger.exception(
|
||
"KI-Struktur-Erkennung fehlgeschlagen (%s) — klassischer Lauf bleibt.",
|
||
url,
|
||
)
|
||
return []
|
||
|
||
def _ki_nachbessern(self, ergebnis: SammelErgebnis) -> None:
|
||
"""Feld-Korrektur unsicherer Treffer (best effort — wirft nicht)."""
|
||
try:
|
||
korrigiert, eingriffe = self.ki_hilfe.treffer_nachbessern(ergebnis.treffer)
|
||
except Exception:
|
||
_logger.exception(
|
||
"KI-Feldkorrektur fehlgeschlagen — klassischer Parserbefund bleibt."
|
||
)
|
||
return
|
||
if korrigiert is not None:
|
||
ergebnis.treffer = korrigiert
|
||
ergebnis.ki_eingriffe = eingriffe or []
|
||
|
||
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."
|
||
)
|