"""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