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

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

View File

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

View File

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

View File

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

View File

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

View File

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