BoardGameGeek verlangt für die XML API2 jetzt Bearer-Authentifizierung
(Thread 3602374); ohne Token antwortet die API mit 401.
- BggClient: neuer Parameter token, sendet Authorization: Bearer … auf
allen Requests; Token kommt aus SPIELE_BGG_TOKEN (vor jedem Sync gelesen)
- HTTP 401 → klarer BggAuthFehler ('BGG hat die Anfrage abgelehnt (401) —
bitte gültigen API-Token in SPIELE_BGG_TOKEN hinterlegen.') statt
generischer Meldung, kein Retry
- Ohne konfigurierten Token wird der Sync übersprungen ('Kein BGG-API-Token
konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen.'), statt Requests zu
feuern; Meldung erscheint im Sync-Ergebnis, UI-Banner und Protokoll
- SyncService: BggAuthFehler bricht den Lauf ab (weitere Suchbegriffe
scheitern zwangsläufig gleich), Teilergebnisse bleiben erhalten
- Tests: Bearer-Header, 401-Fall (Client/Sync/UI), Skip ohne Token;
bestehende Sync-UI-Tests setzen Test-Token
- README: neue Env-Variable SPIELE_BGG_TOKEN + Hinweis auf Token-Pflicht
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""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.
|
|
|
|
Authentifizierung: Die XML API2 verlangt einen Bearer-Token
|
|
(Authorization-Header, siehe https://boardgamegeek.com/thread/3602374);
|
|
der Token wird dem Client über den Parameter `token` übergeben (im Betrieb
|
|
aus `SPIELE_BGG_TOKEN`). Ohne Token lehnt BGG Anfragen mit HTTP 401 ab —
|
|
der Client wandelt das in eine klare `BggAuthFehler`-Meldung um und versucht
|
|
es nicht erneut.
|
|
|
|
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."""
|
|
|
|
|
|
class BggAuthFehler(BggFehler):
|
|
"""BGG hat die Anfrage wegen fehlender/unültiger Authentifizierung abgelehnt."""
|
|
|
|
|
|
MELDUNG_401 = (
|
|
"BGG hat die Anfrage abgelehnt (401) — bitte gültigen API-Token "
|
|
"in SPIELE_BGG_TOKEN hinterlegen."
|
|
)
|
|
|
|
|
|
@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,
|
|
*,
|
|
token: str | None = None,
|
|
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
|
|
# Bearer-Token für die XML API2 (Pflicht seit BGG-Umstellung,
|
|
# https://boardgamegeek.com/thread/3602374); leer/None = nicht gesetzt.
|
|
self.token = token.strip() if isinstance(token, str) and token.strip() else None
|
|
self._schlaf = schlaf
|
|
self._uhr = uhr
|
|
self._letzter_request_um: float | None = None
|
|
headers: dict[str, str] = {"User-Agent": USER_AGENT}
|
|
if self.token:
|
|
headers["Authorization"] = f"Bearer {self.token}"
|
|
self._http = httpx.Client(
|
|
base_url=BASIS_URL,
|
|
timeout=timeout_sekunden,
|
|
transport=transport,
|
|
headers=headers,
|
|
)
|
|
|
|
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 == 401:
|
|
# Fehlender oder ungültiger Token — dauerhaft, ein Retry
|
|
# hilft nicht (Token-Pflicht, siehe Thread 3602374).
|
|
raise BggAuthFehler(MELDUNG_401)
|
|
|
|
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
|