diff --git a/plugins/neuheiten/quellen/cliquenabend.py b/plugins/neuheiten/quellen/cliquenabend.py
index c40cdda..f6ef03f 100644
--- a/plugins/neuheiten/quellen/cliquenabend.py
+++ b/plugins/neuheiten/quellen/cliquenabend.py
@@ -10,12 +10,17 @@ 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"
@@ -40,6 +45,42 @@ class CliquenabendQuelle(QuellenAdapter):
treffer.append(self._zeile(zellen, url))
return [t for t in treffer if t is not None], []
+ 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.
+ 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
+ suppe = BeautifulSoup(antwort.content, "html.parser")
+ 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 _zeile(self, zellen, url: str) -> QuellenTreffer | None:
spiel_zelle = zellen[3]
titel_link = spiel_zelle.find("a", href=re.compile(r"^/spiele/"))
@@ -50,8 +91,6 @@ class CliquenabendQuelle(QuellenAdapter):
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
@@ -66,6 +105,15 @@ class CliquenabendQuelle(QuellenAdapter):
# Datum so übernehmen, wie es in der Quelle steht (z. B. „15.10.25“).
datum = fund.group(0)
+ # Release-Feld der Detailseite hat Vorrang: dort steht das echte
+ # Erscheinungsjahr/-datum statt des Update-Datums der Mese-Tabelle.
+ if titel_link is not None:
+ href = titel_link.get("href")
+ if href:
+ release = self._release_jahr(urljoin(BASIS_URL, href))
+ if release:
+ datum = release
+
return QuellenTreffer(
titel=titel,
verlag=verlag,
diff --git a/tests/test_neuheiten_quellen.py b/tests/test_neuheiten_quellen.py
index 18a9635..f576a89 100644
--- a/tests/test_neuheiten_quellen.py
+++ b/tests/test_neuheiten_quellen.py
@@ -124,10 +124,21 @@ CLIQUENABEND_SEITE = """
Seite ohne Release-Feld
" + class FakeUhr: """Steuerbare Monoton-Uhr.""" @@ -306,7 +317,11 @@ def test_parser_spielessen_nutzt_json_api_endpunkt(): def test_parser_cliquenabend_liest_tabellenspalten(): adapter, _ = _adapter( CliquenabendQuelle, - {"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE}, + { + "https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE, + "https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html": CLIQUENABEND_DETAIL_1, + "https://www.cliquenabend.de/spiele/790700-Elixirus.html": CLIQUENABEND_DETAIL_FEHLT, + }, ) ergebnis = adapter.sammle() @@ -314,9 +329,26 @@ def test_parser_cliquenabend_liest_tabellenspalten(): erster = ergebnis.treffer[0] assert erster.verlag == "Dranda Games, PD Verlag" assert erster.autor == "T.K. King und Koltin Thompson" - assert erster.erscheinungsdatum_oder_quartal == "15.10.25" + # Release-Feld der Detailseite schlägt das Update-Datum der Tabelle: + assert erster.erscheinungsdatum_oder_quartal == "2025" assert erster.quellen_url == "https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html" assert ergebnis.treffer[1].verlag == "Piatnik" + # Ohne Release-Feld auf der Detailseite fällt der Parser auf das + # Update-Datum zurück: + assert ergebnis.treffer[1].erscheinungsdatum_oder_quartal == "26.09.25" + + +def test_parser_cliquenabend_detailseite_fehler_blockiert_nicht(): + adapter, _ = _adapter( + CliquenabendQuelle, + { + "https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE, + # Detailseiten nicht erreichbar → Update-Datum bleibt erhalten. + }, + ) + ergebnis = adapter.sammle() + assert len(ergebnis.treffer) == 2 + assert ergebnis.treffer[0].erscheinungsdatum_oder_quartal == "15.10.25" def test_parser_cliquenabend_toleriert_fehlende_tabelle():