Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI)

This commit is contained in:
Flo Hartmann
2026-08-23 00:41:02 +00:00
parent 8c9f542b8c
commit 3b0626c774
18 changed files with 2247 additions and 18 deletions

View File

@@ -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"