Teil 1 — Coverbilder: - Migration 0004_bild_url (neuheiten), 0002_bild_url (planung, archiv mit beiden Spiegel-Tabellen); nullable VARCHAR(500), SQLite↔Postgres portabel, bestehende Zeilen bleiben erhalten - BGG-Client extrahiert Cover aus der Thing-Antwort (bevorzugt <image>, sonst <thumbnail>; protokoll-relative URLs → https) - Sync-Upsert pflegt bild_url beim Update; fehlendes Bild löscht keines - Web-Quellen übernehmen img-URLs aus den Listenelementen (src/data-src, relative Pfade gegen die Seiten-URL aufgelöst); SPIEL-Essen nutzt das BILDER-Feld des JSON-Endpunkts - Verschiebung Neuheiten→Planung und Archivierung/Wiederherstellen schreiben bild_url mit - Anzeige: kleine Vorschaubilder (40x56) in den Listen, groß (max 200px) in Planung-Bearbeiten/Prüf-Dialog; Platzhalter-Div ohne Bild; Hotlinking mit loading=lazy + decoding=async Teil 2 — Responsives Layout: - Basislayout: Burger-Menü (Alpine.js) auf Mobil, Desktop-Leiste ab md: - Listen kollabieren <768px zu Karten-Ansichten (Cover links, Titel + Metadaten rechts, Status-Badge oben); Desktop behält Tabellenform - Touch-Ziele >= 44px auf Mobil, Formulare vollbreit mit Labels oben - Sync-Übersicht/Audit-Log bleiben horizontal scrollbare Tabellen Tests: 15 neue Fälle (tests/test_covers.py) — Migration inkl. Daten-Erhalt, BGG-Parsing gemockt, Quellen-Übernahme, Template-Render; 316 Tests grün. README aktualisiert.
188 lines
6.9 KiB
Python
188 lines
6.9 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,
|
|
bild_url=_bild_aus_produkt(produkt),
|
|
)
|
|
|
|
|
|
def _bild_aus_produkt(produkt: dict) -> str | None:
|
|
"""Erste nutzbare Bild-URL aus dem BILDER-Feld (tolerant).
|
|
|
|
Das Feld ist je nach Messejahr eine Liste von URLs/Pfaden, ein Objekt
|
|
oder ein String; nur absolute bzw. protokoll-relative http(s)-URLs
|
|
werden übernommen, alles andere bleibt leer.
|
|
"""
|
|
def _aus_wert(wert) -> str | None:
|
|
if isinstance(wert, str):
|
|
url = wert.strip()
|
|
if url.startswith("//"):
|
|
url = f"https:{url}"
|
|
if url.startswith(("http://", "https://")):
|
|
return url
|
|
return None
|
|
if isinstance(wert, dict):
|
|
for schluessel in ("URL", "BILD", "SRC", "PATH", "PFAD"):
|
|
fund = _aus_wert(wert.get(schluessel))
|
|
if fund:
|
|
return fund
|
|
for unterwert in wert.values():
|
|
fund = _aus_wert(unterwert)
|
|
if fund:
|
|
return fund
|
|
return None
|
|
if isinstance(wert, list):
|
|
for element in wert:
|
|
fund = _aus_wert(element)
|
|
if fund:
|
|
return fund
|
|
return None
|
|
|
|
bilder = produkt.get("BILDER")
|
|
if isinstance(bilder, list):
|
|
for einzel in bilder:
|
|
fund = _aus_wert(einzel)
|
|
if fund:
|
|
return fund
|
|
return None
|
|
return _aus_wert(bilder)
|
|
|
|
|
|
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
|