Files
Flo Hartmann 96d6af8163 Coverbilder (bild_url) + responsives Layout für Mobil/Tablet
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.
2026-08-24 16:54:56 +00:00

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