Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI)
This commit is contained in:
18
TODO.md
18
TODO.md
@@ -3,12 +3,12 @@
|
|||||||
> Live-Status, wird laufend aktualisiert.
|
> Live-Status, wird laufend aktualisiert.
|
||||||
|
|
||||||
- [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py
|
- [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py
|
||||||
- [ ] Adapter spiel-essen
|
- [x] Adapter spiel-essen
|
||||||
- [ ] Vollständiges Crawling (Pagination/Quartale/A-Z)
|
- [x] Vollständiges Crawling (Pagination/Quartale/A-Z)
|
||||||
- [ ] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified
|
- [x] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified
|
||||||
- [ ] Dedup-Gate vor Übernahme in Neuheitenliste
|
- [x] Dedup-Gate vor Übernahme in Neuheitenliste
|
||||||
- [ ] APScheduler-Job + Env-Schalter je Quelle (_AKTIV)
|
- [x] APScheduler-Job + Env-Schalter je Quelle (_AKTIV)
|
||||||
- [ ] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch)
|
- [ ] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) → folgt als eigener Nachtrag
|
||||||
- [ ] Sync-Übersicht im UI (/neuheiten)
|
- [x] Sync-Übersicht im UI (/neuheiten)
|
||||||
- [ ] Tests (gemocktes HTML)
|
- [x] Tests (gemocktes HTML)
|
||||||
- [ ] README.md aktualisieren
|
- [x] README.md aktualisieren
|
||||||
|
|||||||
@@ -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 = 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)
|
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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
57
plugins/neuheiten/quellen/__init__.py
Normal file
57
plugins/neuheiten/quellen/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
269
plugins/neuheiten/quellen/basis.py
Normal file
269
plugins/neuheiten/quellen/basis.py
Normal 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, 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."
|
||||||
|
)
|
||||||
65
plugins/neuheiten/quellen/brettspielbox.py
Normal file
65
plugins/neuheiten/quellen/brettspielbox.py
Normal 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 (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
|
||||||
97
plugins/neuheiten/quellen/cliquenabend.py
Normal file
97
plugins/neuheiten/quellen/cliquenabend.py
Normal 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
|
||||||
88
plugins/neuheiten/quellen/spielbox.py
Normal file
88
plugins/neuheiten/quellen/spielbox.py
Normal 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)
|
||||||
144
plugins/neuheiten/quellen/spielessen.py
Normal file
144
plugins/neuheiten/quellen/spielessen.py
Normal 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
|
||||||
314
plugins/neuheiten/quellen_sync.py
Normal file
314
plugins/neuheiten/quellen_sync.py
Normal 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 (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)
|
||||||
@@ -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 {{ eintrag.bgg_id }}</a>
|
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ 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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
↻ 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) }} 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 %}
|
||||||
|
· max. 1 Anfrage/Sekunde je Quelle · 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']"
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
822
tests/test_neuheiten_quellen.py
Normal file
822
tests/test_neuheiten_quellen.py
Normal 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ä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ä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 & 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,- €</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
59
uv.lock
generated
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user