Compare commits

..

3 Commits

Author SHA1 Message Date
Flo Hartmann
c6f9b19f6b Merge wt-quellen: Web-Quellen-Crawler im Neuheiten-Plugin 2026-08-23 00:41:34 +00:00
Flo Hartmann
3b0626c774 Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI) 2026-08-23 00:41:02 +00:00
Flo Hartmann
8c9f542b8c TODO: Arbeitsliste Web-Quellen-Crawler 2026-08-22 23:36:49 +00:00
18 changed files with 2249 additions and 18 deletions

20
TODO.md
View File

@@ -1,12 +1,14 @@
# TODO — LLM-Stufe im Dedup-Plugin (Branch wt-llm) # TODO — Web-Quellen im Neuheiten-Plugin (Branch wt-quellen)
> Live-Status, wird laufend aktualisiert. > Live-Status, wird laufend aktualisiert.
- [x] ki_pruefung.py angelegt - [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py
- [x] Tests test_dedup_ki.py angelegt - [x] Adapter spiel-essen
- [ ] Integration in pruefung.py (Grenzfalls-Erkennung, nur dann LLM) - [x] Vollständiges Crawling (Pagination/Quartale/A-Z)
- [ ] Env-Konfiguration (SPIELE_DEDUP_LLM_*: AKTIV, BASIS_URL, API_KEY, MODELL, KONFIDENZ_MIN) - [x] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified
- [ ] Fallback-Pflicht (ohne Key/bei Fehler = exakt altes Verhalten) - [x] Dedup-Gate vor Übernahme in Neuheitenliste
- [ ] Audit-Logging (Modell, Konfidenz, Entscheidung) - [x] APScheduler-Job + Env-Schalter je Quelle (_AKTIV)
- [ ] JSON-Parsing-Robustheit (Code-Fences, Zusatztext) - [ ] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) → folgt als eigener Nachtrag
- [ ] README.md aktualisieren - [x] Sync-Übersicht im UI (/neuheiten)
- [x] Tests (gemocktes HTML)
- [x] README.md aktualisieren

View File

@@ -24,6 +24,7 @@ Keine Fachlogik im Kern — alles hier im Plugin gemäß Plugin-Vertrag.
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import threading import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from urllib.parse import quote from urllib.parse import quote
@@ -37,7 +38,13 @@ from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
from .bgg import BggClient, BggFehler from .bgg import BggClient, BggFehler
from .models import Neuheit from .models import Neuheit, QuellenStatus
from .quellen import ADAPTER_KLASSEN
from .quellen_sync import (
QuellenLaufZeile,
WebQuellenSyncService,
quelle_aktiv,
)
from .sync import SyncErgebnis, SyncService from .sync import SyncErgebnis, SyncService
_logger = logging.getLogger("plugins.neuheiten") _logger = logging.getLogger("plugins.neuheiten")
@@ -77,6 +84,7 @@ class NeuheitenPlugin(BasePlugin):
super().__init__() super().__init__()
self._scheduler = None # BackgroundScheduler, falls aktiviert self._scheduler = None # BackgroundScheduler, falls aktiviert
self._sync_sperre = threading.Lock() self._sync_sperre = threading.Lock()
self._quellen_sperre = threading.Lock()
self._suchbegriffe: list[str] = [] self._suchbegriffe: list[str] = []
self._max_treffer_pro_suche = 25 self._max_treffer_pro_suche = 25
@@ -117,6 +125,7 @@ class NeuheitenPlugin(BasePlugin):
) )
] ]
quellen_zeilen = self._quellen_uebersicht(db)
kontext = { kontext = {
"user": user, "user": user,
"titel": self.title, "titel": self.title,
@@ -135,6 +144,11 @@ class NeuheitenPlugin(BasePlugin):
"suchbegriffe": ", ".join(self._suchbegriffe) or "", "suchbegriffe": ", ".join(self._suchbegriffe) or "",
"sync_aktiv": self._scheduler is not None and self._scheduler.running, "sync_aktiv": self._scheduler is not None and self._scheduler.running,
"anzahl": len(eintraege), "anzahl": len(eintraege),
"quellen_zeilen": quellen_zeilen,
"quellen_sync_aktiv": (
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip()
== "1"
),
} }
if request.headers.get("HX-Request") == "true": if request.headers.get("HX-Request") == "true":
return self.context.templates.TemplateResponse( return self.context.templates.TemplateResponse(
@@ -167,13 +181,85 @@ class NeuheitenPlugin(BasePlugin):
ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}" ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}"
return RedirectResponse(ziel, status_code=303) return RedirectResponse(ziel, status_code=303)
@self.router.post("/neuheiten/quellen-sync")
def quellen_jetzt_synchronisieren(
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
):
zeilen = self._quellen_sync_ausfuehren()
text = " · ".join(z.als_text() for z in zeilen)
ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}"
return RedirectResponse(ziel[:2000], status_code=303)
def _quellen_uebersicht(self, db) -> list[dict]:
"""Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle."""
status_nach_quelle = {
zeile.quelle: zeile
for zeile in db.scalars(select(QuellenStatus)).all()
}
ansichten = []
for klasse in ADAPTER_KLASSEN:
status = status_nach_quelle.get(klasse.name)
ansichten.append(
{
"name": klasse.name,
"anzeigename": klasse.anzeigename,
"url": klasse.start_urls[0] if klasse.start_urls else "",
"aktiv": quelle_aktiv(klasse.name),
"letzte_laufzeit": status.letzte_laufzeit if status else None,
"dauer_sekunden": status.dauer_sekunden if status else None,
"neu": status.anzahl_neu if status else 0,
"aktualisiert": status.anzahl_aktualisiert if status else 0,
"gefiltert": status.anzahl_gefiltert if status else 0,
"fehleranzahl": status.anzahl_fehler if status else 0,
"fehlermeldung": status.fehlermeldung if status else None,
"unveraendert": status.unveraendert if status else False,
}
)
return ansichten
# ---------- Plugin-Vertrag ---------- # ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]: def migrations(self) -> list[Migration]:
def neuheiten_tabelle(conn) -> None: def neuheiten_tabelle(conn) -> None:
Neuheit.__table__.create(conn, checkfirst=True) Neuheit.__table__.create(conn, checkfirst=True)
return [Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle)] def bgg_id_nullable(conn) -> None:
"""Migration 0002: Web-Quellen-Einträge ohne BGG-ID erlauben."""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql("PRAGMA table_info(neuheiten)").fetchall()
bgg_spalte = next((z for z in info if z[1] == "bgg_id"), None)
if bgg_spalte is None or not bgg_spalte[3]:
# Spalte fehlt (frisch angelegt) oder ist bereits nullable.
return
# SQLite kennt kein ALTER COLUMN: Tabelle nach aktuellem Modell
# neu anlegen, Daten übernehmen, alte Tabelle verwerfen.
conn.exec_driver_sql("ALTER TABLE neuheiten RENAME TO neuheiten_alt_0002")
for ix in Neuheit.__table__.indexes:
conn.exec_driver_sql(f'DROP INDEX IF EXISTS "{ix.name}"')
Neuheit.__table__.create(conn, checkfirst=True)
spalten = ", ".join(f'"{c.name}"' for c in Neuheit.__table__.columns)
conn.exec_driver_sql(
f"INSERT INTO neuheiten ({spalten}) SELECT {spalten} FROM neuheiten_alt_0002"
)
conn.exec_driver_sql("DROP TABLE neuheiten_alt_0002")
else:
ist_nullable = conn.exec_driver_sql(
"SELECT is_nullable FROM information_schema.columns "
"WHERE table_name = 'neuheiten' AND column_name = 'bgg_id'"
).scalar()
if ist_nullable == "NO":
conn.exec_driver_sql(
"ALTER TABLE neuheiten ALTER COLUMN bgg_id DROP NOT NULL"
)
def quellen_status_tabelle(conn) -> None:
QuellenStatus.__table__.create(conn, checkfirst=True)
return [
Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle),
Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable),
Migration(version="0003_quellen_status", up=quellen_status_tabelle),
]
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/neuheiten")] return [NavEntry(label=self.title, url="/neuheiten")]
@@ -192,6 +278,10 @@ class NeuheitenPlugin(BasePlugin):
if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1": if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1":
self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN")) self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN"))
if os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip() == "1":
self._quellen_scheduler_starten(
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_INTERVALL_STUNDEN")
)
def on_unload(self) -> None: def on_unload(self) -> None:
if self._scheduler is not None: if self._scheduler is not None:
@@ -248,7 +338,34 @@ class NeuheitenPlugin(BasePlugin):
finally: finally:
self._sync_sperre.release() self._sync_sperre.release()
def _scheduler_starten(self, intervall_stunden_roh: str | None) -> None: def _quellen_sync_ausfuehren(self) -> list[QuellenLaufZeile]:
"""Ein Lauf über alle aktiven Web-Quellen (Fehler pro Quelle isoliert)."""
dedup = (
self.context.registry.get("dedup")
if self.context is not None and self.context.registry is not None
else None
)
service = WebQuellenSyncService(self.context.session_factory, dedup=dedup)
return service.laufe()
def _quellen_sync_job(self) -> None:
"""Hintergrund-Job: holt regelmäßig die Web-Quellen ab."""
if not self._quellen_sperre.acquire(blocking=False):
_logger.info("Web-Quellen-Sync läuft bereits — Durchlauf übersprungen.")
return
try:
zeilen = self._quellen_sync_ausfuehren()
text = " · ".join(z.als_text() for z in zeilen)
if any(z.fehlermeldung for z in zeilen):
_logger.warning("Web-Quellen-Sync mit Fehlern: %s", text)
else:
_logger.info("Web-Quellen-Sync abgeschlossen: %s", text)
except Exception as exc:
_logger.warning("Web-Quellen-Sync fehlgeschlagen: %s", exc)
finally:
self._quellen_sperre.release()
def _quellen_scheduler_starten(self, intervall_stunden_roh: str | None) -> None:
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
try: try:
@@ -257,7 +374,40 @@ class NeuheitenPlugin(BasePlugin):
intervall_stunden = 24 intervall_stunden = 24
intervall_stunden = max(intervall_stunden, 1) intervall_stunden = max(intervall_stunden, 1)
scheduler = self._scheduler_oder_neu()
erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=15)
scheduler.add_job(
self._quellen_sync_job,
trigger="interval",
hours=intervall_stunden,
next_run_time=erster_lauf,
id="web-quellen-sync",
replace_existing=True,
)
_logger.info(
"Web-Quellen-Sync aktiv: alle %s h (%s)",
intervall_stunden,
", ".join(k.anzeigename for k in ADAPTER_KLASSEN),
)
def _scheduler_oder_neu(self):
"""Ein gemeinsamer BackgroundScheduler für alle Sync-Jobs des Plugins."""
from apscheduler.schedulers.background import BackgroundScheduler
if self._scheduler is None or not self._scheduler.running:
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
scheduler.start()
self._scheduler = scheduler
return self._scheduler
def _scheduler_starten(self, intervall_stunden_roh: str | None) -> None:
try:
intervall_stunden = int(intervall_stunden_roh or "24")
except ValueError:
intervall_stunden = 24
intervall_stunden = max(intervall_stunden, 1)
scheduler = self._scheduler_oder_neu()
erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=10) erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=10)
scheduler.add_job( scheduler.add_job(
self._sync_job, self._sync_job,
@@ -267,8 +417,6 @@ class NeuheitenPlugin(BasePlugin):
id="bgg-neuheiten-sync", id="bgg-neuheiten-sync",
replace_existing=True, replace_existing=True,
) )
scheduler.start()
self._scheduler = scheduler
_logger.info( _logger.info(
"BGG-Neuheiten-Sync aktiv: alle %s h, Suchbegriffe: %s", "BGG-Neuheiten-Sync aktiv: alle %s h, Suchbegriffe: %s",
intervall_stunden, intervall_stunden,

View File

@@ -6,9 +6,10 @@ statt Duplikate anzulegen.
""" """
from __future__ import annotations from __future__ import annotations
import json
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base from redaktionskern.db import Base
@@ -25,7 +26,10 @@ class Neuheit(Base):
autor: Mapped[str | None] = mapped_column(String(300)) autor: Mapped[str | None] = mapped_column(String(300))
# BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum). # BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum).
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer) erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
bgg_id: Mapped[int] = mapped_column(Integer, unique=True, index=True) # Eindeutiges Merge-Kriterium der BGG-Syncs; Web-Quellen haben keine
# BGG-ID und speichern NULL (Migration 0002 macht die Spalte nullable,
# Duplikate werden dort über den Titel gematcht statt über die ID).
bgg_id: Mapped[int | None] = mapped_column(Integer, unique=True, index=True)
status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True) status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True)
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek") quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
@@ -34,4 +38,50 @@ class Neuheit(Base):
) )
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<Neuheit {self.bgg_id} {self.titel!r}>" return f"<Neuheit {self.bgg_id or '?'} {self.titel!r}>"
class QuellenStatus(Base):
"""Sync-Status einer Web-Quelle (Übersicht im UI + HTTP-Validatoren).
Pro Quelle eine Zeile: Ergebnis des letzten Laufes (Zeitpunkt, Dauer,
neu/aktualisiert/gefiltert/Fehler) plus die konditionalen
Request-Header (ETag/Last-Modified je abgerufener URL), damit 304er
auch über Neustarts hinweg respektiert werden.
"""
__tablename__ = "neuheiten_quellen_status"
id: Mapped[int] = mapped_column(primary_key=True)
quelle: Mapped[str] = mapped_column(String(100), unique=True, index=True)
url: Mapped[str | None] = mapped_column(String(500))
letzte_laufzeit: Mapped[datetime | None] = mapped_column(DateTime)
dauer_sekunden: Mapped[float] = mapped_column(Float, default=0.0)
anzahl_neu: Mapped[int] = mapped_column(Integer, default=0)
anzahl_aktualisiert: Mapped[int] = mapped_column(Integer, default=0)
anzahl_gefiltert: Mapped[int] = mapped_column(Integer, default=0)
anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0)
fehlermeldung: Mapped[str | None] = mapped_column(Text)
unveraendert: Mapped[bool] = mapped_column(Boolean, default=False)
#: JSON-Objekt URL → [etag, last_modified]
validatoren_json: Mapped[str | None] = mapped_column(Text)
@property
def validatoren(self) -> dict[str, tuple[str | None, str | None]]:
if not self.validatoren_json:
return {}
try:
roh = json.loads(self.validatoren_json)
except json.JSONDecodeError:
return {}
return {
str(url): (paar[0], paar[1]) if isinstance(paar, (list, tuple)) else (None, None)
for url, paar in roh.items()
}
@validatoren.setter
def validatoren(self, wert: dict[str, tuple[str | None, str | None]]) -> None:
self.validatoren_json = json.dumps(wert or {})
STATUS_UNVERAENDERT_HINWEIS = "unverändert (304) — übersprungen"

View File

@@ -0,0 +1,57 @@
"""Quellen-Adapter des Neuheiten-Plugins (Adapter-Pattern).
Ein Modul pro Quelle plus gemeinsame Basis (`basis.py`); die
Orchestrierung (alle aktiven Quellen, Fehler-Isolation, Dedup, Upsert,
Statusprotokoll) liegt in `quellen_sync.py` des Plugin-Pakets.
Neue Quelle hinzufügen:
1. Modul `meine_quelle.py` mit einer `QuellenAdapter`-Unterklasse
(Klassenattribute `name` und `anzeigename` setzen).
2. Klasse in `ADAPTER_KLASSEN` ergänzen — Env-Schalter, Statuszeile und
Sync-Übersicht ergeben sich automatisch aus dem `name`
(SPIELE_NEUHEITEN_QUELLE_<NAME_GROSS>_AKTIV).
"""
from __future__ import annotations
from .basis import (
USER_AGENT,
QuellenAdapter,
QuellenAntwort,
QuellenFehler,
QuellenTreffer,
SammelErgebnis,
WebQuellenClient,
jahr_aus_datumsangabe,
titel_aehnlichkeit,
titel_normalisieren,
)
from .brettspielbox import BrettspielboxQuelle
from .cliquenabend import CliquenabendQuelle
from .spielessen import SpielEssenQuelle
from .spielbox import SpielboxQuelle
#: Alle verfügbaren Web-Quellen in fester Reihenfolge.
ADAPTER_KLASSEN: tuple[type[QuellenAdapter], ...] = (
SpielboxQuelle,
BrettspielboxQuelle,
SpielEssenQuelle,
CliquenabendQuelle,
)
__all__ = [
"ADAPTER_KLASSEN",
"USER_AGENT",
"QuellenAdapter",
"QuellenAntwort",
"QuellenFehler",
"QuellenTreffer",
"SammelErgebnis",
"WebQuellenClient",
"jahr_aus_datumsangabe",
"titel_aehnlichkeit",
"titel_normalisieren",
"SpielboxQuelle",
"BrettspielboxQuelle",
"SpielEssenQuelle",
"CliquenabendQuelle",
]

View File

@@ -0,0 +1,269 @@
"""Gemeinsame Basis für die Web-Quellen-Adapter des Neuheiten-Plugins.
Enthält:
- `USER_AGENT`: der freundliche Bot-User-Agent, den alle Quellen senden.
- `WebQuellenClient`: HTTP-Client mit Rate-Limit (max. 1 Request/Sekunde
je Domain), Retry/Backoff bei 5xx/429 und konditionalen Requests
(If-None-Match/If-Modified-Since; HTTP 304 → Quelle unverändert).
- `QuellenTreffer`: das gemeinsame Zwischenformat aller Parser-Adapter
(titel, verlag, autor, erscheinungsdatum_oder_quartal, quellen_url).
- `QuellenAdapter`: Basisklasse für einen Quellen-Adapter. Der Standard-
Ablauf crawlt alle Seiten einer Quelle (Pagination wird über die
Folge-Links jeder Seite entdeckt) und übergibt das Parsing an die
Unterklasse (`seite_verarbeiten`). JS-lastige Quellen überschreiben
`sammle()` und nutzen stattdessen ihren Daten-Endpunkt direkt.
- Hilfsfunktionen: Jahr aus einer Datums-/Quartalsangabe, Titel-
Normalisierung und Fuzzy-Titelähnlichkeit (rapidfuzz) für den
Duplikatsvergleich gegen bestehende Einträge (z. B. aus BGG).
Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar —
die Testsuite führt keine echten Netzwerkaufrufe durch.
"""
from __future__ import annotations
import re
import time
import xml.etree.ElementTree # noqa: F401 (dokumentiert: keine XML-Nutzung hier)
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import ClassVar
import httpx
from rapidfuzz import fuzz
#: Freundlicher User-Agent für alle Redaktions-Crawler (Courtesy-Regeln).
USER_AGENT = "SpieleRedaktionBot/1.0 (Redaktions-Tool; Kontakt siehe Repo)"
JAHR_MUSTER = re.compile(r"\b(19|20)(\d{2})\b")
class QuellenFehler(Exception):
"""Fehler beim Abrufen oder Parsen einer Web-Quelle."""
@dataclass(frozen=True)
class QuellenTreffer:
"""Ein geparster Spieleintrag einer Web-Quelle (Zwischenformat).
`erscheinungsdatum_oder_quartal` ist die rohe Angabe der Quelle
(z. B. „24.07.2026“, „10/2026“, „Q3 2026“, „Herbst 2026“);
das Erscheinungsjahr für die Datenbank wird daraus abgeleitet.
"""
titel: str
quellen_url: str
verlag: str | None = None
autor: str | None = None
erscheinungsdatum_oder_quartal: str | None = None
@dataclass
class QuellenAntwort:
"""Ergebnis eines konditionalen GETs."""
status_code: int
content: bytes = b""
etag: str | None = None
last_modified: str | None = None
@dataclass
class SammelErgebnis:
"""Ergebnis eines Adapter-Laufs über eine Quelle."""
treffer: list[QuellenTreffer] = field(default_factory=list)
seiten: int = 0
#: True = mindestens eine Seite kam per 304 als unverändert zurück;
#: der Lauf wurde dann abgekürzt (Quelle übersprungen, kein Fehler).
unveraendert: bool = False
def jahr_aus_datumsangabe(angabe: str | None) -> int | None:
"""Leitet das Erscheinungsjahr aus einer Datums-/Quartalsangabe ab."""
if not angabe:
return None
fund = JAHR_MUSTER.search(angabe)
if fund:
return int(fund.group(0))
return None
def titel_normalisieren(titel: str) -> str:
"""Vergleichsschlüssel für Titel: kleingeschrieben, ohne Satzzeichen."""
geklart = re.sub(r"[^\wäöüß ]+", " ", titel.lower(), flags=re.UNICODE)
return " ".join(geklart.split())
def titel_aehnlichkeit(a: str, b: str) -> float:
"""Fuzzy-Titelähnlichkeit (token_set_ratio, 0100) für Duplikatsprüfung."""
if not a or not b:
return 0.0
return float(fuzz.token_set_ratio(a, b))
class WebQuellenClient:
"""HTTP-Client für Web-Quellen mit Rate-Limit und konditionalen Requests.
- Mindestens `mindestabstand_sekunden` (Standard 1 s) zwischen zwei
Requests (Courtesy-Rate-Limit je Domain).
- Sendet bekannte Validatoren als If-None-Match/If-Modified-Since;
HTTP 304 wird als „unverändert“ gemeldet (kein Retry, kein Fehler).
- 5xx/429 werden mit exponentiellem Backoff erneut versucht,
sonstige Statuscodes erzeugen eine klare `QuellenFehler`.
"""
def __init__(
self,
*,
transport: httpx.BaseTransport | None = None,
mindestabstand_sekunden: float = 1.0,
max_versuche: int = 4,
backoff_basis_sekunden: float = 1.0,
backoff_maximum_sekunden: float = 8.0,
timeout_sekunden: float = 60.0,
schlaf: Callable[[float], None] = time.sleep,
uhr: Callable[[], float] = time.monotonic,
validatoren: dict[str, tuple[str | None, str | None]] | None = None,
) -> None:
self.mindestabstand_sekunden = mindestabstand_sekunden
self.max_versuche = max_versuche
self.backoff_basis_sekunden = backoff_basis_sekunden
self.backoff_maximum_sekunden = backoff_maximum_sekunden
self._schlaf = schlaf
self._uhr = uhr
self._letzter_request_um: float | None = None
#: URL → (etag, last_modified); persistierbar über die Sync-Läufe.
self.validatoren: dict[str, tuple[str | None, str | None]] = dict(
validatoren or {}
)
self._http = httpx.Client(
timeout=timeout_sekunden,
transport=transport,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
)
def schliessen(self) -> None:
self._http.close()
def _rate_limit_abwarten(self) -> None:
if self._letzter_request_um is None:
return
vergangen = self._uhr() - self._letzter_request_um
rest = self.mindestabstand_sekunden - vergangen
if rest > 0:
self._schlaf(rest)
def _konditionale_header(self, url: str) -> dict[str, str]:
header: dict[str, str] = {}
etag, last_modified = self.validatoren.get(url, (None, None))
if etag:
header["If-None-Match"] = etag
if last_modified:
header["If-Modified-Since"] = last_modified
return header
def hole(self, url: str) -> QuellenAntwort:
"""GET mit Rate-Limit, Backoff und konditionalen Headern."""
letzte_fehler: Exception | None = None
for versuch in range(self.max_versuche):
self._rate_limit_abwarten()
try:
antwort = self._http.get(url, headers=self._konditionale_header(url))
except httpx.HTTPError as exc:
letzte_fehler = exc
self._schlaf(self._backoff(versuch))
continue
self._letzter_request_um = self._uhr()
if antwort.status_code == 304:
return QuellenAntwort(304)
if antwort.status_code == 200:
etag = antwort.headers.get("ETag")
last_modified = antwort.headers.get("Last-Modified")
if etag or last_modified:
self.validatoren[url] = (etag, last_modified)
return QuellenAntwort(
200, antwort.content, etag=etag, last_modified=last_modified
)
if antwort.status_code == 429 or antwort.status_code >= 500:
retry_after = antwort.headers.get("Retry-After")
try:
wartezeit = float(retry_after) if retry_after else None
except ValueError:
wartezeit = None
self._schlaf(wartezeit if wartezeit is not None else self._backoff(versuch))
letzte_fehler = QuellenFehler(
f"HTTP {antwort.status_code} von {url} (Versuch {versuch + 1})."
)
continue
raise QuellenFehler(
f"Unerwartete HTTP-Antwort {antwort.status_code} für {url}."
)
raise QuellenFehler(
f"Quelle nach {self.max_versuche} Versuchen nicht erreichbar ({url}):"
f" {letzte_fehler}"
)
def _backoff(self, versuch: int) -> float:
return min(
self.backoff_basis_sekunden * (2**versuch),
self.backoff_maximum_sekunden,
)
class QuellenAdapter:
"""Basisklasse eines Quellen-Adapters.
Klassenattribute:
- `name`: stabiler Kurzname (= Env-Suffix, z. B. „spielbox“ für
SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV) und Wert der Spalte `quelle`.
- `anzeigename`: deutscher Name für die Sync-Übersicht im UI.
- `start_urls`: Einstiegspunkte des Crawls.
- `max_seiten`: Sicherheitsgrenze gegen Endlos-Pagination.
HTML-Quellen implementieren `seite_verarbeiten`; JS-lastige Quellen
mit eigenem Daten-Endpunkt überschreiben stattdessen `sammle()`.
"""
name: ClassVar[str]
anzeigename: ClassVar[str]
start_urls: ClassVar[tuple[str, ...]] = ()
max_seiten: int = 100
def __init__(self, client: WebQuellenClient) -> None:
self.client = client
def sammle(self) -> SammelErgebnis:
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS)."""
ergebnis = SammelErgebnis()
warteschlange = list(self.start_urls)
besucht: set[str] = set()
while warteschlange and len(besucht) < self.max_seiten:
url = warteschlange.pop(0)
if url in besucht:
continue
besucht.add(url)
antwort = self.client.hole(url)
if antwort.status_code == 304:
# Unverändert → Quelle überspringen (kein Fehler).
ergebnis.unveraendert = True
break
treffer, folge_links = self.seite_verarbeiten(antwort.content, url)
ergebnis.treffer.extend(treffer)
ergebnis.seiten += 1
for link in folge_links:
if link not in besucht:
warteschlange.append(link)
return ergebnis
def seite_verarbeiten(
self, inhalt: bytes, url: str
) -> tuple[list[QuellenTreffer], list[str]]:
"""Parst eine Seite: liefert Treffer und Folge-Links (Pagination)."""
raise NotImplementedError(
f"{type(self).__name__} muss seite_verarbeiten oder sammle überschreiben."
)

View File

@@ -0,0 +1,65 @@
"""Adapter für die Brettspielbox-Messevorschau (A-Z-Liste).
Seite: …/brettspiel-neuheiten-spiel-2026-herbst-2026-a-z/ (WordPress).
Struktur (Stand Analyse August 2026): Die komplette A-Z-Liste steht auf
einer Seite in `div.wp-block-columns`-Blöcken — pro Buchstabe ein
Absatz (`p`) mit dem Buchstaben bzw. „0-9“ und eine `ul` mit einem
`li` pro Spiel im Format „Titel (Verlag)“; optionale Links im Eintrag
zeigen auf Vorstellungs-/Rezensionsartikel und dienen als quellen_url,
sonst gilt die Listenseite selbst.
"""
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer
TITEL_VERLAG_MUSTER = re.compile(r"^\s*(.+?)\s*\(([^()]*)\)\s*$")
class BrettspielboxQuelle(QuellenAdapter):
name = "brettspielbox"
anzeigename = "Brettspielbox Messevorschau (AZ)"
start_urls = (
"https://brettspielbox.de/brettspiel-neuheiten-spiel-2026-herbst-2026-a-z/",
)
def seite_verarbeiten(self, inhalt: bytes, url: str):
suppe = BeautifulSoup(inhalt, "html.parser")
treffer: list[QuellenTreffer] = []
for spalte in suppe.select(".wp-block-columns"):
for liste in spalte.find_all("ul"):
for eintrag in liste.find_all("li"):
text = eintrag.get_text(" ", strip=True)
titel, verlag = _zerlege_eintrag(text)
if not titel:
continue
link = eintrag.find("a", href=True)
from urllib.parse import urljoin
quellen_url = (
urljoin(url, link["href"]) if link is not None else url
)
treffer.append(
QuellenTreffer(
titel=titel,
verlag=verlag,
autor=None,
erscheinungsdatum_oder_quartal=None,
quellen_url=quellen_url,
)
)
return treffer, []
def _zerlege_eintrag(text: str) -> tuple[str | None, str | None]:
"""„Titel (Verlag)“ → (Titel, Verlag); ohne Klammer nur Titel."""
fund = TITEL_VERLAG_MUSTER.match(text)
if fund is None:
return (text.strip() or None), None
verlag = fund.group(2).strip() or None
titel = fund.group(1).strip()
return (titel or None), verlag

View File

@@ -0,0 +1,97 @@
"""Adapter für cliquenabend.de Messe-Eventseiten (z. B. SPIEL 2025 Essen).
Seite: …/events/130000-SPIEL-2025-Essen.html (große Einzelseite, ~4 MB).
Struktur (Stand Analyse August 2026): Eine einzige Tabelle mit der
Kopfzeile Update | Verlag | Stand | Spiel | Autor; eine Zeile pro Spiel.
Titel steht in `a[href^="/spiele/"] > b`, Verlage in fett formatierten
`div`-Blöcken der Verlagsspalte, das Update-Datum in der Form TT.MM.JJ.
Die Seite ist nicht paginiert.
"""
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer
DATUM_MUSTER = re.compile(r"\d{2}\.\d{2}\.(\d{2})\b")
BASIS_URL = "https://www.cliquenabend.de"
class CliquenabendQuelle(QuellenAdapter):
name = "cliquenabend"
anzeigename = "Cliquenabend Messe-Übersicht"
start_urls = ("https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html",)
def seite_verarbeiten(self, inhalt: bytes, url: str):
suppe = BeautifulSoup(inhalt, "html.parser")
treffer: list[QuellenTreffer] = []
tabelle = suppe.find("table")
if tabelle is None:
return [], [] # Strukturänderung: nichts tun, kein Crash
for zeile in tabelle.find_all("tr"):
zellen = zeile.find_all("td")
if len(zellen) < 5:
continue
if zellen[0].get_text(strip=True).lower() == "update":
continue # Kopfzeile
treffer.append(self._zeile(zellen, url))
return [t for t in treffer if t is not None], []
def _zeile(self, zellen, url: str) -> QuellenTreffer | None:
spiel_zelle = zellen[3]
titel_link = spiel_zelle.find("a", href=re.compile(r"^/spiele/"))
titel = None
quellen_url = url
if titel_link is not None:
fett = titel_link.find("b")
titel = (fett or titel_link).get_text(" ", strip=True)
href = titel_link.get("href")
if href:
from urllib.parse import urljoin
quellen_url = urljoin(BASIS_URL, href)
if not titel:
return None
verlag = _verlage(zellen[1])
autor = zellen[4].get_text(" ", strip=True) or None
datum = None
jahr_text = zellen[0].get_text(strip=True)
fund = DATUM_MUSTER.search(jahr_text)
if fund:
# Datum so übernehmen, wie es in der Quelle steht (z. B. „15.10.25“).
datum = fund.group(0)
return QuellenTreffer(
titel=titel,
verlag=verlag,
autor=autor,
erscheinungsdatum_oder_quartal=datum,
quellen_url=quellen_url,
)
def _verlage(zelle) -> str | None:
"""Verlagsspalte: fett formatierte `div`-Blöcke enthalten die Namen."""
namen = [
div.get_text(" ", strip=True)
for div in zelle.find_all("div")
if div.get_text(" ", strip=True)
]
if not namen:
roh = zelle.get_text(" ", strip=True)
namen = [teil for teil in (roh.split(",") if roh else [])
if teil.strip() and teil.strip().lower() != "profil"]
bereinigt = []
for name in namen:
name = name.strip().rstrip(",").strip()
# „Piatnik Profil ,“ → Profil-Link-Text hängt am Namen; abschneiden.
if name.lower().endswith(" profil"):
name = name[: -len(" Profil")].strip()
if name and name.lower() != "profil":
bereinigt.append(name)
return ", ".join(bereinigt) if bereinigt else None

View File

@@ -0,0 +1,88 @@
"""Adapter für https://www.spielbox.de/neuheiten-neue-spiele (Joomla-Blog).
Seitenstruktur (Stand Analyse August 2026):
- Pro Beitrag ein `div.item` mit Titel-Link in `div.page-header h2 a`,
Beitragsdatum (TT.MM.JJJJ) im ersten Absatz (`p > strong`).
- Pagination über `.pagination`-Links der Form `?start=N` (6 Beiträge je
Seite) — alle Seiten werden durchlaufen, bis keine neue Seite mehr
gefunden wird.
- Verlag: Heuristik — Präfix des Titels vor dem Doppelpunkt
(z. B. „Pegasus Spiele: Next Station Berlin“); fehlt er, bleibt der
Verlag leer. Ein Autor wird auf dieser Liste nicht strukturiert
mitgeliefert.
"""
from __future__ import annotations
import re
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer
DATUM_MUSTER = re.compile(r"(\d{2}\.\d{2}\.\d{4})")
class SpielboxQuelle(QuellenAdapter):
name = "spielbox"
anzeigename = "spielbox.de Neuheiten"
start_urls = ("https://www.spielbox.de/neuheiten-neue-spiele",)
def seite_verarbeiten(self, inhalt: bytes, url: str):
suppe = BeautifulSoup(inhalt, "html.parser")
treffer: list[QuellenTreffer] = []
for element in suppe.select("div.item"):
kopf_link = element.select_one("div.page-header h2 a") or element.find("h2")
if kopf_link is None:
continue # Strukturänderung: Beitrag überspringen, Lauf hält an
titel = kopf_link.get_text(" ", strip=True)
if not titel:
continue
href = kopf_link.get("href") if hasattr(kopf_link, "get") else None
quellen_url = _absolute_url(href, url) or url
datum = None
for absatz in element.find_all("p"):
strong = absatz.find("strong")
if strong is not None:
fund = DATUM_MUSTER.search(strong.get_text(strip=True))
if fund:
datum = fund.group(1)
break
verlag = _verlag_aus_titel(titel)
treffer.append(
QuellenTreffer(
titel=titel,
verlag=verlag,
autor=None,
erscheinungsdatum_oder_quartal=datum,
quellen_url=quellen_url,
)
)
folge_links: list[str] = []
for link in suppe.select(".pagination a[href]"):
ziel = _absolute_url(link["href"], url)
# Nur echte Paginierung dieser Liste folgen (?start=N),
# nicht den Druck-/Sprung-Links.
if ziel and "start=" in ziel:
folge_links.append(ziel)
return treffer, folge_links
def _verlag_aus_titel(titel: str) -> str | None:
"""„Verlag: Spielname“ → Verlag als Präfix vor dem Doppelpunkt."""
if ":" not in titel:
return None
praefix = titel.split(":", 1)[0].strip()
if 0 < len(praefix) <= 60:
return praefix
return None
def _absolute_url(href: str | None, basis_url: str) -> str | None:
if not href:
return None
from urllib.parse import urljoin
return urljoin(basis_url, href)

View File

@@ -0,0 +1,144 @@
"""Adapter für die Neuheitenliste der SPIEL Essen (spiel-essen.de).
Die Seite https://spiel-essen.de/de/die-spiel/neuheiten rendert ihre
Daten clientseitig mit einer Vue-App („eye-concat-product-list“) — HTML-
Parsing scheidet aus. Die App lädt ihre Daten aus einem stabilen
JSON-Endpunkt (im App-Bundle verdrahtet, Stand Analyse August 2026):
GET https://maps.eyeled-services.de/de/spiel{JJ}/products
?columns=["ID","INFO","S_ORDER","TITEL","FIRMA_ID","UNTERTITEL",
"BILDER","BILDER_VERSIONEN","BILDER_TEXTE"]
GET https://maps.eyeled-services.de/de/spiel{JJ}/exhibitors
?columns=["ID","NAME", …] (FIRMA_ID → Ausstellername)
Das Projekt-Kürzel (`spiel26`, `spiel27`, …) folgt dem aktuellen Jahr.
Antwort: `{"products": [{"ID", "TITEL", "UNTERTITEL", "FIRMA_ID",
"INFO", …}]}`; INFO ist eine HTML-Tabelle mit u. a. Autor, Verlag,
Vertrieb und Erscheinungsdatum (z. B. „10/2026“). Vor Veröffentlichung
der Liste liefert der Endpunkt eine leere Produktliste — das ist ein
normaler Lauf ohne Treffer, kein Fehler.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from urllib.parse import urlencode
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenFehler, QuellenTreffer, SammelErgebnis
API_BASIS_URL = "https://maps.eyeled-services.de"
OEFFENTLICHE_SEITE = "https://spiel-essen.de/de/die-spiel/neuheiten"
PRODUKT_SPALTEN = [
"ID", "INFO", "S_ORDER", "TITEL", "FIRMA_ID", "UNTERTITEL",
"BILDER", "BILDER_VERSIONEN", "BILDER_TEXTE",
]
AUSSTELLER_SPALTEN = [
"ID", "NAME", "ADRESSE", "LAND", "LOGO", "PLZ", "STADT", "WEB",
"EMAIL", "INFO", "TELEFON", "S_ORDER", "STAND", "HALLE",
]
INFO_FELD_VERLAG = ("Verlag", "Vertrieb")
def _projekt_kuerzel(jahr: int | None = None) -> str:
"""API-Projektname der Messe (spiel25, spiel26, …)."""
jahr = jahr if jahr is not None else datetime.now(timezone.utc).year
return f"spiel{jahr % 100:02d}"
def _endpoint(pfad: str, spalten: list[str], jahr: int | None = None) -> str:
query = urlencode({"columns": json.dumps(spalten)})
return f"{API_BASIS_URL}/de/{_projekt_kuerzel(jahr)}/{pfad}?{query}"
class SpielEssenQuelle(QuellenAdapter):
name = "spielessen"
anzeigename = "SPIEL Essen Neuheiten (Produktliste)"
start_urls = (OEFFENTLICHE_SEITE,)
def __init__(self, client, jahr: int | None = None) -> None:
super().__init__(client)
self._jahr = jahr # für Tests überschreibbar (stabile Endpunkte)
def sammle(self) -> SammelErgebnis:
ergebnis = SammelErgebnis()
antwort = self.client.hole(
_endpoint("products", PRODUKT_SPALTEN, self._jahr)
)
if antwort.status_code == 304:
ergebnis.unveraendert = True
return ergebnis
ergebnis.seiten = 1
try:
daten = json.loads(antwort.content.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise QuellenFehler(f"Ungültiges JSON der SPIEL-Essen-Produktliste: {exc}") from exc
aussteller = self._lade_aussteller()
for produkt in daten.get("products") or []:
treffer = self._produkt(produkt, aussteller)
if treffer is not None:
ergebnis.treffer.append(treffer)
return ergebnis
def _lade_aussteller(self) -> dict[str, str]:
"""FIRMA_ID → Ausstellername; Fehler degradieren auf Untertitel-Fallback."""
try:
antwort = self.client.hole(
_endpoint("exhibitors", AUSSTELLER_SPALTEN, self._jahr)
)
except QuellenFehler:
return {}
if antwort.status_code == 304:
return {}
try:
daten = json.loads(antwort.content.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {}
zuordnung: dict[str, str] = {}
for eintrag in daten.get("exhibitors") or []:
if eintrag.get("ID") is not None and eintrag.get("NAME"):
zuordnung[str(eintrag["ID"])] = str(eintrag["NAME"])
return zuordnung
def _produkt(self, produkt: dict, aussteller: dict[str, str]) -> QuellenTreffer | None:
titel = str(produkt.get("TITEL") or "").strip()
if not titel:
return None
felder = _parse_info(produkt.get("INFO") or "")
verlag = (
felder.get("Verlag")
or felder.get("Vertrieb")
or aussteller.get(str(produkt.get("FIRMA_ID") or ""))
or str(produkt.get("UNTERTITEL") or "").strip()
or None
)
return QuellenTreffer(
titel=titel,
verlag=verlag,
autor=felder.get("Autor"),
erscheinungsdatum_oder_quartal=felder.get("Erscheinungsdatum"),
quellen_url=OEFFENTLICHE_SEITE,
)
def _parse_info(info_html: str) -> dict[str, str]:
"""Liest die INFO-Html-Tabelle (Zeilen „Feld: Wert“) tolerant aus."""
felder: dict[str, str] = {}
if not info_html.strip():
return felder
try:
suppe = BeautifulSoup(info_html, "html.parser")
except Exception: # kaputtes HTML darf den Lauf nicht abbrechen
return felder
for zeile in suppe.find_all("tr"):
zellen = zeile.find_all("td")
if len(zellen) != 2:
continue
schluessel = zellen[0].get_text(" ", strip=True).rstrip(":").strip()
wert = zellen[1].get_text(" ", strip=True).strip()
if schluessel and wert:
felder[schluessel] = wert
return felder

View File

@@ -0,0 +1,314 @@
"""Sync-Orchestrierung für die Web-Quellen des Neuheiten-Plugins.
Pro Quelle:
- Env-Schalter SPIELE_NEUHEITEN_QUELLE_<NAME>_AKTIV (Standard „1“ = an).
- Adapter-Lauf mit konditionalen Requests (ETag/Last-Modified werden in
der Statuszeile persistiert; 304 → Quelle übersprungen, kein Fehler).
- Fehler-Isolation: eine kaputte Quelle bricht den Gesamtdurchlauf nicht
ab; ihr Ergebnis wird als Fehler protokolliert, die anderen Quellen
laufen normal weiter.
- Duplikatvermeidung: Web-Quellen haben keine BGG-ID, daher wird jeder
Treffer vor dem Speichern fuzzy gegen die bestehende Liste geprüft
(Titel-Match, rapidfuzz) — Treffer gegen BGG-Einträge werden
übersprungen. Zusätzlich läuft jeder neue Treffer durch die öffentliche
dedup-Prüfung (via Plugin-Registry, sofern das Plugin geladen ist).
- Upsert: bereits vorhandene Treffer derselben Quelle (gleicher Titel)
werden aktualisiert statt doppelt angelegt; neue Einträge bekommen
quelle = '<name>' statt 'boardgamegeek' und keine BGG-ID.
Das Ergebnis jedes Laufes wird pro Quelle in `neuheiten_quellen_status`
protokolliert und im Plugin-UI unter /neuheiten angezeigt.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from .models import STATUS_NEUHEIT, Neuheit, QuellenStatus
from .quellen import (
ADAPTER_KLASSEN,
QuellenAdapter,
QuellenTreffer,
WebQuellenClient,
jahr_aus_datumsangabe,
titel_aehnlichkeit,
titel_normalisieren,
)
_logger = logging.getLogger("plugins.neuheiten")
#: Schwelle für den Fuzzy-Titel-Match gegen bestehende Einträge (0100).
TITEL_FUZZY_SCHWELLE = 92.0
#: Zweite Regel: etwas lockererer Titel-Match, dafür gleicher/ähnlicher Verlag.
TITEL_FUZZY_MIT_VERLAG_SCHWELLE = 85.0
VERLAG_FUZZY_SCHWELLE = 85.0
ENV_SCHABLONE_AKTIV = "SPIELE_NEUHEITEN_QUELLE_{name}_AKTIV"
def quelle_aktiv(name: str, umgebung=None) -> bool:
"""Liest den Env-Schalter einer Quelle (Standard: an)."""
env = umgebung if umgebung is not None else os.environ
wert = env.get(ENV_SCHABLONE_AKTIV.format(name=name.upper()), "1")
return str(wert).strip() != "0"
@dataclass
class QuellenLaufZeile:
"""Ergebnis einer Quelle in einem Sync-Lauf (Grundlage der UI-Übersicht)."""
quelle: str
anzeigename: str
aktiv: bool = True
neu: int = 0
aktualisiert: int = 0
gefiltert: int = 0
fehleranzahl: int = 0
fehlermeldung: str | None = None
unveraendert: bool = False
seiten: int = 0
dauer_sekunden: float = 0.0
url: str | None = None
def als_text(self) -> str:
if not self.aktiv:
return f"{self.anzeigename}: ausgeschaltet"
if self.fehlermeldung:
return f"{self.anzeigename}: Fehler — {self.fehlermeldung}"
if self.unveraendert:
return f"{self.anzeigename}: unverändert (304) — übersprungen"
return (
f"{self.anzeigename}: {self.neu} neu, {self.aktualisiert} aktualisiert, "
f"{self.gefiltert} gefiltert (Duplikate)"
)
class WebQuellenSyncService:
"""Führt einen Sync-Lauf über alle Web-Quellen aus.
`dedup` ist die öffentliche API des dedup-Plugins (via Plugin-Registry)
oder None, wenn das Plugin nicht geladen ist — die Prüfung ist dann
optional und wird übersprungen. `client_fabrik` baut den HTTP-Client
(in Tests mit Fake-Transport überschrieben).
"""
def __init__(
self,
session_factory: sessionmaker,
*,
adapter_klassen: tuple[type[QuellenAdapter], ...] = ADAPTER_KLASSEN,
dedup=None,
client_fabrik: Callable[[dict], WebQuellenClient] | None = None,
umgebung=None,
) -> None:
self.session_factory = session_factory
self.adapter_klassen = adapter_klassen
self.dedup = dedup
self.umgebung = umgebung if umgebung is not None else os.environ
self._client_fabrik = client_fabrik or self._standard_client_fabrik
# ---------- Gesamtdurchlauf ----------
def laufe(self) -> list[QuellenLaufZeile]:
zeilen: list[QuellenLaufZeile] = []
for klasse in self.adapter_klassen:
try:
zeile = self._eine_quelle(klasse)
except Exception as exc: # letzte Verteidigungslinie pro Quelle
_logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name)
zeile = QuellenLaufZeile(
klasse.name,
klasse.anzeigename,
fehleranzahl=1,
fehlermeldung=str(exc)[:300],
)
self._status_speichern(zeile)
zeilen.append(zeile)
return zeilen
def gesamt_text(self, zeilen: list[QuellenLaufZeile]) -> str:
"""Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll."""
teile = [zeile.als_text() for zeile in zeilen]
return " · ".join(teile) if teile else "Keine Web-Quellen konfiguriert."
# ---------- Eine Quelle ----------
def _eine_quelle(self, klasse: type[QuellenAdapter]) -> QuellenLaufZeile:
zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename)
zeile.url = klasse.start_urls[0] if klasse.start_urls else None
if not quelle_aktiv(klasse.name, self.umgebung):
zeile.aktiv = False
return zeile
start = time.monotonic()
client = self._client_fabrik(self._validatoren_laden(klasse.name))
try:
adapter = klasse(client)
try:
ergebnis = adapter.sammle()
except Exception as exc:
_logger.warning("Quelle %s fehlgeschlagen: %s", klasse.name, exc)
zeile.fehleranzahl = 1
zeile.fehlermeldung = str(exc)[:300]
return zeile
zeile.seiten = ergebnis.seiten
if ergebnis.unveraendert:
zeile.unveraendert = True
else:
self._treffer_verarbeiten(zeile, ergebnis.treffer, klasse.name)
finally:
client.schliessen()
zeile.dauer_sekunden = round(time.monotonic() - start, 2)
self._validatoren_speichern(klasse.name, zeile.url, client)
return zeile
def _treffer_verarbeiten(
self, zeile: QuellenLaufZeile, treffer: list[QuellenTreffer], quelle: str
) -> None:
asyncio.run(self._verarbeite_async(zeile, treffer, quelle))
async def _verarbeite_async(
self, zeile: QuellenLaufZeile, treffer: list[QuellenTreffer], quelle: str
) -> None:
with self.session_factory() as db:
try:
for einzel in treffer:
if not einzel.titel or not einzel.titel.strip():
zeile.gefiltert += 1
continue
if self._eigenen_eintrag_aktualisieren(db, einzel, quelle):
zeile.aktualisiert += 1
continue
if self._ist_duplikat(db, einzel):
zeile.gefiltert += 1
continue
if not await self._dedup_erklaert_kein_konflikt(einzel):
zeile.gefiltert += 1
continue
db.add(
Neuheit(
titel=einzel.titel.strip()[:300],
verlag=einzel.verlag,
autor=einzel.autor,
erscheinungsjahr=jahr_aus_datumsangabe(
einzel.erscheinungsdatum_oder_quartal
),
bgg_id=None,
status=STATUS_NEUHEIT,
quelle=quelle,
)
)
zeile.neu += 1
db.commit()
except Exception:
db.rollback()
raise
# ---------- Duplikate / Upsert ----------
def _eigenen_eintrag_aktualisieren(
self, db, treffer: QuellenTreffer, quelle: str
) -> bool:
"""Update statt Duplikat: gleicher Titel + gleiche Quelle."""
schluessel = titel_normalisieren(treffer.titel)
vorhanden = db.scalars(select(Neuheit).where(Neuheit.quelle == quelle)).all()
for eintrag in vorhanden:
if titel_normalisieren(eintrag.titel) == schluessel:
eintrag.verlag = treffer.verlag
eintrag.autor = treffer.autor
eintrag.erscheinungsjahr = jahr_aus_datumsangabe(
treffer.erscheinungsdatum_oder_quartal
)
return True
return False
def _ist_duplikat(self, db, treffer: QuellenTreffer) -> bool:
"""Titel-Match gegen bestehende Einträge (z. B. aus BGG).
BGG-IDs haben Web-Quellen nicht — gematcht wird fuzzy auf den
Titel; bei Grenzfällen entscheidet ein ähnlicher Verlag mit.
"""
for eintrag in db.scalars(select(Neuheit)).all():
if titel_aehnlichkeit(treffer.titel, eintrag.titel) >= TITEL_FUZZY_SCHWELLE:
return True
if (
treffer.verlag
and eintrag.verlag
and titel_aehnlichkeit(treffer.titel, eintrag.titel)
>= TITEL_FUZZY_MIT_VERLAG_SCHWELLE
and titel_aehnlichkeit(treffer.verlag, eintrag.verlag)
>= VERLAG_FUZZY_SCHWELLE
):
return True
return False
async def _dedup_erklaert_kein_konflikt(self, treffer: QuellenTreffer) -> bool:
"""Öffentliche dedup-Prüfung (sofern Plugin geladen); Fehler tolerieren."""
if self.dedup is None:
return True
try:
ergebnis = await self.dedup.check_titel(treffer.titel, treffer.verlag, None)
except Exception as exc:
_logger.warning("dedup-Prüfung fehlgeschlagen (%s): %s", treffer.titel, exc)
return True
if ergebnis is None:
return True
return not ergebnis.hat_konflikte
# ---------- Statusprotokoll ----------
def _status_speichern(self, zeile: QuellenLaufZeile) -> None:
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == zeile.quelle)
)
if eintrag is None:
eintrag = QuellenStatus(quelle=zeile.quelle, url=zeile.url or "")
db.add(eintrag)
if zeile.url:
eintrag.url = zeile.url
eintrag.letzte_laufzeit = datetime.now(timezone.utc).replace(tzinfo=None)
eintrag.dauer_sekunden = zeile.dauer_sekunden
eintrag.anzahl_neu = zeile.neu
eintrag.anzahl_aktualisiert = zeile.aktualisiert
eintrag.anzahl_gefiltert = zeile.gefiltert
eintrag.anzahl_fehler = zeile.fehleranzahl
eintrag.fehlermeldung = zeile.fehlermeldung
eintrag.unveraendert = zeile.unveraendert
db.commit()
def _validatoren_laden(self, quelle: str) -> dict:
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == quelle)
)
return dict(eintrag.validatoren) if eintrag is not None else {}
def _validatoren_speichern(
self, quelle: str, url: str | None, client: WebQuellenClient
) -> None:
if not client.validatoren:
return
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == quelle)
)
if eintrag is None:
eintrag = QuellenStatus(quelle=quelle, url=url or "")
db.add(eintrag)
eintrag.validatoren = client.validatoren
db.commit()
# ---------- Hilfen ----------
@staticmethod
def _standard_client_fabrik(validatoren) -> WebQuellenClient:
return WebQuellenClient(validatoren=validatoren)

View File

@@ -23,8 +23,12 @@
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium"> <td class="px-4 py-3 font-medium">
{{ eintrag.titel }} {{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener" <a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a> class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %}
<span class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 align-super">{{ eintrag.quelle }}</span>
{% endif %}
</td> </td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td> <td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3">{{ eintrag.autor or '—' }}</td> <td class="px-4 py-3">{{ eintrag.autor or '—' }}</td>

View File

@@ -41,6 +41,68 @@
</p> </p>
</div> </div>
<div class="mb-6 bg-white rounded-xl border border-slate-200 shadow-sm p-4">
<div class="flex flex-wrap items-center gap-2 mb-3">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500 mr-auto">Web-Quellen</h2>
{% if ist_redaktion %}
<form method="post" action="/neuheiten/quellen-sync">
<button type="submit"
class="bg-sky-600 hover:bg-sky-700 text-white font-medium px-3 py-1.5 rounded-lg text-xs whitespace-nowrap">
&#8635; Web-Quellen synchronisieren
</button>
</form>
{% endif %}
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="text-left text-slate-400">
<tr>
<th class="px-2 py-1.5 font-medium">Quelle</th>
<th class="px-2 py-1.5 font-medium">Status</th>
<th class="px-2 py-1.5 font-medium">Letzter Lauf</th>
<th class="px-2 py-1.5 font-medium text-right">Neu</th>
<th class="px-2 py-1.5 font-medium text-right">Aktualisiert</th>
<th class="px-2 py-1.5 font-medium text-right">Gefiltert</th>
<th class="px-2 py-1.5 font-medium">Hinweis</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for quelle in quellen_zeilen %}
<tr>
<td class="px-2 py-1.5">
<a href="{{ quelle.url }}" target="_blank" rel="noopener noreferrer"
class="font-medium text-slate-700 hover:text-emerald-700">{{ quelle.anzeigename }}</a>
<span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span>
</td>
<td class="px-2 py-1.5">
{% if quelle.aktiv %}<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700">an</span>
{% else %}<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-400">aus</span>{% endif %}
</td>
<td class="px-2 py-1.5 whitespace-nowrap text-slate-500">
{{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }}
{% if quelle.dauer_sekunden %}<span class="text-slate-300">({{ '%.1f' | format(quelle.dauer_sekunden) }}&nbsp;s)</span>{% endif %}
</td>
<td class="px-2 py-1.5 text-right">{{ quelle.neu }}</td>
<td class="px-2 py-1.5 text-right">{{ quelle.aktualisiert }}</td>
<td class="px-2 py-1.5 text-right">{{ quelle.gefiltert }}</td>
<td class="px-2 py-1.5">
{% if quelle.fehlermeldung %}<span class="text-red-600">{{ quelle.fehlermeldung }}</span>
{% elif quelle.unveraendert %}<span class="text-slate-400">unverändert (304) — übersprungen</span>
{% elif not quelle.aktiv %}<span class="text-slate-300">abgeschaltet</span>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="mt-2 text-[11px] text-slate-400">
Quellen-Sync:
{% if quellen_sync_aktiv %}<span class="text-emerald-700 font-medium">täglich aktiv</span>{% else %}<span class="text-slate-500">ausgeschaltet</span>{% endif %}
&middot; max. 1 Anfrage/Sekunde je Quelle &middot; Duplikate gegen BGG werden per Titel-Match gefiltert
</p>
</div>
<form method="get" action="/neuheiten" <form method="get" action="/neuheiten"
hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML" hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML"
hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status']" hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status']"

View File

@@ -15,6 +15,7 @@ dependencies = [
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"apscheduler>=3.11,<4", "apscheduler>=3.11,<4",
"httpx>=0.27", "httpx>=0.27",
"beautifulsoup4>=4.12",
"rapidfuzz>=3.14.5", "rapidfuzz>=3.14.5",
"weasyprint>=69.0", "weasyprint>=69.0",
] ]

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import importlib.metadata import importlib.metadata
import importlib.util import importlib.util
import sys import sys
import types
from importlib.metadata import entry_points from importlib.metadata import entry_points
from pathlib import Path from pathlib import Path
@@ -81,6 +82,19 @@ def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]:
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.") raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.")
modul = importlib.util.module_from_spec(spec) modul = importlib.util.module_from_spec(spec)
# Eltern-Paketnamen registrieren, damit relative Imports aus
# Plugin-Unterpackages (z. B. `from .basis import …` in
# neuheiten/quellen/) aufgelöst werden können.
eltern = modulname.rpartition(".")[0]
if eltern and eltern not in sys.modules:
# Das Eltern-Paket (Namensraum `spiele_redaktion_plugins`) hat
# kein __init__.py — spec_from_file_location liefert dafür None.
# Ein leeres Namespace-Modul mit __path__ genügt, damit relative
# Imports aus Plugin-Unterpackages (z. B. neuheiten/quellen/)
# aufgelöst werden können.
eltern_modul = types.ModuleType(eltern)
eltern_modul.__path__ = [str(eintrag.parent)]
sys.modules[eltern] = eltern_modul
sys.modules[modulname] = modul sys.modules[modulname] = modul
spec.loader.exec_module(modul) spec.loader.exec_module(modul)
if not hasattr(modul, "plugin"): if not hasattr(modul, "plugin"):

View File

@@ -42,3 +42,36 @@ speichere_oder_aktualisiere = sync_modul.speichere_oder_aktualisiere
Neuheit = models.Neuheit Neuheit = models.Neuheit
STATUS_NEUHEIT = models.STATUS_NEUHEIT STATUS_NEUHEIT = models.STATUS_NEUHEIT
# ---------- Web-Quellen (Adapter + Sync-Orchestrierung) ----------
quellen_basis = _neuheiten.quellen.basis
quellen_sync_modul = _neuheiten.quellen_sync
models = _neuheiten.models # neu mit QuellenStatus
USER_AGENT = quellen_basis.USER_AGENT
QuellenAdapter = quellen_basis.QuellenAdapter
QuellenFehler = quellen_basis.QuellenFehler
QuellenTreffer = quellen_basis.QuellenTreffer
SammelErgebnis = quellen_basis.SammelErgebnis
WebQuellenClient = quellen_basis.WebQuellenClient
jahr_aus_datumsangabe = quellen_basis.jahr_aus_datumsangabe
titel_aehnlichkeit = quellen_basis.titel_aehnlichkeit
titel_normalisieren = quellen_basis.titel_normalisieren
SpielboxQuelle = _neuheiten.quellen.spielbox.SpielboxQuelle
BrettspielboxQuelle = _neuheiten.quellen.brettspielbox.BrettspielboxQuelle
SpielEssenQuelle = _neuheiten.quellen.spielessen.SpielEssenQuelle
CliquenabendQuelle = _neuheiten.quellen.cliquenabend.CliquenabendQuelle
ADAPTER_KLASSEN = quellen_sync_modul.ADAPTER_KLASSEN
WebQuellenSyncService = quellen_sync_modul.WebQuellenSyncService
QuellenLaufZeile = quellen_sync_modul.QuellenLaufZeile
quelle_aktiv = quellen_sync_modul.quelle_aktiv
TITEL_FUZZY_SCHWELLE = quellen_sync_modul.TITEL_FUZZY_SCHWELLE
QuellenStatus = models.QuellenStatus
def sys_modules_plugin():
"""Die geladene NeuheitenPlugin-Instanz (für Migrationen im Test)."""
return sys.modules["spiele_redaktion_plugins.neuheiten"].plugin

View File

@@ -26,6 +26,8 @@ def _hintergrundjobs_deaktiviert(monkeypatch):
# Auch die täglichen Hintergrund-Jobs bleiben in Tests aus. # Auch die täglichen Hintergrund-Jobs bleiben in Tests aus.
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "0") monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "0")
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0") monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0")
# Web-Quellen-Sync (spielbox/brettspielbox/…) ebenfalls: keine Threads.
monkeypatch.setenv("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "0")
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline. # Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0") monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")
# Die LLM-Zweitprüfung des dedup-Plugins bleibt in Tests aus; einzelne # Die LLM-Zweitprüfung des dedup-Plugins bleibt in Tests aus; einzelne

View File

@@ -0,0 +1,822 @@
"""Tests für die Web-Quellen-Erweiterung des Neuheiten-Plugins.
Alle HTTP-Antworten sind gemockt (Fake-Transport / Client-Fabrik) — es
gibt keine echten Netzwerkaufrufe. Abgedeckt: Parser-Extraktion je
Quelle, Pagination-Durchlauf, 304-Behandlung (If-None-Match), Rate-
Limit, Fehler-Isolation pro Quelle, Duplikatprüfung gegen BGG-Titel,
Env-Schalter und Sync-Übersicht im UI.
"""
from __future__ import annotations
import json
import httpx
import pytest
from tests._neuheiten import (
ADAPTER_KLASSEN,
BrettspielboxQuelle,
CliquenabendQuelle,
Neuheit,
QuellenFehler,
QuellenStatus,
QuellenTreffer,
QuellenLaufZeile,
SammelErgebnis,
SpielEssenQuelle,
SpielboxQuelle,
USER_AGENT,
WebQuellenClient,
WebQuellenSyncService,
jahr_aus_datumsangabe,
quelle_aktiv,
titel_aehnlichkeit,
)
# ---------- Gemockte Seiten (realistische Struktur-Anleihen der Quellen) ----
SPIELBOX_SEITE_1 = b"""
<div class="blog">
<div class="item column-1">
<div class="page-header"><h2 itemprop="name">
<a href="/neuheiten-neue-spiele/1369-pegasus-next-station-berlin">Pegasus Spiele: Next Station Berlin</a>
</h2></div>
<p><strong>24.07.2026</strong> - <em>Next Station Berlin</em> von Matthew Dunstan, das neu bei Pegasus Spiele erschienen ist.</p>
</div>
<div class="item column-2">
<div class="page-header"><h2><a href="/neuheiten-neue-spiele/1247-micromacro-kids">MICROMACRO Kids</a></h2></div>
<p><strong>02.03.2026</strong> - Neu im M&auml;rz.</p>
</div>
</div>
<ul class="pagination"><li><a href="/neuheiten-neue-spiele?start=6">2</a></li></ul>
"""
SPIELBOX_SEITE_2 = b"""
<div class="item column-1">
<div class="page-header"><h2><a href="/neuheiten-neue-spiele/1123-schmidt-ringtraeger">Schmidt Spiele: Ringtr&auml;ger</a></h2></div>
<p><strong>15.08.2026</strong> - Neue Titel von Schmidt Spiele.</p>
</div>
<ul class="pagination"></ul>
"""
BRETTSPIELBOX_SEITE = """
<div class="wp-block-columns is-layout-flex">
<div class="wp-block-column">
<p>0-9</p>
<ul>
<li>-60 Grad (dlp games)</li>
<li>5Pairs (<a href="https://brettspielbox.de/vorschau-bsl/">BSL</a>)</li>
</ul>
<p>A</p>
<ul><li>Abaku (Albi)</li></ul>
</div>
<div class="wp-block-column">
<p>M</p>
<ul>
<li>Machu Pichu (HUCH!)</li>
<li>Titel ohne Verlag</li>
<li>Fadenfroh (1 More Time Games)</li>
</ul>
</div>
</div>
"""
SPIELESSEN_PRODUKTE = {
"path": "static/spiel26/x/logos/",
"picturePath": "static/spiel26/x/products/",
"products": [
{
"ID": "188",
"TITEL": "-60°",
"UNTERTITEL": "Nanox Games",
"FIRMA_ID": "436",
"INFO": (
"<table><tr><td>Autor: </td><td>Alexander Pfister &amp; Peter Prinz</td></tr>"
"<tr><td>Verlag: </td><td>Nanox Games</td></tr>"
"<tr><td>Vertrieb: </td><td>dlp games Verlag GmbH</td></tr>"
"<tr><td>Erscheinungsdatum:</td><td>10/2026</td></tr>"
"<tr><td>Spielalter:</td><td>ab 12 Jahren</td></tr></table>"
),
},
{"ID": "189", "TITEL": "", "INFO": ""},
{"ID": "190", "TITEL": "Nur Aussteller-Match", "UNTERTITEL": "", "FIRMA_ID": "777", "INFO": ""},
],
}
SPIELESSEN_AUSSTELLER = {"exhibitors": [{"ID": "777", "NAME": "Mysterium Verlag"}]}
CLIQUENABEND_SEITE = """
<table>
<tr><td>Update</td><td>Verlag</td><td>Stand</td><td>Spiel</td><td>Autor</td></tr>
<tr>
<td>15.10.25</td>
<td>
<div style="font-weight:bold;">Dranda Games</div>
<a class="grey" href="/profile/067900.html">Profil</a>,
<div style="font-weight:bold;">PD Verlag</div>
<a class="grey" href="/profile/152000.html">Profil</a>,
</td>
<td>3-H400</td>
<td><div><a href="/spiele/790610-Galactic-Cruise.html" target="_blank"><b style="font-size:1.1em;">Galactic Cruise</b></a>
<div class="gameinfos">100,- &euro;</div></div></td>
<td>T.K. King und Koltin Thompson</td>
</tr>
<tr><td>26.09.25</td><td>Piatnik Profil ,</td><td>6-G200</td>
<td><div><a href="/spiele/790700-Elixirus.html"><b>Elixirus</b></a></div></td>
<td>Dickie Chapin</td></tr>
<tr><td>01.10.25</td><td>ohne Link</td><td>—</td><td>Nur Texttitel</td><td>Unbekannt</td></tr>
</table>
"""
class FakeUhr:
"""Steuerbare Monoton-Uhr."""
def __init__(self) -> None:
self.zeit = 0.0
def __call__(self) -> float:
return self.zeit
class KartenTransport(httpx.BaseTransport):
"""Antworten je URL; zeichnet Requests (URL, Header, Uhrzeit) auf."""
def __init__(self, antworten: dict[str, httpx.Response], uhr: FakeUhr):
self.antworten = antworten
self.uhr = uhr
self.anfragen: list[tuple[str, float]] = []
self.header: list[dict[str, str]] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
self.anfragen.append((url, self.uhr()))
self.header.append(dict(request.headers))
# 1) exakte URL, 2) URL ohne Query (dynamische Parameter wie
# ?columns=… bei JSON-Endpunkten). URLs mit Query fallen NIE auf
# eine längere gemockte Basis-URL zurück (?start=N wäre sonst
# ein Präfix der Basis-URL und würde endlos Seite 1 liefern).
antwort = self.antworten.get(url)
if antwort is None and "?" in url:
antwort = self.antworten.get(url.split("?")[0])
if antwort is None and "?" not in url:
antwort = self.antworten.get(url.split("?")[0])
if antwort is None or isinstance(antwort, Exception):
raise QuellenFehler(f"Keine gemockte Antwort für {url}")
return antwort
def _antwort(inhalt: bytes | str, etag: str | None = None) -> httpx.Response:
header = {}
if etag:
header["ETag"] = etag
return httpx.Response(
200, content=inhalt.encode("utf-8") if isinstance(inhalt, str) else inhalt,
headers=header, request=httpx.Request("GET", "https://example.org/mock"),
)
def _client(transport, uhr, schlaf=None, validatoren=None) -> WebQuellenClient:
return WebQuellenClient(
transport=transport,
mindestabstand_sekunden=1.0,
schlaf=schlaf or (lambda s: None),
uhr=uhr,
validatoren=validatoren,
)
@pytest.fixture
def session_factory():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
yield fabrik
engine.dispose()
def _adapter(klasse, seiten: dict[str, bytes | str], etag: str | None = None):
uhr = FakeUhr()
transport = KartenTransport(
{u: _antwort(i, etag) for u, i in seiten.items()}, uhr
)
return klasse(_client(transport, uhr)), transport
# ---------- Hilfsfunktionen ----------
def test_jahr_aus_datumsangabe():
assert jahr_aus_datumsangabe("24.07.2026") == 2026
assert jahr_aus_datumsangabe("10/2026") == 2026
assert jahr_aus_datumsangabe("Herbst 2026") == 2026
assert jahr_aus_datumsangabe("Q3 2026") == 2026
assert jahr_aus_datumsangabe(None) is None
assert jahr_aus_datumsangabe("ohne Jahr") is None
def test_titel_aehnlichkeit_erkennt_duplikate_keine_erweiterungen():
# Reale BGG-Duplikatfälle werden erkannt …
assert titel_aehnlichkeit("Die Siedler von Catan", "Catan") >= 92
assert titel_aehnlichkeit("Fadenfroh!", "Fadenfroh") >= 92
assert titel_aehnlichkeit("Machu Pichu", "Machu Picchu") >= 92
# … echte Erweiterungen/andere Spiele nicht.
assert titel_aehnlichkeit("Catan", "Catan: Erweiterung") < 92
assert titel_aehnlichkeit("Azul", "Azul: Summer Pavilion") < 92
# ---------- Parser je Quelle ----------
def test_parser_spielbox_extrahiert_titel_verlag_datum_url():
# Ohne Pagination-Link — der Parser-Test interessiert nur Seite 1.
seite_ohne_pagination = SPIELBOX_SEITE_1.replace(
b'<ul class="pagination"><li><a href="/neuheiten-neue-spiele?start=6">2</a></li></ul>',
b'<ul class="pagination"></ul>',
)
adapter, _ = _adapter(SpielboxQuelle, {"https://www.spielbox.de/neuheiten-neue-spiele": seite_ohne_pagination})
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 2
erster = ergebnis.treffer[0]
assert erster.titel == "Pegasus Spiele: Next Station Berlin"
assert erster.verlag == "Pegasus Spiele"
assert erster.erscheinungsdatum_oder_quartal == "24.07.2026"
assert erster.quellen_url == (
"https://www.spielbox.de/neuheiten-neue-spiele/1369-pegasus-next-station-berlin"
)
assert ergebnis.treffer[1].verlag is None # kein Doppelpunkt im Titel
def test_parser_brettspielbox_zerlegt_titel_verlag_und_links():
adapter, _ = _adapter(
BrettspielboxQuelle,
{"https://brettspielbox.de/brettspiel-neuheiten-spiel-2026-herbst-2026-a-z/": BRETTSPIELBOX_SEITE},
)
ergebnis = adapter.sammle()
titel_verlage = {(t.titel, t.verlag) for t in ergebnis.treffer}
assert ("-60 Grad", "dlp games") in titel_verlage
assert ("5Pairs", "BSL") in titel_verlage
assert ("Abaku", "Albi") in titel_verlage
assert ("Titel ohne Verlag", None) in titel_verlage
# Eintrag mit Vorstellungs-Link nutzt diesen als quellen_url …
fuenf = next(t for t in ergebnis.treffer if t.titel == "5Pairs")
assert fuenf.quellen_url == "https://brettspielbox.de/vorschau-bsl/"
# … alle anderen die Listenseite.
ohne = next(t for t in ergebnis.treffer if t.titel == "Abaku")
assert ohne.quellen_url.endswith("a-z/")
def test_parser_spielessen_nutzt_json_api_endpunkt():
adapter, transport = _adapter(
lambda client: SpielEssenQuelle(client, jahr=2026),
{}, # Endpunkte werden dynamisch gebaut → direkt setzen
)
transport.antworten.update(
{
# Der Adapter hängt ?columns=… an — Mock matcht per Präfix.
"https://maps.eyeled-services.de/de/spiel26/products": _antwort(
json.dumps(SPIELESSEN_PRODUKTE)
),
"https://maps.eyeled-services.de/de/spiel26/exhibitors": _antwort(
json.dumps(SPIELESSEN_AUSSTELLER)
),
}
)
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 2 # leerer TITEL wird übersprungen
erster = ergebnis.treffer[0]
assert erster.titel == "-60°"
assert erster.verlag == "Nanox Games"
assert erster.autor == "Alexander Pfister & Peter Prinz"
assert erster.erscheinungsdatum_oder_quartal == "10/2026"
assert erster.quellen_url == "https://spiel-essen.de/de/die-spiel/neuheiten"
# Aussteller-Fallback für Produkte ohne INFO-Verlag:
assert ergebnis.treffer[1].verlag == "Mysterium Verlag"
def test_parser_cliquenabend_liest_tabellenspalten():
adapter, _ = _adapter(
CliquenabendQuelle,
{"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE},
)
ergebnis = adapter.sammle()
assert [t.titel for t in ergebnis.treffer] == ["Galactic Cruise", "Elixirus"]
erster = ergebnis.treffer[0]
assert erster.verlag == "Dranda Games, PD Verlag"
assert erster.autor == "T.K. King und Koltin Thompson"
assert erster.erscheinungsdatum_oder_quartal == "15.10.25"
assert erster.quellen_url == "https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html"
assert ergebnis.treffer[1].verlag == "Piatnik"
def test_parser_cliquenabend_toleriert_fehlende_tabelle():
adapter, _ = _adapter(
CliquenabendQuelle,
{"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": "<html><body><p>Wartung</p></body></html>"},
)
ergebnis = adapter.sammle()
assert ergebnis.treffer == []
# ---------- Pagination ----------
def test_spielbox_durchlaeuft_alle_pagination_seiten():
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
adapter, transport = _adapter(
SpielboxQuelle,
{basis_url: SPIELBOX_SEITE_1, f"{basis_url}?start=6": SPIELBOX_SEITE_2},
)
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 3 # 2 + 1 über die zweite Seite
angefragt = [u for u, _ in transport.anfragen]
assert angefragt.count(basis_url) == 1
assert f"{basis_url}?start=6" in angefragt
# ---------- Rate-Limit & User-Agent ----------
def test_rate_limit_mindestens_eine_sekunde_zwischen_requests():
uhr = FakeUhr()
wartezeiten: list[float] = []
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
transport = KartenTransport(
{basis_url: _antwort(SPIELBOX_SEITE_1), f"{basis_url}?start=6": _antwort(SPIELBOX_SEITE_2)},
uhr,
)
def schlaf(sekunden: float) -> None:
wartezeiten.append(sekunden)
uhr.zeit += sekunden
client = WebQuellenClient(transport=transport, mindestabstand_sekunden=1.0, schlaf=schlaf, uhr=uhr)
SpielboxQuelle(client).sammle()
assert len(transport.anfragen) == 2
erste_zeit = transport.anfragen[0][1]
zweite_zeit = transport.anfragen[1][1]
assert zweite_zeit - erste_zeit >= 1.0
assert wartezeiten and wartezeiten[0] > 0
def test_client_sendet_redaktions_user_agent():
uhr = FakeUhr()
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
transport = KartenTransport({basis_url: _antwort(SPIELBOX_SEITE_1)}, uhr)
client = WebQuellenClient(transport=transport, schlaf=lambda s: None, uhr=uhr)
client.hole(basis_url)
assert transport.header[0]["user-agent"] == USER_AGENT
# ---------- ETag / 304 ----------
def test_etag_wird_gesendet_und_304_ueberspringt_die_quelle():
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
uhr = FakeUhr()
transport = KartenTransport(
{basis_url: httpx.Response(
304, request=httpx.Request("GET", basis_url)
)},
uhr,
)
client = WebQuellenClient(
transport=transport, schlaf=lambda s: None, uhr=uhr,
validatoren={basis_url: ('"abc123"', None)},
)
ergebnis = SpielboxQuelle(client).sammle()
assert transport.header[0].get("if-none-match") == '"abc123"'
assert ergebnis.unveraendert is True
assert ergebnis.treffer == []
def test_etag_wird_aus_antwort_gespeichert():
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
adapter, _ = _adapter(SpielboxQuelle, {basis_url: SPIELBOX_SEITE_1}, etag='"etag-42"')
adapter.client.hole(basis_url)
assert adapter.client.validatoren[basis_url][0] == '"etag-42"'
# ---------- Sync-Orchestrierung ----------
class StatischerAdapter:
"""Adapter-Ersatz mit fester Trefferliste (für Service-Tests)."""
def __init__(self, name: str, treffer: list[QuellenTreffer]):
self.name = name
self.anzeigename = name.title()
self.start_urls = (f"https://{name}.example.org/liste",)
self._treffer = treffer
def __call__(self, client):
return self
def sammle(self) -> SammelErgebnis:
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
class KaputterAdapter:
def __init__(self, name: str):
self.name = name
self.anzeigename = name.title()
self.start_urls = (f"https://{name}.example.org/liste",)
def __call__(self, client):
return self
def sammle(self) -> SammelErgebnis:
raise QuellenFehler("Seite umgebaut")
def _service(session_factory, klassen, dedup=None):
return WebQuellenSyncService(
session_factory, adapter_klassen=klassen, dedup=dedup,
client_fabrik=lambda validatoren: WebQuellenClient(
schlaf=lambda s: None, uhr=FakeUhr(), validatoren=validatoren
),
)
def test_sync_legt_web_eintraege_mit_quelle_an(session_factory):
quelle = StatischerAdapter(
"spielbox",
[QuellenTreffer(titel="Ringträger", verlag="Schmidt Spiele",
autor=None, erscheinungsdatum_oder_quartal="15.08.2026",
quellen_url="https://www.spielbox.de/x")],
)
service = _service(session_factory, (quelle,))
zeilen = service.laufe()
assert zeilen[0].neu == 1
assert zeilen[0].fehlermeldung is None
with session_factory() as db:
eintrag = db.query(Neuheit).one()
assert eintrag.quelle == "spielbox"
assert eintrag.bgg_id is None
assert eintrag.status == "neuheit"
assert eintrag.erscheinungsjahr == 2026
statuszeile = db_status(session_factory, "spielbox")
assert statuszeile.anzahl_neu == 1
assert statuszeile.letzte_laufzeit is not None
def db_status(session_factory, quelle: str) -> QuellenStatus:
with session_factory() as db:
zeile = db.query(QuellenStatus).filter_by(quelle=quelle).one()
db.expunge(zeile)
return zeile
def test_sync_filtert_duplikate_gegen_bggtitel(session_factory):
with session_factory() as db:
db.add(Neuheit(titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
erscheinungsjahr=1995, bgg_id=13, quelle="boardgamegeek"))
db.commit()
quelle = StatischerAdapter(
"spielbox",
[
QuellenTreffer(titel="Die Siedler von Catan", verlag="KOSMOS",
quellen_url="https://www.spielbox.de/catan"),
QuellenTreffer(titel="Ein ganz neues Spiel", verlag="Anderer Verlag",
quellen_url="https://www.spielbox.de/neu"),
],
)
service = _service(session_factory, (quelle,))
zeilen = service.laufe()
assert zeilen[0].gefiltert == 1 # BGG-Duplikat per Titel-Match
assert zeilen[0].neu == 1
with session_factory() as db:
titel = {e.titel for e in db.query(Neuheit).all()}
assert titel == {"Catan", "Ein ganz neues Spiel"}
neu = db.query(Neuheit).filter_by(titel="Ein ganz neues Spiel").one()
assert neu.bgg_id is None and neu.quelle == "spielbox"
def test_zweiter_lauf_aktualisiert_statt_duplikat(session_factory):
quelle = StatischerAdapter(
"brettspielbox",
[QuellenTreffer(titel="Fadenfroh", verlag="1 More Time Games",
quellen_url="https://brettspielbox.de/fadenfroh")],
)
service = _service(session_factory, (quelle,))
service.laufe()
zeilen = service.laufe()
assert zeilen[0].aktualisiert == 1
assert zeilen[0].neu == 0
with session_factory() as db:
assert db.query(Neuheit).count() == 1
def test_dedup_konflikt_wird_gefiltert_registry_api(session_factory):
class FakeDedup:
async def check_titel(self, titel, verlag, bgg_id, **kwargs):
class Ergebnis:
hat_konflikte = titel.startswith("Konflikt")
return Ergebnis()
quelle = StatischerAdapter(
"cliquenabend",
[
QuellenTreffer(titel="Konfliktfall", verlag="V", quellen_url="u"),
QuellenTreffer(titel="Saubere Ware", verlag="V", quellen_url="u"),
],
)
service = _service(session_factory, (quelle,), dedup=FakeDedup())
zeilen = service.laufe()
assert zeilen[0].gefiltert == 1
assert zeilen[0].neu == 1
assert [e.titel for e in alle(session_factory)] == ["Saubere Ware"]
def test_dedup_ausfall_blockiert_nicht(session_factory):
class KaputterDedup:
async def check_titel(self, titel, verlag, bgg_id, **kwargs):
raise RuntimeError("dedup nicht erreichbar")
quelle = StatischerAdapter(
"cliquenabend",
[QuellenTreffer(titel="Läuft trotzdem", verlag="V", quellen_url="u")],
)
service = _service(session_factory, (quelle,), dedup=KaputterDedup())
zeilen = service.laufe()
assert zeilen[0].neu == 1
def alle(session_factory):
from sqlalchemy import select
with session_factory() as db:
return list(db.scalars(select(Neuheit)).all())
def test_fehler_isolation_eine_quelle_crasht_andere_laeuft_weiter(session_factory):
kaputt = KaputterAdapter("spielbox")
gut = StatischerAdapter(
"cliquenabend",
[QuellenTreffer(titel="Galactic Cruise", verlag="Dranda Games",
autor="T.K. King", erscheinungsdatum_oder_quartal="15.10.25",
quellen_url="https://www.cliquenabend.de/spiele/x")],
)
service = _service(session_factory, (kaputt, gut))
zeilen = service.laufe()
assert zeilen[0].fehleranzahl == 1
assert "Seite umgebaut" in zeilen[0].fehlermeldung
assert zeilen[1].neu == 1 # andere Quelle lief normal weiter
status_kaputt = db_status(session_factory, "spielbox")
assert status_kaputt.fehlermeldung and status_kaputt.anzahl_fehler == 1
status_gut = db_status(session_factory, "cliquenabend")
assert status_gut.anzahl_neu == 1 and not status_gut.fehlermeldung
def test_env_schalter_deaktiviert_einzelne_quelle(session_factory, monkeypatch):
monkeypatch.setenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", "0")
aus = StatischerAdapter("spielbox", [QuellenTreffer(titel="X", quellen_url="u")])
an = StatischerAdapter("cliquenabend", [QuellenTreffer(titel="Y", quellen_url="u")])
class BeobachtenderService(WebQuellenSyncService):
aufgerufen = []
def _client_fabrik_guard(self): # pragma: no cover - nur Signal
raise AssertionError("ausgeschaltete Quelle darf keinen Client bauen")
service = _service(session_factory, (aus, an))
zeilen = service.laufe()
assert zeilen[0].aktiv is False
assert zeilen[0].neu == 0
assert zeilen[1].neu == 1 # angeschaltete Quelle läuft
assert quelle_aktiv("spielbox") is False
assert quelle_aktiv("brettspielbox") is True # Standard „1“
def test_validatoren_werden_persistiert_und_wieder_verwendet(session_factory):
"""304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet."""
import tests._neuheiten as h
basis_url = "https://www.spielbox.de/neuheiten-neuheiten"
uhr = FakeUhr()
class ValidatorenTransport(httpx.BaseTransport):
def __init__(self):
self.etag_gesendet: list[str | None] = []
self.runde = 0
def handle_request(self, request):
self.etag_gesendet.append(request.headers.get("if-none-match"))
self.runde += 1
if self.runde == 1:
return httpx.Response(200, content=b"<div class='item'></div>",
headers={"ETag": '"runde-1"'},
request=httpx.Request("GET", basis_url))
return httpx.Response(304, request=httpx.Request("GET", basis_url))
transport = ValidatorenTransport()
def fabrik(validatoren):
return WebQuellenClient(transport=transport, schlaf=lambda s: None,
uhr=uhr, validatoren=validatoren)
quelle = type("SpielboxPersistenz", (), {
"name": "spielbox",
"anzeigename": "Spielbox",
"start_urls": (basis_url,),
})
# Adapter-Klasse so bauen, dass sie den Transport-Parser nutzt:
from tests._neuheiten import SpielboxQuelle as SB
class PersistenteSpielbox(SB):
pass
PersistenteSpielbox.name = "spielbox"
PersistenteSpielbox.start_urls = (basis_url,)
service = WebQuellenSyncService(
session_factory, adapter_klassen=(PersistenteSpielbox,),
dedup=None, client_fabrik=fabrik,
)
erste = service.laufe()
zweite = service.laufe()
assert erste[0].unveraendert is False
assert transport.etag_gesendet[0] is None
assert zweite[0].unveraendert is True # 304 → Quelle übersprungen
assert transport.etag_gesendet[1] == '"runde-1"' # gespeicherter Validator
statuszeile = db_status(session_factory, "spielbox")
assert statuszeile.validatoren[basis_url][0] == '"runde-1"'
# ---------- UI-Übersicht ----------
def _test_app_und_client(monkeypatch, tmp_path):
from fastapi.testclient import TestClient
from redaktionskern.app import create_app
from redaktionskern.config import Settings
from tests.conftest import ADMIN_PASSWORD, PROJEKT_WURZEL
settings = Settings(
database_url=f"sqlite:///{tmp_path/'q.db'}",
session_secret="test-secret",
initial_admin_password=ADMIN_PASSWORD,
plugins_dir=PROJEKT_WURZEL / "plugins",
)
app = create_app(settings)
client = TestClient(app)
antwort = client.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD},
follow_redirects=False)
assert antwort.status_code == 303
return app, client
def test_ui_zeigt_sync_uebersicht_der_quellen(monkeypatch, tmp_path):
monkeypatch.setenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", "0")
app, client = _test_app_und_client(monkeypatch, tmp_path)
antwort = client.get("/neuheiten")
assert antwort.status_code == 200
for text in ("Web-Quellen", "spielbox.de Neuheiten", "Brettspielbox Messevorschau",
"SPIEL Essen Neuheiten", "Cliquenabend Messe-Übersicht"):
assert text in antwort.text, f"Fehlt: {text!r}"
def test_manueller_quellen_sync_endpunkt_mit_fakes(monkeypatch, tmp_path):
app, client = _test_app_und_client(monkeypatch, tmp_path)
plugin = app.state.registry.get("neuheiten")
fake_zeile = QuellenLaufZeile("spielbox", "spielbox.de Neuheiten")
fake_zeile.neu = 3
monkeypatch.setattr(plugin, "_quellen_sync_ausfuehren", lambda: [fake_zeile])
antwort = client.post("/neuheiten/quellen-sync", follow_redirects=False)
assert antwort.status_code == 303
assert "/neuheiten" in antwort.headers["location"]
# Meldung steckt im Redirect-Ziel — der Folge-Request muss die
# Location (inkl. ?meldung=…) abrufen, nicht die nackte Liste.
folge = client.get(antwort.headers["location"])
assert "Web-Quellen-Sync abgeschlossen" in folge.text
assert "3 neu" in folge.text
def test_rezensent_kann_quellen_sync_nicht_starten(monkeypatch, tmp_path):
from tests.conftest import lege_benutzer_an, melde_an
app, client = _test_app_und_client(monkeypatch, tmp_path)
lege_benutzer_an(app, "resi", "rezensent")
melde_an(client, username="resi", password="test-12345678")
antwort = client.post("/neuheiten/quellen-sync", follow_redirects=False)
assert antwort.status_code == 403
# ---------- Migration: bgg_id nullable ----------
def test_migration_0002_macht_bgg_id_nullable_erhaelt_daten(tmp_path):
"""Bestehende DB mit NOT NULL-bgg_id wird migriert, Daten bleiben erhalten."""
from sqlalchemy import create_engine, inspect, text
from redaktionskern.migrationen import run_migrations
from tests._neuheiten import models as m
engine = create_engine(f"sqlite:///{tmp_path/'mig.db'}")
with engine.begin() as conn:
conn.exec_driver_sql("""
CREATE TABLE neuheiten (
id INTEGER NOT NULL PRIMARY KEY,
titel VARCHAR(300) NOT NULL,
verlag VARCHAR(300),
autor VARCHAR(300),
erscheinungsjahr INTEGER,
bgg_id INTEGER NOT NULL,
status VARCHAR(50),
quelle VARCHAR(100),
erstellt_am DATETIME DEFAULT (CURRENT_TIMESTAMP),
aktualisiert_am DATETIME DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT uq_bgg UNIQUE (bgg_id)
)
""")
conn.exec_driver_sql(
"INSERT INTO neuheiten (titel, bgg_id, status, quelle) "
"VALUES ('Catan', 13, 'neuheit', 'boardgamegeek')"
)
plugin_name = "neuheiten"
plugin_instanz = _plugin_instance()
migrationen = plugin_instanz.migrations()
nur_alt = [migr for migr in migrationen if migr.version == "0001_neuheiten_tabelle"]
with engine.begin() as conn:
run_migrations(conn, plugin_name, nur_alt) # Stand: alte Version
with engine.begin() as conn:
neue = run_migrations(conn, plugin_name, migrationen)
assert "0002_bgg_id_nullable" in neue
inspektor = inspect(engine)
spalten = {c["name"]: c for c in inspektor.get_columns("neuheiten")}
assert spalten["bgg_id"]["nullable"] is True
with engine.connect() as conn:
zeile = conn.execute(text("SELECT titel, bgg_id FROM neuheiten")).one()
assert zeile == ("Catan", 13)
# NULL-bgg_id ist mehrfach erlaubt:
conn.execute(text(
"INSERT INTO neuheiten (titel, bgg_id, status, quelle) "
"VALUES ('Web-Titel A', NULL, 'neuheit', 'spielbox')"
))
conn.execute(text(
"INSERT INTO neuheiten (titel, bgg_id, status, quelle) "
"VALUES ('Web-Titel B', NULL, 'neuheit', 'spielbox')"
))
def _plugin_instance():
from tests._neuheiten import sys_modules_plugin
return sys_modules_plugin()
def test_migration_0003_legt_quellen_status_tabelle_an(tmp_path):
from sqlalchemy import create_engine, inspect
from redaktionskern.migrationen import run_migrations
engine = create_engine(f"sqlite:///{tmp_path/'mig3.db'}")
plugin_instanz = _plugin_instance()
with engine.begin() as conn:
run_migrations(conn, "neuheiten", plugin_instanz.migrations())
inspektor = inspect(engine)
assert "neuheiten_quellen_status" in inspektor.get_table_names()
# ---------- Registrierung ----------
def test_alle_vier_quellen_registriert():
namen = {klasse.name for klasse in ADAPTER_KLASSEN}
assert namen == {"spielbox", "brettspielbox", "spielessen", "cliquenabend"}

59
uv.lock generated
View File

@@ -109,6 +109,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" }, { url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" },
] ]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "1.2.0" version = "1.2.0"
@@ -395,6 +408,18 @@ woff = [
{ name = "zopfli" }, { name = "zopfli" },
] ]
[[package]]
name = "freezegun"
version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.5" version = "3.5.5"
@@ -923,6 +948,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.3" version = "1.2.3"
@@ -1075,6 +1112,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" }, { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "soupsieve"
version = "2.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" },
]
[[package]] [[package]]
name = "spiele-redaktion" name = "spiele-redaktion"
version = "0.1.0" version = "0.1.0"
@@ -1082,6 +1137,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "beautifulsoup4" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "itsdangerous" }, { name = "itsdangerous" },
@@ -1095,6 +1151,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "freezegun" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
] ]
@@ -1103,6 +1160,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "apscheduler", specifier = ">=3.11,<4" }, { name = "apscheduler", specifier = ">=3.11,<4" },
{ name = "argon2-cffi", specifier = ">=23.1" }, { name = "argon2-cffi", specifier = ">=23.1" },
{ name = "beautifulsoup4", specifier = ">=4.12" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "itsdangerous", specifier = ">=2.1" }, { name = "itsdangerous", specifier = ">=2.1" },
@@ -1116,6 +1174,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "freezegun", specifier = ">=1.5.5" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "pytest", specifier = ">=8.0" }, { name = "pytest", specifier = ">=8.0" },
] ]