Fix: dedup BGG-Client nutzt Token; Sync-Filter Jahr+deutscher Titel; Defaults für deutsche Neuerscheinungen

This commit is contained in:
Flo Hartmann
2026-08-25 17:28:35 +00:00
parent d91402881a
commit 929cea1887
10 changed files with 202 additions and 19 deletions

6
.env
View File

@@ -1,3 +1,9 @@
SPIELE_SESSION_SECRET=48ebf7ddae261a931652b5a55cff708aa21cf4936a8bdeb287eeac7cec4ee3e2
SPIELE_INITIAL_ADMIN_PASSWORD=3fb93f84014ff45406b36ec3
SPIELE_BGG_TOKEN=6fa6f9f6-32fd-443d-a5c3-ac2527f6a6fc
# BGG-Sync: deutsche Neuerscheinungen ab aktuellem Jahr
SPIELE_BGG_SUCHBEGRIFFE=brettspiel,gesellschaftsspiel,familienspiel,strategiespiel,kartenspiel,würfelspiel
SPIELE_BGG_MAX_TREFFER_PRO_SUCHE=50
SPIELE_BGG_JAHR_FILTER=1
SPIELE_BGG_DEUTSCHE_TITEL_FILTER=1

View File

@@ -55,9 +55,11 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) |
| `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) |
| `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) |
| `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync |
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
| `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer …`-Header gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen |
| `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel, gesellschaftsspiel, familienspiel, strategiespiel, kartenspiel, würfelspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync (deutsche Spielbegriffe) |
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `50` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
| `SPIELE_BGG_JAHR_FILTER` | `1` | Nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr übernehmen (`1`) oder alle (`0`) |
| `SPIELE_BGG_DEUTSCHE_TITEL_FILTER` | `1` | Nur Spiele mit deutsch klingendem Titel übernehmen (`1`) oder alle (`0`) |
| `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer *** gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen |
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen |
| `SPIELE_DEDUP_LLM_AKTIV` | `0` | LLM-Zweitprüfung der Dedup-Grenzfälle an (`1`) oder aus (`0`) |
| `SPIELE_DEDUP_LLM_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |

View File

@@ -8,6 +8,8 @@ services:
- SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production}
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
- SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin}
# BGG XML API2 (Pflicht seit Token-Umstellung): siehe .env
- SPIELE_BGG_TOKEN=${SPIELE_BGG_TOKEN}
# Benachrichtigungen (optional): ohne SMTP-Host/Bot-Token wird nur protokolliert
#- SPIELE_SMTP_HOST=smtp.example.org
#- SPIELE_SMTP_PORT=587

View File

@@ -192,7 +192,8 @@ class DedupPlugin(BasePlugin):
if not self._bgg_aktiv or self.context is None:
return None
if self._bgg_client is None:
self._bgg_client = BggPruefClient()
token = (os.environ.get("SPIELE_BGG_TOKEN") or "").strip() or None
self._bgg_client = BggPruefClient(token=token)
return self._bgg_client
def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None:

View File

@@ -33,6 +33,7 @@ class BggPruefClient:
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,
@@ -42,8 +43,11 @@ class BggPruefClient:
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
base_url=BASIS_URL, timeout=timeout, transport=transport, headers=headers
)
def schliessen(self) -> None:

View File

@@ -87,6 +87,8 @@ class NeuheitenPlugin(BasePlugin):
self._quellen_sperre = threading.Lock()
self._suchbegriffe: list[str] = []
self._max_treffer_pro_suche = 25
self._jahr_filter = True
self._deutscher_titel_filter = True
@self.router.get("/neuheiten")
def seite(
@@ -396,13 +398,23 @@ class NeuheitenPlugin(BasePlugin):
super().on_load(context)
import os
self._suchbegriffe = _umgebung_liste("SPIELE_BGG_SUCHBEGRIFFE", "brettspiel")
self._suchbegriffe = _umgebung_liste(
"SPIELE_BGG_SUCHBEGRIFFE",
"brettspiel, gesellschaftsspiel, familienspiel, strategiespiel, "
"kartenspiel, würfelspiel",
)
try:
self._max_treffer_pro_suche = int(
os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "25")
os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "50")
)
except ValueError:
self._max_treffer_pro_suche = 25
self._max_treffer_pro_suche = 50
self._jahr_filter = (
os.environ.get("SPIELE_BGG_JAHR_FILTER", "1").strip() != "0"
)
self._deutscher_titel_filter = (
os.environ.get("SPIELE_BGG_DEUTSCHE_TITEL_FILTER", "1").strip() != "0"
)
if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1":
self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN"))
@@ -445,6 +457,8 @@ class NeuheitenPlugin(BasePlugin):
self.context.session_factory,
client,
max_treffer_pro_suchbegriff=self._max_treffer_pro_suche,
jahr_filter=self._jahr_filter,
deutscher_titel_filter=self._deutscher_titel_filter,
)
return service.synchronisiere(suchbegriffe)
finally:

View File

@@ -3,16 +3,19 @@
- Update statt Duplikat: bestehende Einträge werden über die eindeutige
BGG-ID gefunden und aktualisiert (Status bleibt erhalten).
- Erweiterungen werden im Client und hier auf Elementebene gefiltert.
- Prototypen: Die BGG-XML-API2 liefert keinen verlässlichen Prototyp-Marker;
als Heuristik werden Titel mit Prototyp-Schlüsselwörtern ausgeschlossen
(dokumentierte Einschränkung, siehe README).
- Prototypen: Titel mit Prototyp-Schlüsselwörtern werden ausgeschlossen.
- Jahresfilter: optional nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr.
- Deutsch-Filter: optional nur Spiele mit deutsch klingendem Titel.
"""
from __future__ import annotations
import logging
import os
import re
import unicodedata
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
@@ -28,12 +31,58 @@ PROTOTYP_MUSTER = re.compile(
THING_BATCH_GROESSE = 20
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
_DEUTSCHE_INDIKATOREN = frozenset({
"der", "die", "das", "den", "dem", "des",
"ein", "eine", "einen", "einem", "einer", "eines",
"und", "für", "von", "vom", "zu", "zum", "zur",
"im", "am", "beim", "auf", "mit", "aus",
})
#: Typische Begriffe deutscher Spieltitel (+2 Punkte je Treffer).
_DEUTSCHE_SPIELWOERTER = frozenset({
"abenteuer", "berg", "blitz", "burg", "dorf", "drache", "drachen",
"erde", "feuer", "gärten", "haus", "himmel", "insel", "jagd",
"kaiser", "könig", "könige", "königreich", "land", "länder",
"meister", "mond", "norden", "osten", "pfad", "reich", "ritter",
"schiff", "schloss", "see", "spiel", "spiele", "spielen",
"stadt", "stern", "süden", "turm", "wald", "weg", "welt",
"westen", "wiese", "wilder", "wüste", "zauber", "zeit",
})
#: Wörter, die auf einen englischen Titel hindeuten (1 Punkt je Treffer).
_ENGLISCHE_INDIKATOREN = frozenset({
"the", "of", "and", "game", "games", "card", "cards", "dice", "board",
})
_UM_LAUTE = frozenset("äöüßÄÖÜ")
_WORT_MUSTER = re.compile(r"[^\W\d_]+", re.UNICODE)
def ist_deutscher_titel(titel: str) -> bool:
"""Heuristik: > 0 spricht für einen deutschen Titel.
Umlaute/ß zählen am stärksten (+3), deutsche Funktionswörter +1,
typische deutsche Spielbegriffe +2, englische Indikatoren 1.
"""
if not titel:
return False
score = 0
if any(zeichen in _UM_LAUTE for zeichen in titel):
score += 3
woerter = {wort.casefold() for wort in _WORT_MUSTER.findall(titel)}
score += sum(1 for wort in woerter if wort in _DEUTSCHE_INDIKATOREN)
score += sum(2 for wort in woerter if wort in _DEUTSCHE_SPIELWOERTER)
score -= sum(1 for wort in woerter if wort in _ENGLISCHE_INDIKATOREN)
return score > 0
def ist_moeglicher_prototyp(titel: str) -> bool:
"""Heuristik: Titel klingt nach Prototyp/Protoversion."""
return bool(PROTOTYP_MUSTER.search(titel))
def aktuelles_jahr() -> int:
return date.today().year
@dataclass
class SyncErgebnis:
"""Zusammenfassung eines Sync-Laufs."""
@@ -51,7 +100,7 @@ class SyncErgebnis:
return self.abbruch
text = (
f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)"
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen/Jahr/Sprache)"
)
if self.fehler:
text += f"; {len(self.fehler)} Fehler"
@@ -96,11 +145,16 @@ class SyncService:
*,
thing_batch_groesse: int = THING_BATCH_GROESSE,
max_treffer_pro_suchbegriff: int | None = None,
jahr_filter: bool = True,
deutscher_titel_filter: bool = True,
) -> None:
self.session_factory = session_factory
self.client = client
self.thing_batch_groesse = thing_batch_groesse
self.max_treffer_pro_suchbegriff = max_treffer_pro_suchbegriff
self.jahr_filter = jahr_filter
self.deutscher_titel_filter = deutscher_titel_filter
self._min_jahr = aktuelles_jahr()
def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis:
ergebnis = SyncErgebnis()
@@ -139,6 +193,13 @@ class SyncService:
if ist_moeglicher_prototyp(spiel.titel):
ergebnis.gefiltert += 1
continue
if self.jahr_filter and spiel.erscheinungsjahr is not None:
if spiel.erscheinungsjahr < self._min_jahr:
ergebnis.gefiltert += 1
continue
if self.deutscher_titel_filter and not ist_deutscher_titel(spiel.titel):
ergebnis.gefiltert += 1
continue
if speichere_oder_aktualisiere(db, spiel):
ergebnis.neu += 1
else:

View File

@@ -389,3 +389,25 @@ def test_bgg_pruefclient_toleroert_fehler():
assert client.vorgaenger(99) == []
finally:
client.schliessen()
def test_bgg_pruefclient_sendet_token_header():
from plugins.dedup.bgg import BggPruefClient
gesehen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
gesehen.append(request)
return httpx.Response(200, content=THING_XML.encode())
client = BggPruefClient(
mindestabstand_sekunden=0.0,
token="mein-token",
transport=httpx.MockTransport(handler),
)
try:
assert client.alternativen(99) == ["Catan Expansion", "Siedler-Erweiterung"]
finally:
client.schliessen()
assert gesehen
assert gesehen[0].headers["Authorization"] == "Bearer mein-token"

View File

@@ -94,7 +94,9 @@ def test_sync_legt_eintraege_an(session_factory):
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["catan"])
@@ -117,7 +119,9 @@ def test_sync_aktualisiert_bestehende_eintraege_statt_duplikate(session_factory)
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
service.synchronisiere(["catan"])
@@ -142,7 +146,9 @@ def test_sync_veraenderter_titel_gleiche_bgg_id_kein_duplikat(session_factory):
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
service.synchronisiere(["catan"])
client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)")
@@ -176,7 +182,9 @@ def test_sync_filtert_erweiterungen(session_factory):
55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"),
},
)
service = SyncService(session_factory, client)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["catan"])
@@ -200,7 +208,9 @@ def test_sync_filtert_prototypen(session_factory):
78: _spiel(78, "My Prototype Game"),
},
)
service = SyncService(session_factory, client)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["essen"])
@@ -216,6 +226,61 @@ def test_prototyp_heuristik():
assert not ist_moeglicher_prototyp("Die Burgen von Burgund")
# ---------- Filter: Jahr und deutscher Titel ----------
def test_sync_filtert_nach_jahr_und_deutschem_titel(session_factory):
aktuelles = 2026
client = FakeBggClient(
suchergebnisse={
"mix": [
SuchTreffer(13, "Catan", aktuelles - 1),
SuchTreffer(42, "Die neuen Siedler", aktuelles),
SuchTreffer(99, "Future Game", aktuelles + 1),
SuchTreffer(88, "The Old English Game", aktuelles),
]
},
details_pro_id={
13: _spiel(13, "Catan", erscheinungsjahr=aktuelles - 1),
42: _spiel(42, "Die neuen Siedler", erscheinungsjahr=aktuelles),
99: _spiel(99, "Future Game", erscheinungsjahr=aktuelles + 1),
88: _spiel(88, "The Old English Game", erscheinungsjahr=aktuelles),
},
)
service = SyncService(session_factory, client)
service._min_jahr = aktuelles
ergebnis = service.synchronisiere(["mix"])
assert ergebnis.neu == 1 # nur "Die neuen Siedler" überlebt beide Filter
assert ergebnis.gefiltert == 3 # 1 zu alt + 1 zukünftig aber englisch + 1 aktuell aber englisch
eintraege = _alle_eintraege(session_factory)
assert [e.bgg_id for e in eintraege] == [42]
def test_sync_ohne_filter_behält_alle_jahre_und_sprachen(session_factory):
client = FakeBggClient(
suchergebnisse={
"mix": [
SuchTreffer(13, "Catan", 1995),
SuchTreffer(88, "The English Game", 2026),
]
},
details_pro_id={
13: _spiel(13, "Catan", erscheinungsjahr=1995),
88: _spiel(88, "The English Game", erscheinungsjahr=2026),
},
)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["mix"])
assert ergebnis.neu == 2
assert ergebnis.gefiltert == 0
# ---------- Robustheit ----------
@@ -233,7 +298,9 @@ def test_401_bricht_sync_ab_mit_klarer_meldung(session_factory):
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["gut", "abgelehnt", "dritter"])
@@ -255,7 +322,9 @@ def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory):
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["kaputt", "gut"])

View File

@@ -194,6 +194,8 @@ def test_sync_endpunkt_rollen_pruefung(app, settings):
def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
from fastapi.testclient import TestClient
monkeypatch.setenv("SPIELE_BGG_JAHR_FILTER", "0")
monkeypatch.setenv("SPIELE_BGG_DEUTSCHE_TITEL_FILTER", "0")
fake = FakeBggClient(
treffer=[SuchTreffer(13, "Catan", 1995)],
spiel_details=[