Plugin neuheiten: BGG-Sync mit APScheduler, Filtern und deutscher UI
- Datenmodell + eigene Migration 0001_neuheiten_tabelle (Tabelle neuheiten: Titel, Verlag, Autor, Erscheinungsjahr, BGG-ID, Status 'neuheit', Quelle, Zeitstempel; bgg_id eindeutig als Merge-Kriterium) - BoardGameGeek XML API2-Client (search + thing, Batches à 20 IDs): Rate-Limit >= 1 s zwischen Requests, Retry mit exponentiellem Backoff bei 5xx/429/Netzwerkfehlern, HTTP 202 gemäß Retry-After, robustes XML-Parsing; Transport/Uhr/Sleep injizierbar (keine echten Calls in Tests) - Filter: Erweiterungen (boardgameexpansion) auf Request- und Elementebene ausgeschlossen; Prototypen per Titel-Heuristik (BGG hat keinen Marker) - Sync-Service mit Update-statt-Duplikat-Logik über die eindeutige BGG-ID (Status bleibt erhalten); Fehler je Suchbegriff brechen den Lauf nicht ab - APScheduler-Hintergrundjob (Standard 24 h) mit Überlappungsschutz, abschaltbar/intervallkonfigurierbar per Env; manueller 'Jetzt synchronisieren'-Endpunkt nur für Admin/Redakteur, optional mit Sofort-Suchbegriff - UI /neuheiten: sortier-/filterbare Tabelle mit Volltextsuche (HTMX-Teilladung, noscript-fähig), deutsche Oberfläche, BGG-Links, Ergebnis-Banner - Plugin-Loader: idempotentes Laden (Modul-Caching), damit mehrere create_app()-Aufrufe dieselben Plugin-Klassen/Tabellen nutzen - Tests: Parsing, Erweiterungs-/Prototyp-Filter, Rate-Limit/Backoff/202, Update-statt-Duplikat, Rollen am Sync-Endpunkt, Scheduler-Lifecycle — ausschließlich mit gemockten BGG-Antworten (uv run pytest: 96 grün) - README/AGENTS: Plugin-Doku, Env-Variablen, Fortschrittstabelle aktualisiert
This commit is contained in:
133
plugins/neuheiten/sync.py
Normal file
133
plugins/neuheiten/sync.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Sync-Logik des Plugins „neuheiten“: BGG-Suche → Filter → Upsert.
|
||||
|
||||
- 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).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from .bgg import BggSpiel, BggClient
|
||||
from .models import STATUS_NEUHEIT, Neuheit
|
||||
|
||||
_logger = logging.getLogger("plugins.neuheiten")
|
||||
|
||||
PROTOTYP_MUSTER = re.compile(
|
||||
r"(?i)\b(prototyp\w*|prototype|proto)\b"
|
||||
)
|
||||
|
||||
THING_BATCH_GROESSE = 20
|
||||
|
||||
|
||||
def ist_moeglicher_prototyp(titel: str) -> bool:
|
||||
"""Heuristik: Titel klingt nach Prototyp/Protoversion."""
|
||||
return bool(PROTOTYP_MUSTER.search(titel))
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncErgebnis:
|
||||
"""Zusammenfassung eines Sync-Laufs."""
|
||||
|
||||
neu: int = 0
|
||||
aktualisiert: int = 0
|
||||
gefiltert: int = 0
|
||||
fehler: list[str] = field(default_factory=list)
|
||||
|
||||
def als_text(self) -> str:
|
||||
text = (
|
||||
f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
|
||||
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)"
|
||||
)
|
||||
if self.fehler:
|
||||
text += f"; {len(self.fehler)} Fehler"
|
||||
return text
|
||||
|
||||
|
||||
def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
|
||||
"""Upsert nach BGG-ID. Rückgabe: True = neu angelegt, False = aktualisiert."""
|
||||
vorhanden = db.scalar(select(Neuheit).where(Neuheit.bgg_id == spiel.bgg_id))
|
||||
if vorhanden is None:
|
||||
db.add(
|
||||
Neuheit(
|
||||
titel=spiel.titel,
|
||||
verlag=spiel.verlag,
|
||||
autor=spiel.autor,
|
||||
erscheinungsjahr=spiel.erscheinungsjahr,
|
||||
bgg_id=spiel.bgg_id,
|
||||
status=STATUS_NEUHEIT,
|
||||
quelle="boardgamegeek",
|
||||
)
|
||||
)
|
||||
return True
|
||||
vorhanden.titel = spiel.titel
|
||||
vorhanden.verlag = spiel.verlag
|
||||
vorhanden.autor = spiel.autor
|
||||
vorhanden.erscheinungsjahr = spiel.erscheinungsjahr
|
||||
return False
|
||||
|
||||
|
||||
class SyncService:
|
||||
"""Führt einen Sync-Lauf aus: Suchen → Details → Filter → Upsert."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: sessionmaker,
|
||||
client: BggClient,
|
||||
*,
|
||||
thing_batch_groesse: int = THING_BATCH_GROESSE,
|
||||
max_treffer_pro_suchbegriff: int | None = None,
|
||||
) -> 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
|
||||
|
||||
def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis:
|
||||
ergebnis = SyncErgebnis()
|
||||
with self.session_factory() as db:
|
||||
for suchbegriff in suchbegriffe:
|
||||
begriff = suchbegriff.strip()
|
||||
if not begriff:
|
||||
continue
|
||||
try:
|
||||
self._ein_suchbegriff(db, begriff, ergebnis)
|
||||
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}")
|
||||
db.commit()
|
||||
return ergebnis
|
||||
|
||||
def _ein_suchbegriff(
|
||||
self, db: Session, suchbegriff: str, ergebnis: SyncErgebnis
|
||||
) -> None:
|
||||
treffer = self.client.suche(
|
||||
suchbegriff, max_treffer=self.max_treffer_pro_suchbegriff
|
||||
)
|
||||
ids = [t.bgg_id for t in treffer]
|
||||
for batch in _batches(ids, self.thing_batch_groesse):
|
||||
for spiel in self.client.details(list(batch)):
|
||||
if spiel.typ != "boardgame":
|
||||
ergebnis.gefiltert += 1
|
||||
continue
|
||||
if ist_moeglicher_prototyp(spiel.titel):
|
||||
ergebnis.gefiltert += 1
|
||||
continue
|
||||
if speichere_oder_aktualisiere(db, spiel):
|
||||
ergebnis.neu += 1
|
||||
else:
|
||||
ergebnis.aktualisiert += 1
|
||||
|
||||
|
||||
def _batches(werte: Sequence[int], groesse: int) -> Iterable[Sequence[int]]:
|
||||
for start in range(0, len(werte), groesse):
|
||||
yield werte[start : start + groesse]
|
||||
Reference in New Issue
Block a user