"""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 logging
import re
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from bs4.element import Tag
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
_logger = logging.getLogger(__name__)
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 _detailseite(self, spiel_url: str):
"""Lädt eine Spieldetailseite und gibt das BeautifulSoup-Dokument zurück.
Fehler (404, Strukturänderung) führen zu None und blockieren nie.
"""
try:
antwort = self.client.hole(spiel_url)
except Exception:
_logger.warning("Cliquenabend: Detailseite nicht erreichbar (%s)", spiel_url)
return None
if antwort.status_code != 200:
return None
return BeautifulSoup(antwort.content, "html.parser")
def _release_jahr(self, spiel_url: str) -> str | None:
"""Liest das Release-Feld von der Spieldetailseite.
Die Detailseite (…/spiele/….html) enthält ein Feld
`Release:
JAHR
` — meist nur das Jahr (z. B.
„2025“), gelegentlich ein genaues Datum. Liefert die rohe Angabe
zurück; `jahr_aus_datumsangabe` leitet daraus später das Jahr ab.
"""
suppe = self._detailseite(spiel_url)
if suppe is None:
return None
return self._release_jahr_aus_suppe(suppe)
def _release_jahr_aus_suppe(self, suppe) -> str | None:
fett = suppe.find(
"b", string=lambda s: isinstance(s, str) and s.strip() == "Release:"
)
if not isinstance(fett, Tag):
return None
# Wert steht direkt hinter dem Label (
-getrennt); der nächste
# String-Knoten nach dem Label-Element (nicht darin) ist der Wert.
text = fett.next_sibling
while isinstance(text, Tag):
text = text.next_sibling
while isinstance(text, str) and not text.strip():
text = text.next_sibling
while isinstance(text, Tag):
text = text.next_sibling
if not isinstance(text, str):
return None
wert = text.strip()
return wert or None
def _cover_bild(self, spiel_url: str) -> str | None:
"""Liest das Cover-Bild von der Spieldetailseite."""
suppe = self._detailseite(spiel_url)
if suppe is None:
return None
return self._cover_bild_aus_suppe(suppe)
def _cover_bild_aus_suppe(self, suppe) -> str | None:
"""Die Detailseite zeigt das Cover in der rechten Leiste als
`
`.
Liefert die absolute Bild-URL oder None.
"""
for bild in suppe.find_all("img", src=True):
src = str(bild["src"])
if "/images/db/" not in src:
continue # Template-Grafiken (Logo, Pfeile etc.) auslassen
eltern_link = bild.find_parent("a", href=re.compile(r"^/spiele/"))
if eltern_link is not None:
return urljoin(BASIS_URL, src)
return 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:
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
bild_url = bild_aus_element(spiel_zelle, quellen_url)
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)
# Release-Feld und Cover-Bild der Detailseite haben Vorrang: dort
# steht das echte Erscheinungsjahr/-datum bzw. das Cover statt des
# Update-Datums bzw. des Messe-Fotos aus der Tabelle.
if titel_link is not None:
href = titel_link.get("href")
if href:
detail_url = urljoin(BASIS_URL, href)
suppe_detail = self._detailseite(detail_url)
if suppe_detail is not None:
release = self._release_jahr_aus_suppe(suppe_detail)
if release:
datum = release
cover = self._cover_bild_aus_suppe(suppe_detail)
if cover:
bild_url = cover
return QuellenTreffer(
titel=titel,
verlag=verlag,
autor=autor,
erscheinungsdatum_oder_quartal=datum,
quellen_url=quellen_url,
bild_url=bild_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