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

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: