Plugin neuheiten: BGG-Sync mit APScheduler, Filtern und deutscher UI

- Datenmodell + eigene Migration 0001_neuheiten_tabelle (Tabelle neuheiten:
  Titel, Verlag, Autor, Erscheinungsjahr, BGG-ID, Status 'neuheit', Quelle,
  Zeitstempel; bgg_id eindeutig als Merge-Kriterium)
- BoardGameGeek XML API2-Client (search + thing, Batches à 20 IDs):
  Rate-Limit >= 1 s zwischen Requests, Retry mit exponentiellem Backoff bei
  5xx/429/Netzwerkfehlern, HTTP 202 gemäß Retry-After, robustes XML-Parsing;
  Transport/Uhr/Sleep injizierbar (keine echten Calls in Tests)
- Filter: Erweiterungen (boardgameexpansion) auf Request- und Elementebene
  ausgeschlossen; Prototypen per Titel-Heuristik (BGG hat keinen Marker)
- Sync-Service mit Update-statt-Duplikat-Logik über die eindeutige BGG-ID
  (Status bleibt erhalten); Fehler je Suchbegriff brechen den Lauf nicht ab
- APScheduler-Hintergrundjob (Standard 24 h) mit Überlappungsschutz,
  abschaltbar/intervallkonfigurierbar per Env; manueller
  'Jetzt synchronisieren'-Endpunkt nur für Admin/Redakteur, optional mit
  Sofort-Suchbegriff
- UI /neuheiten: sortier-/filterbare Tabelle mit Volltextsuche (HTMX-Teilladung,
  noscript-fähig), deutsche Oberfläche, BGG-Links, Ergebnis-Banner
- Plugin-Loader: idempotentes Laden (Modul-Caching), damit mehrere
  create_app()-Aufrufe dieselben Plugin-Klassen/Tabellen nutzen
- Tests: Parsing, Erweiterungs-/Prototyp-Filter, Rate-Limit/Backoff/202,
  Update-statt-Duplikat, Rollen am Sync-Endpunkt, Scheduler-Lifecycle —
  ausschließlich mit gemockten BGG-Antworten (uv run pytest: 96 grün)
- README/AGENTS: Plugin-Doku, Env-Variablen, Fortschrittstabelle aktualisiert
This commit is contained in:
ox-alpha
2026-08-21 19:10:43 +00:00
parent 83d574d95c
commit 56cc5943b1
16 changed files with 1732 additions and 28 deletions

265
plugins/neuheiten/bgg.py Normal file
View File

@@ -0,0 +1,265 @@
"""Client für die BoardGameGeek XML API2 (https://boardgamegeek.com/xmlapi2).
Eigenschaften:
- Rate-Limit: mindestens `mindestabstand_sekunden` (Standard 1 s) zwischen
zwei HTTP-Requests.
- Retry mit exponentiellem Backoff bei 5xx/429; HTTP 202 (BGG-Warteschlange)
wird gemäß Retry-After-Header erneut versucht.
- Robustes XML-Parsing: fehlende Elemente werden toleriert, kaputte
Antworten erzeugen eine klare `BggFehler`-Ausnahme.
- Filter: Suchanfragen und Thing-Abfragen sind auf `type=boardgame`
beschränkt; Erweiterungen (`boardgameexpansion`) werden zusätzlich auf
Elementebene herausgefiltert.
Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar —
es gibt keine echten Netzwerk-Aufrufe in der Testsuite.
"""
from __future__ import annotations
import time
import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from typing import Any
import httpx
BASIS_URL = "https://boardgamegeek.com/xmlapi2"
USER_AGENT = "spiele-redaktion-neuheiten/0.1 (+https://github.com/local)"
TYP_BRETTSPIEL = "boardgame"
TYP_ERWEITERUNG = "boardgameexpansion"
THING_BATCH_GROESSE = 20
class BggFehler(Exception):
"""Fehler bei der Kommunikation mit oder dem Parsen der BGG-API."""
@dataclass(frozen=True)
class SuchTreffer:
"""Ein Treffer aus der BGG-Suche."""
bgg_id: int
titel: str
erscheinungsjahr: int | None
@dataclass(frozen=True)
class BggSpiel:
"""Ein vollständiges Spiel aus der Thing-Abfrage."""
bgg_id: int
titel: str
verlag: str | None
autor: str | None
erscheinungsjahr: int | None
typ: str = TYP_BRETTSPIEL
def _attribut_wert(element: ET.Element | None, tag: str) -> str | None:
"""Liest `<tag><value>…</value></tag>` tolerant aus."""
if element is None:
return None
kind = element.find(tag)
if kind is None:
return None
wert = kind.get("value")
return wert.strip() if wert and wert.strip() else None
def _jahr(wert: str | None) -> int | None:
if not wert:
return None
try:
return int(wert)
except ValueError:
return None
def _verketten(element: ET.Element | None, link_typ: str) -> str | None:
"""Verbindet alle `<link type="" value=""/>` eines Typs zu einem String."""
if element is None:
return None
werte = [
link.get("value", "").strip()
for link in element.findall("link")
if link.get("type") == link_typ and link.get("value", "").strip()
]
return ", ".join(werte) if werte else None
def parse_suche(xml_daten: bytes | str) -> list[SuchTreffer]:
"""Parst eine Search-Antwort; nicht-Brettspiele werden verworfen."""
try:
wurzel = ET.fromstring(xml_daten)
except ET.ParseError as exc:
raise BggFehler(f"Ungültiges XML in der Suchantwort: {exc}") from exc
treffer: list[SuchTreffer] = []
for element in wurzel.findall("item"):
if element.get("type") != TYP_BRETTSPIEL:
continue # Erweiterungen/Prototyp-Typen auf Elementebene ausschließen
id_roh = element.get("id")
titel = _attribut_wert(element, "name")
if not id_roh or not titel:
continue
try:
bgg_id = int(id_roh)
except ValueError:
continue
treffer.append(
SuchTreffer(
bgg_id=bgg_id,
titel=titel,
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
)
)
return treffer
def parse_things(xml_daten: bytes | str) -> list[BggSpiel]:
"""Parst eine Thing-Antwort; Erweiterungen werden verworfen."""
try:
wurzel = ET.fromstring(xml_daten)
except ET.ParseError as exc:
raise BggFehler(f"Ungültiges XML in der Thing-Antwort: {exc}") from exc
spiele: list[BggSpiel] = []
for element in wurzel.findall("item"):
typ = element.get("type") or TYP_BRETTSPIEL
if typ != TYP_BRETTSPIEL:
continue # boardgameexpansion & Co. ausschließen
id_roh = element.get("id")
titel = _attribut_wert(element, "name")
if not id_roh or not titel:
continue
try:
bgg_id = int(id_roh)
except ValueError:
continue
spiele.append(
BggSpiel(
bgg_id=bgg_id,
titel=titel,
verlag=_verketten(element, "boardgamepublisher"),
autor=_verketten(element, "boardgamedesigner"),
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
typ=typ,
)
)
return spiele
def _stuecke(werte: Sequence[int], groesse: int) -> Iterable[Sequence[int]]:
for start in range(0, len(werte), groesse):
yield werte[start : start + groesse]
class BggClient:
"""HTTP-Client für die XML API2 mit Rate-Limit und Retry/Backoff."""
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 = 30.0,
schlaf: Callable[[float], None] = time.sleep,
uhr: Callable[[], float] = time.monotonic,
) -> 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
self._http = httpx.Client(
base_url=BASIS_URL,
timeout=timeout_sekunden,
transport=transport,
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 _hole(self, pfad: str, params: dict[str, Any]) -> bytes:
letzte_fehler: Exception | None = None
for versuch in range(self.max_versuche):
self._rate_limit_abwarten()
try:
antwort = self._http.get(pfad, params=params)
except httpx.HTTPError as exc:
letzte_fehler = exc
wartezeit = min(
self.backoff_basis_sekunden * (2**versuch),
self.backoff_maximum_sekunden,
)
self._schlaf(wartezeit)
continue
self._letzter_request_um = self._uhr()
if antwort.status_code == 200:
return antwort.content
if antwort.status_code in (202, 429) or antwort.status_code >= 500:
# 202: BGG stellt die Anfrage in eine Warteschlange;
# 429/5xx: temporär nicht bedienbar → Backoff und erneut.
retry_after = antwort.headers.get("Retry-After")
if retry_after:
try:
wartezeit = float(retry_after)
except ValueError:
wartezeit = None
else:
wartezeit = None
if wartezeit is None:
wartezeit = min(
self.backoff_basis_sekunden * (2**versuch),
self.backoff_maximum_sekunden,
)
self._schlaf(wartezeit)
letzte_fehler = BggFehler(
f"HTTP {antwort.status_code} von der BGG-API (Versuch {versuch + 1})."
)
continue
raise BggFehler(
f"Unerwartete HTTP-Antwort {antwort.status_code} für {pfad}."
)
raise BggFehler(
f"BGG-API nach {self.max_versuche} Versuchen nicht erreichbar"
f" ({pfad}): {letzte_fehler}"
)
def suche(
self, suchbegriff: str, *, max_treffer: int | None = None
) -> list[SuchTreffer]:
"""Sucht Brettspiele (`type=boardgame`) nach einem Suchbegriff."""
treffer = parse_suche(
self._hole("/search", {"query": suchbegriff, "type": TYP_BRETTSPIEL})
)
return treffer[:max_treffer] if max_treffer is not None else treffer
def details(self, bgg_ids: Sequence[int]) -> list[BggSpiel]:
"""Lädt Thing-Details (in Batches) und filtert Erweiterungen heraus."""
spiele: list[BggSpiel] = []
for batch in _stuecke(list(bgg_ids), THING_BATCH_GROESSE):
xml_daten = self._hole(
"/thing",
{"id": ",".join(str(i) for i in batch), "type": TYP_BRETTSPIEL},
)
spiele.extend(parse_things(xml_daten))
return spiele