Files
Flo Hartmann 96d6af8163 Coverbilder (bild_url) + responsives Layout für Mobil/Tablet
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.
2026-08-24 16:54:56 +00:00

389 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, 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
#: 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."
)