Plugin neuheiten: Bearer-Token-Pflicht der BGG-XML-API2 (Fix für HTTP 401)

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
This commit is contained in:
Flo Hartmann
2026-08-21 23:14:58 +00:00
parent 55f6f32f73
commit 60c4b6e818
8 changed files with 259 additions and 9 deletions

View File

@@ -7,12 +7,17 @@ Umfang:
- Manueller „Jetzt synchronisieren“-Button für Admins/Redakteure.
- Sortier-/filterbare Neuheitenliste mit Suche (deutsche UI).
Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start):
Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start;
SPIELE_BGG_TOKEN wird dagegen vor jedem Sync gelesen):
- SPIELE_BGG_SYNC_AKTIV „1“ (Standard) = Hintergrund-Job an
- SPIELE_BGG_SYNC_INTERVALL_STUNDEN Intervall in Stunden (Standard 24)
- SPIELE_BGG_SUCHBEGRIFFE Komma-getrennte Suchbegriffe
(Standard: „brettspiel“)
- SPIELE_BGG_MAX_TREFFER_PRO_SUCHE Obergrenze Treffer/Suchbegriff (25)
- SPIELE_BGG_TOKEN API-Token für die BGG-XML-API2
(Pflicht seit BGG-Umstellung, wird als
„Authorization: Bearer …“ gesendet);
ohne Token wird der Sync übersprungen
Keine Fachlogik im Kern — alles hier im Plugin gemäß Plugin-Vertrag.
"""
@@ -33,10 +38,14 @@ from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginCont
from .bgg import BggClient, BggFehler
from .models import Neuheit
from .sync import SyncService
from .sync import SyncErgebnis, SyncService
_logger = logging.getLogger("plugins.neuheiten")
MELDUNG_OHNE_TOKEN = (
"Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen."
)
SORTIERBAR = {
"titel": Neuheit.titel,
"verlag": Neuheit.verlag,
@@ -151,7 +160,11 @@ class NeuheitenPlugin(BasePlugin):
_logger.warning("Manueller BGG-Sync fehlgeschlagen: %s", exc)
ziel = f"/neuheiten?meldung={quote(f'Sync fehlgeschlagen: {exc}')}"
return RedirectResponse(ziel, status_code=303)
ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}"
if ergebnis.abbruch:
# z. B. fehlender Token oder HTTP 401 — klar benennbare Ursache
ziel = f"/neuheiten?fehler={quote(ergebnis.abbruch)}"
else:
ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}"
return RedirectResponse(ziel, status_code=303)
# ---------- Plugin-Vertrag ----------
@@ -188,11 +201,26 @@ class NeuheitenPlugin(BasePlugin):
# ---------- Internas ----------
def _aktives_bgg_token(self) -> str | None:
"""Liest den BGG-API-Token aus der Umgebung (leer = nicht gesetzt).
Bewusst pro Sync gelesen, damit ein rotierter Token ohne Neustart
greift.
"""
import os
return (os.environ.get("SPIELE_BGG_TOKEN") or "").strip() or None
def _neuer_client(self) -> BggClient:
"""Fabrik für den BGG-Client; von Tests überschreibbar."""
return BggClient()
return BggClient(token=self._aktives_bgg_token())
def _sync_ausfuehren(self, suchbegriffe: list[str]):
if self._aktives_bgg_token() is None:
# Ohne Token lehnt die BGG-API jeden Request mit 401 ab —
# den Sync gar nicht erst starten.
_logger.warning(MELDUNG_OHNE_TOKEN)
return SyncErgebnis(abbruch=MELDUNG_OHNE_TOKEN)
client = self._neuer_client()
try:
service = SyncService(
@@ -211,7 +239,10 @@ class NeuheitenPlugin(BasePlugin):
return
try:
ergebnis = self._sync_ausfuehren(self._suchbegriffe)
_logger.info("BGG-Sync abgeschlossen: %s", ergebnis.als_text())
if ergebnis.abbruch:
_logger.warning("BGG-Sync nicht ausgeführt: %s", ergebnis.abbruch)
else:
_logger.info("BGG-Sync abgeschlossen: %s", ergebnis.als_text())
except Exception as exc:
_logger.warning("BGG-Sync fehlgeschlagen: %s", exc)
finally:

View File

@@ -11,6 +11,13 @@ Eigenschaften:
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.
"""
@@ -35,6 +42,16 @@ 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."""
@@ -161,6 +178,7 @@ class BggClient:
def __init__(
self,
*,
token: str | None = None,
transport: httpx.BaseTransport | None = None,
mindestabstand_sekunden: float = 1.0,
max_versuche: int = 4,
@@ -174,14 +192,20 @@ class BggClient:
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={"User-Agent": USER_AGENT},
headers=headers,
)
def schliessen(self) -> None:
@@ -214,6 +238,11 @@ class BggClient:
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.

View File

@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from .bgg import BggSpiel, BggClient
from .bgg import BggAuthFehler, BggSpiel, BggClient
from .models import STATUS_NEUHEIT, Neuheit
_logger = logging.getLogger("plugins.neuheiten")
@@ -42,8 +42,13 @@ class SyncErgebnis:
aktualisiert: int = 0
gefiltert: int = 0
fehler: list[str] = field(default_factory=list)
# Grund, warum der Lauf vorzeitig endete oder ganz übersprungen wurde
# (z. B. fehlender BGG-Token oder HTTP 401); None = normal gelaufen.
abbruch: str | None = None
def als_text(self) -> str:
if self.abbruch:
return self.abbruch
text = (
f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)"
@@ -101,6 +106,13 @@ class SyncService:
continue
try:
self._ein_suchbegriff(db, begriff, ergebnis)
except BggAuthFehler as exc:
# HTTP 401 trifft jede weitere Anfrage genauso — der Lauf
# wird abgebrochen, statt alle Begriffe einzeln scheitern
# zu lassen; bereits gespeicherte Treffer bleiben erhalten.
_logger.warning("BGG-Sync abgebrochen für %r: %s", begriff, exc)
ergebnis.abbruch = str(exc)
break
except Exception as exc: # ein Begriff darf den Lauf nicht abbrechen
_logger.warning("BGG-Sync fehlgeschlagen für %r: %s", begriff, exc)
ergebnis.fehler.append(f"{begriff}: {exc}")