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