diff --git a/.env b/.env index 748a898..fc0762b 100644 --- a/.env +++ b/.env @@ -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 diff --git a/README.md b/README.md index 0e9128f..0ffa2ec 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/docker-compose.yml b/docker-compose.yml index 49f89e8..41cc23f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/plugins/dedup/__init__.py b/plugins/dedup/__init__.py index 6f0fc00..79746e7 100644 --- a/plugins/dedup/__init__.py +++ b/plugins/dedup/__init__.py @@ -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: diff --git a/plugins/dedup/bgg.py b/plugins/dedup/bgg.py index c471138..563c0b3 100644 --- a/plugins/dedup/bgg.py +++ b/plugins/dedup/bgg.py @@ -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: diff --git a/plugins/neuheiten/__init__.py b/plugins/neuheiten/__init__.py index 14c4f4e..77728ca 100644 --- a/plugins/neuheiten/__init__.py +++ b/plugins/neuheiten/__init__.py @@ -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: diff --git a/plugins/neuheiten/sync.py b/plugins/neuheiten/sync.py index 756c391..ec2dd67 100644 --- a/plugins/neuheiten/sync.py +++ b/plugins/neuheiten/sync.py @@ -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: diff --git a/tests/test_dedup.py b/tests/test_dedup.py index d31bac2..6d80b68 100644 --- a/tests/test_dedup.py +++ b/tests/test_dedup.py @@ -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" diff --git a/tests/test_neuheiten_sync.py b/tests/test_neuheiten_sync.py index 870e826..6d36197 100644 --- a/tests/test_neuheiten_sync.py +++ b/tests/test_neuheiten_sync.py @@ -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"]) diff --git a/tests/test_neuheiten_ui.py b/tests/test_neuheiten_ui.py index 741a16d..c30011f 100644 --- a/tests/test_neuheiten_ui.py +++ b/tests/test_neuheiten_ui.py @@ -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=[