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.
This commit is contained in:
@@ -24,6 +24,7 @@ from .basis import (
|
||||
jahr_aus_datumsangabe,
|
||||
titel_aehnlichkeit,
|
||||
titel_normalisieren,
|
||||
bild_aus_element,
|
||||
)
|
||||
from .brettspielbox import BrettspielboxQuelle
|
||||
from .cliquenabend import CliquenabendQuelle
|
||||
@@ -50,6 +51,7 @@ __all__ = [
|
||||
"jahr_aus_datumsangabe",
|
||||
"titel_aehnlichkeit",
|
||||
"titel_normalisieren",
|
||||
"bild_aus_element",
|
||||
"SpielboxQuelle",
|
||||
"BrettspielboxQuelle",
|
||||
"SpielEssenQuelle",
|
||||
|
||||
@@ -60,6 +60,52 @@ class QuellenTreffer:
|
||||
verlag: str | None = None
|
||||
autor: str | None = None
|
||||
erscheinungsdatum_oder_quartal: str | None = None
|
||||
#: Bild-URL der Quelle (img im Listenelement), sofern vorhanden; wird
|
||||
#: direkt eingebettet (Hotlinking), nicht heruntergeladen.
|
||||
bild_url: str | None = None
|
||||
|
||||
|
||||
def _bild_url_normalisieren(url: str, basis_url: str | None) -> str | None:
|
||||
"""Macht aus einer rohen Bildangabe eine absolute http(s)-URL.
|
||||
|
||||
Protokoll-relative URLs (`//host/bild.jpg`) erhalten https voran,
|
||||
pfadrelative Angaben werden gegen die Seiten-URL aufgelöst; alles
|
||||
andere (data:-URIs etc.) wird verworfen.
|
||||
"""
|
||||
url = url.strip()
|
||||
if url.startswith("//"):
|
||||
url = f"https:{url}"
|
||||
elif not url.startswith(("http://", "https://")):
|
||||
if basis_url is None:
|
||||
return None
|
||||
from urllib.parse import urljoin
|
||||
|
||||
url = urljoin(basis_url, url)
|
||||
return url if url.startswith(("http://", "https://")) else None
|
||||
|
||||
|
||||
def bild_aus_element(element, basis_url: str | None = None) -> str | None:
|
||||
"""Liest die erste sinnvolle Bild-URL aus einem HTML-Element.
|
||||
|
||||
Akzeptiert `src` und gängige Lazy-Loading-Attribute (`data-src`,
|
||||
`data-srcset`-Erster); protokoll- und pfadrelative URLs werden gegen
|
||||
`basis_url` (die Seiten-URL) aufgelöst. Kein Bild → None.
|
||||
"""
|
||||
if element is None:
|
||||
return None
|
||||
bild = element.find("img") if hasattr(element, "find") else None
|
||||
if bild is None:
|
||||
return None
|
||||
for attribut in ("src", "data-src"):
|
||||
url = bild.get(attribut)
|
||||
if url and url.strip():
|
||||
return _bild_url_normalisieren(url, basis_url)
|
||||
srcset = bild.get("data-srcset") or bild.get("srcset")
|
||||
if srcset:
|
||||
erster = srcset.split(",")[0].strip().split(" ")[0]
|
||||
if erster:
|
||||
return _bild_url_normalisieren(erster, basis_url)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -15,7 +15,7 @@ import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .basis import QuellenAdapter, QuellenTreffer
|
||||
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
|
||||
|
||||
TITEL_VERLAG_MUSTER = re.compile(r"^\s*(.+?)\s*\(([^()]*)\)\s*$")
|
||||
|
||||
@@ -50,6 +50,7 @@ class BrettspielboxQuelle(QuellenAdapter):
|
||||
autor=None,
|
||||
erscheinungsdatum_oder_quartal=None,
|
||||
quellen_url=quellen_url,
|
||||
bild_url=bild_aus_element(eintrag, url),
|
||||
)
|
||||
)
|
||||
return treffer, []
|
||||
|
||||
@@ -14,7 +14,7 @@ import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .basis import QuellenAdapter, QuellenTreffer
|
||||
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"
|
||||
@@ -72,6 +72,7 @@ class CliquenabendQuelle(QuellenAdapter):
|
||||
autor=autor,
|
||||
erscheinungsdatum_oder_quartal=datum,
|
||||
quellen_url=quellen_url,
|
||||
bild_url=bild_aus_element(spiel_zelle, quellen_url),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .basis import QuellenAdapter, QuellenTreffer
|
||||
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
|
||||
|
||||
DATUM_MUSTER = re.compile(r"(\d{2}\.\d{2}\.\d{4})")
|
||||
|
||||
@@ -57,6 +57,7 @@ class SpielboxQuelle(QuellenAdapter):
|
||||
autor=None,
|
||||
erscheinungsdatum_oder_quartal=datum,
|
||||
quellen_url=quellen_url,
|
||||
bild_url=bild_aus_element(element, url),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -121,9 +121,52 @@ class SpielEssenQuelle(QuellenAdapter):
|
||||
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] = {}
|
||||
|
||||
Reference in New Issue
Block a user