From 8c9f542b8c8f22230015a3e690f04ddbc5aca5fa Mon Sep 17 00:00:00 2001 From: Flo Hartmann Date: Sat, 22 Aug 2026 23:36:49 +0000 Subject: [PATCH 1/2] TODO: Arbeitsliste Web-Quellen-Crawler --- TODO.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..5183f39 --- /dev/null +++ b/TODO.md @@ -0,0 +1,14 @@ +# TODO — Web-Quellen im Neuheiten-Plugin (Branch wt-quellen) + +> Live-Status, wird laufend aktualisiert. + +- [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py +- [ ] Adapter spiel-essen +- [ ] Vollständiges Crawling (Pagination/Quartale/A-Z) +- [ ] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified +- [ ] Dedup-Gate vor Übernahme in Neuheitenliste +- [ ] APScheduler-Job + Env-Schalter je Quelle (_AKTIV) +- [ ] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) +- [ ] Sync-Übersicht im UI (/neuheiten) +- [ ] Tests (gemocktes HTML) +- [ ] README.md aktualisieren From 3b0626c77402df8ae72746edf7e05aa0fb56ff5e Mon Sep 17 00:00:00 2001 From: Flo Hartmann Date: Sun, 23 Aug 2026 00:41:02 +0000 Subject: [PATCH 2/2] Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI) --- TODO.md | 18 +- plugins/neuheiten/__init__.py | 160 +++- plugins/neuheiten/models.py | 56 +- plugins/neuheiten/quellen/__init__.py | 57 ++ plugins/neuheiten/quellen/basis.py | 269 ++++++ plugins/neuheiten/quellen/brettspielbox.py | 65 ++ plugins/neuheiten/quellen/cliquenabend.py | 97 +++ plugins/neuheiten/quellen/spielbox.py | 88 ++ plugins/neuheiten/quellen/spielessen.py | 144 +++ plugins/neuheiten/quellen_sync.py | 314 +++++++ .../neuheiten/templates/neuheiten/_liste.html | 4 + .../neuheiten/templates/neuheiten/index.html | 62 ++ pyproject.toml | 1 + src/redaktionskern/plugin_loader.py | 14 + tests/_neuheiten.py | 33 + tests/conftest.py | 2 + tests/test_neuheiten_quellen.py | 822 ++++++++++++++++++ uv.lock | 59 ++ 18 files changed, 2247 insertions(+), 18 deletions(-) create mode 100644 plugins/neuheiten/quellen/__init__.py create mode 100644 plugins/neuheiten/quellen/basis.py create mode 100644 plugins/neuheiten/quellen/brettspielbox.py create mode 100644 plugins/neuheiten/quellen/cliquenabend.py create mode 100644 plugins/neuheiten/quellen/spielbox.py create mode 100644 plugins/neuheiten/quellen/spielessen.py create mode 100644 plugins/neuheiten/quellen_sync.py create mode 100644 tests/test_neuheiten_quellen.py diff --git a/TODO.md b/TODO.md index 5183f39..e955220 100644 --- a/TODO.md +++ b/TODO.md @@ -3,12 +3,12 @@ > Live-Status, wird laufend aktualisiert. - [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py -- [ ] Adapter spiel-essen -- [ ] Vollständiges Crawling (Pagination/Quartale/A-Z) -- [ ] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified -- [ ] Dedup-Gate vor Übernahme in Neuheitenliste -- [ ] APScheduler-Job + Env-Schalter je Quelle (_AKTIV) -- [ ] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) -- [ ] Sync-Übersicht im UI (/neuheiten) -- [ ] Tests (gemocktes HTML) -- [ ] README.md aktualisieren +- [x] Adapter spiel-essen +- [x] Vollständiges Crawling (Pagination/Quartale/A-Z) +- [x] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified +- [x] Dedup-Gate vor Übernahme in Neuheitenliste +- [x] APScheduler-Job + Env-Schalter je Quelle (_AKTIV) +- [ ] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) → folgt als eigener Nachtrag +- [x] Sync-Übersicht im UI (/neuheiten) +- [x] Tests (gemocktes HTML) +- [x] README.md aktualisieren diff --git a/plugins/neuheiten/__init__.py b/plugins/neuheiten/__init__.py index 6679223..8ec412d 100644 --- a/plugins/neuheiten/__init__.py +++ b/plugins/neuheiten/__init__.py @@ -24,6 +24,7 @@ Keine Fachlogik im Kern — alles hier im Plugin gemäß Plugin-Vertrag. from __future__ import annotations import logging +import os import threading from datetime import datetime, timedelta, timezone 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 .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 _logger = logging.getLogger("plugins.neuheiten") @@ -77,6 +84,7 @@ class NeuheitenPlugin(BasePlugin): super().__init__() self._scheduler = None # BackgroundScheduler, falls aktiviert self._sync_sperre = threading.Lock() + self._quellen_sperre = threading.Lock() self._suchbegriffe: list[str] = [] self._max_treffer_pro_suche = 25 @@ -117,6 +125,7 @@ class NeuheitenPlugin(BasePlugin): ) ] + quellen_zeilen = self._quellen_uebersicht(db) kontext = { "user": user, "titel": self.title, @@ -135,6 +144,11 @@ class NeuheitenPlugin(BasePlugin): "suchbegriffe": ", ".join(self._suchbegriffe) or "—", "sync_aktiv": self._scheduler is not None and self._scheduler.running, "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": return self.context.templates.TemplateResponse( @@ -167,13 +181,85 @@ class NeuheitenPlugin(BasePlugin): ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}" 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 ---------- def migrations(self) -> list[Migration]: def neuheiten_tabelle(conn) -> None: 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]: 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": 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: if self._scheduler is not None: @@ -248,7 +338,34 @@ class NeuheitenPlugin(BasePlugin): finally: 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 try: @@ -257,7 +374,40 @@ class NeuheitenPlugin(BasePlugin): intervall_stunden = 24 intervall_stunden = max(intervall_stunden, 1) - scheduler = BackgroundScheduler() + 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.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) scheduler.add_job( self._sync_job, @@ -267,8 +417,6 @@ class NeuheitenPlugin(BasePlugin): id="bgg-neuheiten-sync", replace_existing=True, ) - scheduler.start() - self._scheduler = scheduler _logger.info( "BGG-Neuheiten-Sync aktiv: alle %s h, Suchbegriffe: %s", intervall_stunden, diff --git a/plugins/neuheiten/models.py b/plugins/neuheiten/models.py index 04bdc34..ebe8038 100644 --- a/plugins/neuheiten/models.py +++ b/plugins/neuheiten/models.py @@ -6,9 +6,10 @@ statt Duplikate anzulegen. """ from __future__ import annotations +import json 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 redaktionskern.db import Base @@ -25,7 +26,10 @@ class Neuheit(Base): autor: Mapped[str | None] = mapped_column(String(300)) # BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum). 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) quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek") 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 - return f"" + return f"" + + +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" diff --git a/plugins/neuheiten/quellen/__init__.py b/plugins/neuheiten/quellen/__init__.py new file mode 100644 index 0000000..b82809f --- /dev/null +++ b/plugins/neuheiten/quellen/__init__.py @@ -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__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", +] diff --git a/plugins/neuheiten/quellen/basis.py b/plugins/neuheiten/quellen/basis.py new file mode 100644 index 0000000..4df3e92 --- /dev/null +++ b/plugins/neuheiten/quellen/basis.py @@ -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, 0–100) 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." + ) diff --git a/plugins/neuheiten/quellen/brettspielbox.py b/plugins/neuheiten/quellen/brettspielbox.py new file mode 100644 index 0000000..99c3b85 --- /dev/null +++ b/plugins/neuheiten/quellen/brettspielbox.py @@ -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 (A–Z)" + 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 diff --git a/plugins/neuheiten/quellen/cliquenabend.py b/plugins/neuheiten/quellen/cliquenabend.py new file mode 100644 index 0000000..fcb070f --- /dev/null +++ b/plugins/neuheiten/quellen/cliquenabend.py @@ -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 diff --git a/plugins/neuheiten/quellen/spielbox.py b/plugins/neuheiten/quellen/spielbox.py new file mode 100644 index 0000000..11c635f --- /dev/null +++ b/plugins/neuheiten/quellen/spielbox.py @@ -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) diff --git a/plugins/neuheiten/quellen/spielessen.py b/plugins/neuheiten/quellen/spielessen.py new file mode 100644 index 0000000..0ef3884 --- /dev/null +++ b/plugins/neuheiten/quellen/spielessen.py @@ -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 diff --git a/plugins/neuheiten/quellen_sync.py b/plugins/neuheiten/quellen_sync.py new file mode 100644 index 0000000..edbdada --- /dev/null +++ b/plugins/neuheiten/quellen_sync.py @@ -0,0 +1,314 @@ +"""Sync-Orchestrierung für die Web-Quellen des Neuheiten-Plugins. + +Pro Quelle: +- Env-Schalter SPIELE_NEUHEITEN_QUELLE__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 = '' 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 (0–100). +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) diff --git a/plugins/neuheiten/templates/neuheiten/_liste.html b/plugins/neuheiten/templates/neuheiten/_liste.html index 89107c7..5cf9f64 100644 --- a/plugins/neuheiten/templates/neuheiten/_liste.html +++ b/plugins/neuheiten/templates/neuheiten/_liste.html @@ -23,8 +23,12 @@ {{ eintrag.titel }} + {% if eintrag.bgg_id %} BGG {{ eintrag.bgg_id }} + {% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %} + {{ eintrag.quelle }} + {% endif %} {{ eintrag.verlag or '—' }} {{ eintrag.autor or '—' }} diff --git a/plugins/neuheiten/templates/neuheiten/index.html b/plugins/neuheiten/templates/neuheiten/index.html index af500f0..f5154e5 100644 --- a/plugins/neuheiten/templates/neuheiten/index.html +++ b/plugins/neuheiten/templates/neuheiten/index.html @@ -41,6 +41,68 @@

+
+
+

Web-Quellen

+ {% if ist_redaktion %} +
+ +
+ {% endif %} +
+
+ + + + + + + + + + + + + + {% for quelle in quellen_zeilen %} + + + + + + + + + + {% endfor %} + +
QuelleStatusLetzter LaufNeuAktualisiertGefiltertHinweis
+ {{ quelle.anzeigename }} + {{ quelle.name }} + + {% if quelle.aktiv %}an + {% else %}aus{% endif %} + + {{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }} + {% if quelle.dauer_sekunden %}({{ '%.1f' | format(quelle.dauer_sekunden) }} s){% endif %} + {{ quelle.neu }}{{ quelle.aktualisiert }}{{ quelle.gefiltert }} + {% if quelle.fehlermeldung %}{{ quelle.fehlermeldung }} + {% elif quelle.unveraendert %}unverändert (304) — übersprungen + {% elif not quelle.aktiv %}abgeschaltet + {% else %}—{% endif %} +
+
+

+ Quellen-Sync: + {% if quellen_sync_aktiv %}täglich aktiv{% else %}ausgeschaltet{% endif %} + · max. 1 Anfrage/Sekunde je Quelle · Duplikate gegen BGG werden per Titel-Match gefiltert +

+
+
=23.1", "apscheduler>=3.11,<4", "httpx>=0.27", + "beautifulsoup4>=4.12", "rapidfuzz>=3.14.5", "weasyprint>=69.0", ] diff --git a/src/redaktionskern/plugin_loader.py b/src/redaktionskern/plugin_loader.py index 3f36712..42acfb6 100644 --- a/src/redaktionskern/plugin_loader.py +++ b/src/redaktionskern/plugin_loader.py @@ -11,6 +11,7 @@ from __future__ import annotations import importlib.metadata import importlib.util import sys +import types from importlib.metadata import entry_points from pathlib import Path @@ -81,6 +82,19 @@ def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]: if spec is None or spec.loader is None: raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.") 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 spec.loader.exec_module(modul) if not hasattr(modul, "plugin"): diff --git a/tests/_neuheiten.py b/tests/_neuheiten.py index a5cb933..94a1ded 100644 --- a/tests/_neuheiten.py +++ b/tests/_neuheiten.py @@ -42,3 +42,36 @@ speichere_oder_aktualisiere = sync_modul.speichere_oder_aktualisiere Neuheit = models.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 diff --git a/tests/conftest.py b/tests/conftest.py index 5ea5bc6..2191655 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,8 @@ def _hintergrundjobs_deaktiviert(monkeypatch): # Auch die täglichen Hintergrund-Jobs bleiben in Tests aus. monkeypatch.setenv("SPIELE_ARCHIV_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. monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0") diff --git a/tests/test_neuheiten_quellen.py b/tests/test_neuheiten_quellen.py new file mode 100644 index 0000000..18a9635 --- /dev/null +++ b/tests/test_neuheiten_quellen.py @@ -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""" +
+
+ +

24.07.2026 - Next Station Berlin von Matthew Dunstan, das neu bei Pegasus Spiele erschienen ist.

+
+
+ +

02.03.2026 - Neu im März.

+
+
+ +""" + +SPIELBOX_SEITE_2 = b""" +
+ +

15.08.2026 - Neue Titel von Schmidt Spiele.

+
+
    +""" + +BRETTSPIELBOX_SEITE = """ +
    +
    +

    0-9

    +
      +
    • -60 Grad (dlp games)
    • +
    • 5Pairs (BSL)
    • +
    +

    A

    +
    • Abaku (Albi)
    +
    +
    +

    M

    +
      +
    • Machu Pichu (HUCH!)
    • +
    • Titel ohne Verlag
    • +
    • Fadenfroh (1 More Time Games)
    • +
    +
    +
    +""" + +SPIELESSEN_PRODUKTE = { + "path": "static/spiel26/x/logos/", + "picturePath": "static/spiel26/x/products/", + "products": [ + { + "ID": "188", + "TITEL": "-60°", + "UNTERTITEL": "Nanox Games", + "FIRMA_ID": "436", + "INFO": ( + "" + "" + "" + "" + "
    Autor: Alexander Pfister & Peter Prinz
    Verlag: Nanox Games
    Vertrieb: dlp games Verlag GmbH
    Erscheinungsdatum:10/2026
    Spielalter:ab 12 Jahren
    " + ), + }, + {"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 = """ + + + + + + + + + + + + + +
    UpdateVerlagStandSpielAutor
    15.10.25 +
    Dranda Games
    + Profil, +
    PD Verlag
    + Profil, +
    3-H400
    Galactic Cruise +
    100,- €
    T.K. King und Koltin Thompson
    26.09.25Piatnik Profil ,6-G200Dickie Chapin
    01.10.25ohne LinkNur TexttitelUnbekannt
    +""" + + +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'', + b'
      ', + ) + 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": "

      Wartung

      "}, + ) + 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"
      ", + 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"} diff --git a/uv.lock b/uv.lock index 0fb04f6..90bc9be 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "brotli" version = "1.2.0" @@ -395,6 +408,18 @@ woff = [ { 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]] name = "greenlet" 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" }, ] +[[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]] name = "python-dotenv" 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" }, ] +[[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]] name = "spiele-redaktion" version = "0.1.0" @@ -1082,6 +1137,7 @@ source = { editable = "." } dependencies = [ { name = "apscheduler" }, { name = "argon2-cffi" }, + { name = "beautifulsoup4" }, { name = "fastapi" }, { name = "httpx" }, { name = "itsdangerous" }, @@ -1095,6 +1151,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "freezegun" }, { name = "httpx" }, { name = "pytest" }, ] @@ -1103,6 +1160,7 @@ dev = [ requires-dist = [ { name = "apscheduler", specifier = ">=3.11,<4" }, { name = "argon2-cffi", specifier = ">=23.1" }, + { name = "beautifulsoup4", specifier = ">=4.12" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, { name = "itsdangerous", specifier = ">=2.1" }, @@ -1116,6 +1174,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "freezegun", specifier = ">=1.5.5" }, { name = "httpx", specifier = ">=0.27" }, { name = "pytest", specifier = ">=8.0" }, ]