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.
90 lines
3.2 KiB
Python
90 lines
3.2 KiB
Python
"""Adapter für https://www.spielbox.de/neuheiten-neue-spiele (Joomla-Blog).
|
|
|
|
Seitenstruktur (Stand Analyse August 2026):
|
|
- Pro Beitrag ein `div.item` mit Titel-Link in `div.page-header h2 a`,
|
|
Beitragsdatum (TT.MM.JJJJ) im ersten Absatz (`p > strong`).
|
|
- Pagination über `.pagination`-Links der Form `?start=N` (6 Beiträge je
|
|
Seite) — alle Seiten werden durchlaufen, bis keine neue Seite mehr
|
|
gefunden wird.
|
|
- Verlag: Heuristik — Präfix des Titels vor dem Doppelpunkt
|
|
(z. B. „Pegasus Spiele: Next Station Berlin“); fehlt er, bleibt der
|
|
Verlag leer. Ein Autor wird auf dieser Liste nicht strukturiert
|
|
mitgeliefert.
|
|
"""
|
|
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{4})")
|
|
|
|
|
|
class SpielboxQuelle(QuellenAdapter):
|
|
name = "spielbox"
|
|
anzeigename = "spielbox.de Neuheiten"
|
|
start_urls = ("https://www.spielbox.de/neuheiten-neue-spiele",)
|
|
|
|
def seite_verarbeiten(self, inhalt: bytes, url: str):
|
|
suppe = BeautifulSoup(inhalt, "html.parser")
|
|
treffer: list[QuellenTreffer] = []
|
|
for element in suppe.select("div.item"):
|
|
kopf_link = element.select_one("div.page-header h2 a") or element.find("h2")
|
|
if kopf_link is None:
|
|
continue # Strukturänderung: Beitrag überspringen, Lauf hält an
|
|
titel = kopf_link.get_text(" ", strip=True)
|
|
if not titel:
|
|
continue
|
|
href = kopf_link.get("href") if hasattr(kopf_link, "get") else None
|
|
quellen_url = _absolute_url(href, url) or url
|
|
|
|
datum = None
|
|
for absatz in element.find_all("p"):
|
|
strong = absatz.find("strong")
|
|
if strong is not None:
|
|
fund = DATUM_MUSTER.search(strong.get_text(strip=True))
|
|
if fund:
|
|
datum = fund.group(1)
|
|
break
|
|
|
|
verlag = _verlag_aus_titel(titel)
|
|
treffer.append(
|
|
QuellenTreffer(
|
|
titel=titel,
|
|
verlag=verlag,
|
|
autor=None,
|
|
erscheinungsdatum_oder_quartal=datum,
|
|
quellen_url=quellen_url,
|
|
bild_url=bild_aus_element(element, url),
|
|
)
|
|
)
|
|
|
|
folge_links: list[str] = []
|
|
for link in suppe.select(".pagination a[href]"):
|
|
ziel = _absolute_url(link["href"], url)
|
|
# Nur echte Paginierung dieser Liste folgen (?start=N),
|
|
# nicht den Druck-/Sprung-Links.
|
|
if ziel and "start=" in ziel:
|
|
folge_links.append(ziel)
|
|
return treffer, folge_links
|
|
|
|
|
|
def _verlag_aus_titel(titel: str) -> str | None:
|
|
"""„Verlag: Spielname“ → Verlag als Präfix vor dem Doppelpunkt."""
|
|
if ":" not in titel:
|
|
return None
|
|
praefix = titel.split(":", 1)[0].strip()
|
|
if 0 < len(praefix) <= 60:
|
|
return praefix
|
|
return None
|
|
|
|
|
|
def _absolute_url(href: str | None, basis_url: str) -> str | None:
|
|
if not href:
|
|
return None
|
|
from urllib.parse import urljoin
|
|
|
|
return urljoin(basis_url, href)
|