Files
spiele-redaktion/plugins/dedup/bgg.py

109 lines
4.0 KiB
Python

"""Hilfsclient des Plugins „dedup“ für die BoardGameGeek XML API2.
Besorgt zwei Zusatzinformationen für die Prüfung:
- Alternate-Names eines Spiels (für die deutsche Titel-Empfehlung),
- die `boardgameexpansion`-Relation — welches Basisspiel eine Erweiterung
ergänzt (für die Vorgänger-Erkennung).
Der Client ist bewusst defensiv: Fehlt das Netzwerk oder liefert BGG
Unsinn, geben die Methoden leere Listen zurück und die Prüfung fällt auf
die reine Titel-Heuristik zurück, statt den Eintrage-Vorgang zu blockieren.
Rate-Limit wie im Neuheiten-Plugin: mindestens 1 Sekunde zwischen Requests;
Uhr, Schlaf-Funktion und HTTP-Transport sind injizierbar, damit Tests
netzwerkfrei bleiben.
"""
from __future__ import annotations
import logging
import time
import xml.etree.ElementTree as ET
from collections.abc import Callable
import httpx
_logger = logging.getLogger("plugins.dedup")
BASIS_URL = "https://boardgamegeek.com/xmlapi2"
class BggPruefClient:
"""Liest Alternate-Names und Erweiterungs-Relationen von BGG."""
def __init__(
self,
mindestabstand_sekunden: float = 1.0,
*,
token: str | None = None,
transport: httpx.BaseTransport | None = None,
uhr: Callable[[], float] = time.monotonic,
schlafen: Callable[[float], None] = time.sleep,
timeout: float = 10.0,
) -> None:
self._mindestabstand = max(0.0, mindestabstand_sekunden)
self._zuletzt: float | None = None
self._uhr = uhr
self._schlafen = schlafen
headers: dict[str, str] = {}
if token:
headers["Authorization"] = f"Bearer {token}"
self._client = httpx.Client(
base_url=BASIS_URL, timeout=timeout, transport=transport, headers=headers
)
def schliessen(self) -> None:
self._client.close()
# ---------- Internas ----------
def _hole(self, pfad: str, parameter: dict[str, str]) -> ET.Element | None:
"""Ein GET mit Rate-Limit; bei jedem Fehler None (Prüfung degradiert)."""
if self._zuletzt is not None:
vergangen = self._uhr() - self._zuletzt
if vergangen < self._mindestabstand:
self._schlafen(self._mindestabstand - vergangen)
self._zuletzt = self._uhr()
try:
antwort = self._client.get(pfad, params=parameter)
antwort.raise_for_status()
return ET.fromstring(antwort.content)
except Exception as exc:
_logger.warning("BGG-Hilfsabruf fehlgeschlagen (%s %s): %s", pfad, parameter, exc)
return None
# ---------- Öffentliche API ----------
def alternativen(self, bgg_id: int) -> list[str]:
"""Alternate-Names des Spiels laut BGG (leer bei Problemen)."""
wurzel = self._hole("/thing", {"id": str(bgg_id), "type": "boardgame"})
namen: list[str] = []
if wurzel is None:
return namen
for element in wurzel.iter("name"):
if element.get("type") == "alternate":
wert = (element.get("value") or "").strip()
if wert:
namen.append(wert)
return namen
def vorgaenger(self, bgg_id: int) -> list[tuple[int, str]]:
"""Basisspiele, die dieses Spiel ergänzt — als (bgg_id, titel).
Die XML API2 listet auf der Seite einer Erweiterung die Links vom
Typ `boardgameexpansion` auf das jeweils ergänzte Basisspiel.
"""
wurzel = self._hole("/thing", {"id": str(bgg_id), "type": "boardgame"})
ergebnis: list[tuple[int, str]] = []
if wurzel is None:
return ergebnis
for element in wurzel.iter("link"):
if element.get("type") != "boardgameexpansion":
continue
try:
fremd_id = int(element.get("id") or "")
except ValueError:
continue
titel = (element.get("value") or "").strip()
if titel:
ergebnis.append((fremd_id, titel))
return ergebnis