145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
"""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
|