Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI)
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user