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.
99 lines
3.6 KiB
Python
99 lines
3.6 KiB
Python
"""Adapter für cliquenabend.de Messe-Eventseiten (z. B. SPIEL 2025 Essen).
|
|
|
|
Seite: …/events/130000-SPIEL-2025-Essen.html (große Einzelseite, ~4 MB).
|
|
|
|
Struktur (Stand Analyse August 2026): Eine einzige Tabelle mit der
|
|
Kopfzeile Update | Verlag | Stand | Spiel | Autor; eine Zeile pro Spiel.
|
|
Titel steht in `a[href^="/spiele/"] > b`, Verlage in fett formatierten
|
|
`div`-Blöcken der Verlagsspalte, das Update-Datum in der Form TT.MM.JJ.
|
|
Die Seite ist nicht paginiert.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
|
|
|
|
DATUM_MUSTER = re.compile(r"\d{2}\.\d{2}\.(\d{2})\b")
|
|
BASIS_URL = "https://www.cliquenabend.de"
|
|
|
|
|
|
class CliquenabendQuelle(QuellenAdapter):
|
|
name = "cliquenabend"
|
|
anzeigename = "Cliquenabend Messe-Übersicht"
|
|
start_urls = ("https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html",)
|
|
|
|
def seite_verarbeiten(self, inhalt: bytes, url: str):
|
|
suppe = BeautifulSoup(inhalt, "html.parser")
|
|
treffer: list[QuellenTreffer] = []
|
|
tabelle = suppe.find("table")
|
|
if tabelle is None:
|
|
return [], [] # Strukturänderung: nichts tun, kein Crash
|
|
for zeile in tabelle.find_all("tr"):
|
|
zellen = zeile.find_all("td")
|
|
if len(zellen) < 5:
|
|
continue
|
|
if zellen[0].get_text(strip=True).lower() == "update":
|
|
continue # Kopfzeile
|
|
treffer.append(self._zeile(zellen, url))
|
|
return [t for t in treffer if t is not None], []
|
|
|
|
def _zeile(self, zellen, url: str) -> QuellenTreffer | None:
|
|
spiel_zelle = zellen[3]
|
|
titel_link = spiel_zelle.find("a", href=re.compile(r"^/spiele/"))
|
|
titel = None
|
|
quellen_url = url
|
|
if titel_link is not None:
|
|
fett = titel_link.find("b")
|
|
titel = (fett or titel_link).get_text(" ", strip=True)
|
|
href = titel_link.get("href")
|
|
if href:
|
|
from urllib.parse import urljoin
|
|
|
|
quellen_url = urljoin(BASIS_URL, href)
|
|
if not titel:
|
|
return None
|
|
|
|
verlag = _verlage(zellen[1])
|
|
autor = zellen[4].get_text(" ", strip=True) or None
|
|
|
|
datum = None
|
|
jahr_text = zellen[0].get_text(strip=True)
|
|
fund = DATUM_MUSTER.search(jahr_text)
|
|
if fund:
|
|
# Datum so übernehmen, wie es in der Quelle steht (z. B. „15.10.25“).
|
|
datum = fund.group(0)
|
|
|
|
return QuellenTreffer(
|
|
titel=titel,
|
|
verlag=verlag,
|
|
autor=autor,
|
|
erscheinungsdatum_oder_quartal=datum,
|
|
quellen_url=quellen_url,
|
|
bild_url=bild_aus_element(spiel_zelle, quellen_url),
|
|
)
|
|
|
|
|
|
def _verlage(zelle) -> str | None:
|
|
"""Verlagsspalte: fett formatierte `div`-Blöcke enthalten die Namen."""
|
|
namen = [
|
|
div.get_text(" ", strip=True)
|
|
for div in zelle.find_all("div")
|
|
if div.get_text(" ", strip=True)
|
|
]
|
|
if not namen:
|
|
roh = zelle.get_text(" ", strip=True)
|
|
namen = [teil for teil in (roh.split(",") if roh else [])
|
|
if teil.strip() and teil.strip().lower() != "profil"]
|
|
bereinigt = []
|
|
for name in namen:
|
|
name = name.strip().rstrip(",").strip()
|
|
# „Piatnik Profil ,“ → Profil-Link-Text hängt am Namen; abschneiden.
|
|
if name.lower().endswith(" profil"):
|
|
name = name[: -len(" Profil")].strip()
|
|
if name and name.lower() != "profil":
|
|
bereinigt.append(name)
|
|
return ", ".join(bereinigt) if bereinigt else None
|