Plugin neuheiten: Web-Quellen-Crawler (4 Adapter, Rate-Limit, Dedup-Gate, Sync-UI)
This commit is contained in:
57
plugins/neuheiten/quellen/__init__.py
Normal file
57
plugins/neuheiten/quellen/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Quellen-Adapter des Neuheiten-Plugins (Adapter-Pattern).
|
||||
|
||||
Ein Modul pro Quelle plus gemeinsame Basis (`basis.py`); die
|
||||
Orchestrierung (alle aktiven Quellen, Fehler-Isolation, Dedup, Upsert,
|
||||
Statusprotokoll) liegt in `quellen_sync.py` des Plugin-Pakets.
|
||||
|
||||
Neue Quelle hinzufügen:
|
||||
1. Modul `meine_quelle.py` mit einer `QuellenAdapter`-Unterklasse
|
||||
(Klassenattribute `name` und `anzeigename` setzen).
|
||||
2. Klasse in `ADAPTER_KLASSEN` ergänzen — Env-Schalter, Statuszeile und
|
||||
Sync-Übersicht ergeben sich automatisch aus dem `name`
|
||||
(SPIELE_NEUHEITEN_QUELLE_<NAME_GROSS>_AKTIV).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .basis import (
|
||||
USER_AGENT,
|
||||
QuellenAdapter,
|
||||
QuellenAntwort,
|
||||
QuellenFehler,
|
||||
QuellenTreffer,
|
||||
SammelErgebnis,
|
||||
WebQuellenClient,
|
||||
jahr_aus_datumsangabe,
|
||||
titel_aehnlichkeit,
|
||||
titel_normalisieren,
|
||||
)
|
||||
from .brettspielbox import BrettspielboxQuelle
|
||||
from .cliquenabend import CliquenabendQuelle
|
||||
from .spielessen import SpielEssenQuelle
|
||||
from .spielbox import SpielboxQuelle
|
||||
|
||||
#: Alle verfügbaren Web-Quellen in fester Reihenfolge.
|
||||
ADAPTER_KLASSEN: tuple[type[QuellenAdapter], ...] = (
|
||||
SpielboxQuelle,
|
||||
BrettspielboxQuelle,
|
||||
SpielEssenQuelle,
|
||||
CliquenabendQuelle,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ADAPTER_KLASSEN",
|
||||
"USER_AGENT",
|
||||
"QuellenAdapter",
|
||||
"QuellenAntwort",
|
||||
"QuellenFehler",
|
||||
"QuellenTreffer",
|
||||
"SammelErgebnis",
|
||||
"WebQuellenClient",
|
||||
"jahr_aus_datumsangabe",
|
||||
"titel_aehnlichkeit",
|
||||
"titel_normalisieren",
|
||||
"SpielboxQuelle",
|
||||
"BrettspielboxQuelle",
|
||||
"SpielEssenQuelle",
|
||||
"CliquenabendQuelle",
|
||||
]
|
||||
269
plugins/neuheiten/quellen/basis.py
Normal file
269
plugins/neuheiten/quellen/basis.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""Gemeinsame Basis für die Web-Quellen-Adapter des Neuheiten-Plugins.
|
||||
|
||||
Enthält:
|
||||
- `USER_AGENT`: der freundliche Bot-User-Agent, den alle Quellen senden.
|
||||
- `WebQuellenClient`: HTTP-Client mit Rate-Limit (max. 1 Request/Sekunde
|
||||
je Domain), Retry/Backoff bei 5xx/429 und konditionalen Requests
|
||||
(If-None-Match/If-Modified-Since; HTTP 304 → Quelle unverändert).
|
||||
- `QuellenTreffer`: das gemeinsame Zwischenformat aller Parser-Adapter
|
||||
(titel, verlag, autor, erscheinungsdatum_oder_quartal, quellen_url).
|
||||
- `QuellenAdapter`: Basisklasse für einen Quellen-Adapter. Der Standard-
|
||||
Ablauf crawlt alle Seiten einer Quelle (Pagination wird über die
|
||||
Folge-Links jeder Seite entdeckt) und übergibt das Parsing an die
|
||||
Unterklasse (`seite_verarbeiten`). JS-lastige Quellen überschreiben
|
||||
`sammle()` und nutzen stattdessen ihren Daten-Endpunkt direkt.
|
||||
- Hilfsfunktionen: Jahr aus einer Datums-/Quartalsangabe, Titel-
|
||||
Normalisierung und Fuzzy-Titelähnlichkeit (rapidfuzz) für den
|
||||
Duplikatsvergleich gegen bestehende Einträge (z. B. aus BGG).
|
||||
|
||||
Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar —
|
||||
die Testsuite führt keine echten Netzwerkaufrufe durch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree # noqa: F401 (dokumentiert: keine XML-Nutzung hier)
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar
|
||||
|
||||
import httpx
|
||||
from rapidfuzz import fuzz
|
||||
|
||||
#: Freundlicher User-Agent für alle Redaktions-Crawler (Courtesy-Regeln).
|
||||
USER_AGENT = "SpieleRedaktionBot/1.0 (Redaktions-Tool; Kontakt siehe Repo)"
|
||||
|
||||
JAHR_MUSTER = re.compile(r"\b(19|20)(\d{2})\b")
|
||||
|
||||
|
||||
class QuellenFehler(Exception):
|
||||
"""Fehler beim Abrufen oder Parsen einer Web-Quelle."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuellenTreffer:
|
||||
"""Ein geparster Spieleintrag einer Web-Quelle (Zwischenformat).
|
||||
|
||||
`erscheinungsdatum_oder_quartal` ist die rohe Angabe der Quelle
|
||||
(z. B. „24.07.2026“, „10/2026“, „Q3 2026“, „Herbst 2026“);
|
||||
das Erscheinungsjahr für die Datenbank wird daraus abgeleitet.
|
||||
"""
|
||||
|
||||
titel: str
|
||||
quellen_url: str
|
||||
verlag: str | None = None
|
||||
autor: str | None = None
|
||||
erscheinungsdatum_oder_quartal: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuellenAntwort:
|
||||
"""Ergebnis eines konditionalen GETs."""
|
||||
|
||||
status_code: int
|
||||
content: bytes = b""
|
||||
etag: str | None = None
|
||||
last_modified: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SammelErgebnis:
|
||||
"""Ergebnis eines Adapter-Laufs über eine Quelle."""
|
||||
|
||||
treffer: list[QuellenTreffer] = field(default_factory=list)
|
||||
seiten: int = 0
|
||||
#: True = mindestens eine Seite kam per 304 als unverändert zurück;
|
||||
#: der Lauf wurde dann abgekürzt (Quelle übersprungen, kein Fehler).
|
||||
unveraendert: bool = False
|
||||
|
||||
|
||||
def jahr_aus_datumsangabe(angabe: str | None) -> int | None:
|
||||
"""Leitet das Erscheinungsjahr aus einer Datums-/Quartalsangabe ab."""
|
||||
if not angabe:
|
||||
return None
|
||||
fund = JAHR_MUSTER.search(angabe)
|
||||
if fund:
|
||||
return int(fund.group(0))
|
||||
return None
|
||||
|
||||
|
||||
def titel_normalisieren(titel: str) -> str:
|
||||
"""Vergleichsschlüssel für Titel: kleingeschrieben, ohne Satzzeichen."""
|
||||
geklart = re.sub(r"[^\wäöüß ]+", " ", titel.lower(), flags=re.UNICODE)
|
||||
return " ".join(geklart.split())
|
||||
|
||||
|
||||
def titel_aehnlichkeit(a: str, b: str) -> float:
|
||||
"""Fuzzy-Titelähnlichkeit (token_set_ratio, 0–100) für Duplikatsprüfung."""
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
return float(fuzz.token_set_ratio(a, b))
|
||||
|
||||
|
||||
class WebQuellenClient:
|
||||
"""HTTP-Client für Web-Quellen mit Rate-Limit und konditionalen Requests.
|
||||
|
||||
- Mindestens `mindestabstand_sekunden` (Standard 1 s) zwischen zwei
|
||||
Requests (Courtesy-Rate-Limit je Domain).
|
||||
- Sendet bekannte Validatoren als If-None-Match/If-Modified-Since;
|
||||
HTTP 304 wird als „unverändert“ gemeldet (kein Retry, kein Fehler).
|
||||
- 5xx/429 werden mit exponentiellem Backoff erneut versucht,
|
||||
sonstige Statuscodes erzeugen eine klare `QuellenFehler`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
mindestabstand_sekunden: float = 1.0,
|
||||
max_versuche: int = 4,
|
||||
backoff_basis_sekunden: float = 1.0,
|
||||
backoff_maximum_sekunden: float = 8.0,
|
||||
timeout_sekunden: float = 60.0,
|
||||
schlaf: Callable[[float], None] = time.sleep,
|
||||
uhr: Callable[[], float] = time.monotonic,
|
||||
validatoren: dict[str, tuple[str | None, str | None]] | None = None,
|
||||
) -> None:
|
||||
self.mindestabstand_sekunden = mindestabstand_sekunden
|
||||
self.max_versuche = max_versuche
|
||||
self.backoff_basis_sekunden = backoff_basis_sekunden
|
||||
self.backoff_maximum_sekunden = backoff_maximum_sekunden
|
||||
self._schlaf = schlaf
|
||||
self._uhr = uhr
|
||||
self._letzter_request_um: float | None = None
|
||||
#: URL → (etag, last_modified); persistierbar über die Sync-Läufe.
|
||||
self.validatoren: dict[str, tuple[str | None, str | None]] = dict(
|
||||
validatoren or {}
|
||||
)
|
||||
self._http = httpx.Client(
|
||||
timeout=timeout_sekunden,
|
||||
transport=transport,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
|
||||
def schliessen(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
def _rate_limit_abwarten(self) -> None:
|
||||
if self._letzter_request_um is None:
|
||||
return
|
||||
vergangen = self._uhr() - self._letzter_request_um
|
||||
rest = self.mindestabstand_sekunden - vergangen
|
||||
if rest > 0:
|
||||
self._schlaf(rest)
|
||||
|
||||
def _konditionale_header(self, url: str) -> dict[str, str]:
|
||||
header: dict[str, str] = {}
|
||||
etag, last_modified = self.validatoren.get(url, (None, None))
|
||||
if etag:
|
||||
header["If-None-Match"] = etag
|
||||
if last_modified:
|
||||
header["If-Modified-Since"] = last_modified
|
||||
return header
|
||||
|
||||
def hole(self, url: str) -> QuellenAntwort:
|
||||
"""GET mit Rate-Limit, Backoff und konditionalen Headern."""
|
||||
letzte_fehler: Exception | None = None
|
||||
for versuch in range(self.max_versuche):
|
||||
self._rate_limit_abwarten()
|
||||
try:
|
||||
antwort = self._http.get(url, headers=self._konditionale_header(url))
|
||||
except httpx.HTTPError as exc:
|
||||
letzte_fehler = exc
|
||||
self._schlaf(self._backoff(versuch))
|
||||
continue
|
||||
self._letzter_request_um = self._uhr()
|
||||
|
||||
if antwort.status_code == 304:
|
||||
return QuellenAntwort(304)
|
||||
|
||||
if antwort.status_code == 200:
|
||||
etag = antwort.headers.get("ETag")
|
||||
last_modified = antwort.headers.get("Last-Modified")
|
||||
if etag or last_modified:
|
||||
self.validatoren[url] = (etag, last_modified)
|
||||
return QuellenAntwort(
|
||||
200, antwort.content, etag=etag, last_modified=last_modified
|
||||
)
|
||||
|
||||
if antwort.status_code == 429 or antwort.status_code >= 500:
|
||||
retry_after = antwort.headers.get("Retry-After")
|
||||
try:
|
||||
wartezeit = float(retry_after) if retry_after else None
|
||||
except ValueError:
|
||||
wartezeit = None
|
||||
self._schlaf(wartezeit if wartezeit is not None else self._backoff(versuch))
|
||||
letzte_fehler = QuellenFehler(
|
||||
f"HTTP {antwort.status_code} von {url} (Versuch {versuch + 1})."
|
||||
)
|
||||
continue
|
||||
|
||||
raise QuellenFehler(
|
||||
f"Unerwartete HTTP-Antwort {antwort.status_code} für {url}."
|
||||
)
|
||||
raise QuellenFehler(
|
||||
f"Quelle nach {self.max_versuche} Versuchen nicht erreichbar ({url}):"
|
||||
f" {letzte_fehler}"
|
||||
)
|
||||
|
||||
def _backoff(self, versuch: int) -> float:
|
||||
return min(
|
||||
self.backoff_basis_sekunden * (2**versuch),
|
||||
self.backoff_maximum_sekunden,
|
||||
)
|
||||
|
||||
|
||||
class QuellenAdapter:
|
||||
"""Basisklasse eines Quellen-Adapters.
|
||||
|
||||
Klassenattribute:
|
||||
- `name`: stabiler Kurzname (= Env-Suffix, z. B. „spielbox“ für
|
||||
SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV) und Wert der Spalte `quelle`.
|
||||
- `anzeigename`: deutscher Name für die Sync-Übersicht im UI.
|
||||
- `start_urls`: Einstiegspunkte des Crawls.
|
||||
- `max_seiten`: Sicherheitsgrenze gegen Endlos-Pagination.
|
||||
|
||||
HTML-Quellen implementieren `seite_verarbeiten`; JS-lastige Quellen
|
||||
mit eigenem Daten-Endpunkt überschreiben stattdessen `sammle()`.
|
||||
"""
|
||||
|
||||
name: ClassVar[str]
|
||||
anzeigename: ClassVar[str]
|
||||
start_urls: ClassVar[tuple[str, ...]] = ()
|
||||
max_seiten: int = 100
|
||||
|
||||
def __init__(self, client: WebQuellenClient) -> None:
|
||||
self.client = client
|
||||
|
||||
def sammle(self) -> SammelErgebnis:
|
||||
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS)."""
|
||||
ergebnis = SammelErgebnis()
|
||||
warteschlange = list(self.start_urls)
|
||||
besucht: set[str] = set()
|
||||
while warteschlange and len(besucht) < self.max_seiten:
|
||||
url = warteschlange.pop(0)
|
||||
if url in besucht:
|
||||
continue
|
||||
besucht.add(url)
|
||||
antwort = self.client.hole(url)
|
||||
if antwort.status_code == 304:
|
||||
# Unverändert → Quelle überspringen (kein Fehler).
|
||||
ergebnis.unveraendert = True
|
||||
break
|
||||
treffer, folge_links = self.seite_verarbeiten(antwort.content, url)
|
||||
ergebnis.treffer.extend(treffer)
|
||||
ergebnis.seiten += 1
|
||||
for link in folge_links:
|
||||
if link not in besucht:
|
||||
warteschlange.append(link)
|
||||
return ergebnis
|
||||
|
||||
def seite_verarbeiten(
|
||||
self, inhalt: bytes, url: str
|
||||
) -> tuple[list[QuellenTreffer], list[str]]:
|
||||
"""Parst eine Seite: liefert Treffer und Folge-Links (Pagination)."""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} muss seite_verarbeiten oder sammle überschreiben."
|
||||
)
|
||||
65
plugins/neuheiten/quellen/brettspielbox.py
Normal file
65
plugins/neuheiten/quellen/brettspielbox.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Adapter für die Brettspielbox-Messevorschau (A-Z-Liste).
|
||||
|
||||
Seite: …/brettspiel-neuheiten-spiel-2026-herbst-2026-a-z/ (WordPress).
|
||||
|
||||
Struktur (Stand Analyse August 2026): Die komplette A-Z-Liste steht auf
|
||||
einer Seite in `div.wp-block-columns`-Blöcken — pro Buchstabe ein
|
||||
Absatz (`p`) mit dem Buchstaben bzw. „0-9“ und eine `ul` mit einem
|
||||
`li` pro Spiel im Format „Titel (Verlag)“; optionale Links im Eintrag
|
||||
zeigen auf Vorstellungs-/Rezensionsartikel und dienen als quellen_url,
|
||||
sonst gilt die Listenseite selbst.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .basis import QuellenAdapter, QuellenTreffer
|
||||
|
||||
TITEL_VERLAG_MUSTER = re.compile(r"^\s*(.+?)\s*\(([^()]*)\)\s*$")
|
||||
|
||||
|
||||
class BrettspielboxQuelle(QuellenAdapter):
|
||||
name = "brettspielbox"
|
||||
anzeigename = "Brettspielbox Messevorschau (A–Z)"
|
||||
start_urls = (
|
||||
"https://brettspielbox.de/brettspiel-neuheiten-spiel-2026-herbst-2026-a-z/",
|
||||
)
|
||||
|
||||
def seite_verarbeiten(self, inhalt: bytes, url: str):
|
||||
suppe = BeautifulSoup(inhalt, "html.parser")
|
||||
treffer: list[QuellenTreffer] = []
|
||||
for spalte in suppe.select(".wp-block-columns"):
|
||||
for liste in spalte.find_all("ul"):
|
||||
for eintrag in liste.find_all("li"):
|
||||
text = eintrag.get_text(" ", strip=True)
|
||||
titel, verlag = _zerlege_eintrag(text)
|
||||
if not titel:
|
||||
continue
|
||||
link = eintrag.find("a", href=True)
|
||||
from urllib.parse import urljoin
|
||||
|
||||
quellen_url = (
|
||||
urljoin(url, link["href"]) if link is not None else url
|
||||
)
|
||||
treffer.append(
|
||||
QuellenTreffer(
|
||||
titel=titel,
|
||||
verlag=verlag,
|
||||
autor=None,
|
||||
erscheinungsdatum_oder_quartal=None,
|
||||
quellen_url=quellen_url,
|
||||
)
|
||||
)
|
||||
return treffer, []
|
||||
|
||||
|
||||
def _zerlege_eintrag(text: str) -> tuple[str | None, str | None]:
|
||||
"""„Titel (Verlag)“ → (Titel, Verlag); ohne Klammer nur Titel."""
|
||||
fund = TITEL_VERLAG_MUSTER.match(text)
|
||||
if fund is None:
|
||||
return (text.strip() or None), None
|
||||
verlag = fund.group(2).strip() or None
|
||||
titel = fund.group(1).strip()
|
||||
return (titel or None), verlag
|
||||
97
plugins/neuheiten/quellen/cliquenabend.py
Normal file
97
plugins/neuheiten/quellen/cliquenabend.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
88
plugins/neuheiten/quellen/spielbox.py
Normal file
88
plugins/neuheiten/quellen/spielbox.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""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
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
144
plugins/neuheiten/quellen/spielessen.py
Normal file
144
plugins/neuheiten/quellen/spielessen.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Adapter für die Neuheitenliste der SPIEL Essen (spiel-essen.de).
|
||||
|
||||
Die Seite https://spiel-essen.de/de/die-spiel/neuheiten rendert ihre
|
||||
Daten clientseitig mit einer Vue-App („eye-concat-product-list“) — HTML-
|
||||
Parsing scheidet aus. Die App lädt ihre Daten aus einem stabilen
|
||||
JSON-Endpunkt (im App-Bundle verdrahtet, Stand Analyse August 2026):
|
||||
|
||||
GET https://maps.eyeled-services.de/de/spiel{JJ}/products
|
||||
?columns=["ID","INFO","S_ORDER","TITEL","FIRMA_ID","UNTERTITEL",
|
||||
"BILDER","BILDER_VERSIONEN","BILDER_TEXTE"]
|
||||
GET https://maps.eyeled-services.de/de/spiel{JJ}/exhibitors
|
||||
?columns=["ID","NAME", …] (FIRMA_ID → Ausstellername)
|
||||
|
||||
Das Projekt-Kürzel (`spiel26`, `spiel27`, …) folgt dem aktuellen Jahr.
|
||||
Antwort: `{"products": [{"ID", "TITEL", "UNTERTITEL", "FIRMA_ID",
|
||||
"INFO", …}]}`; INFO ist eine HTML-Tabelle mit u. a. Autor, Verlag,
|
||||
Vertrieb und Erscheinungsdatum (z. B. „10/2026“). Vor Veröffentlichung
|
||||
der Liste liefert der Endpunkt eine leere Produktliste — das ist ein
|
||||
normaler Lauf ohne Treffer, kein Fehler.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .basis import QuellenAdapter, QuellenFehler, QuellenTreffer, SammelErgebnis
|
||||
|
||||
API_BASIS_URL = "https://maps.eyeled-services.de"
|
||||
OEFFENTLICHE_SEITE = "https://spiel-essen.de/de/die-spiel/neuheiten"
|
||||
PRODUKT_SPALTEN = [
|
||||
"ID", "INFO", "S_ORDER", "TITEL", "FIRMA_ID", "UNTERTITEL",
|
||||
"BILDER", "BILDER_VERSIONEN", "BILDER_TEXTE",
|
||||
]
|
||||
AUSSTELLER_SPALTEN = [
|
||||
"ID", "NAME", "ADRESSE", "LAND", "LOGO", "PLZ", "STADT", "WEB",
|
||||
"EMAIL", "INFO", "TELEFON", "S_ORDER", "STAND", "HALLE",
|
||||
]
|
||||
INFO_FELD_VERLAG = ("Verlag", "Vertrieb")
|
||||
|
||||
|
||||
def _projekt_kuerzel(jahr: int | None = None) -> str:
|
||||
"""API-Projektname der Messe (spiel25, spiel26, …)."""
|
||||
jahr = jahr if jahr is not None else datetime.now(timezone.utc).year
|
||||
return f"spiel{jahr % 100:02d}"
|
||||
|
||||
|
||||
def _endpoint(pfad: str, spalten: list[str], jahr: int | None = None) -> str:
|
||||
query = urlencode({"columns": json.dumps(spalten)})
|
||||
return f"{API_BASIS_URL}/de/{_projekt_kuerzel(jahr)}/{pfad}?{query}"
|
||||
|
||||
|
||||
class SpielEssenQuelle(QuellenAdapter):
|
||||
name = "spielessen"
|
||||
anzeigename = "SPIEL Essen Neuheiten (Produktliste)"
|
||||
start_urls = (OEFFENTLICHE_SEITE,)
|
||||
|
||||
def __init__(self, client, jahr: int | None = None) -> None:
|
||||
super().__init__(client)
|
||||
self._jahr = jahr # für Tests überschreibbar (stabile Endpunkte)
|
||||
|
||||
def sammle(self) -> SammelErgebnis:
|
||||
ergebnis = SammelErgebnis()
|
||||
antwort = self.client.hole(
|
||||
_endpoint("products", PRODUKT_SPALTEN, self._jahr)
|
||||
)
|
||||
if antwort.status_code == 304:
|
||||
ergebnis.unveraendert = True
|
||||
return ergebnis
|
||||
ergebnis.seiten = 1
|
||||
|
||||
try:
|
||||
daten = json.loads(antwort.content.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise QuellenFehler(f"Ungültiges JSON der SPIEL-Essen-Produktliste: {exc}") from exc
|
||||
|
||||
aussteller = self._lade_aussteller()
|
||||
for produkt in daten.get("products") or []:
|
||||
treffer = self._produkt(produkt, aussteller)
|
||||
if treffer is not None:
|
||||
ergebnis.treffer.append(treffer)
|
||||
return ergebnis
|
||||
|
||||
def _lade_aussteller(self) -> dict[str, str]:
|
||||
"""FIRMA_ID → Ausstellername; Fehler degradieren auf Untertitel-Fallback."""
|
||||
try:
|
||||
antwort = self.client.hole(
|
||||
_endpoint("exhibitors", AUSSTELLER_SPALTEN, self._jahr)
|
||||
)
|
||||
except QuellenFehler:
|
||||
return {}
|
||||
if antwort.status_code == 304:
|
||||
return {}
|
||||
try:
|
||||
daten = json.loads(antwort.content.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return {}
|
||||
zuordnung: dict[str, str] = {}
|
||||
for eintrag in daten.get("exhibitors") or []:
|
||||
if eintrag.get("ID") is not None and eintrag.get("NAME"):
|
||||
zuordnung[str(eintrag["ID"])] = str(eintrag["NAME"])
|
||||
return zuordnung
|
||||
|
||||
def _produkt(self, produkt: dict, aussteller: dict[str, str]) -> QuellenTreffer | None:
|
||||
titel = str(produkt.get("TITEL") or "").strip()
|
||||
if not titel:
|
||||
return None
|
||||
felder = _parse_info(produkt.get("INFO") or "")
|
||||
verlag = (
|
||||
felder.get("Verlag")
|
||||
or felder.get("Vertrieb")
|
||||
or aussteller.get(str(produkt.get("FIRMA_ID") or ""))
|
||||
or str(produkt.get("UNTERTITEL") or "").strip()
|
||||
or None
|
||||
)
|
||||
return QuellenTreffer(
|
||||
titel=titel,
|
||||
verlag=verlag,
|
||||
autor=felder.get("Autor"),
|
||||
erscheinungsdatum_oder_quartal=felder.get("Erscheinungsdatum"),
|
||||
quellen_url=OEFFENTLICHE_SEITE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_info(info_html: str) -> dict[str, str]:
|
||||
"""Liest die INFO-Html-Tabelle (Zeilen „Feld: Wert“) tolerant aus."""
|
||||
felder: dict[str, str] = {}
|
||||
if not info_html.strip():
|
||||
return felder
|
||||
try:
|
||||
suppe = BeautifulSoup(info_html, "html.parser")
|
||||
except Exception: # kaputtes HTML darf den Lauf nicht abbrechen
|
||||
return felder
|
||||
for zeile in suppe.find_all("tr"):
|
||||
zellen = zeile.find_all("td")
|
||||
if len(zellen) != 2:
|
||||
continue
|
||||
schluessel = zellen[0].get_text(" ", strip=True).rstrip(":").strip()
|
||||
wert = zellen[1].get_text(" ", strip=True).strip()
|
||||
if schluessel and wert:
|
||||
felder[schluessel] = wert
|
||||
return felder
|
||||
Reference in New Issue
Block a user