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