Compare commits
14 Commits
8e5f65d864
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929cea1887 | ||
|
|
d91402881a | ||
|
|
ca6d94938c | ||
|
|
667196a4ba | ||
|
|
8b38bc6c89 | ||
|
|
199f8b080d | ||
|
|
aea52d1563 | ||
|
|
fa453518d4 | ||
|
|
96d6af8163 | ||
|
|
20b84a8e1a | ||
|
|
f27940db8e | ||
|
|
c6f9b19f6b | ||
|
|
3b0626c774 | ||
|
|
8c9f542b8c |
9
.env
Normal file
9
.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
SPIELE_SESSION_SECRET=48ebf7ddae261a931652b5a55cff708aa21cf4936a8bdeb287eeac7cec4ee3e2
|
||||||
|
SPIELE_INITIAL_ADMIN_PASSWORD=3fb93f84014ff45406b36ec3
|
||||||
|
SPIELE_BGG_TOKEN=6fa6f9f6-32fd-443d-a5c3-ac2527f6a6fc
|
||||||
|
|
||||||
|
# BGG-Sync: deutsche Neuerscheinungen ab aktuellem Jahr
|
||||||
|
SPIELE_BGG_SUCHBEGRIFFE=brettspiel,gesellschaftsspiel,familienspiel,strategiespiel,kartenspiel,würfelspiel
|
||||||
|
SPIELE_BGG_MAX_TREFFER_PRO_SUCHE=50
|
||||||
|
SPIELE_BGG_JAHR_FILTER=1
|
||||||
|
SPIELE_BGG_DEUTSCHE_TITEL_FILTER=1
|
||||||
109
README.md
109
README.md
@@ -55,9 +55,11 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
|
|||||||
| `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) |
|
| `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) |
|
||||||
| `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) |
|
| `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) |
|
||||||
| `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) |
|
| `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) |
|
||||||
| `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync |
|
| `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel, gesellschaftsspiel, familienspiel, strategiespiel, kartenspiel, würfelspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync (deutsche Spielbegriffe) |
|
||||||
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
|
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `50` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
|
||||||
| `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer …`-Header gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen |
|
| `SPIELE_BGG_JAHR_FILTER` | `1` | Nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr übernehmen (`1`) oder alle (`0`) |
|
||||||
|
| `SPIELE_BGG_DEUTSCHE_TITEL_FILTER` | `1` | Nur Spiele mit deutsch klingendem Titel übernehmen (`1`) oder alle (`0`) |
|
||||||
|
| `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer *** gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen |
|
||||||
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen |
|
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen |
|
||||||
| `SPIELE_DEDUP_LLM_AKTIV` | `0` | LLM-Zweitprüfung der Dedup-Grenzfälle an (`1`) oder aus (`0`) |
|
| `SPIELE_DEDUP_LLM_AKTIV` | `0` | LLM-Zweitprüfung der Dedup-Grenzfälle an (`1`) oder aus (`0`) |
|
||||||
| `SPIELE_DEDUP_LLM_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |
|
| `SPIELE_DEDUP_LLM_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |
|
||||||
@@ -65,6 +67,12 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
|
|||||||
| `SPIELE_DEDUP_LLM_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` |
|
| `SPIELE_DEDUP_LLM_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` |
|
||||||
| `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das LLM-Ergebnis verworfen und nur der Regelbefund angezeigt |
|
| `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das LLM-Ergebnis verworfen und nur der Regelbefund angezeigt |
|
||||||
| `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt |
|
| `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_AKTIV` | `0` | Optionale LLM-Hilfsstufe des Web-Quellen-Crawlers an (`1`) oder aus (`0`) |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_API_KEY` | *(leer)* | API-Key, wird als `Authorization: Bearer …` gesendet |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das KI-Ergebnis verworfen und der klassische Parser-Befund behalten |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt |
|
||||||
| `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) |
|
| `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) |
|
||||||
| `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` |
|
| `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` |
|
||||||
| `SPIELE_ERINNERUNG_JOB_AKTIV` | `1` | Täglicher Erinnerungs-Check an (`1`) oder aus (`0`) |
|
| `SPIELE_ERINNERUNG_JOB_AKTIV` | `1` | Täglicher Erinnerungs-Check an (`1`) oder aus (`0`) |
|
||||||
@@ -90,6 +98,11 @@ Fake-BGG-Client, Prüfprotokoll, Prüfseite) und **planung** (beide Eintragswege
|
|||||||
mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl-
|
mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl-
|
||||||
Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein
|
Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein
|
||||||
Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag).
|
Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag).
|
||||||
|
Dazu die **Coverbilder-Erweiterung**: Migrationen für `bild_url` in allen
|
||||||
|
vier Tabellen inkl. Daten-Erhalt beim ALTER TABLE, BGG-Bildextraktion
|
||||||
|
(`<image>` bevorzugt, `<thumbnail>` als Rückfallebene, gemockte Antworten),
|
||||||
|
Bildübernahme aus Web-Quellen, Update-Pflege des Bildes im Sync-Upsert sowie
|
||||||
|
Template-Darstellung (`loading="lazy"`/`decoding="async"` bzw. Platzhalter).
|
||||||
Dazu das **archiv**-Plugin: 12-Monats-Regel mit eingefrorener Uhr (Grenzfälle
|
Dazu das **archiv**-Plugin: 12-Monats-Regel mit eingefrorener Uhr (Grenzfälle
|
||||||
„genau 12 Monate“, Schaltjahr/29. Februar, UTC-Konsistenz bei
|
„genau 12 Monate“, Schaltjahr/29. Februar, UTC-Konsistenz bei
|
||||||
Zeitzonen-Unterschieden), Job-Lauf mit Audit-Einträgen, Wiederherstellen
|
Zeitzonen-Unterschieden), Job-Lauf mit Audit-Einträgen, Wiederherstellen
|
||||||
@@ -264,7 +277,8 @@ manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste.
|
|||||||
|
|
||||||
### Datentabelle
|
### Datentabelle
|
||||||
|
|
||||||
Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
|
Eigene Migrationen `0001_neuheiten_tabelle`, `0002_bgg_id_nullable`,
|
||||||
|
`0003_quellen_status` und `0004_bild_url`; Tabelle `neuheiten`
|
||||||
(SQLite ↔ Postgres portabel):
|
(SQLite ↔ Postgres portabel):
|
||||||
|
|
||||||
| Spalte | Inhalt |
|
| Spalte | Inhalt |
|
||||||
@@ -273,6 +287,7 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
|
|||||||
| `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt |
|
| `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt |
|
||||||
| `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum |
|
| `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum |
|
||||||
| `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs |
|
| `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs |
|
||||||
|
| `bild_url` | URL des Coverbildes (nullable; BGG-Image/-Thumbnail oder Bild der Web-Quelle) |
|
||||||
| `status` | `neuheit` (spätere Plugins: Planung/Archiv) |
|
| `status` | `neuheit` (spätere Plugins: Planung/Archiv) |
|
||||||
| `quelle` | Datenquelle (`boardgamegeek`) |
|
| `quelle` | Datenquelle (`boardgamegeek`) |
|
||||||
| `erstellt_am` / `aktualisiert_am` | Zeitstempel |
|
| `erstellt_am` / `aktualisiert_am` | Zeitstempel |
|
||||||
@@ -282,6 +297,14 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
|
|||||||
Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln →
|
Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln →
|
||||||
`/thing?id=…&type=boardgame` in Batches à 20 IDs.
|
`/thing?id=…&type=boardgame` in Batches à 20 IDs.
|
||||||
|
|
||||||
|
**Coverbilder:** Aus der Thing-Antwort wird die Bild-URL extrahiert
|
||||||
|
(bevorzugt das große `<image>`, sonst das kleinere `<thumbnail>`;
|
||||||
|
protokoll-relative URLs werden auf https normalisiert) und in
|
||||||
|
`bild_url` gespeichert. Beim Update bestehender Einträge wird das Bild
|
||||||
|
mitgepflegt; fehlt es in einer Antwort, bleibt ein bereits gespeichertes
|
||||||
|
Bild erhalten. Bilder werden direkt von BGG eingebettet (Hotlinking,
|
||||||
|
kein Download) — im UI immer mit `loading="lazy"` und `decoding="async"`.
|
||||||
|
|
||||||
- **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests.
|
- **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests.
|
||||||
- **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und
|
- **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und
|
||||||
Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After`
|
Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After`
|
||||||
@@ -331,6 +354,46 @@ Autor, Erscheinungsjahr, Status, aktualisiert am):
|
|||||||
- Jeder Titel verlinkt direkt auf den BGG-Eintrag.
|
- Jeder Titel verlinkt direkt auf den BGG-Eintrag.
|
||||||
- Sync-Steuerung nur für Rolle Admin/Redakteur.
|
- Sync-Steuerung nur für Rolle Admin/Redakteur.
|
||||||
|
|
||||||
|
### Web-Quellen-Crawler mit optionaler KI-Hilfsstufe
|
||||||
|
|
||||||
|
Neben BGG crawlt das Plugin zusätzlich redaktionell gepflegte Web-Quellen
|
||||||
|
(spielbox.de, brettspielbox.de, spiel-essen.de, cliquenabend.de) über je einen
|
||||||
|
Adapter in `plugins/neuheiten/quellen/` — mit Rate-Limit (1 Request/s),
|
||||||
|
Retry/Backoff, konditionalen Requests (ETag/304), Fehler-Isolation pro Quelle
|
||||||
|
und Duplikat-Gate gegen die BGG-Liste. Der Crawler arbeitet standardmäßig rein
|
||||||
|
regelbasiert. Liefert eine Quelle im Listenelement ein Bild (`img` mit `src`
|
||||||
|
bzw. `data-src`), wird dessen URL in `bild_url` übernommen — sonst bleibt das
|
||||||
|
Feld leer.
|
||||||
|
|
||||||
|
Optional kann eine **LLM-Hilfsstufe** (`plugins/neuheiten/quellen/ki_hilfe.py`)
|
||||||
|
zugeschaltet werden — konsistent zur Dedup-KI über die
|
||||||
|
`SPIELE_NEUHEITEN_KI_*`-Variablen (siehe Tabelle oben). Das LLM wird nur in
|
||||||
|
zwei Situationen genutzt:
|
||||||
|
|
||||||
|
- **Struktur-Erkennung:** Liefert der Regel-Parser keine oder verdächtig wenige
|
||||||
|
Einträge (0 Treffer; 1–2 Treffer auf einer sehr linkreichen Seite), darf das
|
||||||
|
LLM aus dem gelieferten HTML Pagination-/Filter-Folge-URLs vorschlagen.
|
||||||
|
Der Vorschlag wird im Code hart gefiltert: nur URLs derselben Domain wie die
|
||||||
|
geparste Seite, maximal 20 je Seite, bereits besuchte/geplante URLs werden
|
||||||
|
übersprungen, nicht erreichbare Vorschläge brechen den Lauf nicht ab —
|
||||||
|
Schutz vor Rate-Limit-Überlastung und Domain-Ausbruch.
|
||||||
|
- **Feld-Extraktion:** Einzelne Treffer sind unsicher (fehlender Verlag oder
|
||||||
|
verdächtiger/generischer Titel). Nur solche Treffer gehen ans LLM, das
|
||||||
|
höchstens `titel`, `verlag` und `autor` korrigiert — die URL ist strukturell
|
||||||
|
nicht änderbar.
|
||||||
|
|
||||||
|
Unter der Mindest-Konfidenz (`SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN`,
|
||||||
|
Standard 0.7) wird jedes KI-Ergebnis verworfen; der klassische Parser-Befund
|
||||||
|
bleibt dann unverändert bestehen.
|
||||||
|
|
||||||
|
- **Fallback-Pflicht:** Standardmäßig aus (`SPIELE_NEUHEITEN_KI_AKTIV=0`).
|
||||||
|
Ohne vollständige Konfiguration oder bei jedem Fehler (Timeout,
|
||||||
|
Server-Störung nach genau einem Retry, kaputtes JSON) läuft EXAKT der
|
||||||
|
klassische Crawler — ein KI-Fehler kann den Sync nie blockieren.
|
||||||
|
- **Audit:** KI-Eingriffe eines Sync-Laufs landen gebündelt im audit-log
|
||||||
|
(`korrigiert` / `neuheiten_ki_crawler`) mit Quelle, Modell, Konfidenz und
|
||||||
|
den jeweils korrigierten Feldern sowie den verfolgten KI-Folge-URLs.
|
||||||
|
|
||||||
## Plugin „dedup“ (implementiert)
|
## Plugin „dedup“ (implementiert)
|
||||||
|
|
||||||
Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt
|
Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt
|
||||||
@@ -408,12 +471,14 @@ laufen automatisch durch die dedup-Prüfung.
|
|||||||
|
|
||||||
### Datentabelle
|
### Datentabelle
|
||||||
|
|
||||||
Eigene Migration `0001_planungsliste`, Tabelle `planungsliste`:
|
Eigene Migrationen `0001_planungsliste` und `0002_bild_url`,
|
||||||
|
Tabelle `planungsliste`:
|
||||||
|
|
||||||
| Spalte | Inhalt |
|
| Spalte | Inhalt |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `titel` / `verlag` / `autor` | Spieldaten |
|
| `titel` / `verlag` / `autor` | Spieldaten |
|
||||||
| `bgg_id` | BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung) |
|
| `bgg_id` | BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung) |
|
||||||
|
| `bild_url` | URL des Coverbildes (nullable; wird beim Verschieben aus den Neuheiten übernommen) |
|
||||||
| `ausgabe` | Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich) |
|
| `ausgabe` | Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich) |
|
||||||
| `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) |
|
| `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) |
|
||||||
| `status` | `offen` → `in_bearbeitung` → `abgeschlossen` |
|
| `status` | `offen` → `in_bearbeitung` → `abgeschlossen` |
|
||||||
@@ -457,9 +522,10 @@ unverändert zurück in die aktive Liste verschieben.
|
|||||||
|
|
||||||
### Datentabellen
|
### Datentabellen
|
||||||
|
|
||||||
Eigene Migration `0001_archiv_tabellen` mit zwei Spiegel-Tabellen
|
Eigene Migrationen `0001_archiv_tabellen` und `0002_bild_url`
|
||||||
(SQLite ↔ Postgres portabel, ohne Fremdschlüssel — archivierte Zeilen
|
(Spiegel-Tabellen, SQLite ↔ Postgres portabel, ohne Fremdschlüssel —
|
||||||
überleben auch das Löschen eines Benutzers):
|
archivierte Zeilen überleben auch das Löschen eines Benutzers;
|
||||||
|
`bild_url` wird mit archiviert und beim Wiederherstellen zurückgeschrieben):
|
||||||
|
|
||||||
| Tabelle | Spiegel von | Besondere Spalten |
|
| Tabelle | Spiegel von | Besondere Spalten |
|
||||||
|---------|-------------|-------------------|
|
|---------|-------------|-------------------|
|
||||||
@@ -595,6 +661,31 @@ Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
|
|||||||
Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel
|
Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel
|
||||||
geschrieben.
|
geschrieben.
|
||||||
|
|
||||||
|
## Oberfläche: Coverbilder & responsives Layout
|
||||||
|
|
||||||
|
**Coverbilder:** Die Listen (Neuheiten, Planung, Archiv) zeigen je Eintrag
|
||||||
|
eine kleine Cover-Vorschau (40×56 px, `loading="lazy"`, `decoding="async"`,
|
||||||
|
Alt-Text = Spieltitel); die Detail-/Prüf-Ansichten der Planung zeigen das
|
||||||
|
Cover großformatig (max. 200 px Breite). Fehlt ein Bild, erscheint ein
|
||||||
|
dezenter CSS-Platzhalter mit Spielicon-Zeichen. Eingebettet wird per
|
||||||
|
Hotlink direkt von BGG bzw. der jeweiligen Quelle — es werden keine Bilder
|
||||||
|
heruntergeladen oder lokal gespeichert.
|
||||||
|
|
||||||
|
**Responsive Verhalten:** Das Basis-Layout ist mobil-first:
|
||||||
|
|
||||||
|
- Navigation: Ab Tablet (`md:`) horizontale Leiste; auf Mobilgeräten ein
|
||||||
|
Burger-Menü (Alpine.js) mit vollhöhen Touch-Zielen.
|
||||||
|
- Listen: Auf schmalen Screens (<768 px) kollabieren Neuheiten, Planung und
|
||||||
|
Archiv zu Karten-Ansichten — Cover links, Titel + Metadaten rechts,
|
||||||
|
Status-Badge oben. Desktop behält die Tabellenform.
|
||||||
|
- Formulare (Planung, Benutzerverwaltung, Einstellungen): vollbreite
|
||||||
|
Eingabefelder mit Labels oberhalb auf Mobil.
|
||||||
|
- Sync-Übersicht und Audit-Log bleiben Tabellen und scrollen horizontal
|
||||||
|
(`overflow-x-auto`).
|
||||||
|
- Buttons/Links haben auf Mobil mindestens 44 px Höhe (Touch-Ziel).
|
||||||
|
|
||||||
|
Getestet gegen 375 px (Smartphone), 768 px (Tablet) und Desktop.
|
||||||
|
|
||||||
## Projekt-Fortschritt
|
## Projekt-Fortschritt
|
||||||
|
|
||||||
> Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert.
|
> Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert.
|
||||||
@@ -610,7 +701,7 @@ geschrieben.
|
|||||||
| 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig |
|
| 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig |
|
||||||
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig |
|
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig |
|
||||||
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig |
|
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig |
|
||||||
| 10 | Integrationstests über alle Plugins | ✅ 217 Tests grün |
|
| 10 | Integrationstests über alle Plugins | ✅ 316 Tests grün |
|
||||||
| 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ✅ live |
|
| 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ✅ live |
|
||||||
|
|
||||||
Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten
|
Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten
|
||||||
|
|||||||
22
TODO.md
22
TODO.md
@@ -1,12 +1,14 @@
|
|||||||
# TODO — LLM-Stufe im Dedup-Plugin (Branch wt-llm)
|
# TODO — Web-Quellen im Neuheiten-Plugin (Branch wt-quellen)
|
||||||
|
|
||||||
> Live-Status, wird laufend aktualisiert.
|
> Abgeschlossen.
|
||||||
|
|
||||||
- [x] ki_pruefung.py angelegt
|
- [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py
|
||||||
- [x] Tests test_dedup_ki.py angelegt
|
- [x] Adapter spiel-essen
|
||||||
- [ ] Integration in pruefung.py (Grenzfalls-Erkennung, nur dann LLM)
|
- [x] Vollständiges Crawling (Pagination/Quartale/A-Z)
|
||||||
- [ ] Env-Konfiguration (SPIELE_DEDUP_LLM_*: AKTIV, BASIS_URL, API_KEY, MODELL, KONFIDENZ_MIN)
|
- [x] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified
|
||||||
- [ ] Fallback-Pflicht (ohne Key/bei Fehler = exakt altes Verhalten)
|
- [x] Dedup-Gate vor Übernahme in Neuheitenliste
|
||||||
- [ ] Audit-Logging (Modell, Konfidenz, Entscheidung)
|
- [x] APScheduler-Job + Env-Schalter je Quelle (_AKTIV)
|
||||||
- [ ] JSON-Parsing-Robustheit (Code-Fences, Zusatztext)
|
- [x] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) → folgt als eigener Nachtrag
|
||||||
- [ ] README.md aktualisieren
|
- [x] Sync-Übersicht im UI (/neuheiten)
|
||||||
|
- [x] Tests (gemocktes HTML)
|
||||||
|
- [x] README.md aktualisieren
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ services:
|
|||||||
- SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production}
|
- SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production}
|
||||||
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
|
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
|
||||||
- SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin}
|
- SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin}
|
||||||
|
# BGG XML API2 (Pflicht seit Token-Umstellung): siehe .env
|
||||||
|
- SPIELE_BGG_TOKEN=${SPIELE_BGG_TOKEN}
|
||||||
# Benachrichtigungen (optional): ohne SMTP-Host/Bot-Token wird nur protokolliert
|
# Benachrichtigungen (optional): ohne SMTP-Host/Bot-Token wird nur protokolliert
|
||||||
#- SPIELE_SMTP_HOST=smtp.example.org
|
#- SPIELE_SMTP_HOST=smtp.example.org
|
||||||
#- SPIELE_SMTP_PORT=587
|
#- SPIELE_SMTP_PORT=587
|
||||||
|
|||||||
@@ -71,6 +71,33 @@ def _tabelle_anlegen(conn) -> None:
|
|||||||
ArchivPlanung.__table__.create(conn, checkfirst=True)
|
ArchivPlanung.__table__.create(conn, checkfirst=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _spalte_existiert(conn, tabelle: str, spalte: str) -> bool:
|
||||||
|
"""Dialekt-agnostische Spaltenprüfung (PRAGMA bzw. information_schema)."""
|
||||||
|
if conn.dialect.name == "sqlite":
|
||||||
|
info = conn.exec_driver_sql(f"PRAGMA table_info({tabelle})").fetchall()
|
||||||
|
return any(zeile[1] == spalte for zeile in info)
|
||||||
|
existiert = conn.exec_driver_sql(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :tabelle AND column_name = :spalte",
|
||||||
|
{"tabelle": tabelle, "spalte": spalte},
|
||||||
|
).scalar()
|
||||||
|
return existiert is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _bild_url_spalten(conn) -> None:
|
||||||
|
"""Migration 0002: Spalte bild_url in beiden Spiegel-Tabellen.
|
||||||
|
|
||||||
|
`ALTER TABLE … ADD COLUMN` mit nullable VARCHAR ohne Default ist
|
||||||
|
SQLite- und Postgres-portabel und erhält bestehende Zeilen.
|
||||||
|
"""
|
||||||
|
for tabelle in ("archiv_neuheiten", "archiv_planung"):
|
||||||
|
if _spalte_existiert(conn, tabelle, "bild_url"):
|
||||||
|
continue
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
f"ALTER TABLE {tabelle} ADD COLUMN bild_url VARCHAR(500)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ArchivPlugin(BasePlugin):
|
class ArchivPlugin(BasePlugin):
|
||||||
name = "archiv"
|
name = "archiv"
|
||||||
title = "Archiv"
|
title = "Archiv"
|
||||||
@@ -89,7 +116,10 @@ class ArchivPlugin(BasePlugin):
|
|||||||
# ---------- Plugin-Vertrag ----------
|
# ---------- Plugin-Vertrag ----------
|
||||||
|
|
||||||
def migrations(self) -> list[Migration]:
|
def migrations(self) -> list[Migration]:
|
||||||
return [Migration(version="0001_archiv_tabellen", up=_tabelle_anlegen)]
|
return [
|
||||||
|
Migration(version="0001_archiv_tabellen", up=_tabelle_anlegen),
|
||||||
|
Migration(version="0002_bild_url", up=_bild_url_spalten),
|
||||||
|
]
|
||||||
|
|
||||||
def navigation(self) -> list[NavEntry]:
|
def navigation(self) -> list[NavEntry]:
|
||||||
return [NavEntry(label=self.title, url="/archiv")]
|
return [NavEntry(label=self.title, url="/archiv")]
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class ArchivNeuheit(_ArchivMeta, Base):
|
|||||||
autor: Mapped[str | None] = mapped_column(String(300))
|
autor: Mapped[str | None] = mapped_column(String(300))
|
||||||
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
|
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
|
||||||
bgg_id: Mapped[int | None] = mapped_column(Integer)
|
bgg_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
bild_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
status: Mapped[str] = mapped_column(String(50), default="neuheit")
|
status: Mapped[str] = mapped_column(String(50), default="neuheit")
|
||||||
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
|
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
|
||||||
erstellt_am: Mapped[datetime | None] = mapped_column(DateTime)
|
erstellt_am: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
@@ -83,6 +84,7 @@ class ArchivPlanung(_ArchivMeta, Base):
|
|||||||
verlag: Mapped[str | None] = mapped_column(String(300))
|
verlag: Mapped[str | None] = mapped_column(String(300))
|
||||||
autor: Mapped[str | None] = mapped_column(String(300))
|
autor: Mapped[str | None] = mapped_column(String(300))
|
||||||
bgg_id: Mapped[int | None] = mapped_column(Integer)
|
bgg_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
bild_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
ausgabe: Mapped[str] = mapped_column(String(100), default="")
|
ausgabe: Mapped[str] = mapped_column(String(100), default="")
|
||||||
#: Kein FK — Archiv-Zeilen überleben das Löschen des Benutzers.
|
#: Kein FK — Archiv-Zeilen überleben das Löschen des Benutzers.
|
||||||
rezensent_id: Mapped[int | None] = mapped_column(Integer)
|
rezensent_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ class ArchivService:
|
|||||||
autor=zeile["autor"],
|
autor=zeile["autor"],
|
||||||
erscheinungsjahr=zeile["erscheinungsjahr"],
|
erscheinungsjahr=zeile["erscheinungsjahr"],
|
||||||
bgg_id=zeile["bgg_id"],
|
bgg_id=zeile["bgg_id"],
|
||||||
|
bild_url=zeile["bild_url"],
|
||||||
status=zeile["status"],
|
status=zeile["status"],
|
||||||
quelle=zeile["quelle"],
|
quelle=zeile["quelle"],
|
||||||
erstellt_am=zeile["erstellt_am"],
|
erstellt_am=zeile["erstellt_am"],
|
||||||
@@ -250,6 +251,7 @@ class ArchivService:
|
|||||||
verlag=zeile["verlag"],
|
verlag=zeile["verlag"],
|
||||||
autor=zeile["autor"],
|
autor=zeile["autor"],
|
||||||
bgg_id=zeile["bgg_id"],
|
bgg_id=zeile["bgg_id"],
|
||||||
|
bild_url=zeile["bild_url"],
|
||||||
ausgabe=zeile["ausgabe"],
|
ausgabe=zeile["ausgabe"],
|
||||||
rezensent_id=zeile["rezensent_id"],
|
rezensent_id=zeile["rezensent_id"],
|
||||||
status=zeile["status"],
|
status=zeile["status"],
|
||||||
@@ -305,6 +307,7 @@ class ArchivService:
|
|||||||
"verlag": a.verlag,
|
"verlag": a.verlag,
|
||||||
"autor": a.autor,
|
"autor": a.autor,
|
||||||
"bgg_id": a.bgg_id,
|
"bgg_id": a.bgg_id,
|
||||||
|
"bild_url": a.bild_url,
|
||||||
"jahr": a.erscheinungsjahr,
|
"jahr": a.erscheinungsjahr,
|
||||||
"ausgabe": "",
|
"ausgabe": "",
|
||||||
"status": a.status,
|
"status": a.status,
|
||||||
@@ -328,6 +331,7 @@ class ArchivService:
|
|||||||
"verlag": a.verlag,
|
"verlag": a.verlag,
|
||||||
"autor": a.autor,
|
"autor": a.autor,
|
||||||
"bgg_id": a.bgg_id,
|
"bgg_id": a.bgg_id,
|
||||||
|
"bild_url": a.bild_url,
|
||||||
"jahr": None,
|
"jahr": None,
|
||||||
"ausgabe": a.ausgabe,
|
"ausgabe": a.ausgabe,
|
||||||
"status": a.status,
|
"status": a.status,
|
||||||
@@ -409,6 +413,7 @@ class ArchivService:
|
|||||||
autor=archiv.autor,
|
autor=archiv.autor,
|
||||||
erscheinungsjahr=archiv.erscheinungsjahr,
|
erscheinungsjahr=archiv.erscheinungsjahr,
|
||||||
bgg_id=archiv.bgg_id,
|
bgg_id=archiv.bgg_id,
|
||||||
|
bild_url=archiv.bild_url,
|
||||||
status=archiv.status,
|
status=archiv.status,
|
||||||
quelle=archiv.quelle,
|
quelle=archiv.quelle,
|
||||||
erstellt_am=archiv.erstellt_am,
|
erstellt_am=archiv.erstellt_am,
|
||||||
@@ -464,6 +469,7 @@ class ArchivService:
|
|||||||
verlag=archiv.verlag,
|
verlag=archiv.verlag,
|
||||||
autor=archiv.autor,
|
autor=archiv.autor,
|
||||||
bgg_id=archiv.bgg_id,
|
bgg_id=archiv.bgg_id,
|
||||||
|
bild_url=archiv.bild_url,
|
||||||
ausgabe=archiv.ausgabe,
|
ausgabe=archiv.ausgabe,
|
||||||
rezensent_id=rezensent_id,
|
rezensent_id=rezensent_id,
|
||||||
status=archiv.status,
|
status=archiv.status,
|
||||||
|
|||||||
24
plugins/archiv/templates/archiv/_cover.html
Normal file
24
plugins/archiv/templates/archiv/_cover.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{# Coverbild-Vorschau des Archiv-Plugins.
|
||||||
|
Erwartete Variablen: cover_bild (URL oder None), cover_titel,
|
||||||
|
cover_gross (optional: true für großformatige Ansichten).
|
||||||
|
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
|
||||||
|
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
|
||||||
|
das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #}
|
||||||
|
{% set _gross = cover_gross | default(false) %}
|
||||||
|
{% if _gross %}
|
||||||
|
{% if cover_bild %}
|
||||||
|
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
|
||||||
|
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
|
{% else %}
|
||||||
|
<div aria-hidden="true" title="Kein Cover vorhanden"
|
||||||
|
class="flex h-[210px] w-[150px] max-w-[200px] items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-6xl text-slate-300 select-none">🎲</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if cover_bild %}
|
||||||
|
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="40" height="56"
|
||||||
|
class="block h-14 w-10 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
|
||||||
|
{% else %}
|
||||||
|
<div aria-hidden="true" title="Kein Cover vorhanden"
|
||||||
|
class="flex h-14 w-10 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-lg text-slate-400 select-none">🎲</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
@@ -26,16 +26,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
||||||
<form method="get" action="/archiv" class="flex flex-wrap items-end gap-3">
|
<form method="get" action="/archiv" class="grid grid-cols-1 gap-3 sm:flex sm:flex-wrap sm:items-end">
|
||||||
<div>
|
<div class="sm:w-64 lg:w-72">
|
||||||
<label for="q" class="block text-xs font-medium text-slate-500 mb-1">Suche (Titel, Verlag, Autor)</label>
|
<label for="q" class="block text-xs font-medium text-slate-500 mb-1">Suche (Titel, Verlag, Autor)</label>
|
||||||
<input type="text" id="q" name="q" value="{{ q }}"
|
<input type="text" id="q" name="q" value="{{ q }}"
|
||||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-72 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="sm:w-48">
|
||||||
<label for="quelle" class="block text-xs font-medium text-slate-500 mb-1">Herkunft</label>
|
<label for="quelle" class="block text-xs font-medium text-slate-500 mb-1">Herkunft</label>
|
||||||
<select id="quelle" name="quelle"
|
<select id="quelle" name="quelle"
|
||||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
class="w-full sm:w-auto min-h-[44px] md:min-h-0 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
||||||
onchange="this.form.submit()">
|
onchange="this.form.submit()">
|
||||||
<option value="">Alle Listen</option>
|
<option value="">Alle Listen</option>
|
||||||
<option value="neuheiten" {% if quelle_filter == 'neuheiten' %}selected{% endif %}>Neuheiten</option>
|
<option value="neuheiten" {% if quelle_filter == 'neuheiten' %}selected{% endif %}>Neuheiten</option>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
class="w-full sm:w-auto min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
||||||
Suchen
|
Suchen
|
||||||
</button>
|
</button>
|
||||||
{% if q or quelle_filter %}
|
{% if q or quelle_filter %}
|
||||||
@@ -54,10 +54,65 @@
|
|||||||
|
|
||||||
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Einträge im Archiv</p>
|
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Einträge im Archiv</p>
|
||||||
|
|
||||||
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
|
<!-- Mobil (<768px): Karten-Ansicht — Cover links, Titel+Metadaten rechts, Status oben -->
|
||||||
|
<ul class="md:hidden space-y-3" role="list">
|
||||||
|
{% for eintrag in eintraege %}
|
||||||
|
<li class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 flex gap-3">
|
||||||
|
<div class="pt-0.5">
|
||||||
|
{% set cover_bild = eintrag.bild_url %}
|
||||||
|
{% set cover_titel = eintrag.titel %}
|
||||||
|
{% include "archiv/_cover.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<h3 class="text-sm font-medium leading-snug break-words">
|
||||||
|
{{ eintrag.titel }}
|
||||||
|
{% if eintrag.bgg_id %}
|
||||||
|
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
|
||||||
|
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ eintrag.bgg_id }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
{% if eintrag.quelle == 'neuheiten' %}
|
||||||
|
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">Neuheiten</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-violet-100 text-violet-800 text-xs">Planung</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
|
||||||
|
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
|
||||||
|
{% if eintrag.jahr or eintrag.ausgabe %}<div><dt class="inline font-medium text-slate-400">Jahr/Ausgabe: </dt><dd class="inline">{{ eintrag.jahr if eintrag.jahr else eintrag.ausgabe }}</dd></div>{% endif %}
|
||||||
|
<div><dt class="inline font-medium text-slate-400">Status: </dt><dd class="inline">{{ status_anzeige.get(eintrag.status, eintrag.status) }}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt class="inline font-medium text-slate-400">Archiviert am: </dt>
|
||||||
|
<dd class="inline">{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt class="inline font-medium text-slate-400">Grund: </dt><dd class="inline">{{ eintrag.grund }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen" class="mt-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full min-h-[44px] inline-flex items-center justify-center gap-1 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap"
|
||||||
|
title="Eintrag unverändert zurück in die aktive Liste verschieben">
|
||||||
|
↻ Wiederherstellen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="bg-white rounded-xl border border-slate-200 shadow-sm px-4 py-8 text-center text-sm text-slate-400">
|
||||||
|
{% if q or quelle_filter %}
|
||||||
|
Keine Treffer für diese Suche.
|
||||||
|
{% else %}
|
||||||
|
Das Archiv ist leer — nichts zu sehen.
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="hidden md:block overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead class="bg-slate-50 text-left text-slate-500">
|
<thead class="bg-slate-50 text-left text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-medium">Cover</th>
|
||||||
<th class="px-4 py-3 font-medium">Spieltitel</th>
|
<th class="px-4 py-3 font-medium">Spieltitel</th>
|
||||||
<th class="px-4 py-3 font-medium">Herkunft</th>
|
<th class="px-4 py-3 font-medium">Herkunft</th>
|
||||||
<th class="px-4 py-3 font-medium">Verlag</th>
|
<th class="px-4 py-3 font-medium">Verlag</th>
|
||||||
@@ -72,6 +127,11 @@
|
|||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
{% for eintrag in eintraege %}
|
{% for eintrag in eintraege %}
|
||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% set cover_bild = eintrag.bild_url %}
|
||||||
|
{% set cover_titel = eintrag.titel %}
|
||||||
|
{% include "archiv/_cover.html" %}
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 font-medium">
|
<td class="px-4 py-3 font-medium">
|
||||||
{{ eintrag.titel }}
|
{{ eintrag.titel }}
|
||||||
{% if eintrag.bgg_id %}
|
{% if eintrag.bgg_id %}
|
||||||
@@ -97,7 +157,7 @@
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen">
|
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-1.5 rounded-lg whitespace-nowrap"
|
class="text-xs bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-2.5 py-2 rounded-lg whitespace-nowrap"
|
||||||
title="Eintrag unverändert zurück in die aktive Liste verschieben">
|
title="Eintrag unverändert zurück in die aktive Liste verschieben">
|
||||||
↻ Wiederherstellen
|
↻ Wiederherstellen
|
||||||
</button>
|
</button>
|
||||||
@@ -106,7 +166,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="px-4 py-8 text-center text-slate-400">
|
<td colspan="10" class="px-4 py-8 text-center text-slate-400">
|
||||||
{% if q or quelle_filter %}
|
{% if q or quelle_filter %}
|
||||||
Keine Treffer für diese Suche.
|
Keine Treffer für diese Suche.
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700">
|
<button type="submit" class="w-full sm:w-auto min-h-[44px] inline-flex items-center justify-center px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 active:bg-emerald-800">
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-slate-400">Ohne ausgewählten Kanal erhalten Sie keine Benachrichtigungen.</span>
|
<span class="text-xs text-slate-400">Ohne ausgewählten Kanal erhalten Sie keine Benachrichtigungen.</span>
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ class DedupPlugin(BasePlugin):
|
|||||||
if not self._bgg_aktiv or self.context is None:
|
if not self._bgg_aktiv or self.context is None:
|
||||||
return None
|
return None
|
||||||
if self._bgg_client is None:
|
if self._bgg_client is None:
|
||||||
self._bgg_client = BggPruefClient()
|
token = (os.environ.get("SPIELE_BGG_TOKEN") or "").strip() or None
|
||||||
|
self._bgg_client = BggPruefClient(token=token)
|
||||||
return self._bgg_client
|
return self._bgg_client
|
||||||
|
|
||||||
def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None:
|
def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class BggPruefClient:
|
|||||||
self,
|
self,
|
||||||
mindestabstand_sekunden: float = 1.0,
|
mindestabstand_sekunden: float = 1.0,
|
||||||
*,
|
*,
|
||||||
|
token: str | None = None,
|
||||||
transport: httpx.BaseTransport | None = None,
|
transport: httpx.BaseTransport | None = None,
|
||||||
uhr: Callable[[], float] = time.monotonic,
|
uhr: Callable[[], float] = time.monotonic,
|
||||||
schlafen: Callable[[float], None] = time.sleep,
|
schlafen: Callable[[float], None] = time.sleep,
|
||||||
@@ -42,8 +43,11 @@ class BggPruefClient:
|
|||||||
self._zuletzt: float | None = None
|
self._zuletzt: float | None = None
|
||||||
self._uhr = uhr
|
self._uhr = uhr
|
||||||
self._schlafen = schlafen
|
self._schlafen = schlafen
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
self._client = httpx.Client(
|
self._client = httpx.Client(
|
||||||
base_url=BASIS_URL, timeout=timeout, transport=transport
|
base_url=BASIS_URL, timeout=timeout, transport=transport, headers=headers
|
||||||
)
|
)
|
||||||
|
|
||||||
def schliessen(self) -> None:
|
def schliessen(self) -> None:
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ Keine Fachlogik im Kern — alles hier im Plugin gemäß Plugin-Vertrag.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import Depends, Form, Request
|
from fastapi import Depends, Form, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
|
|
||||||
@@ -37,7 +38,13 @@ from redaktionskern.auth.models import Role, User
|
|||||||
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
|
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
|
||||||
|
|
||||||
from .bgg import BggClient, BggFehler
|
from .bgg import BggClient, BggFehler
|
||||||
from .models import Neuheit
|
from .models import Neuheit, QuellenStatus
|
||||||
|
from .quellen import ADAPTER_KLASSEN
|
||||||
|
from .quellen_sync import (
|
||||||
|
QuellenLaufZeile,
|
||||||
|
WebQuellenSyncService,
|
||||||
|
quelle_aktiv,
|
||||||
|
)
|
||||||
from .sync import SyncErgebnis, SyncService
|
from .sync import SyncErgebnis, SyncService
|
||||||
|
|
||||||
_logger = logging.getLogger("plugins.neuheiten")
|
_logger = logging.getLogger("plugins.neuheiten")
|
||||||
@@ -77,8 +84,11 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._scheduler = None # BackgroundScheduler, falls aktiviert
|
self._scheduler = None # BackgroundScheduler, falls aktiviert
|
||||||
self._sync_sperre = threading.Lock()
|
self._sync_sperre = threading.Lock()
|
||||||
|
self._quellen_sperre = threading.Lock()
|
||||||
self._suchbegriffe: list[str] = []
|
self._suchbegriffe: list[str] = []
|
||||||
self._max_treffer_pro_suche = 25
|
self._max_treffer_pro_suche = 25
|
||||||
|
self._jahr_filter = True
|
||||||
|
self._deutscher_titel_filter = True
|
||||||
|
|
||||||
@self.router.get("/neuheiten")
|
@self.router.get("/neuheiten")
|
||||||
def seite(
|
def seite(
|
||||||
@@ -86,14 +96,17 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
user: User = Depends(require_user),
|
user: User = Depends(require_user),
|
||||||
q: str = "",
|
q: str = "",
|
||||||
status: str = "",
|
status: str = "",
|
||||||
|
jahr: int | None = None,
|
||||||
sort: str = STANDARD_SORTIERUNG[0],
|
sort: str = STANDARD_SORTIERUNG[0],
|
||||||
richtung: str = STANDARD_SORTIERUNG[1],
|
richtung: str = STANDARD_SORTIERUNG[1],
|
||||||
meldung: str = "",
|
meldung: str = "",
|
||||||
fehler: str = "",
|
fehler: str = "",
|
||||||
|
seite_nr: int = 1,
|
||||||
):
|
):
|
||||||
spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]])
|
spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]])
|
||||||
abwaerts = richtung == "ab" if sort in SORTIERBAR else False
|
abwaerts = richtung == "ab" if sort in SORTIERBAR else False
|
||||||
spalte_sortiert = spalte.desc() if abwaerts else spalte.asc()
|
spalte_sortiert = spalte.desc() if abwaerts else spalte.asc()
|
||||||
|
pro_seite = 50
|
||||||
|
|
||||||
with self.context.session_factory() as db:
|
with self.context.session_factory() as db:
|
||||||
abfrage = select(Neuheit)
|
abfrage = select(Neuheit)
|
||||||
@@ -108,8 +121,31 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
)
|
)
|
||||||
if status.strip():
|
if status.strip():
|
||||||
abfrage = abfrage.where(Neuheit.status == status.strip())
|
abfrage = abfrage.where(Neuheit.status == status.strip())
|
||||||
|
# Erscheinungsjahr-Filter: Standard „aktuelles Jahr und neuer“.
|
||||||
|
jahr_standard = datetime.now(timezone.utc).year
|
||||||
|
if jahr is None:
|
||||||
|
jahr_filter = jahr_standard
|
||||||
|
abfrage = abfrage.where(
|
||||||
|
or_(
|
||||||
|
Neuheit.erscheinungsjahr >= jahr_standard,
|
||||||
|
Neuheit.erscheinungsjahr.is_(None), # ohne Jahr nicht wegfiltern
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
jahr_filter = jahr
|
||||||
|
abfrage = abfrage.where(Neuheit.erscheinungsjahr >= jahr)
|
||||||
|
jahr_optionen = [
|
||||||
|
j for j in db.scalars(
|
||||||
|
select(Neuheit.erscheinungsjahr).distinct().order_by(
|
||||||
|
Neuheit.erscheinungsjahr.desc()
|
||||||
|
)
|
||||||
|
) if j is not None
|
||||||
|
]
|
||||||
|
gesamt = db.scalar(select(func.count()).select_from(abfrage.subquery())) or 0
|
||||||
eintraege = db.scalars(
|
eintraege = db.scalars(
|
||||||
abfrage.order_by(spalte_sortiert, Neuheit.id.asc())
|
abfrage.order_by(spalte_sortiert, Neuheit.id.asc())
|
||||||
|
.offset((max(seite_nr, 1) - 1) * pro_seite)
|
||||||
|
.limit(pro_seite)
|
||||||
).all()
|
).all()
|
||||||
status_optionen = [
|
status_optionen = [
|
||||||
zeile for zeile in db.scalars(
|
zeile for zeile in db.scalars(
|
||||||
@@ -117,12 +153,17 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
quellen_zeilen = self._quellen_uebersicht(db)
|
||||||
|
hat_weitere = max(seite_nr, 1) * pro_seite < gesamt
|
||||||
kontext = {
|
kontext = {
|
||||||
"user": user,
|
"user": user,
|
||||||
"titel": self.title,
|
"titel": self.title,
|
||||||
"eintraege": eintraege,
|
"eintraege": eintraege,
|
||||||
"q": q,
|
"q": q,
|
||||||
"status_filter": status,
|
"status_filter": status,
|
||||||
|
"jahr_filter": jahr_filter,
|
||||||
|
"jahr_standard": jahr_standard,
|
||||||
|
"jahr_optionen": jahr_optionen,
|
||||||
"sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0],
|
"sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0],
|
||||||
"richtung": "ab" if abwaerts else "auf",
|
"richtung": "ab" if abwaerts else "auf",
|
||||||
"status_optionen": status_optionen,
|
"status_optionen": status_optionen,
|
||||||
@@ -134,7 +175,14 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
),
|
),
|
||||||
"suchbegriffe": ", ".join(self._suchbegriffe) or "—",
|
"suchbegriffe": ", ".join(self._suchbegriffe) or "—",
|
||||||
"sync_aktiv": self._scheduler is not None and self._scheduler.running,
|
"sync_aktiv": self._scheduler is not None and self._scheduler.running,
|
||||||
"anzahl": len(eintraege),
|
"anzahl": gesamt,
|
||||||
|
"hat_weitere": hat_weitere,
|
||||||
|
"naechste_seite": max(seite_nr, 1) + 1,
|
||||||
|
"quellen_zeilen": quellen_zeilen,
|
||||||
|
"quellen_sync_aktiv": (
|
||||||
|
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip()
|
||||||
|
== "1"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if request.headers.get("HX-Request") == "true":
|
if request.headers.get("HX-Request") == "true":
|
||||||
return self.context.templates.TemplateResponse(
|
return self.context.templates.TemplateResponse(
|
||||||
@@ -167,13 +215,181 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}"
|
ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}"
|
||||||
return RedirectResponse(ziel, status_code=303)
|
return RedirectResponse(ziel, status_code=303)
|
||||||
|
|
||||||
|
@self.router.post("/neuheiten/quellen-sync")
|
||||||
|
def quellen_jetzt_synchronisieren(
|
||||||
|
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
|
||||||
|
quelle: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Gesamt-Sync oder Einzel-Quellen-Sync (`quelle` = Quellname)."""
|
||||||
|
if quelle:
|
||||||
|
zeilen = self._quellen_sync_ausfuehren(nur_quellen={quelle})
|
||||||
|
ziel = f"/neuheiten?meldung={quote('Web-Quelle „' + quelle + '“ synchronisiert: ' + ' · '.join(z.als_text() for z in zeilen))}"
|
||||||
|
else:
|
||||||
|
zeilen = self._quellen_sync_ausfuehren()
|
||||||
|
text = " · ".join(z.als_text() for z in zeilen)
|
||||||
|
ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}"
|
||||||
|
return RedirectResponse(ziel[:2000], status_code=303)
|
||||||
|
|
||||||
|
@self.router.post("/neuheiten/quellen/{quellen_name}/schalter")
|
||||||
|
def quellen_schalter(
|
||||||
|
quellen_name: str,
|
||||||
|
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
|
||||||
|
aktiv: str = Form(...),
|
||||||
|
):
|
||||||
|
"""An/Aus-Schalter einer Web-Quelle (übersteuert den Env-Standard)."""
|
||||||
|
if quellen_name not in {k.name for k in ADAPTER_KLASSEN}:
|
||||||
|
raise HTTPException(status_code=404, detail="Unbekannte Quelle")
|
||||||
|
assert self.context is not None and self.context.session_factory is not None
|
||||||
|
with self.context.session_factory() as db:
|
||||||
|
eintrag = db.scalar(
|
||||||
|
select(QuellenStatus).where(QuellenStatus.quelle == quellen_name)
|
||||||
|
)
|
||||||
|
if eintrag is None:
|
||||||
|
eintrag = QuellenStatus(quelle=quellen_name, url="")
|
||||||
|
db.add(eintrag)
|
||||||
|
eintrag.aktiv_schalter = aktiv == "1"
|
||||||
|
db.commit()
|
||||||
|
zustand = "eingeschaltet" if aktiv == "1" else "ausgeschaltet"
|
||||||
|
ziel = f"/neuheiten?meldung={quote(f'Quelle „{quellen_name}“ {zustand}.')}"
|
||||||
|
return RedirectResponse(ziel, status_code=303)
|
||||||
|
|
||||||
|
def _quellen_uebersicht(self, db) -> list[dict]:
|
||||||
|
"""Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle."""
|
||||||
|
status_nach_quelle = {
|
||||||
|
zeile.quelle: zeile
|
||||||
|
for zeile in db.scalars(select(QuellenStatus)).all()
|
||||||
|
}
|
||||||
|
ansichten = []
|
||||||
|
for klasse in ADAPTER_KLASSEN:
|
||||||
|
status = status_nach_quelle.get(klasse.name)
|
||||||
|
schalter = status.aktiv_schalter if status is not None else None
|
||||||
|
aktiv = (
|
||||||
|
schalter
|
||||||
|
if schalter is not None
|
||||||
|
else quelle_aktiv(klasse.name)
|
||||||
|
)
|
||||||
|
ansichten.append(
|
||||||
|
{
|
||||||
|
"name": klasse.name,
|
||||||
|
"anzeigename": klasse.anzeigename,
|
||||||
|
"url": klasse.start_urls[0] if klasse.start_urls else "",
|
||||||
|
"aktiv": aktiv,
|
||||||
|
"letzte_laufzeit": status.letzte_laufzeit if status else None,
|
||||||
|
"dauer_sekunden": status.dauer_sekunden if status else None,
|
||||||
|
"neu": status.anzahl_neu if status else 0,
|
||||||
|
"aktualisiert": status.anzahl_aktualisiert if status else 0,
|
||||||
|
"gefiltert": status.anzahl_gefiltert if status else 0,
|
||||||
|
"fehleranzahl": status.anzahl_fehler if status else 0,
|
||||||
|
"fehlermeldung": status.fehlermeldung if status else None,
|
||||||
|
"unveraendert": status.unveraendert if status else False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return ansichten
|
||||||
|
|
||||||
# ---------- Plugin-Vertrag ----------
|
# ---------- Plugin-Vertrag ----------
|
||||||
|
|
||||||
def migrations(self) -> list[Migration]:
|
def migrations(self) -> list[Migration]:
|
||||||
def neuheiten_tabelle(conn) -> None:
|
def neuheiten_tabelle(conn) -> None:
|
||||||
Neuheit.__table__.create(conn, checkfirst=True)
|
Neuheit.__table__.create(conn, checkfirst=True)
|
||||||
|
|
||||||
return [Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle)]
|
def bgg_id_nullable(conn) -> None:
|
||||||
|
"""Migration 0002: Web-Quellen-Einträge ohne BGG-ID erlauben."""
|
||||||
|
if conn.dialect.name == "sqlite":
|
||||||
|
info = conn.exec_driver_sql("PRAGMA table_info(neuheiten)").fetchall()
|
||||||
|
bgg_spalte = next((z for z in info if z[1] == "bgg_id"), None)
|
||||||
|
if bgg_spalte is None or not bgg_spalte[3]:
|
||||||
|
# Spalte fehlt (frisch angelegt) oder ist bereits nullable.
|
||||||
|
return
|
||||||
|
# SQLite kennt kein ALTER COLUMN: Tabelle nach aktuellem Modell
|
||||||
|
# neu anlegen, Daten übernehmen, alte Tabelle verwerfen.
|
||||||
|
conn.exec_driver_sql("ALTER TABLE neuheiten RENAME TO neuheiten_alt_0002")
|
||||||
|
for ix in Neuheit.__table__.indexes:
|
||||||
|
conn.exec_driver_sql(f'DROP INDEX IF EXISTS "{ix.name}"')
|
||||||
|
Neuheit.__table__.create(conn, checkfirst=True)
|
||||||
|
spalten = ", ".join(f'"{c.name}"' for c in Neuheit.__table__.columns)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
f"INSERT INTO neuheiten ({spalten}) SELECT {spalten} FROM neuheiten_alt_0002"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql("DROP TABLE neuheiten_alt_0002")
|
||||||
|
else:
|
||||||
|
ist_nullable = conn.exec_driver_sql(
|
||||||
|
"SELECT is_nullable FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'neuheiten' AND column_name = 'bgg_id'"
|
||||||
|
).scalar()
|
||||||
|
if ist_nullable == "NO":
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE neuheiten ALTER COLUMN bgg_id DROP NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
def quellen_status_tabelle(conn) -> None:
|
||||||
|
QuellenStatus.__table__.create(conn, checkfirst=True)
|
||||||
|
|
||||||
|
def bild_url_spalte(conn) -> None:
|
||||||
|
"""Migration 0004: Spalte bild_url für Coverbilder (nullable).
|
||||||
|
|
||||||
|
`ALTER TABLE … ADD COLUMN` mit nullable VARCHAR ohne Default ist
|
||||||
|
SQLite- und Postgres-portabel und erhält bestehende Zeilen.
|
||||||
|
"""
|
||||||
|
info = conn.exec_driver_sql("PRAGMA table_info(neuheiten)").fetchall()
|
||||||
|
vorhanden = any(zeile[1] == "bild_url" for zeile in info)
|
||||||
|
if conn.dialect.name == "sqlite" and vorhanden:
|
||||||
|
return
|
||||||
|
if conn.dialect.name != "sqlite":
|
||||||
|
existiert = conn.exec_driver_sql(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'neuheiten' AND column_name = 'bild_url'"
|
||||||
|
).scalar()
|
||||||
|
if existiert is not None:
|
||||||
|
return
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE neuheiten ADD COLUMN bild_url VARCHAR(500)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def quellen_url_spalte(conn) -> None:
|
||||||
|
"""Migration 0005: Spalte quellen_url für den Link zur Quelle."""
|
||||||
|
info = conn.exec_driver_sql("PRAGMA table_info(neuheiten)").fetchall()
|
||||||
|
vorhanden = any(zeile[1] == "quellen_url" for zeile in info)
|
||||||
|
if conn.dialect.name == "sqlite" and vorhanden:
|
||||||
|
return
|
||||||
|
if conn.dialect.name != "sqlite":
|
||||||
|
existiert = conn.exec_driver_sql(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'neuheiten' AND column_name = 'quellen_url'"
|
||||||
|
).scalar()
|
||||||
|
if existiert is not None:
|
||||||
|
return
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE neuheiten ADD COLUMN quellen_url VARCHAR(500)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def aktiv_schalter_spalte(conn) -> None:
|
||||||
|
"""Migration 0006: UI-Schalter pro Web-Quelle (nullable Boolean)."""
|
||||||
|
info = conn.exec_driver_sql(
|
||||||
|
"PRAGMA table_info(neuheiten_quellen_status)"
|
||||||
|
).fetchall()
|
||||||
|
vorhanden = any(zeile[1] == "aktiv_schalter" for zeile in info)
|
||||||
|
if conn.dialect.name == "sqlite" and vorhanden:
|
||||||
|
return
|
||||||
|
if conn.dialect.name != "sqlite":
|
||||||
|
existiert = conn.exec_driver_sql(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'neuheiten_quellen_status' "
|
||||||
|
"AND column_name = 'aktiv_schalter'"
|
||||||
|
).scalar()
|
||||||
|
if existiert is not None:
|
||||||
|
return
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE neuheiten_quellen_status ADD COLUMN aktiv_schalter BOOLEAN"
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle),
|
||||||
|
Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable),
|
||||||
|
Migration(version="0003_quellen_status", up=quellen_status_tabelle),
|
||||||
|
Migration(version="0004_bild_url", up=bild_url_spalte),
|
||||||
|
Migration(version="0005_quellen_url", up=quellen_url_spalte),
|
||||||
|
Migration(version="0006_aktiv_schalter", up=aktiv_schalter_spalte),
|
||||||
|
]
|
||||||
|
|
||||||
def navigation(self) -> list[NavEntry]:
|
def navigation(self) -> list[NavEntry]:
|
||||||
return [NavEntry(label=self.title, url="/neuheiten")]
|
return [NavEntry(label=self.title, url="/neuheiten")]
|
||||||
@@ -182,16 +398,30 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
super().on_load(context)
|
super().on_load(context)
|
||||||
import os
|
import os
|
||||||
|
|
||||||
self._suchbegriffe = _umgebung_liste("SPIELE_BGG_SUCHBEGRIFFE", "brettspiel")
|
self._suchbegriffe = _umgebung_liste(
|
||||||
|
"SPIELE_BGG_SUCHBEGRIFFE",
|
||||||
|
"brettspiel, gesellschaftsspiel, familienspiel, strategiespiel, "
|
||||||
|
"kartenspiel, würfelspiel",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self._max_treffer_pro_suche = int(
|
self._max_treffer_pro_suche = int(
|
||||||
os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "25")
|
os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "50")
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self._max_treffer_pro_suche = 25
|
self._max_treffer_pro_suche = 50
|
||||||
|
self._jahr_filter = (
|
||||||
|
os.environ.get("SPIELE_BGG_JAHR_FILTER", "1").strip() != "0"
|
||||||
|
)
|
||||||
|
self._deutscher_titel_filter = (
|
||||||
|
os.environ.get("SPIELE_BGG_DEUTSCHE_TITEL_FILTER", "1").strip() != "0"
|
||||||
|
)
|
||||||
|
|
||||||
if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1":
|
if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1":
|
||||||
self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN"))
|
self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN"))
|
||||||
|
if os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip() == "1":
|
||||||
|
self._quellen_scheduler_starten(
|
||||||
|
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_INTERVALL_STUNDEN")
|
||||||
|
)
|
||||||
|
|
||||||
def on_unload(self) -> None:
|
def on_unload(self) -> None:
|
||||||
if self._scheduler is not None:
|
if self._scheduler is not None:
|
||||||
@@ -227,6 +457,8 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
self.context.session_factory,
|
self.context.session_factory,
|
||||||
client,
|
client,
|
||||||
max_treffer_pro_suchbegriff=self._max_treffer_pro_suche,
|
max_treffer_pro_suchbegriff=self._max_treffer_pro_suche,
|
||||||
|
jahr_filter=self._jahr_filter,
|
||||||
|
deutscher_titel_filter=self._deutscher_titel_filter,
|
||||||
)
|
)
|
||||||
return service.synchronisiere(suchbegriffe)
|
return service.synchronisiere(suchbegriffe)
|
||||||
finally:
|
finally:
|
||||||
@@ -248,7 +480,44 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
finally:
|
finally:
|
||||||
self._sync_sperre.release()
|
self._sync_sperre.release()
|
||||||
|
|
||||||
def _scheduler_starten(self, intervall_stunden_roh: str | None) -> None:
|
def _quellen_sync_ausfuehren(
|
||||||
|
self, nur_quellen: set[str] | None = None
|
||||||
|
) -> list[QuellenLaufZeile]:
|
||||||
|
"""Ein Lauf über alle aktiven Web-Quellen (Fehler pro Quelle isoliert).
|
||||||
|
|
||||||
|
`nur_quellen`: optional nur diese Quellnamen (manueller Einzellauf,
|
||||||
|
ignoriert den An/Aus-Schalter der Quelle).
|
||||||
|
"""
|
||||||
|
registry = (
|
||||||
|
self.context.registry
|
||||||
|
if self.context is not None and self.context.registry is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
dedup = registry.get("dedup") if registry is not None else None
|
||||||
|
audit = registry.get("audit-log") if registry is not None else None
|
||||||
|
service = WebQuellenSyncService(
|
||||||
|
self.context.session_factory, dedup=dedup, audit=audit
|
||||||
|
)
|
||||||
|
return service.laufe(nur_quellen=nur_quellen)
|
||||||
|
|
||||||
|
def _quellen_sync_job(self) -> None:
|
||||||
|
"""Hintergrund-Job: holt regelmäßig die Web-Quellen ab."""
|
||||||
|
if not self._quellen_sperre.acquire(blocking=False):
|
||||||
|
_logger.info("Web-Quellen-Sync läuft bereits — Durchlauf übersprungen.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
zeilen = self._quellen_sync_ausfuehren()
|
||||||
|
text = " · ".join(z.als_text() for z in zeilen)
|
||||||
|
if any(z.fehlermeldung for z in zeilen):
|
||||||
|
_logger.warning("Web-Quellen-Sync mit Fehlern: %s", text)
|
||||||
|
else:
|
||||||
|
_logger.info("Web-Quellen-Sync abgeschlossen: %s", text)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Web-Quellen-Sync fehlgeschlagen: %s", exc)
|
||||||
|
finally:
|
||||||
|
self._quellen_sperre.release()
|
||||||
|
|
||||||
|
def _quellen_scheduler_starten(self, intervall_stunden_roh: str | None) -> None:
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -257,7 +526,40 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
intervall_stunden = 24
|
intervall_stunden = 24
|
||||||
intervall_stunden = max(intervall_stunden, 1)
|
intervall_stunden = max(intervall_stunden, 1)
|
||||||
|
|
||||||
|
scheduler = self._scheduler_oder_neu()
|
||||||
|
erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=15)
|
||||||
|
scheduler.add_job(
|
||||||
|
self._quellen_sync_job,
|
||||||
|
trigger="interval",
|
||||||
|
hours=intervall_stunden,
|
||||||
|
next_run_time=erster_lauf,
|
||||||
|
id="web-quellen-sync",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
_logger.info(
|
||||||
|
"Web-Quellen-Sync aktiv: alle %s h (%s)",
|
||||||
|
intervall_stunden,
|
||||||
|
", ".join(k.anzeigename for k in ADAPTER_KLASSEN),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _scheduler_oder_neu(self):
|
||||||
|
"""Ein gemeinsamer BackgroundScheduler für alle Sync-Jobs des Plugins."""
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
if self._scheduler is None or not self._scheduler.running:
|
||||||
scheduler = BackgroundScheduler()
|
scheduler = BackgroundScheduler()
|
||||||
|
scheduler.start()
|
||||||
|
self._scheduler = scheduler
|
||||||
|
return self._scheduler
|
||||||
|
|
||||||
|
def _scheduler_starten(self, intervall_stunden_roh: str | None) -> None:
|
||||||
|
try:
|
||||||
|
intervall_stunden = int(intervall_stunden_roh or "24")
|
||||||
|
except ValueError:
|
||||||
|
intervall_stunden = 24
|
||||||
|
intervall_stunden = max(intervall_stunden, 1)
|
||||||
|
|
||||||
|
scheduler = self._scheduler_oder_neu()
|
||||||
erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=10)
|
erster_lauf = datetime.now(timezone.utc) + timedelta(seconds=10)
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
self._sync_job,
|
self._sync_job,
|
||||||
@@ -267,8 +569,6 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
id="bgg-neuheiten-sync",
|
id="bgg-neuheiten-sync",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
scheduler.start()
|
|
||||||
self._scheduler = scheduler
|
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"BGG-Neuheiten-Sync aktiv: alle %s h, Suchbegriffe: %s",
|
"BGG-Neuheiten-Sync aktiv: alle %s h, Suchbegriffe: %s",
|
||||||
intervall_stunden,
|
intervall_stunden,
|
||||||
|
|||||||
@@ -70,9 +70,34 @@ class BggSpiel:
|
|||||||
verlag: str | None
|
verlag: str | None
|
||||||
autor: str | None
|
autor: str | None
|
||||||
erscheinungsjahr: int | None
|
erscheinungsjahr: int | None
|
||||||
|
#: URL des Coverbildes — bevorzugt das größere <image>, sonst das
|
||||||
|
#: <thumbnail>; None, wenn BGG keins liefert.
|
||||||
|
bild_url: str | None = None
|
||||||
typ: str = TYP_BRETTSPIEL
|
typ: str = TYP_BRETTSPIEL
|
||||||
|
|
||||||
|
|
||||||
|
def _bild_url(element: ET.Element) -> str | None:
|
||||||
|
"""Extrahiert die Coverbild-URL aus einem Thing-Item.
|
||||||
|
|
||||||
|
Bevorzugt das große `<image>`, fällt zurück auf das kleinere
|
||||||
|
`<thumbnail>`. Protokoll-relative URLs (`//cf.geekdo-images.com/…`)
|
||||||
|
werden auf https normalisiert; leere/kaputte Angaben → None.
|
||||||
|
"""
|
||||||
|
for tag in ("image", "thumbnail"):
|
||||||
|
roh = element.findtext(tag)
|
||||||
|
if roh is None:
|
||||||
|
continue
|
||||||
|
url = roh.strip()
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
if url.startswith("//"):
|
||||||
|
url = f"https:{url}"
|
||||||
|
if url.startswith(("http://", "https://")):
|
||||||
|
return url
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _attribut_wert(element: ET.Element | None, tag: str) -> str | None:
|
def _attribut_wert(element: ET.Element | None, tag: str) -> str | None:
|
||||||
"""Liest `<tag><value>…</value></tag>` tolerant aus."""
|
"""Liest `<tag><value>…</value></tag>` tolerant aus."""
|
||||||
if element is None:
|
if element is None:
|
||||||
@@ -161,6 +186,7 @@ def parse_things(xml_daten: bytes | str) -> list[BggSpiel]:
|
|||||||
verlag=_verketten(element, "boardgamepublisher"),
|
verlag=_verketten(element, "boardgamepublisher"),
|
||||||
autor=_verketten(element, "boardgamedesigner"),
|
autor=_verketten(element, "boardgamedesigner"),
|
||||||
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
|
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
|
||||||
|
bild_url=_bild_url(element),
|
||||||
typ=typ,
|
typ=typ,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ statt Duplikate anzulegen.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Integer, String, func
|
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from redaktionskern.db import Base
|
from redaktionskern.db import Base
|
||||||
@@ -25,7 +26,14 @@ class Neuheit(Base):
|
|||||||
autor: Mapped[str | None] = mapped_column(String(300))
|
autor: Mapped[str | None] = mapped_column(String(300))
|
||||||
# BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum).
|
# BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum).
|
||||||
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
|
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
|
||||||
bgg_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
# Eindeutiges Merge-Kriterium der BGG-Syncs; Web-Quellen haben keine
|
||||||
|
# BGG-ID und speichern NULL (Migration 0002 macht die Spalte nullable,
|
||||||
|
# Duplikate werden dort über den Titel gematcht statt über die ID).
|
||||||
|
bgg_id: Mapped[int | None] = mapped_column(Integer, unique=True, index=True)
|
||||||
|
# URL des Coverbildes (BGG-Image/Thumbnail bzw. Bild der Web-Quelle);
|
||||||
|
# wird direkt verlinkt (Hotlinking), kein Download (Migration 0004).
|
||||||
|
bild_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
quellen_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True)
|
status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True)
|
||||||
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
|
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
|
||||||
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
@@ -34,4 +42,53 @@ class Neuheit(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
|
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
|
||||||
return f"<Neuheit {self.bgg_id} {self.titel!r}>"
|
return f"<Neuheit {self.bgg_id or '?'} {self.titel!r}>"
|
||||||
|
|
||||||
|
|
||||||
|
class QuellenStatus(Base):
|
||||||
|
"""Sync-Status einer Web-Quelle (Übersicht im UI + HTTP-Validatoren).
|
||||||
|
|
||||||
|
Pro Quelle eine Zeile: Ergebnis des letzten Laufes (Zeitpunkt, Dauer,
|
||||||
|
neu/aktualisiert/gefiltert/Fehler) plus die konditionalen
|
||||||
|
Request-Header (ETag/Last-Modified je abgerufener URL), damit 304er
|
||||||
|
auch über Neustarts hinweg respektiert werden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "neuheiten_quellen_status"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
quelle: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
letzte_laufzeit: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
dauer_sekunden: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
anzahl_neu: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
anzahl_aktualisiert: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
anzahl_gefiltert: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
fehlermeldung: Mapped[str | None] = mapped_column(Text)
|
||||||
|
unveraendert: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
#: UI-Schalter: Quelle im Sync ein-/ausgeschaltet (None = Env-Standard).
|
||||||
|
#: Übersteuert den Env-Wert SPIELE_NEUHEITEN_QUELLE_<NAME>_AKTIV.
|
||||||
|
aktiv_schalter: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
#: JSON-Objekt URL → [etag, last_modified]
|
||||||
|
validatoren_json: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validatoren(self) -> dict[str, tuple[str | None, str | None]]:
|
||||||
|
if not self.validatoren_json:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
roh = json.loads(self.validatoren_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(url): (paar[0], paar[1]) if isinstance(paar, (list, tuple)) else (None, None)
|
||||||
|
for url, paar in roh.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
@validatoren.setter
|
||||||
|
def validatoren(self, wert: dict[str, tuple[str | None, str | None]]) -> None:
|
||||||
|
self.validatoren_json = json.dumps(wert or {})
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_UNVERAENDERT_HINWEIS = "unverändert (304) — übersprungen"
|
||||||
|
|||||||
59
plugins/neuheiten/quellen/__init__.py
Normal file
59
plugins/neuheiten/quellen/__init__.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""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,
|
||||||
|
bild_aus_element,
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
"bild_aus_element",
|
||||||
|
"SpielboxQuelle",
|
||||||
|
"BrettspielboxQuelle",
|
||||||
|
"SpielEssenQuelle",
|
||||||
|
"CliquenabendQuelle",
|
||||||
|
]
|
||||||
388
plugins/neuheiten/quellen/basis.py
Normal file
388
plugins/neuheiten/quellen/basis.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""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 logging
|
||||||
|
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
|
||||||
|
|
||||||
|
from .ki_hilfe import KiHilfe
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: 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
|
||||||
|
#: 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
|
||||||
|
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
|
||||||
|
#: KI-Feldkorrekturen dieses Laufs (Protokoll fürs Audit-Log); leer bei
|
||||||
|
#: rein regelbasiertem Lauf.
|
||||||
|
ki_eingriffe: list[dict] = field(default_factory=list)
|
||||||
|
#: Von der KI vorgeschlagene und tatsächlich verfolgte Folge-URLs
|
||||||
|
#: (Struktur-Erkennung); leer bei rein regelbasiertem Lauf.
|
||||||
|
ki_folge_urls: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
#: Optionale LLM-Hilfsstufe (`ki_hilfe.KiHilfe` oder gleiches Protokoll).
|
||||||
|
#: None (Standard) = rein regelbasiert; KI-Fehler blockieren den Lauf nie.
|
||||||
|
self.ki_hilfe: KiHilfe | None = None
|
||||||
|
|
||||||
|
def sammle(self) -> SammelErgebnis:
|
||||||
|
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS).
|
||||||
|
|
||||||
|
Mit gesetzter `ki_hilfe`: Bei verdächtig leer geparsten Seiten darf
|
||||||
|
das LLM Pagination-/Filter-Folge-URLs vorschlagen (nur gleiche
|
||||||
|
Domain, max. 20), unsichere Treffer werden per LLM nachgebessert
|
||||||
|
(nur titel/verlag/autor, nie die URL). Jeder KI-Fehler fällt auf den
|
||||||
|
klassischen Befund zurück.
|
||||||
|
"""
|
||||||
|
ergebnis = SammelErgebnis()
|
||||||
|
warteschlange = list(self.start_urls)
|
||||||
|
besucht: set[str] = set()
|
||||||
|
ki_vorschlaege: set[str] = set()
|
||||||
|
while warteschlange and len(besucht) < self.max_seiten:
|
||||||
|
url = warteschlange.pop(0)
|
||||||
|
if url in besucht:
|
||||||
|
continue
|
||||||
|
besucht.add(url)
|
||||||
|
try:
|
||||||
|
antwort = self.client.hole(url)
|
||||||
|
except Exception:
|
||||||
|
if url in ki_vorschlaege:
|
||||||
|
# Eine nicht erreichbare KI-Suggestion darf den klassischen
|
||||||
|
# Lauf nie abbrechen — überspringen statt Fehler.
|
||||||
|
_logger.warning(
|
||||||
|
"KI-vorgeschlagene URL nicht erreichbar (%s) — übersprungen.",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
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)
|
||||||
|
if self.ki_hilfe is not None:
|
||||||
|
for link in self._ki_folge_urls(treffer, antwort.content, url, besucht):
|
||||||
|
if link not in besucht and link not in warteschlange:
|
||||||
|
warteschlange.append(link)
|
||||||
|
ki_vorschlaege.add(link)
|
||||||
|
ergebnis.ki_folge_urls.append(link)
|
||||||
|
if self.ki_hilfe is not None:
|
||||||
|
self._ki_nachbessern(ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
def _ki_folge_urls(
|
||||||
|
self,
|
||||||
|
treffer: list[QuellenTreffer],
|
||||||
|
inhalt: bytes,
|
||||||
|
url: str,
|
||||||
|
besucht: set[str],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Struktur-Erkennung der Hilfsstufe (best effort — wirft nicht)."""
|
||||||
|
try:
|
||||||
|
return self.ki_hilfe.folge_urls_bei_bedarf(
|
||||||
|
len(treffer), inhalt, url, besucht
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"KI-Struktur-Erkennung fehlgeschlagen (%s) — klassischer Lauf bleibt.",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _ki_nachbessern(self, ergebnis: SammelErgebnis) -> None:
|
||||||
|
"""Feld-Korrektur unsicherer Treffer (best effort — wirft nicht)."""
|
||||||
|
try:
|
||||||
|
korrigiert, eingriffe = self.ki_hilfe.treffer_nachbessern(ergebnis.treffer)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"KI-Feldkorrektur fehlgeschlagen — klassischer Parserbefund bleibt."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if korrigiert is not None:
|
||||||
|
ergebnis.treffer = korrigiert
|
||||||
|
ergebnis.ki_eingriffe = eingriffe or []
|
||||||
|
|
||||||
|
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."
|
||||||
|
)
|
||||||
66
plugins/neuheiten/quellen/brettspielbox.py
Normal file
66
plugins/neuheiten/quellen/brettspielbox.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""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, bild_aus_element
|
||||||
|
|
||||||
|
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,
|
||||||
|
bild_url=bild_aus_element(eintrag, 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
|
||||||
186
plugins/neuheiten/quellen/cliquenabend.py
Normal file
186
plugins/neuheiten/quellen/cliquenabend.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""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
|
||||||
|
`<b>Release:</b><br> JAHR <br>` — 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 (<br>-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
|
||||||
|
`<a href="[diese Spielseite]"><img src="/images/db/ID_360x240.jpg">`.
|
||||||
|
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
|
||||||
575
plugins/neuheiten/quellen/ki_hilfe.py
Normal file
575
plugins/neuheiten/quellen/ki_hilfe.py
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
"""Optionale LLM-Hilfsstufe für den Web-Quellen-Crawler des Neuheiten-Plugins.
|
||||||
|
|
||||||
|
Der Crawler (`basis.py` + Adapter) bleibt maßgeblich und arbeitet rein
|
||||||
|
regelbasiert. Das LLM wird ausschließlich in zwei Situationen eingeschaltet:
|
||||||
|
|
||||||
|
1. **Struktur-Erkennung:** Liefert der Regel-Parser keine oder verdächtig
|
||||||
|
wenige Einträge (0 Treffer, oder wenige Treffer auf einer sehr link-
|
||||||
|
reichen Seite), darf das LLM Pagination-/Filter-Folge-URLs aus dem
|
||||||
|
gelieferten HTML vorschlagen. Der Vorschlag wird im Code hart gefiltert:
|
||||||
|
nur URLs derselben Domain wie die geparste Seite, maximal 20 je Seite —
|
||||||
|
Schutz vor Rate-Limit-Überlastung und Domain-Ausbruch.
|
||||||
|
2. **Feld-Extraktion:** Ein einzelner Treffer ist *unsicher* (fehlender
|
||||||
|
Verlag oder verdächtiger Titel). Dann darf das LLM höchstens `titel`,
|
||||||
|
`verlag` und `autor` korrigieren — niemals die URL.
|
||||||
|
|
||||||
|
Konfiguration konsistent zur dedup-KI über Umgebungsvariablen:
|
||||||
|
|
||||||
|
| Variable | Bedeutung |
|
||||||
|
|----------|-----------|
|
||||||
|
| `SPIELE_NEUHEITEN_KI_AKTIV` | `1` = Hilfsstufe an (Standard `0`) |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_BASIS_URL` | Basis-URL, z. B. `https://api.openai.com/v1` |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_API_KEY` | API-Key (wird als Bearer gesendet) |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_MODELL` | Modellname, z. B. `gpt-4o-mini` |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN` | Mindest-Konfidenz (Standard `0.7`) |
|
||||||
|
| `SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN` | Timeout je Aufruf (Standard `20`) |
|
||||||
|
|
||||||
|
**Fallback-Pflicht:** Ohne Konfiguration oder bei jedem Fehler läuft EXAKT
|
||||||
|
der klassische Crawler weiter — ein KI-Fehler blockiert den Sync nie.
|
||||||
|
Ergebnisse unterhalb der Mindest-Konfidenz werden verworfen; der klassische
|
||||||
|
Parser-Befund bleibt dann unverändert bestehen.
|
||||||
|
|
||||||
|
Schnittstelle: OpenAI-kompatible Chat-Completions-API via httpx;
|
||||||
|
Netzwerk-/Server-Fehler werden genau einmal wiederholt (insgesamt zwei
|
||||||
|
Versuche), Antwort-Parsing-Fehler nicht.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
_logger = logging.getLogger("plugins.neuheiten")
|
||||||
|
|
||||||
|
#: Standard-Timeout je Chat-Completions-Aufruf in Sekunden.
|
||||||
|
STANDARD_TIMEOUT = 20.0
|
||||||
|
|
||||||
|
#: Gesamtzahl der Versuche (erster Versuch + genau ein Retry).
|
||||||
|
VERSUCHE = 2
|
||||||
|
|
||||||
|
#: HTTP-Statuscodes, die einen Retry rechtfertigen (zeitweilige Störungen).
|
||||||
|
RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504})
|
||||||
|
|
||||||
|
#: Mindest-Konfidenz, ab der ein KI-Befund übernommen wird.
|
||||||
|
STANDARD_KONFIDENZ_MIN = 0.7
|
||||||
|
|
||||||
|
#: Maximalzahl der KI-vorgeschlagenen Folge-URLs je Seite (Rate-Limit-Schutz).
|
||||||
|
MAX_FOLGE_URLS = 20
|
||||||
|
|
||||||
|
#: Obergrenze des HTML-Ausschnitts, der pro Struktur-Anfrage ans LLM geht.
|
||||||
|
HTML_MAX_ZEICHEN = 12000
|
||||||
|
|
||||||
|
# ---------------- Verdachts-Heuristiken (wann hilft das LLM?) ----------------
|
||||||
|
|
||||||
|
#: Weniger Treffer als das gilt als „verdächtig wenig“ …
|
||||||
|
STRUKTUR_MIN_TREFFER = 3
|
||||||
|
#: … aber nur, wenn die Seite zugleich sehr linkreich ist (Indiz für eine
|
||||||
|
#: nicht erkannte Liste/Pagination statt einer wirklich leeren Seite).
|
||||||
|
STRUKTUR_LINK_SCHWELLE = 30
|
||||||
|
|
||||||
|
_LINK_MUSTER = re.compile(r"<a\s", re.IGNORECASE)
|
||||||
|
|
||||||
|
#: Generische Navigations-/Platzhaltertexte, die als Spieltitel verdächtig sind.
|
||||||
|
GENERISCHE_TITEL = frozenset(
|
||||||
|
{
|
||||||
|
"hier", "mehr", "mehr infos", "details", "weiterlesen", "weiter",
|
||||||
|
"klick", "klick hier", "link", "produkt", "neuheit", "unbekannt",
|
||||||
|
"titel", "n/a", "-", "–",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _als_text(inhalt: bytes | str) -> str:
|
||||||
|
if isinstance(inhalt, bytes):
|
||||||
|
return inhalt.decode("utf-8", errors="replace")
|
||||||
|
return inhalt
|
||||||
|
|
||||||
|
|
||||||
|
def titel_ist_verdaechtig(titel: str | None) -> bool:
|
||||||
|
"""True bei fehlendem, zu kurzem oder generischem Navigationstext."""
|
||||||
|
if not titel or not titel.strip():
|
||||||
|
return True
|
||||||
|
gekuerzt = titel.strip()
|
||||||
|
if len(gekuerzt) < 3:
|
||||||
|
return True
|
||||||
|
return gekuerzt.rstrip(".!? ").lower() in GENERISCHE_TITEL
|
||||||
|
|
||||||
|
|
||||||
|
def treffer_ist_unsicher(treffer: Any) -> bool:
|
||||||
|
"""Ein Treffer ist unsicher, wenn der Verlag fehlt oder der Titel verdächtig ist.
|
||||||
|
|
||||||
|
Nur solche Treffer gehen in die KI-Feld-Extraktion; sichere Treffer
|
||||||
|
kosten kein Token und bleiben garantiert unangetastet.
|
||||||
|
"""
|
||||||
|
verlag = getattr(treffer, "verlag", None)
|
||||||
|
if not verlag or not str(verlag).strip():
|
||||||
|
return True
|
||||||
|
return titel_ist_verdaechtig(getattr(treffer, "titel", None))
|
||||||
|
|
||||||
|
|
||||||
|
def struktur_verdaechtig(anzahl_treffer: int, inhalt: bytes | str) -> bool:
|
||||||
|
"""True, wenn der Parser keine oder verdächtig wenige Einträge lieferte.
|
||||||
|
|
||||||
|
0 Treffer sind immer verdächtig. 1–2 Treffer nur dann, wenn die Seite
|
||||||
|
sehr viele Links enthält — sonst ist sie schlicht klein/leer.
|
||||||
|
"""
|
||||||
|
if anzahl_treffer <= 0:
|
||||||
|
return True
|
||||||
|
if anzahl_treffer >= STRUKTUR_MIN_TREFFER:
|
||||||
|
return False
|
||||||
|
links = len(_LINK_MUSTER.findall(_als_text(inhalt)))
|
||||||
|
return links > STRUKTUR_LINK_SCHWELLE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Konfiguration ----------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class KiKonfiguration:
|
||||||
|
"""Effektive Konfiguration der KI-Hilfsstufe (aus Env gelesen)."""
|
||||||
|
|
||||||
|
aktiv: bool = False
|
||||||
|
basis_url: str = ""
|
||||||
|
api_key: str = ""
|
||||||
|
modell: str = ""
|
||||||
|
konfidenz_min: float = STANDARD_KONFIDENZ_MIN
|
||||||
|
timeout: float = STANDARD_TIMEOUT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vollstaendig(self) -> bool:
|
||||||
|
"""True, wenn Aktiv-Schalter und Zugangsdaten vollständig sind."""
|
||||||
|
return bool(self.aktiv and self.basis_url and self.api_key and self.modell)
|
||||||
|
|
||||||
|
|
||||||
|
def lade_konfiguration(quelle: Mapping[str, str] | None = None) -> KiKonfiguration:
|
||||||
|
"""Liest die KI-Konfiguration aus der Umgebung (fehlertolerant).
|
||||||
|
|
||||||
|
Kaputte Zahlenwerte führen nicht zum Fehler, sondern zum Standardwert —
|
||||||
|
die Hilfsstufe darf den Crawler niemals blockieren.
|
||||||
|
"""
|
||||||
|
env = os.environ if quelle is None else quelle
|
||||||
|
aktiv = env.get("SPIELE_NEUHEITEN_KI_AKTIV", "0").strip() == "1"
|
||||||
|
|
||||||
|
try:
|
||||||
|
konfidenz_min = float(env.get("SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN", ""))
|
||||||
|
except ValueError:
|
||||||
|
konfidenz_min = STANDARD_KONFIDENZ_MIN
|
||||||
|
konfidenz_min = min(1.0, max(0.0, konfidenz_min))
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout = float(env.get("SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN", ""))
|
||||||
|
except ValueError:
|
||||||
|
timeout = STANDARD_TIMEOUT
|
||||||
|
timeout = max(1.0, timeout)
|
||||||
|
|
||||||
|
return KiKonfiguration(
|
||||||
|
aktiv=aktiv,
|
||||||
|
basis_url=env.get("SPIELE_NEUHEITEN_KI_BASIS_URL", "").strip().rstrip("/"),
|
||||||
|
api_key=env.get("SPIELE_NEUHEITEN_KI_API_KEY", "").strip(),
|
||||||
|
modell=env.get("SPIELE_NEUHEITEN_KI_MODELL", "").strip(),
|
||||||
|
konfidenz_min=konfidenz_min,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Antwort-Parsing (robust gegen Code-Fences u. Ä.) ----------------
|
||||||
|
|
||||||
|
_FENCE_MUSTER = re.compile(r"```(?:json|JSON)?\s*(.*?)\s*```", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def extrahiere_json(text: str) -> dict | None:
|
||||||
|
"""Extrahiert das erste JSON-Objekt aus einer LLM-Antwort.
|
||||||
|
|
||||||
|
Toleriert Code-Fences und begleitenden Text; None, wenn nichts
|
||||||
|
Sinnvolles übrig bleibt.
|
||||||
|
"""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return None
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
versuche = [text]
|
||||||
|
versuche.extend(m.group(1).strip() for m in _FENCE_MUSTER.finditer(text))
|
||||||
|
erstes, letztes = text.find("{"), text.rfind("}")
|
||||||
|
if 0 <= erstes < letztes:
|
||||||
|
versuche.append(text[erstes : letztes + 1])
|
||||||
|
|
||||||
|
for kandidat in versuche:
|
||||||
|
try:
|
||||||
|
daten = json.loads(kandidat)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if isinstance(daten, dict):
|
||||||
|
return daten
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalisiere_konfidenz(daten: Mapping[str, Any]) -> float | None:
|
||||||
|
"""Konfidenz aus der Antwort; Prozent-Skalen werden toleriert."""
|
||||||
|
try:
|
||||||
|
konfidenz = float(daten.get("konfidenz"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if 10.0 <= konfidenz <= 100.0: # z. B. 92 → 0.92
|
||||||
|
konfidenz = konfidenz / 100.0
|
||||||
|
if not 0.0 <= konfidenz <= 1.0: # 1 < x < 10 ist auf keiner Skala plausibel
|
||||||
|
return None
|
||||||
|
return round(konfidenz, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(wert: Any) -> str | None:
|
||||||
|
"""Normalisiert ein nullable String-Feld der Antwort."""
|
||||||
|
if isinstance(wert, str):
|
||||||
|
wert = wert.strip()
|
||||||
|
return wert or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validiere_url_vorschlaege(daten: Any, seiten_url: str) -> dict | None:
|
||||||
|
"""Prüft die Struktur-Antwort und filtert hart auf gleiche Domain + max 20.
|
||||||
|
|
||||||
|
Erwartet `{folge_urls: [...], konfidenz: float}`. Relative URLs werden
|
||||||
|
gegen die Seiten-URL aufgelöst; fremde Domains, Nicht-HTTP-Schemata und
|
||||||
|
Duplikate (inkl. Fragment-Unterschiede) werden verworfen — auch wenn das
|
||||||
|
LLM sie vorschlägt.
|
||||||
|
"""
|
||||||
|
if not isinstance(daten, dict):
|
||||||
|
return None
|
||||||
|
roh = daten.get("folge_urls")
|
||||||
|
if not isinstance(roh, list):
|
||||||
|
return None
|
||||||
|
konfidenz = _normalisiere_konfidenz(daten)
|
||||||
|
if konfidenz is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
basis_domain = urlsplit(seiten_url).netloc.lower()
|
||||||
|
if not basis_domain:
|
||||||
|
return None
|
||||||
|
gefiltert: list[str] = []
|
||||||
|
gesehen: set[str] = set()
|
||||||
|
for eintrag in roh:
|
||||||
|
if not isinstance(eintrag, str) or not eintrag.strip():
|
||||||
|
continue
|
||||||
|
absolut = urljoin(seiten_url, eintrag.strip())
|
||||||
|
teile = urlsplit(absolut)
|
||||||
|
if teile.scheme not in ("http", "https"):
|
||||||
|
continue
|
||||||
|
if teile.netloc.lower() != basis_domain:
|
||||||
|
continue
|
||||||
|
ohne_fragment = absolut.split("#", 1)[0]
|
||||||
|
if ohne_fragment in gesehen:
|
||||||
|
continue
|
||||||
|
gesehen.add(ohne_fragment)
|
||||||
|
gefiltert.append(ohne_fragment)
|
||||||
|
if len(gefiltert) >= MAX_FOLGE_URLS:
|
||||||
|
break
|
||||||
|
return {"folge_urls": gefiltert, "konfidenz": konfidenz}
|
||||||
|
|
||||||
|
|
||||||
|
def validiere_feld_antwort(daten: Any) -> dict | None:
|
||||||
|
"""Prüft die Feld-Antwort gegen das Schema (titel/verlag/autor/konfidenz).
|
||||||
|
|
||||||
|
Die URL ist bewusst kein Teil des Schemas — eine Korrektur kann sie
|
||||||
|
strukturell nie ändern.
|
||||||
|
"""
|
||||||
|
if not isinstance(daten, dict):
|
||||||
|
return None
|
||||||
|
titel = daten.get("titel")
|
||||||
|
if not isinstance(titel, str) or not titel.strip():
|
||||||
|
return None
|
||||||
|
konfidenz = _normalisiere_konfidenz(daten)
|
||||||
|
if konfidenz is None:
|
||||||
|
return None
|
||||||
|
begruendung = daten.get("begruendung")
|
||||||
|
if not isinstance(begruendung, str):
|
||||||
|
begruendung = ""
|
||||||
|
return {
|
||||||
|
"titel": titel.strip(),
|
||||||
|
"verlag": _optional_text(daten.get("verlag")),
|
||||||
|
"autor": _optional_text(daten.get("autor")),
|
||||||
|
"konfidenz": konfidenz,
|
||||||
|
"begruendung": begruendung.strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Prompt ----------------
|
||||||
|
|
||||||
|
_SYSTEM_STRUKTUR = (
|
||||||
|
"Du hilfst einem Crawler einer Spielemagazin-Redaktion, Brettspiel-"
|
||||||
|
"Neuheitenlisten im Web zu finden. Der Regel-Parser hat die folgende "
|
||||||
|
"Seite fast leer geparst — vermutlich nutzt die Liste Pagination, "
|
||||||
|
"Archiv- oder Filter-Links. Identifiziere aus dem HTML die URLs, unter "
|
||||||
|
"denen weitere Neuheiten-Einträge zu erwarten sind. Antworte "
|
||||||
|
"AUSSCHLIESSLICH mit einem JSON-Objekt nach exakt diesem Schema:\n"
|
||||||
|
'{"folge_urls": ["<absolute oder relative URL>", …], '
|
||||||
|
'"konfidenz": <float 0.0-1.0>}\n'
|
||||||
|
f"Höchstens {MAX_FOLGE_URLS} URLs und nur Links derselben Domain wie die "
|
||||||
|
"Seiten-URL. Wenn du nichts Sinnvolles findest, liefere eine leere Liste "
|
||||||
|
"mit niedriger Konfidenz. Kein weiterer Text, keine Code-Fences."
|
||||||
|
)
|
||||||
|
|
||||||
|
_SYSTEM_FELDER = (
|
||||||
|
"Du assistierst einer Spielemagazin-Redaktion beim Parsen von Brettspiel-"
|
||||||
|
"Neuheiten. Der folgende Treffer eines regelbasierten Crawlers ist "
|
||||||
|
"unsicher. Bereinige die Felder: trenne „Verlag: Titel“-Muster, entferne "
|
||||||
|
"Navigationstext und ergänze den Verlag, falls er sicher erkennbar ist. "
|
||||||
|
"Korrigiere ausschließlich titel, verlag und autor — ändere niemals URLs "
|
||||||
|
"und erfinde nichts. Ein Feld, das du nicht sicher bestimmen kannst, "
|
||||||
|
"bleibt null. Antworte AUSSCHLIESSLICH mit einem JSON-Objekt nach exakt "
|
||||||
|
"diesem Schema:\n"
|
||||||
|
'{"titel": "<bereinigter Titel>", "verlag": "<Verlagsname oder null>", '
|
||||||
|
'"autor": "<Autor oder null>", "konfidenz": <float 0.0-1.0>, '
|
||||||
|
'"begruendung": "<kurzer deutscher Satz>"}\n'
|
||||||
|
"Kein weiterer Text, keine Code-Fences."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def baue_struktur_prompt(seiten_url: str, html_text: str) -> str:
|
||||||
|
"""User-Nachricht für die Struktur-Erkennung (gekürzter HTML-Ausschnitt)."""
|
||||||
|
ausschnitt = html_text[:HTML_MAX_ZEICHEN]
|
||||||
|
return (
|
||||||
|
f"Seiten-URL: {seiten_url}\n\n"
|
||||||
|
"HTML-Ausschnitt der geparsten Seite:\n"
|
||||||
|
f"{ausschnitt}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def baue_feld_prompt(titel: str, verlag: str | None, autor: str | None) -> str:
|
||||||
|
"""User-Nachricht mit dem unsicheren Treffer (ohne URL — bleibt fix)."""
|
||||||
|
zeilen = [f"Unsicherer Treffer:", f"titel: {titel}"]
|
||||||
|
zeilen.append(f"verlag: {verlag if verlag else '(leer)'}")
|
||||||
|
zeilen.append(f"autor: {autor if autor else '(leer)'}")
|
||||||
|
zeilen.append(
|
||||||
|
"Gib die bereinigten Felder zurück; die quellen_url selbst bleibt "
|
||||||
|
"nicht veränderbar und ist nicht Teil der Aufgabe."
|
||||||
|
)
|
||||||
|
return "\n".join(zeilen)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Client ----------------
|
||||||
|
|
||||||
|
class KiHilfe:
|
||||||
|
"""OpenAI-kompatibler Chat-Completions-Client für die Crawler-Hilfsstufe.
|
||||||
|
|
||||||
|
Bewusst genauso defensiv wie der dedup-KI-Client: Alle öffentlichen
|
||||||
|
Methoden werfen nicht (Ausnahmen werden intern geschluckt) — ohne
|
||||||
|
Konfiguration oder bei jedem Problem greift exakt der klassische
|
||||||
|
Crawler-Befund. Transport injizierbar, damit Tests netzwerkfrei bleiben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
konfiguration: KiKonfiguration,
|
||||||
|
*,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._konfiguration = konfiguration
|
||||||
|
self._client = httpx.Client(
|
||||||
|
base_url=konfiguration.basis_url,
|
||||||
|
timeout=konfiguration.timeout,
|
||||||
|
transport=transport,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {konfiguration.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modell(self) -> str:
|
||||||
|
return self._konfiguration.modell
|
||||||
|
|
||||||
|
def schliessen(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
# ---------- Struktur-Erkennung ----------
|
||||||
|
|
||||||
|
def folge_urls_bei_bedarf(
|
||||||
|
self,
|
||||||
|
anzahl_treffer: int,
|
||||||
|
inhalt: bytes | str,
|
||||||
|
seiten_url: str,
|
||||||
|
bereits_geprueft: set[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Pagination-/Filter-URLs bei verdächtig leerem Parser-Ergebnis.
|
||||||
|
|
||||||
|
Prüft selbst die Verdachts-Heuristik (sonst []), fragt dann das LLM
|
||||||
|
und filtert dessen Vorschläge hart: gleiche Domain, max. 20, bereits
|
||||||
|
geplante/besuchte URLs raus, Konfidenz unter der Schwelle → alles
|
||||||
|
verwerfen. Fehler → [] (klassischer Lauf bleibt).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not struktur_verdaechtig(anzahl_treffer, inhalt):
|
||||||
|
return []
|
||||||
|
if not self._konfiguration.vollstaendig:
|
||||||
|
return []
|
||||||
|
|
||||||
|
nutzernachricht = baue_struktur_prompt(seiten_url, _als_text(inhalt))
|
||||||
|
daten = self._chat(_SYSTEM_STRUKTUR, nutzernachricht)
|
||||||
|
ergebnis = validiere_url_vorschlaege(daten, seiten_url)
|
||||||
|
if ergebnis is None:
|
||||||
|
return []
|
||||||
|
if ergebnis["konfidenz"] < self._konfiguration.konfidenz_min:
|
||||||
|
_logger.info(
|
||||||
|
"neuheiten/KI: URL-Vorschläge verworfen (Konfidenz %.2f < %.2f).",
|
||||||
|
ergebnis["konfidenz"], self._konfiguration.konfidenz_min,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
bekannt = bereits_geprueft or set()
|
||||||
|
return [
|
||||||
|
url for url in ergebnis["folge_urls"] if url not in bekannt
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"neuheiten/KI: Struktur-Erkennung fehlgeschlagen — klassischer Lauf bleibt."
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ---------- Feld-Extraktion ----------
|
||||||
|
|
||||||
|
def treffer_nachbessern(
|
||||||
|
self, treffer_liste: list
|
||||||
|
) -> tuple[list, list[dict]]:
|
||||||
|
"""Korrigiert ausschließlich unsichere Treffer (titel/verlag/autor).
|
||||||
|
|
||||||
|
Sichere Treffer werden nicht angefasst und kosten kein Token. Die
|
||||||
|
quellen_url bleibt immer unangetastet (kein Teil des Antwort-Schemas).
|
||||||
|
Rückgabe: (neue Trefferliste, Eingriffs-Protokoll fürs Audit-Log mit
|
||||||
|
Modell, Konfidenz und den jeweils korrigierten Feldern).
|
||||||
|
"""
|
||||||
|
if not treffer_liste:
|
||||||
|
return [], []
|
||||||
|
if not self._konfiguration.vollstaendig:
|
||||||
|
return list(treffer_liste), []
|
||||||
|
|
||||||
|
neue_liste: list = []
|
||||||
|
eingriffe: list[dict] = []
|
||||||
|
for einzel in treffer_liste:
|
||||||
|
if not treffer_ist_unsicher(einzel):
|
||||||
|
neue_liste.append(einzel)
|
||||||
|
continue
|
||||||
|
ergebnis = self._einzeln_korrigieren(einzel)
|
||||||
|
if ergebnis is None:
|
||||||
|
neue_liste.append(einzel) # klassischer Befund bleibt
|
||||||
|
continue
|
||||||
|
korrigiert, antwort = ergebnis
|
||||||
|
if korrigiert is einzel: # nichts tatsächlich geändert
|
||||||
|
neue_liste.append(einzel)
|
||||||
|
continue
|
||||||
|
neue_liste.append(korrigiert)
|
||||||
|
eingriffe.append(self._eingriff_protokoll(einzel, korrigiert, antwort))
|
||||||
|
return neue_liste, eingriffe
|
||||||
|
|
||||||
|
def _einzeln_korrigieren(self, treffer: Any) -> tuple[Any, dict] | None:
|
||||||
|
"""Eine Feld-Korrektur; Rückgabe (neuer Treffer, validierte Antwort).
|
||||||
|
|
||||||
|
None bzw. der unveränderte Treffer bedeutet: klassischer Befund bleibt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
nutzernachricht = baue_feld_prompt(
|
||||||
|
getattr(treffer, "titel", "") or "",
|
||||||
|
getattr(treffer, "verlag", None),
|
||||||
|
getattr(treffer, "autor", None),
|
||||||
|
)
|
||||||
|
daten = self._chat(_SYSTEM_FELDER, nutzernachricht)
|
||||||
|
antwort = validiere_feld_antwort(daten)
|
||||||
|
if antwort is None:
|
||||||
|
return None
|
||||||
|
if antwort["konfidenz"] < self._konfiguration.konfidenz_min:
|
||||||
|
_logger.info(
|
||||||
|
"neuheiten/KI: Feldkorrektur verworfen (Konfidenz %.2f < %.2f).",
|
||||||
|
antwort["konfidenz"], self._konfiguration.konfidenz_min,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
felder: dict[str, str | None] = {}
|
||||||
|
for feld in ("titel", "verlag", "autor"):
|
||||||
|
alter_wert = getattr(treffer, feld, None)
|
||||||
|
alter_norm = (alter_wert or "").strip() or None
|
||||||
|
neu_norm = (antwort[feld] or "").strip() or None
|
||||||
|
if neu_norm != alter_norm:
|
||||||
|
felder[feld] = neu_norm
|
||||||
|
if not felder:
|
||||||
|
return treffer, antwort # nichts tatsächlich korrigiert
|
||||||
|
try:
|
||||||
|
return dataclasses.replace(treffer, **felder), antwort
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"neuheiten/KI: Feldkorrektur fehlgeschlagen — klassischer Befund bleibt."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _eingriff_protokoll(self, alt: Any, neu: Any, antwort: dict) -> dict:
|
||||||
|
"""Audit-Zeile: was wurde korrigiert, mit welchem Modell/welcher Konfidenz."""
|
||||||
|
korrigiert: dict[str, str | None] = {}
|
||||||
|
for feld in ("titel", "verlag", "autor"):
|
||||||
|
vorher = getattr(alt, feld, None)
|
||||||
|
nachher = getattr(neu, feld, None)
|
||||||
|
if vorher != nachher:
|
||||||
|
korrigiert[feld] = nachher
|
||||||
|
return {
|
||||||
|
"quellen_url": getattr(alt, "quellen_url", ""),
|
||||||
|
"modell": self.modell,
|
||||||
|
"konfidenz": antwort["konfidenz"],
|
||||||
|
"begruendung": antwort.get("begruendung", ""),
|
||||||
|
"korrigiert": korrigiert,
|
||||||
|
"vorher": {f: getattr(alt, f, None) for f in korrigiert},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- Gemeinsamer Chat-Completions-Aufruf ----------
|
||||||
|
|
||||||
|
def _chat(self, system: str, nutzer: str) -> dict | None:
|
||||||
|
"""POST /chat/completions mit genau einem Retry; None bei jedem Problem.
|
||||||
|
|
||||||
|
Netzwerk-/Timeout-Fehler und zeitweilige Server-Störungen werden
|
||||||
|
genau einmal wiederholt (insgesamt zwei Versuche); andere 4xx und
|
||||||
|
kaputte Antworten nicht — dort hilft kein Retry.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"model": self._konfiguration.modell,
|
||||||
|
"temperature": 0,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": nutzer},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for versuch in range(1, VERSUCHE + 1):
|
||||||
|
try:
|
||||||
|
antwort = self._client.post("/chat/completions", json=payload)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"neuheiten/KI: Versuch %d/%d fehlgeschlagen (%s)",
|
||||||
|
versuch, VERSUCHE, exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if antwort.status_code in RETRY_STATUS:
|
||||||
|
_logger.warning(
|
||||||
|
"neuheiten/KI: Versuch %d/%d mit Status %d — wiederholt.",
|
||||||
|
versuch, VERSUCHE, antwort.status_code,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
antwort.raise_for_status() # andere 4xx: kein Retry hilft
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
_logger.warning("neuheiten/KI: Anfrage abgelehnt (%s).", exc)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
inhalte = antwort.json()["choices"][0]["message"]["content"]
|
||||||
|
except Exception as exc: # kaputtes Antwort-Layout
|
||||||
|
_logger.warning("neuheiten/KI: unbrauchbare Antwort (%s)", exc)
|
||||||
|
return None
|
||||||
|
return extrahiere_json(inhalte)
|
||||||
|
_logger.warning(
|
||||||
|
"neuheiten/KI: alle %d Versuche fehlgeschlagen — klassischer Befund.", VERSUCHE
|
||||||
|
)
|
||||||
|
return None
|
||||||
89
plugins/neuheiten/quellen/spielbox.py
Normal file
89
plugins/neuheiten/quellen/spielbox.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""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)
|
||||||
187
plugins/neuheiten/quellen/spielessen.py
Normal file
187
plugins/neuheiten/quellen/spielessen.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""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,
|
||||||
|
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] = {}
|
||||||
|
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
|
||||||
425
plugins/neuheiten/quellen_sync.py
Normal file
425
plugins/neuheiten/quellen_sync.py
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
"""Sync-Orchestrierung für die Web-Quellen des Neuheiten-Plugins.
|
||||||
|
|
||||||
|
Pro Quelle:
|
||||||
|
- Env-Schalter SPIELE_NEUHEITEN_QUELLE_<NAME>_AKTIV (Standard „1“ = an).
|
||||||
|
- Adapter-Lauf mit konditionalen Requests (ETag/Last-Modified werden in
|
||||||
|
der Statuszeile persistiert; 304 → Quelle übersprungen, kein Fehler).
|
||||||
|
- Fehler-Isolation: eine kaputte Quelle bricht den Gesamtdurchlauf nicht
|
||||||
|
ab; ihr Ergebnis wird als Fehler protokolliert, die anderen Quellen
|
||||||
|
laufen normal weiter.
|
||||||
|
- Duplikatvermeidung: Web-Quellen haben keine BGG-ID, daher wird jeder
|
||||||
|
Treffer vor dem Speichern fuzzy gegen die bestehende Liste geprüft
|
||||||
|
(Titel-Match, rapidfuzz) — Treffer gegen BGG-Einträge werden
|
||||||
|
übersprungen. Zusätzlich läuft jeder neue Treffer durch die öffentliche
|
||||||
|
dedup-Prüfung (via Plugin-Registry, sofern das Plugin geladen ist).
|
||||||
|
- Upsert: bereits vorhandene Treffer derselben Quelle (gleicher Titel)
|
||||||
|
werden aktualisiert statt doppelt angelegt; neue Einträge bekommen
|
||||||
|
quelle = '<name>' statt 'boardgamegeek' und keine BGG-ID.
|
||||||
|
|
||||||
|
Das Ergebnis jedes Laufes wird pro Quelle in `neuheiten_quellen_status`
|
||||||
|
protokolliert und im Plugin-UI unter /neuheiten angezeigt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from .quellen.ki_hilfe import KiHilfe, lade_konfiguration as lade_ki_konfiguration
|
||||||
|
from .models import STATUS_NEUHEIT, Neuheit, QuellenStatus
|
||||||
|
from .quellen import (
|
||||||
|
ADAPTER_KLASSEN,
|
||||||
|
QuellenAdapter,
|
||||||
|
QuellenTreffer,
|
||||||
|
WebQuellenClient,
|
||||||
|
jahr_aus_datumsangabe,
|
||||||
|
titel_aehnlichkeit,
|
||||||
|
titel_normalisieren,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("plugins.neuheiten")
|
||||||
|
|
||||||
|
#: Schwelle für den Fuzzy-Titel-Match gegen bestehende Einträge (0–100).
|
||||||
|
TITEL_FUZZY_SCHWELLE = 92.0
|
||||||
|
#: Zweite Regel: etwas lockererer Titel-Match, dafür gleicher/ähnlicher Verlag.
|
||||||
|
TITEL_FUZZY_MIT_VERLAG_SCHWELLE = 85.0
|
||||||
|
VERLAG_FUZZY_SCHWELLE = 85.0
|
||||||
|
|
||||||
|
ENV_SCHABLONE_AKTIV = "SPIELE_NEUHEITEN_QUELLE_{name}_AKTIV"
|
||||||
|
|
||||||
|
|
||||||
|
def quelle_aktiv(name: str, umgebung=None) -> bool:
|
||||||
|
"""Liest den Env-Schalter einer Quelle (Standard: an)."""
|
||||||
|
env = umgebung if umgebung is not None else os.environ
|
||||||
|
wert = env.get(ENV_SCHABLONE_AKTIV.format(name=name.upper()), "1")
|
||||||
|
return str(wert).strip() != "0"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QuellenLaufZeile:
|
||||||
|
"""Ergebnis einer Quelle in einem Sync-Lauf (Grundlage der UI-Übersicht)."""
|
||||||
|
|
||||||
|
quelle: str
|
||||||
|
anzeigename: str
|
||||||
|
aktiv: bool = True
|
||||||
|
neu: int = 0
|
||||||
|
aktualisiert: int = 0
|
||||||
|
gefiltert: int = 0
|
||||||
|
fehleranzahl: int = 0
|
||||||
|
fehlermeldung: str | None = None
|
||||||
|
unveraendert: bool = False
|
||||||
|
seiten: int = 0
|
||||||
|
dauer_sekunden: float = 0.0
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
def als_text(self) -> str:
|
||||||
|
if not self.aktiv:
|
||||||
|
return f"{self.anzeigename}: ausgeschaltet"
|
||||||
|
if self.fehlermeldung:
|
||||||
|
return f"{self.anzeigename}: Fehler — {self.fehlermeldung}"
|
||||||
|
if self.unveraendert:
|
||||||
|
return f"{self.anzeigename}: unverändert (304) — übersprungen"
|
||||||
|
return (
|
||||||
|
f"{self.anzeigename}: {self.neu} neu, {self.aktualisiert} aktualisiert, "
|
||||||
|
f"{self.gefiltert} gefiltert (Duplikate)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WebQuellenSyncService:
|
||||||
|
"""Führt einen Sync-Lauf über alle Web-Quellen aus.
|
||||||
|
|
||||||
|
`dedup` ist die öffentliche API des dedup-Plugins (via Plugin-Registry)
|
||||||
|
oder None, wenn das Plugin nicht geladen ist — die Prüfung ist dann
|
||||||
|
optional und wird übersprungen. `client_fabrik` baut den HTTP-Client
|
||||||
|
(in Tests mit Fake-Transport überschrieben).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_factory: sessionmaker,
|
||||||
|
*,
|
||||||
|
adapter_klassen: tuple[type[QuellenAdapter], ...] = ADAPTER_KLASSEN,
|
||||||
|
dedup=None,
|
||||||
|
client_fabrik: Callable[[dict], WebQuellenClient] | None = None,
|
||||||
|
umgebung=None,
|
||||||
|
ki_fabrik: Callable[[], KiHilfe] | None = None,
|
||||||
|
audit=None,
|
||||||
|
) -> None:
|
||||||
|
self.session_factory = session_factory
|
||||||
|
self.adapter_klassen = adapter_klassen
|
||||||
|
self.dedup = dedup
|
||||||
|
self.audit = audit
|
||||||
|
self.umgebung = umgebung if umgebung is not None else os.environ
|
||||||
|
self._client_fabrik = client_fabrik or self._standard_client_fabrik
|
||||||
|
# KI-Hilfsstufe: Fabrik für Tests; ohne vollständige Konfiguration wird
|
||||||
|
# nie ein Client gebaut (und damit nie ein LLM-Aufruf getätigt).
|
||||||
|
self._ki_fabrik = ki_fabrik
|
||||||
|
|
||||||
|
# ---------- Gesamtdurchlauf ----------
|
||||||
|
|
||||||
|
def laufe(self, nur_quellen: set[str] | None = None) -> list[QuellenLaufZeile]:
|
||||||
|
"""Führt den Web-Quellen-Sync aus.
|
||||||
|
|
||||||
|
`nur_quellen`: optionaler Filter — nur diese Quellnamen laufen
|
||||||
|
(manueller Einzel-Quellen-Sync aus dem UI). Der An/Aus-Schalter
|
||||||
|
der Quelle wird dabei ignoriert, ein explizit angeforderter
|
||||||
|
Einzellauf läuft immer.
|
||||||
|
"""
|
||||||
|
zeilen: list[QuellenLaufZeile] = []
|
||||||
|
ki = self._ki_hilfe()
|
||||||
|
for klasse in self.adapter_klassen:
|
||||||
|
if nur_quellen is not None and klasse.name not in nur_quellen:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
zeile = self._eine_quelle(
|
||||||
|
klasse, ki, einzellauf=nur_quellen is not None
|
||||||
|
)
|
||||||
|
except Exception as exc: # letzte Verteidigungslinie pro Quelle
|
||||||
|
_logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name)
|
||||||
|
zeile = QuellenLaufZeile(
|
||||||
|
klasse.name,
|
||||||
|
klasse.anzeigename,
|
||||||
|
fehleranzahl=1,
|
||||||
|
fehlermeldung=str(exc)[:300],
|
||||||
|
)
|
||||||
|
self._status_speichern(zeile)
|
||||||
|
zeilen.append(zeile)
|
||||||
|
return zeilen
|
||||||
|
|
||||||
|
def schalter_gesetzt(self, name: str, wert: bool) -> None:
|
||||||
|
"""Setzt den UI-An/Aus-Schalter einer Quelle (persistiert).
|
||||||
|
|
||||||
|
Der Schalter übersteuert den Env-Standard; der Hintergrund-Sync
|
||||||
|
überspringt ausgeschaltete Quellen.
|
||||||
|
"""
|
||||||
|
with self.session_factory() as db:
|
||||||
|
eintrag = db.scalar(
|
||||||
|
select(QuellenStatus).where(QuellenStatus.quelle == name)
|
||||||
|
)
|
||||||
|
if eintrag is None:
|
||||||
|
eintrag = QuellenStatus(quelle=name, url="")
|
||||||
|
db.add(eintrag)
|
||||||
|
eintrag.aktiv_schalter = wert
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def quelle_laeuft(self, name: str) -> bool:
|
||||||
|
"""Effektiver Aktiv-Status einer Quelle (Schalter übersteuert Env)."""
|
||||||
|
with self.session_factory() as db:
|
||||||
|
eintrag = db.scalar(
|
||||||
|
select(QuellenStatus).where(QuellenStatus.quelle == name)
|
||||||
|
)
|
||||||
|
if eintrag is not None and eintrag.aktiv_schalter is not None:
|
||||||
|
return eintrag.aktiv_schalter
|
||||||
|
return quelle_aktiv(name, self.umgebung)
|
||||||
|
|
||||||
|
def gesamt_text(self, zeilen: list[QuellenLaufZeile]) -> str:
|
||||||
|
"""Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll."""
|
||||||
|
teile = [zeile.als_text() for zeile in zeilen]
|
||||||
|
return " · ".join(teile) if teile else "Keine Web-Quellen konfiguriert."
|
||||||
|
|
||||||
|
# ---------- Eine Quelle ----------
|
||||||
|
|
||||||
|
def _ki_hilfe(self) -> KiHilfe | None:
|
||||||
|
"""KI-Hilfsstufe nur bei vollständiger Konfiguration (sonst None).
|
||||||
|
|
||||||
|
Die Aktivierungsprüfung gilt vor jeder injizierten Fabrik — ist die
|
||||||
|
Hilfsstufe aus oder unvollständig konfiguriert, wird nie ein Client
|
||||||
|
gebaut und damit nie ein LLM-Aufruf getätigt.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
konfiguration = lade_ki_konfiguration(self.umgebung)
|
||||||
|
if not konfiguration.vollstaendig:
|
||||||
|
return None
|
||||||
|
if self._ki_fabrik is not None:
|
||||||
|
return self._ki_fabrik()
|
||||||
|
return KiHilfe(konfiguration)
|
||||||
|
except Exception:
|
||||||
|
# Auch ein kaputtes KI-Setup darf den Sync nie blockieren.
|
||||||
|
_logger.exception("neuheiten/KI: Hilfsstufe nicht verfügbar — klassischer Lauf.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _eine_quelle(
|
||||||
|
self,
|
||||||
|
klasse: type[QuellenAdapter],
|
||||||
|
ki: KiHilfe | None = None,
|
||||||
|
einzellauf: bool = False,
|
||||||
|
) -> QuellenLaufZeile:
|
||||||
|
zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename)
|
||||||
|
zeile.url = klasse.start_urls[0] if klasse.start_urls else None
|
||||||
|
# Einzellauf (nur_quellen) läuft immer; sonst entscheidet der Schalter.
|
||||||
|
if not einzellauf and not self.quelle_laeuft(klasse.name):
|
||||||
|
zeile.aktiv = False
|
||||||
|
return zeile
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
client = self._client_fabrik(self._validatoren_laden(klasse.name))
|
||||||
|
try:
|
||||||
|
adapter = klasse(client)
|
||||||
|
adapter.ki_hilfe = ki # None = rein regelbasiert (Fallback-Pflicht)
|
||||||
|
try:
|
||||||
|
ergebnis = adapter.sammle()
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Quelle %s fehlgeschlagen: %s", klasse.name, exc)
|
||||||
|
zeile.fehleranzahl = 1
|
||||||
|
zeile.fehlermeldung = str(exc)[:300]
|
||||||
|
return zeile
|
||||||
|
zeile.seiten = ergebnis.seiten
|
||||||
|
self._ki_auditieren(klasse.name, ergebnis)
|
||||||
|
if ergebnis.unveraendert:
|
||||||
|
zeile.unveraendert = True
|
||||||
|
else:
|
||||||
|
self._treffer_verarbeiten(zeile, ergebnis.treffer, klasse.name)
|
||||||
|
finally:
|
||||||
|
client.schliessen()
|
||||||
|
zeile.dauer_sekunden = round(time.monotonic() - start, 2)
|
||||||
|
self._validatoren_speichern(klasse.name, zeile.url, client)
|
||||||
|
return zeile
|
||||||
|
|
||||||
|
def _treffer_verarbeiten(
|
||||||
|
self, zeile: QuellenLaufZeile, treffer: list[QuellenTreffer], quelle: str
|
||||||
|
) -> None:
|
||||||
|
asyncio.run(self._verarbeite_async(zeile, treffer, quelle))
|
||||||
|
|
||||||
|
async def _verarbeite_async(
|
||||||
|
self, zeile: QuellenLaufZeile, treffer: list[QuellenTreffer], quelle: str
|
||||||
|
) -> None:
|
||||||
|
with self.session_factory() as db:
|
||||||
|
try:
|
||||||
|
for einzel in treffer:
|
||||||
|
if not einzel.titel or not einzel.titel.strip():
|
||||||
|
zeile.gefiltert += 1
|
||||||
|
continue
|
||||||
|
if self._eigenen_eintrag_aktualisieren(db, einzel, quelle):
|
||||||
|
zeile.aktualisiert += 1
|
||||||
|
continue
|
||||||
|
if self._ist_duplikat(db, einzel):
|
||||||
|
zeile.gefiltert += 1
|
||||||
|
continue
|
||||||
|
if not await self._dedup_erklaert_kein_konflikt(einzel):
|
||||||
|
zeile.gefiltert += 1
|
||||||
|
continue
|
||||||
|
db.add(
|
||||||
|
Neuheit(
|
||||||
|
titel=einzel.titel.strip()[:300],
|
||||||
|
verlag=einzel.verlag,
|
||||||
|
autor=einzel.autor,
|
||||||
|
erscheinungsjahr=jahr_aus_datumsangabe(
|
||||||
|
einzel.erscheinungsdatum_oder_quartal
|
||||||
|
),
|
||||||
|
bgg_id=None,
|
||||||
|
bild_url=einzel.bild_url,
|
||||||
|
quellen_url=einzel.quellen_url,
|
||||||
|
status=STATUS_NEUHEIT,
|
||||||
|
quelle=quelle,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
zeile.neu += 1
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ---------- Duplikate / Upsert ----------
|
||||||
|
|
||||||
|
def _eigenen_eintrag_aktualisieren(
|
||||||
|
self, db, treffer: QuellenTreffer, quelle: str
|
||||||
|
) -> bool:
|
||||||
|
"""Update statt Duplikat: gleicher Titel + gleiche Quelle."""
|
||||||
|
schluessel = titel_normalisieren(treffer.titel)
|
||||||
|
vorhanden = db.scalars(select(Neuheit).where(Neuheit.quelle == quelle)).all()
|
||||||
|
for eintrag in vorhanden:
|
||||||
|
if titel_normalisieren(eintrag.titel) == schluessel:
|
||||||
|
eintrag.verlag = treffer.verlag
|
||||||
|
eintrag.autor = treffer.autor
|
||||||
|
eintrag.erscheinungsjahr = jahr_aus_datumsangabe(
|
||||||
|
treffer.erscheinungsdatum_oder_quartal
|
||||||
|
)
|
||||||
|
# Coverbild + Quellen-URL mitpflegen; fehlen sie im Treffer,
|
||||||
|
# bleiben die bereits gespeicherten Werte erhalten.
|
||||||
|
if treffer.bild_url:
|
||||||
|
eintrag.bild_url = treffer.bild_url
|
||||||
|
if treffer.quellen_url:
|
||||||
|
eintrag.quellen_url = treffer.quellen_url
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _ist_duplikat(self, db, treffer: QuellenTreffer) -> bool:
|
||||||
|
"""Titel-Match gegen bestehende Einträge (z. B. aus BGG).
|
||||||
|
|
||||||
|
BGG-IDs haben Web-Quellen nicht — gematcht wird fuzzy auf den
|
||||||
|
Titel; bei Grenzfällen entscheidet ein ähnlicher Verlag mit.
|
||||||
|
"""
|
||||||
|
for eintrag in db.scalars(select(Neuheit)).all():
|
||||||
|
if titel_aehnlichkeit(treffer.titel, eintrag.titel) >= TITEL_FUZZY_SCHWELLE:
|
||||||
|
return True
|
||||||
|
if (
|
||||||
|
treffer.verlag
|
||||||
|
and eintrag.verlag
|
||||||
|
and titel_aehnlichkeit(treffer.titel, eintrag.titel)
|
||||||
|
>= TITEL_FUZZY_MIT_VERLAG_SCHWELLE
|
||||||
|
and titel_aehnlichkeit(treffer.verlag, eintrag.verlag)
|
||||||
|
>= VERLAG_FUZZY_SCHWELLE
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _dedup_erklaert_kein_konflikt(self, treffer: QuellenTreffer) -> bool:
|
||||||
|
"""Öffentliche dedup-Prüfung (sofern Plugin geladen); Fehler tolerieren."""
|
||||||
|
if self.dedup is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
ergebnis = await self.dedup.check_titel(treffer.titel, treffer.verlag, None)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("dedup-Prüfung fehlgeschlagen (%s): %s", treffer.titel, exc)
|
||||||
|
return True
|
||||||
|
if ergebnis is None:
|
||||||
|
return True
|
||||||
|
return not ergebnis.hat_konflikte
|
||||||
|
|
||||||
|
# ---------- KI-Audit ----------
|
||||||
|
|
||||||
|
def _ki_auditieren(self, quelle: str, ergebnis) -> None:
|
||||||
|
"""KI-Eingriffe des Laufs ins audit-log schreiben (best effort).
|
||||||
|
|
||||||
|
Ein Eintrag je Quelle und Lauf mit Modell, Konfidenz, korrigierten
|
||||||
|
Feldern und den verfolgten KI-Folge-URLs. Ohne audit-log (Plugin
|
||||||
|
nicht geladen) oder bei Fehlern wird nur protokolliert — der Sync
|
||||||
|
wird nie blockiert.
|
||||||
|
"""
|
||||||
|
eingriffe = list(getattr(ergebnis, "ki_eingriffe", []) or [])
|
||||||
|
folge_urls = list(getattr(ergebnis, "ki_folge_urls", []) or [])
|
||||||
|
if not eingriffe and not folge_urls:
|
||||||
|
return
|
||||||
|
if self.audit is None:
|
||||||
|
_logger.info(
|
||||||
|
"Quelle %s: %d KI-Korrekturen, %d KI-Folge-URLs (kein audit-log geladen).",
|
||||||
|
quelle, len(eingriffe), len(folge_urls),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
details = {
|
||||||
|
"quelle": quelle,
|
||||||
|
"modell": eingriffe[0].get("modell", "") if eingriffe else "",
|
||||||
|
"eingriffe": eingriffe,
|
||||||
|
"folge_urls": folge_urls,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
self.audit.log_sync(None, "korrigiert", "neuheiten_ki_crawler", quelle, details)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("neuheiten/KI: Audit-Eintrag konnte nicht geschrieben werden.")
|
||||||
|
|
||||||
|
# ---------- Statusprotokoll ----------
|
||||||
|
|
||||||
|
def _status_speichern(self, zeile: QuellenLaufZeile) -> None:
|
||||||
|
with self.session_factory() as db:
|
||||||
|
eintrag = db.scalar(
|
||||||
|
select(QuellenStatus).where(QuellenStatus.quelle == zeile.quelle)
|
||||||
|
)
|
||||||
|
if eintrag is None:
|
||||||
|
eintrag = QuellenStatus(quelle=zeile.quelle, url=zeile.url or "")
|
||||||
|
db.add(eintrag)
|
||||||
|
if zeile.url:
|
||||||
|
eintrag.url = zeile.url
|
||||||
|
eintrag.letzte_laufzeit = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
eintrag.dauer_sekunden = zeile.dauer_sekunden
|
||||||
|
eintrag.anzahl_neu = zeile.neu
|
||||||
|
eintrag.anzahl_aktualisiert = zeile.aktualisiert
|
||||||
|
eintrag.anzahl_gefiltert = zeile.gefiltert
|
||||||
|
eintrag.anzahl_fehler = zeile.fehleranzahl
|
||||||
|
eintrag.fehlermeldung = zeile.fehlermeldung
|
||||||
|
eintrag.unveraendert = zeile.unveraendert
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def _validatoren_laden(self, quelle: str) -> dict:
|
||||||
|
with self.session_factory() as db:
|
||||||
|
eintrag = db.scalar(
|
||||||
|
select(QuellenStatus).where(QuellenStatus.quelle == quelle)
|
||||||
|
)
|
||||||
|
return dict(eintrag.validatoren) if eintrag is not None else {}
|
||||||
|
|
||||||
|
def _validatoren_speichern(
|
||||||
|
self, quelle: str, url: str | None, client: WebQuellenClient
|
||||||
|
) -> None:
|
||||||
|
if not client.validatoren:
|
||||||
|
return
|
||||||
|
with self.session_factory() as db:
|
||||||
|
eintrag = db.scalar(
|
||||||
|
select(QuellenStatus).where(QuellenStatus.quelle == quelle)
|
||||||
|
)
|
||||||
|
if eintrag is None:
|
||||||
|
eintrag = QuellenStatus(quelle=quelle, url=url or "")
|
||||||
|
db.add(eintrag)
|
||||||
|
eintrag.validatoren = client.validatoren
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# ---------- Hilfen ----------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _standard_client_fabrik(validatoren) -> WebQuellenClient:
|
||||||
|
return WebQuellenClient(validatoren=validatoren)
|
||||||
@@ -3,16 +3,19 @@
|
|||||||
- Update statt Duplikat: bestehende Einträge werden über die eindeutige
|
- Update statt Duplikat: bestehende Einträge werden über die eindeutige
|
||||||
BGG-ID gefunden und aktualisiert (Status bleibt erhalten).
|
BGG-ID gefunden und aktualisiert (Status bleibt erhalten).
|
||||||
- Erweiterungen werden im Client und hier auf Elementebene gefiltert.
|
- Erweiterungen werden im Client und hier auf Elementebene gefiltert.
|
||||||
- Prototypen: Die BGG-XML-API2 liefert keinen verlässlichen Prototyp-Marker;
|
- Prototypen: Titel mit Prototyp-Schlüsselwörtern werden ausgeschlossen.
|
||||||
als Heuristik werden Titel mit Prototyp-Schlüsselwörtern ausgeschlossen
|
- Jahresfilter: optional nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr.
|
||||||
(dokumentierte Einschränkung, siehe README).
|
- Deutsch-Filter: optional nur Spiele mit deutsch klingendem Titel.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Iterable, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -28,12 +31,58 @@ PROTOTYP_MUSTER = re.compile(
|
|||||||
|
|
||||||
THING_BATCH_GROESSE = 20
|
THING_BATCH_GROESSE = 20
|
||||||
|
|
||||||
|
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
|
||||||
|
_DEUTSCHE_INDIKATOREN = frozenset({
|
||||||
|
"der", "die", "das", "den", "dem", "des",
|
||||||
|
"ein", "eine", "einen", "einem", "einer", "eines",
|
||||||
|
"und", "für", "von", "vom", "zu", "zum", "zur",
|
||||||
|
"im", "am", "beim", "auf", "mit", "aus",
|
||||||
|
})
|
||||||
|
#: Typische Begriffe deutscher Spieltitel (+2 Punkte je Treffer).
|
||||||
|
_DEUTSCHE_SPIELWOERTER = frozenset({
|
||||||
|
"abenteuer", "berg", "blitz", "burg", "dorf", "drache", "drachen",
|
||||||
|
"erde", "feuer", "gärten", "haus", "himmel", "insel", "jagd",
|
||||||
|
"kaiser", "könig", "könige", "königreich", "land", "länder",
|
||||||
|
"meister", "mond", "norden", "osten", "pfad", "reich", "ritter",
|
||||||
|
"schiff", "schloss", "see", "spiel", "spiele", "spielen",
|
||||||
|
"stadt", "stern", "süden", "turm", "wald", "weg", "welt",
|
||||||
|
"westen", "wiese", "wilder", "wüste", "zauber", "zeit",
|
||||||
|
})
|
||||||
|
#: Wörter, die auf einen englischen Titel hindeuten (−1 Punkt je Treffer).
|
||||||
|
_ENGLISCHE_INDIKATOREN = frozenset({
|
||||||
|
"the", "of", "and", "game", "games", "card", "cards", "dice", "board",
|
||||||
|
})
|
||||||
|
_UM_LAUTE = frozenset("äöüßÄÖÜ")
|
||||||
|
_WORT_MUSTER = re.compile(r"[^\W\d_]+", re.UNICODE)
|
||||||
|
|
||||||
|
|
||||||
|
def ist_deutscher_titel(titel: str) -> bool:
|
||||||
|
"""Heuristik: > 0 spricht für einen deutschen Titel.
|
||||||
|
|
||||||
|
Umlaute/ß zählen am stärksten (+3), deutsche Funktionswörter +1,
|
||||||
|
typische deutsche Spielbegriffe +2, englische Indikatoren −1.
|
||||||
|
"""
|
||||||
|
if not titel:
|
||||||
|
return False
|
||||||
|
score = 0
|
||||||
|
if any(zeichen in _UM_LAUTE for zeichen in titel):
|
||||||
|
score += 3
|
||||||
|
woerter = {wort.casefold() for wort in _WORT_MUSTER.findall(titel)}
|
||||||
|
score += sum(1 for wort in woerter if wort in _DEUTSCHE_INDIKATOREN)
|
||||||
|
score += sum(2 for wort in woerter if wort in _DEUTSCHE_SPIELWOERTER)
|
||||||
|
score -= sum(1 for wort in woerter if wort in _ENGLISCHE_INDIKATOREN)
|
||||||
|
return score > 0
|
||||||
|
|
||||||
|
|
||||||
def ist_moeglicher_prototyp(titel: str) -> bool:
|
def ist_moeglicher_prototyp(titel: str) -> bool:
|
||||||
"""Heuristik: Titel klingt nach Prototyp/Protoversion."""
|
"""Heuristik: Titel klingt nach Prototyp/Protoversion."""
|
||||||
return bool(PROTOTYP_MUSTER.search(titel))
|
return bool(PROTOTYP_MUSTER.search(titel))
|
||||||
|
|
||||||
|
|
||||||
|
def aktuelles_jahr() -> int:
|
||||||
|
return date.today().year
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SyncErgebnis:
|
class SyncErgebnis:
|
||||||
"""Zusammenfassung eines Sync-Laufs."""
|
"""Zusammenfassung eines Sync-Laufs."""
|
||||||
@@ -51,7 +100,7 @@ class SyncErgebnis:
|
|||||||
return self.abbruch
|
return self.abbruch
|
||||||
text = (
|
text = (
|
||||||
f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
|
f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
|
||||||
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)"
|
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen/Jahr/Sprache)"
|
||||||
)
|
)
|
||||||
if self.fehler:
|
if self.fehler:
|
||||||
text += f"; {len(self.fehler)} Fehler"
|
text += f"; {len(self.fehler)} Fehler"
|
||||||
@@ -69,6 +118,7 @@ def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
|
|||||||
autor=spiel.autor,
|
autor=spiel.autor,
|
||||||
erscheinungsjahr=spiel.erscheinungsjahr,
|
erscheinungsjahr=spiel.erscheinungsjahr,
|
||||||
bgg_id=spiel.bgg_id,
|
bgg_id=spiel.bgg_id,
|
||||||
|
bild_url=spiel.bild_url,
|
||||||
status=STATUS_NEUHEIT,
|
status=STATUS_NEUHEIT,
|
||||||
quelle="boardgamegeek",
|
quelle="boardgamegeek",
|
||||||
)
|
)
|
||||||
@@ -78,6 +128,10 @@ def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
|
|||||||
vorhanden.verlag = spiel.verlag
|
vorhanden.verlag = spiel.verlag
|
||||||
vorhanden.autor = spiel.autor
|
vorhanden.autor = spiel.autor
|
||||||
vorhanden.erscheinungsjahr = spiel.erscheinungsjahr
|
vorhanden.erscheinungsjahr = spiel.erscheinungsjahr
|
||||||
|
# Coverbild mitpflegen: BGG liefert es ggf. erst nachträglich; ein
|
||||||
|
# fehlendes Bild in der Antwort löscht kein bereits gespeichertes.
|
||||||
|
if spiel.bild_url:
|
||||||
|
vorhanden.bild_url = spiel.bild_url
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -91,11 +145,16 @@ class SyncService:
|
|||||||
*,
|
*,
|
||||||
thing_batch_groesse: int = THING_BATCH_GROESSE,
|
thing_batch_groesse: int = THING_BATCH_GROESSE,
|
||||||
max_treffer_pro_suchbegriff: int | None = None,
|
max_treffer_pro_suchbegriff: int | None = None,
|
||||||
|
jahr_filter: bool = True,
|
||||||
|
deutscher_titel_filter: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self.client = client
|
self.client = client
|
||||||
self.thing_batch_groesse = thing_batch_groesse
|
self.thing_batch_groesse = thing_batch_groesse
|
||||||
self.max_treffer_pro_suchbegriff = max_treffer_pro_suchbegriff
|
self.max_treffer_pro_suchbegriff = max_treffer_pro_suchbegriff
|
||||||
|
self.jahr_filter = jahr_filter
|
||||||
|
self.deutscher_titel_filter = deutscher_titel_filter
|
||||||
|
self._min_jahr = aktuelles_jahr()
|
||||||
|
|
||||||
def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis:
|
def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis:
|
||||||
ergebnis = SyncErgebnis()
|
ergebnis = SyncErgebnis()
|
||||||
@@ -134,6 +193,13 @@ class SyncService:
|
|||||||
if ist_moeglicher_prototyp(spiel.titel):
|
if ist_moeglicher_prototyp(spiel.titel):
|
||||||
ergebnis.gefiltert += 1
|
ergebnis.gefiltert += 1
|
||||||
continue
|
continue
|
||||||
|
if self.jahr_filter and spiel.erscheinungsjahr is not None:
|
||||||
|
if spiel.erscheinungsjahr < self._min_jahr:
|
||||||
|
ergebnis.gefiltert += 1
|
||||||
|
continue
|
||||||
|
if self.deutscher_titel_filter and not ist_deutscher_titel(spiel.titel):
|
||||||
|
ergebnis.gefiltert += 1
|
||||||
|
continue
|
||||||
if speichere_oder_aktualisiere(db, spiel):
|
if speichere_oder_aktualisiere(db, spiel):
|
||||||
ergebnis.neu += 1
|
ergebnis.neu += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
24
plugins/neuheiten/templates/neuheiten/_cover.html
Normal file
24
plugins/neuheiten/templates/neuheiten/_cover.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{# Coverbild-Vorschau des Neuheiten-Plugins.
|
||||||
|
Erwartete Variablen: cover_bild (URL oder None), cover_titel,
|
||||||
|
cover_gross (optional: true für großformatige Ansichten).
|
||||||
|
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
|
||||||
|
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
|
||||||
|
das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #}
|
||||||
|
{% set _gross = cover_gross | default(false) %}
|
||||||
|
{% if _gross %}
|
||||||
|
{% if cover_bild %}
|
||||||
|
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
|
||||||
|
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
|
{% else %}
|
||||||
|
<div aria-hidden="true" title="Kein Cover vorhanden"
|
||||||
|
class="flex h-[210px] w-[150px] max-w-[200px] items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-6xl text-slate-300 select-none">🎲</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if cover_bild %}
|
||||||
|
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="40" height="56"
|
||||||
|
class="block h-14 w-10 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
|
||||||
|
{% else %}
|
||||||
|
<div aria-hidden="true" title="Kein Cover vorhanden"
|
||||||
|
class="flex h-14 w-10 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-lg text-slate-400 select-none">🎲</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
@@ -1,9 +1,61 @@
|
|||||||
<div id="neuheiten-liste">
|
<div id="neuheiten-liste">
|
||||||
<p class="text-xs text-slate-500 mb-2">{{ anzahl }} Einträge</p>
|
<p class="text-xs text-slate-500 mb-2">{{ anzahl }} Einträge</p>
|
||||||
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
|
|
||||||
|
<!-- Mobil (<768px): Karten-Ansicht — Cover links, Titel+Metadaten rechts, Status oben -->
|
||||||
|
<ul class="md:hidden space-y-3" role="list">
|
||||||
|
{% for eintrag in eintraege %}
|
||||||
|
<li class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 flex gap-3">
|
||||||
|
<div class="pt-0.5">
|
||||||
|
{% set cover_bild = eintrag.bild_url %}
|
||||||
|
{% set cover_titel = eintrag.titel %}
|
||||||
|
{% include "neuheiten/_cover.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<h3 class="text-sm font-medium leading-snug break-words">
|
||||||
|
{{ eintrag.titel }}
|
||||||
|
{% if eintrag.bgg_id %}
|
||||||
|
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
|
||||||
|
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ eintrag.bgg_id }}</a>
|
||||||
|
{% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %}
|
||||||
|
<a href="{{ eintrag.quellen_url or '#' }}" target="_blank" rel="noopener"
|
||||||
|
title="Zur Quelle ({{ eintrag.quelle }})"
|
||||||
|
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }} ↗</a>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ eintrag.status }}</span>
|
||||||
|
</div>
|
||||||
|
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
|
||||||
|
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
|
||||||
|
{% if eintrag.autor %}<div><dt class="inline font-medium text-slate-400">Autor: </dt><dd class="inline">{{ eintrag.autor }}</dd></div>{% endif %}
|
||||||
|
{% if eintrag.erscheinungsjahr %}<div><dt class="inline font-medium text-slate-400">Jahr: </dt><dd class="inline">{{ eintrag.erscheinungsjahr }}</dd></div>{% endif %}
|
||||||
|
<div>
|
||||||
|
<dt class="inline font-medium text-slate-400">Aktualisiert: </dt>
|
||||||
|
<dd class="inline">{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{% if eintrag.status == 'neuheit' %}
|
||||||
|
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}" class="mt-2">
|
||||||
|
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
|
||||||
|
class="w-full min-h-[44px] inline-flex items-center justify-center gap-1 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">→ Zur Planung</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="bg-white rounded-xl border border-slate-200 shadow-sm px-4 py-8 text-center text-sm text-slate-400">
|
||||||
|
Keine Einträge gefunden.
|
||||||
|
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Desktop/Tablet (≥768px): Tabellenform -->
|
||||||
|
<div class="hidden md:block overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead class="bg-slate-50 text-left text-slate-500">
|
<thead class="bg-slate-50 text-left text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-medium">Cover</th>
|
||||||
{% set basis = '/neuheiten?q=' ~ (q | urlencode) ~ '&status=' ~ (status_filter | urlencode) %}
|
{% set basis = '/neuheiten?q=' ~ (q | urlencode) ~ '&status=' ~ (status_filter | urlencode) %}
|
||||||
{% for key, label in [('titel', 'Spieltitel'), ('verlag', 'Verlag'), ('autor', 'Autor'), ('jahr', 'Erscheinungsjahr'), ('status', 'Status'), ('aktualisiert', 'Aktualisiert')] %}
|
{% for key, label in [('titel', 'Spieltitel'), ('verlag', 'Verlag'), ('autor', 'Autor'), ('jahr', 'Erscheinungsjahr'), ('status', 'Status'), ('aktualisiert', 'Aktualisiert')] %}
|
||||||
<th class="px-4 py-3 font-medium whitespace-nowrap">
|
<th class="px-4 py-3 font-medium whitespace-nowrap">
|
||||||
@@ -21,10 +73,21 @@
|
|||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
{% for eintrag in eintraege %}
|
{% for eintrag in eintraege %}
|
||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% set cover_bild = eintrag.bild_url %}
|
||||||
|
{% set cover_titel = eintrag.titel %}
|
||||||
|
{% include "neuheiten/_cover.html" %}
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 font-medium">
|
<td class="px-4 py-3 font-medium">
|
||||||
{{ eintrag.titel }}
|
{{ eintrag.titel }}
|
||||||
|
{% if eintrag.bgg_id %}
|
||||||
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
|
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
|
||||||
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ eintrag.bgg_id }}</a>
|
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ eintrag.bgg_id }}</a>
|
||||||
|
{% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %}
|
||||||
|
<a href="{{ eintrag.quellen_url or '#' }}" target="_blank" rel="noopener"
|
||||||
|
title="Zur Quelle ({{ eintrag.quelle }})"
|
||||||
|
class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }} ↗</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
|
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
|
||||||
<td class="px-4 py-3">{{ eintrag.autor or '—' }}</td>
|
<td class="px-4 py-3">{{ eintrag.autor or '—' }}</td>
|
||||||
@@ -39,14 +102,14 @@
|
|||||||
{% if eintrag.status == 'neuheit' %}
|
{% if eintrag.status == 'neuheit' %}
|
||||||
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}">
|
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}">
|
||||||
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
|
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
|
||||||
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">→ Zur Planung</button>
|
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">→ Zur Planung</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="px-4 py-8 text-center text-slate-400">
|
<td colspan="8" class="px-4 py-8 text-center text-slate-400">
|
||||||
Keine Einträge gefunden.
|
Keine Einträge gefunden.
|
||||||
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -56,3 +119,10 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if hat_weitere %}
|
||||||
|
<div id="lade-mehr"
|
||||||
|
hx-get="/neuheiten?seite_nr={{ naechste_seite }}&q={{ q }}&status={{ status_filter }}&sort={{ sort }}&richtung={{ richtung }}"
|
||||||
|
hx-trigger="revealed delay:150ms" hx-swap="outerHTML">
|
||||||
|
<div class="py-6 text-center text-sm text-slate-400">Lade weitere Einträge …</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -20,16 +20,16 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||||
{% if ist_redaktion %}
|
{% if ist_redaktion %}
|
||||||
<form method="post" action="/neuheiten/sync" class="flex items-end gap-2">
|
<form method="post" action="/neuheiten/sync" class="flex flex-col sm:flex-row items-stretch sm:items-end gap-2">
|
||||||
<div>
|
<div class="sm:w-72">
|
||||||
<label for="suchbegriff" class="block text-xs font-medium text-slate-500 mb-1">
|
<label for="suchbegriff" class="block text-xs font-medium text-slate-500 mb-1">
|
||||||
Sofort auf BoardGameGeek suchen & importieren (optional)
|
Sofort auf BoardGameGeek suchen & importieren (optional)
|
||||||
</label>
|
</label>
|
||||||
<input type="text" id="suchbegriff" name="suchbegriff" placeholder="Spielname oder Suchbegriff …"
|
<input type="text" id="suchbegriff" name="suchbegriff" placeholder="Spielname oder Suchbegriff …"
|
||||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-72 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
class="min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
||||||
↻ Jetzt synchronisieren
|
↻ Jetzt synchronisieren
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -41,20 +41,105 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 bg-white rounded-xl border border-slate-200 shadow-sm p-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500 mr-auto">Web-Quellen</h2>
|
||||||
|
{% if ist_redaktion %}
|
||||||
|
<form method="post" action="/neuheiten/quellen-sync">
|
||||||
|
<button type="submit"
|
||||||
|
class="min-h-[44px] md:min-h-0 bg-sky-600 hover:bg-sky-700 active:bg-sky-800 text-white font-medium px-3 py-1.5 rounded-lg text-xs whitespace-nowrap">
|
||||||
|
↻ Web-Quellen synchronisieren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead class="text-left text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1.5 font-medium">Quelle</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium">Status</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium">Letzter Lauf</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium text-right">Neu</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium text-right">Aktualisiert</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium text-right">Gefiltert</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium">Hinweis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for quelle in quellen_zeilen %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 py-1.5">
|
||||||
|
<a href="{{ quelle.url }}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="font-medium text-slate-700 hover:text-emerald-700">{{ quelle.anzeigename }}</a>
|
||||||
|
<span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1.5">
|
||||||
|
<form method="post" action="/neuheiten/quellen/{{ quelle.name }}/schalter" class="inline-flex items-center gap-2">
|
||||||
|
{% if sync_aktiv %}
|
||||||
|
<input type="hidden" name="aktiv" value="{{ '0' if quelle.aktiv else '1' }}">
|
||||||
|
<button type="submit"
|
||||||
|
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors {{ 'bg-emerald-500' if quelle.aktiv else 'bg-slate-300' }}"
|
||||||
|
title="{{ 'Quelle ausschalten' if quelle.aktiv else 'Quelle einschalten' }}">
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {{ 'translate-x-4' if quelle.aktiv else 'translate-x-0.5' }}"></span>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-slate-400">{{ 'an' if quelle.aktiv else 'aus' }} (Env)</span>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/neuheiten/quellen-sync" class="inline">
|
||||||
|
<input type="hidden" name="quelle" value="{{ quelle.name }}">
|
||||||
|
<button type="submit"
|
||||||
|
class="ml-2 text-xs text-emerald-700 hover:text-emerald-900 underline decoration-dotted"
|
||||||
|
title="Nur diese Quelle jetzt synchronisieren">jetzt sync</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1.5 whitespace-nowrap text-slate-500">
|
||||||
|
{{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }}
|
||||||
|
{% if quelle.dauer_sekunden %}<span class="text-slate-300">({{ '%.1f' | format(quelle.dauer_sekunden) }} s)</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1.5 text-right">{{ quelle.neu }}</td>
|
||||||
|
<td class="px-2 py-1.5 text-right">{{ quelle.aktualisiert }}</td>
|
||||||
|
<td class="px-2 py-1.5 text-right">{{ quelle.gefiltert }}</td>
|
||||||
|
<td class="px-2 py-1.5">
|
||||||
|
{% if quelle.fehlermeldung %}<span class="text-red-600">{{ quelle.fehlermeldung }}</span>
|
||||||
|
{% elif quelle.unveraendert %}<span class="text-slate-400">unverändert (304) — übersprungen</span>
|
||||||
|
{% elif not quelle.aktiv %}<span class="text-slate-300">abgeschaltet</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-[11px] text-slate-400">
|
||||||
|
Quellen-Sync:
|
||||||
|
{% if quellen_sync_aktiv %}<span class="text-emerald-700 font-medium">täglich aktiv</span>{% else %}<span class="text-slate-500">ausgeschaltet</span>{% endif %}
|
||||||
|
· max. 1 Anfrage/Sekunde je Quelle · Duplikate gegen BGG werden per Titel-Match gefiltert
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="get" action="/neuheiten"
|
<form method="get" action="/neuheiten"
|
||||||
hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML"
|
hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML"
|
||||||
hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status']"
|
hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status'], change from:find select[name='jahr']"
|
||||||
class="flex flex-wrap gap-3 mb-4">
|
class="grid grid-cols-1 sm:flex sm:flex-wrap gap-3 mb-4">
|
||||||
<input type="text" name="q" value="{{ q }}" placeholder="Suche in Titel, Verlag, Autor …"
|
<input type="text" name="q" value="{{ q }}" placeholder="Suche in Titel, Verlag, Autor …"
|
||||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-80 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 sm:w-80">
|
||||||
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
|
<select name="status" class="w-full sm:w-auto min-h-[44px] md:min-h-0 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
|
||||||
<option value="">Alle Status</option>
|
<option value="">Alle Status</option>
|
||||||
{% for option in status_optionen %}
|
{% for option in status_optionen %}
|
||||||
<option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option>
|
<option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
<select name="jahr" class="w-full sm:w-auto min-h-[44px] md:min-h-0 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
|
||||||
|
<option value="{{ jahr_standard }}">ab {{ jahr_standard }}</option>
|
||||||
|
{% for jahr in jahr_optionen %}
|
||||||
|
<option value="{{ jahr }}" {% if jahr|string == jahr_filter|string %}selected{% endif %}>{{ jahr }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="" {% if not jahr_filter %}selected{% endif %}>Alle Jahre</option>
|
||||||
|
</select>
|
||||||
<noscript>
|
<noscript>
|
||||||
<button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm">Filtern</button>
|
<button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm min-h-[44px] md:min-h-0">Filtern</button>
|
||||||
</noscript>
|
</noscript>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
from fastapi import Depends, Form, Request
|
from fastapi import Depends, Form, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from redaktionskern.auth.deps import AccessDenied, get_db, require_user
|
from redaktionskern.auth.deps import AccessDenied, get_db, require_user
|
||||||
@@ -83,7 +83,53 @@ class PlanungPlugin(BasePlugin):
|
|||||||
# ---------- Plugin-Vertrag ----------
|
# ---------- Plugin-Vertrag ----------
|
||||||
|
|
||||||
def migrations(self) -> list[Migration]:
|
def migrations(self) -> list[Migration]:
|
||||||
return [Migration(version="0001_planungsliste", up=_planungs_tabelle)]
|
def bild_url_spalte(conn) -> None:
|
||||||
|
"""Migration 0002: Spalte bild_url für Coverbilder (nullable).
|
||||||
|
|
||||||
|
Portabel (SQLite/Postgres): `ALTER TABLE … ADD COLUMN` mit
|
||||||
|
nullable VARCHAR ohne Default; bestehende Zeilen bleiben erhalten.
|
||||||
|
"""
|
||||||
|
if conn.dialect.name == "sqlite":
|
||||||
|
info = conn.exec_driver_sql(
|
||||||
|
"PRAGMA table_info(planungsliste)"
|
||||||
|
).fetchall()
|
||||||
|
if any(zeile[1] == "bild_url" for zeile in info):
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
existiert = conn.exec_driver_sql(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'planungsliste' AND column_name = 'bild_url'"
|
||||||
|
).scalar()
|
||||||
|
if existiert is not None:
|
||||||
|
return
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE planungsliste ADD COLUMN bild_url VARCHAR(500)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def quellen_url_spalte(conn) -> None:
|
||||||
|
"""Migration 0003: Spalte quellen_url (Link zur Herkunftsquelle)."""
|
||||||
|
if conn.dialect.name == "sqlite":
|
||||||
|
info = conn.exec_driver_sql(
|
||||||
|
"PRAGMA table_info(planungsliste)"
|
||||||
|
).fetchall()
|
||||||
|
if any(zeile[1] == "quellen_url" for zeile in info):
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
existiert = conn.exec_driver_sql(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'planungsliste' AND column_name = 'quellen_url'"
|
||||||
|
).scalar()
|
||||||
|
if existiert is not None:
|
||||||
|
return
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE planungsliste ADD COLUMN quellen_url VARCHAR(500)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
Migration(version="0001_planungsliste", up=_planungs_tabelle),
|
||||||
|
Migration(version="0002_bild_url", up=bild_url_spalte),
|
||||||
|
Migration(version="0003_quellen_url", up=quellen_url_spalte),
|
||||||
|
]
|
||||||
|
|
||||||
def navigation(self) -> list[NavEntry]:
|
def navigation(self) -> list[NavEntry]:
|
||||||
return [NavEntry(label=self.title, url="/planung")]
|
return [NavEntry(label=self.title, url="/planung")]
|
||||||
@@ -161,6 +207,7 @@ class PlanungPlugin(BasePlugin):
|
|||||||
aktion: str,
|
aktion: str,
|
||||||
versteckt: dict[str, str],
|
versteckt: dict[str, str],
|
||||||
abbrechen_url: str = "/planung",
|
abbrechen_url: str = "/planung",
|
||||||
|
bild_url: str | None = None,
|
||||||
):
|
):
|
||||||
"""Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?"""
|
"""Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?"""
|
||||||
return self.context.templates.TemplateResponse(
|
return self.context.templates.TemplateResponse(
|
||||||
@@ -170,6 +217,7 @@ class PlanungPlugin(BasePlugin):
|
|||||||
"user": user,
|
"user": user,
|
||||||
"titel_headline": f"Dedup-Prüfung — {titel}",
|
"titel_headline": f"Dedup-Prüfung — {titel}",
|
||||||
"spiel_titel": titel,
|
"spiel_titel": titel,
|
||||||
|
"bild_url": bild_url,
|
||||||
"konflikte": ergebnis.konflikte,
|
"konflikte": ergebnis.konflikte,
|
||||||
"konflikt_anzeige": KONFLIKT_ANZEIGE,
|
"konflikt_anzeige": KONFLIKT_ANZEIGE,
|
||||||
"titel_empfehlung": ergebnis.titel_empfehlung,
|
"titel_empfehlung": ergebnis.titel_empfehlung,
|
||||||
@@ -192,15 +240,22 @@ class PlanungPlugin(BasePlugin):
|
|||||||
status: str = "",
|
status: str = "",
|
||||||
meldung: str = "",
|
meldung: str = "",
|
||||||
fehler: str = "",
|
fehler: str = "",
|
||||||
|
seite_nr: int = 1,
|
||||||
):
|
):
|
||||||
"""Planungsliste: alle Einträge mit Zuordnung und Statuswechsel."""
|
"""Planungsliste: Einträge paginiert (50/Seite) mit Zuordnung."""
|
||||||
|
pro_seite = 50
|
||||||
abfrage = select(Planungseintrag)
|
abfrage = select(Planungseintrag)
|
||||||
if status in GUELTIGE_STATUS:
|
if status in GUELTIGE_STATUS:
|
||||||
abfrage = abfrage.where(Planungseintrag.status == status)
|
abfrage = abfrage.where(Planungseintrag.status == status)
|
||||||
|
gesamt = db.scalar(
|
||||||
|
select(func.count()).select_from(abfrage.subquery())
|
||||||
|
) or 0
|
||||||
eintraege = db.scalars(
|
eintraege = db.scalars(
|
||||||
abfrage.order_by(
|
abfrage.order_by(
|
||||||
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
|
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
|
||||||
)
|
)
|
||||||
|
.offset((max(seite_nr, 1) - 1) * pro_seite)
|
||||||
|
.limit(pro_seite)
|
||||||
).all()
|
).all()
|
||||||
namen = {
|
namen = {
|
||||||
benutzer.id: (benutzer.display_name or benutzer.username)
|
benutzer.id: (benutzer.display_name or benutzer.username)
|
||||||
@@ -221,7 +276,9 @@ class PlanungPlugin(BasePlugin):
|
|||||||
"ist_redaktion": user.role in REDAKTION,
|
"ist_redaktion": user.role in REDAKTION,
|
||||||
"meldung": meldung[:300],
|
"meldung": meldung[:300],
|
||||||
"fehler": fehler[:300],
|
"fehler": fehler[:300],
|
||||||
"anzahl": len(eintraege),
|
"anzahl": gesamt,
|
||||||
|
"hat_weitere": max(seite_nr, 1) * pro_seite < gesamt,
|
||||||
|
"naechste_seite": max(seite_nr, 1) + 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -354,6 +411,7 @@ class PlanungPlugin(BasePlugin):
|
|||||||
aktion=f"/planung/uebernehmen/{neuheit_id}",
|
aktion=f"/planung/uebernehmen/{neuheit_id}",
|
||||||
versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"},
|
versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"},
|
||||||
abbrechen_url="/neuheiten",
|
abbrechen_url="/neuheiten",
|
||||||
|
bild_url=zeile.bild_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None
|
gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None
|
||||||
@@ -362,6 +420,8 @@ class PlanungPlugin(BasePlugin):
|
|||||||
verlag=gewaehlter_verlag,
|
verlag=gewaehlter_verlag,
|
||||||
autor=zeile.autor,
|
autor=zeile.autor,
|
||||||
bgg_id=zeile.bgg_id,
|
bgg_id=zeile.bgg_id,
|
||||||
|
bild_url=zeile.bild_url,
|
||||||
|
quellen_url=zeile.quellen_url,
|
||||||
ausgabe="",
|
ausgabe="",
|
||||||
rezensent_id=user.id,
|
rezensent_id=user.id,
|
||||||
status="offen",
|
status="offen",
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class Planungseintrag(Base):
|
|||||||
autor: Mapped[str | None] = mapped_column(String(300))
|
autor: Mapped[str | None] = mapped_column(String(300))
|
||||||
#: BoardGameGeek-ID, sofern bekannt (Match-Kriterium der dedup-Prüfung).
|
#: BoardGameGeek-ID, sofern bekannt (Match-Kriterium der dedup-Prüfung).
|
||||||
bgg_id: Mapped[int | None] = mapped_column(Integer)
|
bgg_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
#: URL des Coverbildes (aus der Neuheitenliste übernommen oder leer).
|
||||||
|
bild_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
quellen_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
#: Magazin-Ausgabe, z. B. „3/2025“ (mehrere Ausgaben parallel möglich).
|
#: Magazin-Ausgabe, z. B. „3/2025“ (mehrere Ausgaben parallel möglich).
|
||||||
ausgabe: Mapped[str] = mapped_column(String(100), default="")
|
ausgabe: Mapped[str] = mapped_column(String(100), default="")
|
||||||
rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||||
|
|||||||
24
plugins/planung/templates/planung/_cover.html
Normal file
24
plugins/planung/templates/planung/_cover.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{# Coverbild-Vorschau des Planung-Plugins.
|
||||||
|
Erwartete Variablen: cover_bild (URL oder None), cover_titel,
|
||||||
|
cover_gross (optional: true für großformatige Ansichten).
|
||||||
|
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
|
||||||
|
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
|
||||||
|
das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #}
|
||||||
|
{% set _gross = cover_gross | default(false) %}
|
||||||
|
{% if _gross %}
|
||||||
|
{% if cover_bild %}
|
||||||
|
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
|
||||||
|
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
|
{% else %}
|
||||||
|
<div aria-hidden="true" title="Kein Cover vorhanden"
|
||||||
|
class="flex h-[210px] w-[150px] max-w-[200px] items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-6xl text-slate-300 select-none">🎲</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if cover_bild %}
|
||||||
|
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="40" height="56"
|
||||||
|
class="block h-14 w-10 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
|
||||||
|
{% else %}
|
||||||
|
<div aria-hidden="true" title="Kein Cover vorhanden"
|
||||||
|
class="flex h-14 w-10 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-lg text-slate-400 select-none">🎲</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
@@ -6,8 +6,16 @@
|
|||||||
{% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %}
|
{% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-5 items-start mb-4">
|
||||||
|
<div class="shrink-0 mx-auto sm:mx-0">
|
||||||
|
{% set cover_bild = eintrag.bild_url %}
|
||||||
|
{% set cover_titel = eintrag.titel %}
|
||||||
|
{% set cover_gross = true %}
|
||||||
|
{% include "planung/_cover.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/planung/{{ eintrag.id }}/bearbeiten"
|
<form method="post" action="/planung/{{ eintrag.id }}/bearbeiten"
|
||||||
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4">
|
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4 w-full">
|
||||||
<div>
|
<div>
|
||||||
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
|
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
|
||||||
<input type="text" id="titel" name="titel" required value="{{ eintrag.titel }}"
|
<input type="text" id="titel" name="titel" required value="{{ eintrag.titel }}"
|
||||||
@@ -38,4 +46,5 @@
|
|||||||
<a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
|
<a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -17,29 +17,30 @@
|
|||||||
|
|
||||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
||||||
<h2 class="font-semibold mb-3">Titel händisch nachtragen</h2>
|
<h2 class="font-semibold mb-3">Titel händisch nachtragen</h2>
|
||||||
<form method="post" action="/planung/neu" class="flex flex-wrap items-end gap-3">
|
<!-- Mobil: Felder untereinander vollbreit; ab sm: flexibel nebeneinander -->
|
||||||
<div>
|
<form method="post" action="/planung/neu" class="grid grid-cols-1 gap-3 sm:flex sm:flex-wrap sm:items-end">
|
||||||
|
<div class="sm:w-64 lg:w-72">
|
||||||
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
|
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
|
||||||
<input type="text" id="titel" name="titel" required
|
<input type="text" id="titel" name="titel" required
|
||||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-72 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="sm:w-48 lg:w-52">
|
||||||
<label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label>
|
<label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label>
|
||||||
<input type="text" id="verlag" name="verlag"
|
<input type="text" id="verlag" name="verlag"
|
||||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-52 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="sm:w-32">
|
||||||
<label for="ausgabe" class="block text-xs font-medium text-slate-500 mb-1">Ausgabe</label>
|
<label for="ausgabe" class="block text-xs font-medium text-slate-500 mb-1">Ausgabe</label>
|
||||||
<input type="text" id="ausgabe" name="ausgabe" placeholder="z. B. 3/2025"
|
<input type="text" id="ausgabe" name="ausgabe" placeholder="z. B. 3/2025"
|
||||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="sm:w-32">
|
||||||
<label for="bgg_id" class="block text-xs font-medium text-slate-500 mb-1">BGG-ID (optional)</label>
|
<label for="bgg_id" class="block text-xs font-medium text-slate-500 mb-1">BGG-ID (optional)</label>
|
||||||
<input type="text" id="bgg_id" name="bgg_id"
|
<input type="text" id="bgg_id" name="bgg_id" inputmode="numeric"
|
||||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
class="w-full sm:w-auto min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
||||||
Prüfen & eintragen
|
Prüfen & eintragen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -47,22 +48,99 @@
|
|||||||
|
|
||||||
<div class="flex items-center mb-3 gap-3">
|
<div class="flex items-center mb-3 gap-3">
|
||||||
<form method="get" action="/planung" class="flex items-center gap-2">
|
<form method="get" action="/planung" class="flex items-center gap-2">
|
||||||
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
|
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white min-h-[44px] md:min-h-0"
|
||||||
onchange="this.form.submit()">
|
onchange="this.form.submit()">
|
||||||
<option value="">Alle Status</option>
|
<option value="">Alle Status</option>
|
||||||
{% for wert in status_optionen %}
|
{% for wert in status_optionen %}
|
||||||
<option value="{{ wert }}" {% if wert == status_filter %}selected{% endif %}>{{ status_anzeige[wert] }}</option>
|
<option value="{{ wert }}" {% if wert == status_filter %}selected{% endif %}>{{ status_anzeige[wert] }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<noscript><button type="submit" class="bg-slate-200 hover:bg-slate-300 px-3 py-2 rounded-lg text-sm">Filtern</button></noscript>
|
<noscript><button type="submit" class="bg-slate-200 hover:bg-slate-300 px-3 py-2 rounded-lg text-sm min-h-[44px] md:min-h-0">Filtern</button></noscript>
|
||||||
</form>
|
</form>
|
||||||
<p class="text-xs text-slate-400 ml-auto">{{ anzahl }} Einträge</p>
|
<p class="text-xs text-slate-400 ml-auto">{{ anzahl }} Einträge</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
|
<!-- Mobil (<768px): Karten-Ansicht — Cover links, Titel+Metadaten rechts, Status oben -->
|
||||||
|
<ul class="md:hidden space-y-3" role="list">
|
||||||
|
{% for eintrag in eintraege %}
|
||||||
|
<li class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 flex gap-3">
|
||||||
|
<div class="pt-0.5">
|
||||||
|
{% set cover_bild = eintrag.bild_url %}
|
||||||
|
{% set cover_titel = eintrag.titel %}
|
||||||
|
{% include "planung/_cover.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<h3 class="text-sm font-medium leading-snug break-words">
|
||||||
|
{{ eintrag.titel }}
|
||||||
|
{% if eintrag.bgg_id %}
|
||||||
|
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
|
||||||
|
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ eintrag.bgg_id }}</a>
|
||||||
|
{% elif eintrag.quellen_url %}
|
||||||
|
<a href="{{ eintrag.quellen_url }}" target="_blank" rel="noopener"
|
||||||
|
title="Zur Quelle ({{ eintrag.quelle }})"
|
||||||
|
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }} ↗</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if eintrag.pruefung %}
|
||||||
|
<span class="ml-0.5 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs align-middle"
|
||||||
|
title="Die dedup-Prüfung hatte beim Eintragen Treffer.">⚠ Prüfhinweis</span>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
{% if eintrag.status == 'offen' %}
|
||||||
|
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-700 text-xs">{{ status_anzeige[eintrag.status] }}</span>
|
||||||
|
{% elif eintrag.status == 'in_bearbeitung' %}
|
||||||
|
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
|
||||||
|
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
|
||||||
|
{% if eintrag.ausgabe %}<div><dt class="inline font-medium text-slate-400">Ausgabe: </dt><dd class="inline">{{ eintrag.ausgabe }}</dd></div>{% endif %}
|
||||||
|
<div><dt class="inline font-medium text-slate-400">Rezensent: </dt><dd class="inline">{{ namen.get(eintrag.rezensent_id, '—') }}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt class="inline font-medium text-slate-400">Aktualisiert: </dt>
|
||||||
|
<dd class="inline">{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{% if kann_bearbeiten(eintrag) %}
|
||||||
|
<div class="mt-2 grid grid-cols-1 gap-2">
|
||||||
|
{% if eintrag.status == 'offen' %}
|
||||||
|
<form method="post" action="/planung/{{ eintrag.id }}/status">
|
||||||
|
<input type="hidden" name="status" value="in_bearbeitung">
|
||||||
|
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center bg-sky-600 hover:bg-sky-700 active:bg-sky-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">In Bearbeitung</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if eintrag.status != 'abgeschlossen' %}
|
||||||
|
<form method="post" action="/planung/{{ eintrag.id }}/status">
|
||||||
|
<input type="hidden" name="status" value="abgeschlossen">
|
||||||
|
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">Abgeschlossen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/planung/{{ eintrag.id }}/bearbeiten"
|
||||||
|
class="min-h-[44px] inline-flex items-center justify-center border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-3 py-2 rounded-lg text-sm whitespace-nowrap">Bearbeiten</a>
|
||||||
|
<form method="post" action="/planung/{{ eintrag.id }}/loeschen">
|
||||||
|
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center border border-red-200 text-red-600 hover:bg-red-50 px-3 py-2 rounded-lg text-sm whitespace-nowrap">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-2 text-xs text-slate-400">nur {{ namen.get(eintrag.rezensent_id, '') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="bg-white rounded-xl border border-slate-200 shadow-sm px-4 py-8 text-center text-sm text-slate-400">
|
||||||
|
Noch keine Einträge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben händisch nach.
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Desktop/Tablet (≥768px): Tabellenform -->
|
||||||
|
<div class="hidden md:block overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead class="bg-slate-50 text-left text-slate-500">
|
<thead class="bg-slate-50 text-left text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-medium">Cover</th>
|
||||||
<th class="px-4 py-3 font-medium">Spieltitel</th>
|
<th class="px-4 py-3 font-medium">Spieltitel</th>
|
||||||
<th class="px-4 py-3 font-medium">Verlag</th>
|
<th class="px-4 py-3 font-medium">Verlag</th>
|
||||||
<th class="px-4 py-3 font-medium">Ausgabe</th>
|
<th class="px-4 py-3 font-medium">Ausgabe</th>
|
||||||
@@ -75,11 +153,20 @@
|
|||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
{% for eintrag in eintraege %}
|
{% for eintrag in eintraege %}
|
||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% set cover_bild = eintrag.bild_url %}
|
||||||
|
{% set cover_titel = eintrag.titel %}
|
||||||
|
{% include "planung/_cover.html" %}
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 font-medium">
|
<td class="px-4 py-3 font-medium">
|
||||||
{{ eintrag.titel }}
|
{{ eintrag.titel }}
|
||||||
{% if eintrag.bgg_id %}
|
{% if eintrag.bgg_id %}
|
||||||
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
|
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
|
||||||
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ eintrag.bgg_id }}</a>
|
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ eintrag.bgg_id }}</a>
|
||||||
|
{% elif eintrag.quellen_url %}
|
||||||
|
<a href="{{ eintrag.quellen_url }}" target="_blank" rel="noopener"
|
||||||
|
title="Zur Quelle ({{ eintrag.quelle }})"
|
||||||
|
class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }} ↗</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if eintrag.pruefung %}
|
{% if eintrag.pruefung %}
|
||||||
<span class="ml-1 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs"
|
<span class="ml-1 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs"
|
||||||
@@ -107,19 +194,19 @@
|
|||||||
{% if eintrag.status == 'offen' %}
|
{% if eintrag.status == 'offen' %}
|
||||||
<form method="post" action="/planung/{{ eintrag.id }}/status">
|
<form method="post" action="/planung/{{ eintrag.id }}/status">
|
||||||
<input type="hidden" name="status" value="in_bearbeitung">
|
<input type="hidden" name="status" value="in_bearbeitung">
|
||||||
<button type="submit" class="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">In Bearbeitung</button>
|
<button type="submit" class="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">In Bearbeitung</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if eintrag.status != 'abgeschlossen' %}
|
{% if eintrag.status != 'abgeschlossen' %}
|
||||||
<form method="post" action="/planung/{{ eintrag.id }}/status">
|
<form method="post" action="/planung/{{ eintrag.id }}/status">
|
||||||
<input type="hidden" name="status" value="abgeschlossen">
|
<input type="hidden" name="status" value="abgeschlossen">
|
||||||
<button type="submit" class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">Abgeschlossen</button>
|
<button type="submit" class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">Abgeschlossen</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/planung/{{ eintrag.id }}/bearbeiten"
|
<a href="/planung/{{ eintrag.id }}/bearbeiten"
|
||||||
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2 py-1 rounded-lg whitespace-nowrap">Bearbeiten</a>
|
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2.5 py-2 rounded-lg whitespace-nowrap">Bearbeiten</a>
|
||||||
<form method="post" action="/planung/{{ eintrag.id }}/loeschen">
|
<form method="post" action="/planung/{{ eintrag.id }}/loeschen">
|
||||||
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">Löschen</button>
|
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2.5 py-2 rounded-lg whitespace-nowrap">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -129,7 +216,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="px-4 py-8 text-center text-slate-400">
|
<td colspan="8" class="px-4 py-8 text-center text-slate-400">
|
||||||
Noch keine Einträge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben händisch nach.
|
Noch keine Einträge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben händisch nach.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -137,4 +224,11 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{% if hat_weitere %}
|
||||||
|
<div id="planung-lade-mehr"
|
||||||
|
hx-get="/planung?seite_nr={{ naechste_seite }}&status={{ status_filter }}"
|
||||||
|
hx-trigger="revealed delay:150ms" hx-swap="outerHTML">
|
||||||
|
<div class="py-6 text-center text-sm text-slate-400">Lade weitere Einträge …</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,7 +7,14 @@
|
|||||||
der Eintrag angelegt wird.
|
der Eintrag angelegt wird.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-6 text-sm">
|
<div class="flex flex-col sm:flex-row gap-4 items-start">
|
||||||
|
<div class="shrink-0 mx-auto sm:mx-0">
|
||||||
|
{% set cover_bild = bild_url %}
|
||||||
|
{% set cover_titel = spiel_titel %}
|
||||||
|
{% set cover_gross = true %}
|
||||||
|
{% include "planung/_cover.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-0 sm:mb-6 text-sm w-full">
|
||||||
<p class="font-medium text-amber-800 mb-2">⚠ {{ konflikte|length }} Treffer für „{{ spiel_titel }}“</p>
|
<p class="font-medium text-amber-800 mb-2">⚠ {{ konflikte|length }} Treffer für „{{ spiel_titel }}“</p>
|
||||||
<ul class="list-disc list-inside space-y-1 text-slate-700">
|
<ul class="list-disc list-inside space-y-1 text-slate-700">
|
||||||
{% for konflikt in konflikte %}
|
{% for konflikt in konflikte %}
|
||||||
@@ -27,6 +34,7 @@
|
|||||||
<p class="mt-2 text-slate-600">Empfohlener Titel: <span class="font-medium">{{ titel_empfehlung }}</span></p>
|
<p class="mt-2 text-slate-600">Empfohlener Titel: <span class="font-medium">{{ titel_empfehlung }}</span></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{{ aktion }}" class="bg-white border border-slate-200 rounded-xl shadow-sm p-4">
|
<form method="post" action="{{ aktion }}" class="bg-white border border-slate-200 rounded-xl shadow-sm p-4">
|
||||||
{% for name, wert in versteckt.items() %}
|
{% for name, wert in versteckt.items() %}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"argon2-cffi>=23.1",
|
"argon2-cffi>=23.1",
|
||||||
"apscheduler>=3.11,<4",
|
"apscheduler>=3.11,<4",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"beautifulsoup4>=4.12",
|
||||||
"rapidfuzz>=3.14.5",
|
"rapidfuzz>=3.14.5",
|
||||||
"weasyprint>=69.0",
|
"weasyprint>=69.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
|
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@@ -109,6 +110,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
)
|
)
|
||||||
|
# Statische Kern-Dateien (z. B. „Powered by BGG“-Logo, Pflicht für
|
||||||
|
# öffentliche BGG-XML-API-Anwendungen).
|
||||||
|
statisch = Path(__file__).parent / "static"
|
||||||
|
app.mount("/static", StaticFiles(directory=statisch), name="statisch")
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.engine = engine
|
app.state.engine = engine
|
||||||
app.state.session_factory = session_factory
|
app.state.session_factory = session_factory
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
|
import types
|
||||||
from importlib.metadata import entry_points
|
from importlib.metadata import entry_points
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -81,6 +82,19 @@ def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]:
|
|||||||
if spec is None or spec.loader is None:
|
if spec is None or spec.loader is None:
|
||||||
raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.")
|
raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.")
|
||||||
modul = importlib.util.module_from_spec(spec)
|
modul = importlib.util.module_from_spec(spec)
|
||||||
|
# Eltern-Paketnamen registrieren, damit relative Imports aus
|
||||||
|
# Plugin-Unterpackages (z. B. `from .basis import …` in
|
||||||
|
# neuheiten/quellen/) aufgelöst werden können.
|
||||||
|
eltern = modulname.rpartition(".")[0]
|
||||||
|
if eltern and eltern not in sys.modules:
|
||||||
|
# Das Eltern-Paket (Namensraum `spiele_redaktion_plugins`) hat
|
||||||
|
# kein __init__.py — spec_from_file_location liefert dafür None.
|
||||||
|
# Ein leeres Namespace-Modul mit __path__ genügt, damit relative
|
||||||
|
# Imports aus Plugin-Unterpackages (z. B. neuheiten/quellen/)
|
||||||
|
# aufgelöst werden können.
|
||||||
|
eltern_modul = types.ModuleType(eltern)
|
||||||
|
eltern_modul.__path__ = [str(eintrag.parent)]
|
||||||
|
sys.modules[eltern] = eltern_modul
|
||||||
sys.modules[modulname] = modul
|
sys.modules[modulname] = modul
|
||||||
spec.loader.exec_module(modul)
|
spec.loader.exec_module(modul)
|
||||||
if not hasattr(modul, "plugin"):
|
if not hasattr(modul, "plugin"):
|
||||||
|
|||||||
BIN
src/redaktionskern/static/powered-by-bgg.png
Normal file
BIN
src/redaktionskern/static/powered-by-bgg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -9,16 +9,32 @@
|
|||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col">
|
<body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col">
|
||||||
<header class="bg-white border-b border-slate-200">
|
<header class="bg-white border-b border-slate-200" x-data="{ menue: false }">
|
||||||
<nav class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-5" aria-label="Hauptnavigation">
|
<nav class="max-w-6xl mx-auto px-4 py-2 md:py-3 flex items-center gap-5" aria-label="Hauptnavigation">
|
||||||
<a href="/" class="font-bold text-emerald-700 whitespace-nowrap">🎳 {{ app_name }}</a>
|
<a href="/" class="font-bold text-emerald-700 whitespace-nowrap">🎳 {{ app_name }}</a>
|
||||||
|
<!-- Desktop: horizontale Navigationsleiste -->
|
||||||
|
<span class="hidden md:flex items-center gap-5 flex-1">
|
||||||
{% for eintrag in plugin_nav %}
|
{% for eintrag in plugin_nav %}
|
||||||
<a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a>
|
<a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="ml-auto relative" x-data="{ offen: false }">
|
</span>
|
||||||
|
<!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel ≥≥ 44px -->
|
||||||
|
<button type="button" @click="menue = !menue" :aria-expanded="menue.toString()"
|
||||||
|
aria-controls="hauptmenue-mobil" aria-label="Navigation umschalten"
|
||||||
|
class="md:hidden ml-auto inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-slate-600 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||||
|
<svg x-show="!menue" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
<svg x-show="menue" x-cloak xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="relative" x-data="{ offen: false }">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<button type="button" @click="offen = !offen"
|
<button type="button" @click="offen = !offen"
|
||||||
class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100">
|
class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100 inline-flex items-center min-h-[44px] md:min-h-0">
|
||||||
{{ user.display_name }} ▾
|
{{ user.display_name }} ▾
|
||||||
</button>
|
</button>
|
||||||
<div x-show="offen" x-cloak @click.outside="offen = false"
|
<div x-show="offen" x-cloak @click.outside="offen = false"
|
||||||
@@ -33,16 +49,36 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/login" class="text-sm font-medium text-emerald-700">Anmelden</a>
|
<a href="/login" class="text-sm font-medium text-emerald-700 inline-flex items-center min-h-[44px] md:min-h-0 px-2">Anmelden</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<!-- Mobil: ausklappbare Navigation (Burger-Menü) -->
|
||||||
|
<div id="hauptmenue-mobil" x-show="menue" x-cloak @click.outside="menue = false"
|
||||||
|
class="md:hidden border-t border-slate-200 bg-white px-4 pb-3 pt-2">
|
||||||
|
{% for eintrag in plugin_nav %}
|
||||||
|
<a href="{{ eintrag.url }}" @click="menue = false"
|
||||||
|
class="block py-3 text-sm font-medium text-slate-700 hover:text-emerald-700 border-b border-slate-100 last:border-0">
|
||||||
|
{{ eintrag.label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not user %}
|
||||||
|
<a href="/login" @click="menue = false"
|
||||||
|
class="block py-3 text-sm font-medium text-emerald-700 border-t border-slate-100 mt-1">Anmelden</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6">
|
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6">
|
||||||
{% block inhalt %}{% endblock %}
|
{% block inhalt %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
<footer class="text-center text-xs text-slate-400 py-4">
|
<footer class="text-center text-xs text-slate-400 py-4">
|
||||||
{{ app_name }} · Plugin-Anzahl: {{ geladene_plugins|length }}
|
{{ app_name }} · Plugin-Anzahl: {{ geladene_plugins|length }}
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="https://boardgamegeek.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="/static/powered-by-bgg.png" alt="Powered by BoardGameGeek"
|
||||||
|
width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -66,13 +66,13 @@
|
|||||||
class="w-full border border-slate-300 rounded-lg px-3 py-2">
|
class="w-full border border-slate-300 rounded-lg px-3 py-2">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex flex-col sm:flex-row gap-3 pt-2">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-4 py-2">
|
class="min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium rounded-lg px-4 py-2">
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/benutzer"
|
<a href="/admin/benutzer"
|
||||||
class="border border-slate-300 rounded-lg px-4 py-2 text-sm hover:bg-slate-50">Abbrechen</a>
|
class="min-h-[44px] inline-flex items-center justify-center border border-slate-300 rounded-lg px-4 py-2 text-sm hover:bg-slate-50">Abbrechen</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h1 class="text-2xl font-bold">Benutzerverwaltung</h1>
|
<h1 class="text-2xl font-bold">Benutzerverwaltung</h1>
|
||||||
<a href="/admin/benutzer/neu"
|
<a href="/admin/benutzer/neu"
|
||||||
class="bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg px-3 py-2 text-sm font-medium">
|
class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white rounded-lg px-3 py-2 text-sm font-medium min-h-[44px] inline-flex items-center">
|
||||||
+ Neuer Benutzer
|
+ Neuer Benutzer
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,10 +27,16 @@
|
|||||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-3 py-2">
|
class="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium rounded-lg px-3 py-2">
|
||||||
Anmelden
|
Anmelden
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="https://boardgamegeek.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="/static/powered-by-bgg.png" alt="Powered by BoardGameGeek"
|
||||||
|
width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -42,3 +42,48 @@ speichere_oder_aktualisiere = sync_modul.speichere_oder_aktualisiere
|
|||||||
|
|
||||||
Neuheit = models.Neuheit
|
Neuheit = models.Neuheit
|
||||||
STATUS_NEUHEIT = models.STATUS_NEUHEIT
|
STATUS_NEUHEIT = models.STATUS_NEUHEIT
|
||||||
|
|
||||||
|
# ---------- Web-Quellen (Adapter + Sync-Orchestrierung) ----------
|
||||||
|
|
||||||
|
quellen_basis = _neuheiten.quellen.basis
|
||||||
|
quellen_sync_modul = _neuheiten.quellen_sync
|
||||||
|
models = _neuheiten.models # neu mit QuellenStatus
|
||||||
|
|
||||||
|
USER_AGENT = quellen_basis.USER_AGENT
|
||||||
|
QuellenAdapter = quellen_basis.QuellenAdapter
|
||||||
|
QuellenFehler = quellen_basis.QuellenFehler
|
||||||
|
QuellenTreffer = quellen_basis.QuellenTreffer
|
||||||
|
SammelErgebnis = quellen_basis.SammelErgebnis
|
||||||
|
WebQuellenClient = quellen_basis.WebQuellenClient
|
||||||
|
jahr_aus_datumsangabe = quellen_basis.jahr_aus_datumsangabe
|
||||||
|
titel_aehnlichkeit = quellen_basis.titel_aehnlichkeit
|
||||||
|
titel_normalisieren = quellen_basis.titel_normalisieren
|
||||||
|
|
||||||
|
ki_hilfe_modul = _neuheiten.quellen.ki_hilfe
|
||||||
|
KiHilfe = ki_hilfe_modul.KiHilfe
|
||||||
|
KiKonfiguration = ki_hilfe_modul.KiKonfiguration
|
||||||
|
lade_ki_konfiguration = ki_hilfe_modul.lade_konfiguration
|
||||||
|
extrahiere_ki_json = ki_hilfe_modul.extrahiere_json
|
||||||
|
struktur_verdaechtig = ki_hilfe_modul.struktur_verdaechtig
|
||||||
|
treffer_ist_unsicher = ki_hilfe_modul.treffer_ist_unsicher
|
||||||
|
titel_ist_verdaechtig = ki_hilfe_modul.titel_ist_verdaechtig
|
||||||
|
validiere_url_vorschlaege = ki_hilfe_modul.validiere_url_vorschlaege
|
||||||
|
validiere_feld_antwort = ki_hilfe_modul.validiere_feld_antwort
|
||||||
|
MAX_FOLGE_URLS = ki_hilfe_modul.MAX_FOLGE_URLS
|
||||||
|
|
||||||
|
SpielboxQuelle = _neuheiten.quellen.spielbox.SpielboxQuelle
|
||||||
|
BrettspielboxQuelle = _neuheiten.quellen.brettspielbox.BrettspielboxQuelle
|
||||||
|
SpielEssenQuelle = _neuheiten.quellen.spielessen.SpielEssenQuelle
|
||||||
|
CliquenabendQuelle = _neuheiten.quellen.cliquenabend.CliquenabendQuelle
|
||||||
|
|
||||||
|
ADAPTER_KLASSEN = quellen_sync_modul.ADAPTER_KLASSEN
|
||||||
|
WebQuellenSyncService = quellen_sync_modul.WebQuellenSyncService
|
||||||
|
QuellenLaufZeile = quellen_sync_modul.QuellenLaufZeile
|
||||||
|
quelle_aktiv = quellen_sync_modul.quelle_aktiv
|
||||||
|
TITEL_FUZZY_SCHWELLE = quellen_sync_modul.TITEL_FUZZY_SCHWELLE
|
||||||
|
QuellenStatus = models.QuellenStatus
|
||||||
|
|
||||||
|
|
||||||
|
def sys_modules_plugin():
|
||||||
|
"""Die geladene NeuheitenPlugin-Instanz (für Migrationen im Test)."""
|
||||||
|
return sys.modules["spiele_redaktion_plugins.neuheiten"].plugin
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ def _hintergrundjobs_deaktiviert(monkeypatch):
|
|||||||
# Auch die täglichen Hintergrund-Jobs bleiben in Tests aus.
|
# Auch die täglichen Hintergrund-Jobs bleiben in Tests aus.
|
||||||
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "0")
|
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "0")
|
||||||
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0")
|
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0")
|
||||||
|
# Web-Quellen-Sync (spielbox/brettspielbox/…) ebenfalls: keine Threads.
|
||||||
|
monkeypatch.setenv("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "0")
|
||||||
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
|
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
|
||||||
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")
|
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")
|
||||||
# Die LLM-Zweitprüfung des dedup-Plugins bleibt in Tests aus; einzelne
|
# Die LLM-Zweitprüfung des dedup-Plugins bleibt in Tests aus; einzelne
|
||||||
|
|||||||
482
tests/test_covers.py
Normal file
482
tests/test_covers.py
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
"""Tests für die Coverbilder-Erweiterung (bild_url).
|
||||||
|
|
||||||
|
Abgedeckt:
|
||||||
|
- Migrationen: Spalte bild_url existiert in allen vier Tabellen
|
||||||
|
(neuheiten, planungsliste, archiv_neuheiten, archiv_planung);
|
||||||
|
bestehende Zeilen bleiben beim ALTER TABLE erhalten.
|
||||||
|
- BGG-Parsing: `<image>` wird bevorzugt, sonst `<thumbnail>`;
|
||||||
|
protokoll-relative URLs werden auf https normalisiert.
|
||||||
|
- Sync-Upsert: bild_url wird angelegt und beim Update mitgepflegt;
|
||||||
|
ein fehlendes Bild in der Antwort löscht kein gespeichertes.
|
||||||
|
- Web-Quellen: img im Listenelement wird übernommen und gespeichert.
|
||||||
|
- Templates: img-Tag mit loading="lazy"/decoding="async" bei Bild,
|
||||||
|
Platzhalter-Div ohne Bild; große Ansicht in Detailseiten.
|
||||||
|
|
||||||
|
Kein echter Netzwerkverkehr — XML/HTML sind gemockt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from redaktionskern.db import Base
|
||||||
|
from redaktionskern.migrationen import run_migrations
|
||||||
|
|
||||||
|
from tests._neuheiten import (
|
||||||
|
BggSpiel,
|
||||||
|
Neuheit,
|
||||||
|
QuellenTreffer,
|
||||||
|
SpielboxQuelle,
|
||||||
|
WebQuellenClient,
|
||||||
|
WebQuellenSyncService,
|
||||||
|
parse_things,
|
||||||
|
)
|
||||||
|
from tests.conftest import melde_an
|
||||||
|
|
||||||
|
# ---------- Hilfsbausteine ----------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUhr:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.zeit = 0.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.zeit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_factory():
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
|
||||||
|
yield fabrik
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _spalten(conn, tabelle: str) -> set[str]:
|
||||||
|
info = conn.exec_driver_sql(f"PRAGMA table_info({tabelle})").fetchall()
|
||||||
|
return {zeile[1] for zeile in info}
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_modul(app, name: str):
|
||||||
|
"""Das Modul des geladenen Plugins (Registry bevorzugt)."""
|
||||||
|
plugin_instanz = app.state.registry.get(name) or sys.modules.get(
|
||||||
|
f"spiele_redaktion_plugins.{name}"
|
||||||
|
)
|
||||||
|
assert plugin_instanz is not None, f"Plugin {name} nicht geladen"
|
||||||
|
return sys.modules[type(plugin_instanz).__module__]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Migrationen ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrations_legen_bild_url_in_allen_tabellen_an(app):
|
||||||
|
with app.state.engine.connect() as conn:
|
||||||
|
for tabelle in ("neuheiten", "planungsliste", "archiv_neuheiten",
|
||||||
|
"archiv_planung"):
|
||||||
|
assert "bild_url" in _spalten(conn, tabelle), \
|
||||||
|
f"bild_url fehlt in {tabelle}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bild_url_migration_erhaelt_bestehende_daten(app, tmp_path):
|
||||||
|
"""ALTER-TABLE-Pfad: alte DB ohne bild_url → Migration fügt die Spalte
|
||||||
|
hinzu und erhält alle bestehenden Zeilen."""
|
||||||
|
engine = create_engine(f"sqlite:///{tmp_path / 'mig.db'}")
|
||||||
|
|
||||||
|
# Alte Migrationen (ohne die bild_url-Schritte) je Plugin ausführen.
|
||||||
|
alte_schritte = {
|
||||||
|
"neuheiten": [m for m in app.state.registry.get("neuheiten").migrations()
|
||||||
|
if not m.version.startswith("0004")],
|
||||||
|
"planung": [m for m in app.state.registry.get("planung").migrations()
|
||||||
|
if not m.version.startswith("0002")],
|
||||||
|
"archiv": [m for m in app.state.registry.get("archiv").migrations()
|
||||||
|
if not m.version.startswith("0002")],
|
||||||
|
}
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for plugin_name, schritte in alte_schritte.items():
|
||||||
|
run_migrations(conn, plugin_name, schritte)
|
||||||
|
# Alten Stand simulieren: die aktuellen Modelle erzeugen die Spalte
|
||||||
|
# schon mit, daher für den Test wieder entfernen.
|
||||||
|
for tabelle in ("neuheiten", "planungsliste",
|
||||||
|
"archiv_neuheiten", "archiv_planung"):
|
||||||
|
conn.exec_driver_sql(f"ALTER TABLE {tabelle} DROP COLUMN bild_url")
|
||||||
|
assert "bild_url" not in _spalten(conn, tabelle)
|
||||||
|
|
||||||
|
# Bestehende Zeilen einfügen (NOT-NULL-Pflichtspalten gesetzt; die
|
||||||
|
# Defaults der Modelle sind Python-seitig, daher hier explizit).
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"INSERT INTO neuheiten (titel, status, quelle)"
|
||||||
|
" VALUES ('Alter BGG-Titel', 'neuheit', 'boardgamegeek')"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"INSERT INTO planungsliste"
|
||||||
|
" (titel, rezensent_id, status, quelle, ausgabe)"
|
||||||
|
" VALUES ('Alter Plan', 1, 'offen', 'manuell', '')"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"INSERT INTO archiv_neuheiten"
|
||||||
|
" (quell_id, titel, status, quelle, archiviert_von, grund)"
|
||||||
|
" VALUES (1, 'Alt Neuheit', 'neuheit', 'boardgamegeek', 'System', '')"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"INSERT INTO archiv_planung"
|
||||||
|
" (quell_id, titel, rezensent_id, status, quelle, ausgabe,"
|
||||||
|
" archiviert_von, grund)"
|
||||||
|
" VALUES (2, 'Alt Planung', 1, 'offen', 'manuell', '', 'System', '')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Neue bild_url-Migrationen anwenden (nur diese sind noch offen).
|
||||||
|
neue_schritte = {
|
||||||
|
"neuheiten": [m for m in app.state.registry.get("neuheiten").migrations()
|
||||||
|
if m.version == "0004_bild_url"],
|
||||||
|
"planung": [m for m in app.state.registry.get("planung").migrations()
|
||||||
|
if m.version == "0002_bild_url"],
|
||||||
|
"archiv": [m for m in app.state.registry.get("archiv").migrations()
|
||||||
|
if m.version == "0002_bild_url"],
|
||||||
|
}
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for plugin_name, schritte in neue_schritte.items():
|
||||||
|
run_migrations(conn, plugin_name, schritte)
|
||||||
|
|
||||||
|
# Spalte vorhanden, Daten vollständig erhalten.
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for tabelle in ("neuheiten", "planungsliste",
|
||||||
|
"archiv_neuheiten", "archiv_planung"):
|
||||||
|
assert "bild_url" in _spalten(conn, tabelle)
|
||||||
|
titel = {
|
||||||
|
zeile[0]
|
||||||
|
for zeile in conn.exec_driver_sql(
|
||||||
|
"SELECT titel FROM neuheiten"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
assert titel == {"Alter BGG-Titel"}
|
||||||
|
assert conn.exec_driver_sql(
|
||||||
|
"SELECT COUNT(*) FROM planungsliste"
|
||||||
|
).scalar() == 1
|
||||||
|
assert conn.exec_driver_sql(
|
||||||
|
"SELECT COUNT(*) FROM archiv_neuheiten"
|
||||||
|
).scalar() == 1
|
||||||
|
assert conn.exec_driver_sql(
|
||||||
|
"SELECT COUNT(*) FROM archiv_planung"
|
||||||
|
).scalar() == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- BGG-Parsing ----------
|
||||||
|
|
||||||
|
THING_MIT_BEIDEN = """<?xml version="1.0"?>
|
||||||
|
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
|
||||||
|
<item type="boardgame" id="13">
|
||||||
|
<thumbnail>https://cf.geekdo-images.com/klein_thumb.jpg</thumbnail>
|
||||||
|
<image>https://cf.geekdo-images.com/gross.png</image>
|
||||||
|
<name type="primary" value="Catan"/>
|
||||||
|
</item>
|
||||||
|
</items>
|
||||||
|
"""
|
||||||
|
|
||||||
|
THING_NUR_THUMBNAIL = """<?xml version="1.0"?>
|
||||||
|
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
|
||||||
|
<item type="boardgame" id="13">
|
||||||
|
<thumbnail>https://cf.geekdo-images.com/nur_thumb.jpg</thumbnail>
|
||||||
|
<name type="primary" value="Catan"/>
|
||||||
|
</item>
|
||||||
|
</items>
|
||||||
|
"""
|
||||||
|
|
||||||
|
THING_PROTOKOLL_RELATIV = """<?xml version="1.0"?>
|
||||||
|
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
|
||||||
|
<item type="boardgame" id="13">
|
||||||
|
<image>//cf.geekdo-images.com/relativ.jpg</image>
|
||||||
|
<name type="primary" value="Catan"/>
|
||||||
|
</item>
|
||||||
|
</items>
|
||||||
|
"""
|
||||||
|
|
||||||
|
THING_OHNE_BILD = """<?xml version="1.0"?>
|
||||||
|
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
|
||||||
|
<item type="boardgame" id="13">
|
||||||
|
<name type="primary" value="Catan"/>
|
||||||
|
</item>
|
||||||
|
</items>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_things_bevorzugt_grosses_image():
|
||||||
|
spiele = parse_things(THING_MIT_BEIDEN)
|
||||||
|
assert len(spiele) == 1
|
||||||
|
assert spiele[0].bild_url == "https://cf.geekdo-images.com/gross.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_things_faellt_auf_thumbnail_zurueck():
|
||||||
|
spiele = parse_things(THING_NUR_THUMBNAIL)
|
||||||
|
assert spiele[0].bild_url == "https://cf.geekdo-images.com/nur_thumb.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_things_normalisiert_protokoll_relative_urls():
|
||||||
|
spiele = parse_things(THING_PROTOKOLL_RELATIV)
|
||||||
|
assert spiele[0].bild_url == "https://cf.geekdo-images.com/relativ.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_things_ohne_bild_liefert_none():
|
||||||
|
spiele = parse_things(THING_OHNE_BILD)
|
||||||
|
assert spiele[0].bild_url is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Sync-Upsert pflegt bild_url ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_legt_und_pflegt_bild_url(session_factory):
|
||||||
|
from tests._neuheiten import speichere_oder_aktualisiere
|
||||||
|
|
||||||
|
spiel = BggSpiel(
|
||||||
|
bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
|
||||||
|
erscheinungsjahr=1995, bild_url="https://cf.geekdo-images.com/a.jpg",
|
||||||
|
)
|
||||||
|
with session_factory() as db:
|
||||||
|
assert speichere_oder_aktualisiere(db, spiel) is True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update: neues Bild wird übernommen …
|
||||||
|
update = BggSpiel(
|
||||||
|
bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
|
||||||
|
erscheinungsjahr=1995, bild_url="https://cf.geekdo-images.com/b.jpg",
|
||||||
|
)
|
||||||
|
assert speichere_oder_aktualisiere(db, update) is False
|
||||||
|
db.commit()
|
||||||
|
eintrag = db.scalar(select(Neuheit).where(Neuheit.bgg_id == 13))
|
||||||
|
assert eintrag.bild_url == "https://cf.geekdo-images.com/b.jpg"
|
||||||
|
|
||||||
|
# … ein fehlendes Bild in der Antwort löscht das gespeicherte nicht.
|
||||||
|
ohne = BggSpiel(
|
||||||
|
bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
|
||||||
|
erscheinungsjahr=1995, bild_url=None,
|
||||||
|
)
|
||||||
|
speichere_oder_aktualisiere(db, ohne)
|
||||||
|
db.commit()
|
||||||
|
db.expire_all()
|
||||||
|
eintrag = db.scalar(select(Neuheit).where(Neuheit.bgg_id == 13))
|
||||||
|
assert eintrag.bild_url == "https://cf.geekdo-images.com/b.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Web-Quellen: Bild aus Listenelement übernehmen ----------
|
||||||
|
|
||||||
|
SPIELBOX_MIT_BILD = b"""
|
||||||
|
<div class="blog">
|
||||||
|
<div class="item column-1">
|
||||||
|
<div class="page-header"><h2><a href="/beitrag/x">Pegasus Spiele: Next Station Berlin</a></h2></div>
|
||||||
|
<img src="/media/next-station.jpg" alt="Next Station Berlin">
|
||||||
|
<p><strong>24.07.2026</strong> - Vorstellung.</p>
|
||||||
|
</div>
|
||||||
|
<div class="item column-2">
|
||||||
|
<div class="page-header"><h2><a href="/beitrag/y">MICROMACRO Kids</a></h2></div>
|
||||||
|
<p><strong>02.03.2026</strong> - Neu.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_spielbox_adapter_uebernimmt_img_url():
|
||||||
|
quelle = SpielboxQuelle(WebQuellenClient(schlaf=lambda s: None, uhr=FakeUhr()))
|
||||||
|
treffer, folge_links = quelle.seite_verarbeiten(
|
||||||
|
SPIELBOX_MIT_BILD, "https://www.spielbox.de/neuheiten-neue-spiele"
|
||||||
|
)
|
||||||
|
assert [t.titel for t in treffer] == [
|
||||||
|
"Pegasus Spiele: Next Station Berlin", "MICROMACRO Kids",
|
||||||
|
]
|
||||||
|
# Erstes Element liefert ein Bild, das zweite keins → leer lassen.
|
||||||
|
assert treffer[0].bild_url == "https://www.spielbox.de/media/next-station.jpg"
|
||||||
|
assert treffer[1].bild_url is None
|
||||||
|
assert folge_links == []
|
||||||
|
|
||||||
|
|
||||||
|
class _StatischerAdapter:
|
||||||
|
"""Adapter-Ersatz mit fester Trefferliste (für Service-Tests)."""
|
||||||
|
|
||||||
|
name = "spielbox"
|
||||||
|
anzeigename = "spielbox Test"
|
||||||
|
start_urls = ("https://spielbox.example.org/liste",)
|
||||||
|
|
||||||
|
def __init__(self, treffer):
|
||||||
|
self._treffer = treffer
|
||||||
|
|
||||||
|
def __call__(self, client):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def sammle(self):
|
||||||
|
from tests._neuheiten import SammelErgebnis
|
||||||
|
|
||||||
|
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quellen_sync_speichert_und_pflegt_bild_url(session_factory):
|
||||||
|
quelle = _StatischerAdapter([
|
||||||
|
QuellenTreffer(
|
||||||
|
titel="Ringträger", verlag="Schmidt Spiele",
|
||||||
|
quellen_url="https://x.example.org/r",
|
||||||
|
bild_url="https://bilder.example.org/ringtraeger.jpg",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
service = WebQuellenSyncService(
|
||||||
|
session_factory,
|
||||||
|
adapter_klassen=(quelle,),
|
||||||
|
client_fabrik=lambda validatoren: WebQuellenClient(
|
||||||
|
schlaf=lambda s: None, uhr=FakeUhr(), validatoren=validatoren
|
||||||
|
),
|
||||||
|
)
|
||||||
|
zeilen = service.laufe()
|
||||||
|
assert zeilen[0].neu == 1
|
||||||
|
|
||||||
|
with session_factory() as db:
|
||||||
|
eintrag = db.query(Neuheit).one()
|
||||||
|
assert eintrag.quelle == "spielbox"
|
||||||
|
assert eintrag.bild_url == "https://bilder.example.org/ringtraeger.jpg"
|
||||||
|
|
||||||
|
# Zweiter Lauf aktualisiert das Bild statt es zu duplizieren.
|
||||||
|
quelle._treffer = [
|
||||||
|
QuellenTreffer(
|
||||||
|
titel="Ringträger", verlag="Schmidt Spiele",
|
||||||
|
quellen_url="https://x.example.org/r",
|
||||||
|
bild_url="https://bilder.example.org/ringtraeger-neu.jpg",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
zeilen = service.laufe()
|
||||||
|
assert zeilen[0].aktualisiert == 1 and zeilen[0].neu == 0
|
||||||
|
with session_factory() as db:
|
||||||
|
assert db.query(Neuheit).count() == 1
|
||||||
|
assert db.query(Neuheit).one().bild_url == \
|
||||||
|
"https://bilder.example.org/ringtraeger-neu.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spielessen_extrahiert_bild_aus_bilder_feld(app):
|
||||||
|
klasse = plugin_modul(app, "neuheiten").quellen.spielessen.SpielEssenQuelle
|
||||||
|
adapter = klasse(client=None) # Parsing-Hilfen brauchen keinen Client
|
||||||
|
|
||||||
|
produkt = {
|
||||||
|
"TITEL": "-60°",
|
||||||
|
"BILDER": [{"URL": "//maps.eyeled-services.de/media/bild.jpg"}],
|
||||||
|
}
|
||||||
|
treffer = adapter._produkt(produkt, {})
|
||||||
|
assert treffer is not None
|
||||||
|
assert treffer.bild_url == "https://maps.eyeled-services.de/media/bild.jpg"
|
||||||
|
|
||||||
|
treffer_ohne = adapter._produkt({"TITEL": "X", "BILDER": []}, {})
|
||||||
|
assert treffer_ohne is not None
|
||||||
|
assert treffer_ohne.bild_url is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Planung: Übernahme trägt das Bild mit ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebernehmen_kopiert_bild_url_in_die_planung(app, client):
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
db.add(
|
||||||
|
Neuheit(
|
||||||
|
titel="Cascadia",
|
||||||
|
bgg_id=295947,
|
||||||
|
status="neuheit",
|
||||||
|
bild_url="https://cf.geekdo-images.com/cascadia.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.post("/planung/uebernehmen/1", follow_redirects=False)
|
||||||
|
assert antwort.status_code in (303, 200)
|
||||||
|
|
||||||
|
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
eintrag = db.scalars(select(Planungseintrag)).one()
|
||||||
|
assert eintrag.titel == "Cascadia"
|
||||||
|
assert eintrag.bild_url == "https://cf.geekdo-images.com/cascadia.jpg"
|
||||||
|
|
||||||
|
# Die Neuheit selbst ist jetzt „planung“ und behält ihr Bild.
|
||||||
|
neuheit = db.scalar(select(Neuheit).where(Neuheit.titel == "Cascadia"))
|
||||||
|
assert neuheit.status == "planung"
|
||||||
|
assert neuheit.bild_url == "https://cf.geekdo-images.com/cascadia.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Template-Darstellung ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_neuheitenliste_rendert_img_mit_lazy_loading(app, client):
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
db.add(
|
||||||
|
Neuheit(
|
||||||
|
titel="Catan", bgg_id=13, status="neuheit",
|
||||||
|
bild_url="https://cf.geekdo-images.com/catan.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/neuheiten")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
text = antwort.text
|
||||||
|
assert '<img src="https://cf.geekdo-images.com/catan.jpg"' in text
|
||||||
|
assert 'loading="lazy"' in text
|
||||||
|
assert 'decoding="async"' in text
|
||||||
|
assert 'alt="Cover: Catan"' in text
|
||||||
|
# Fallback-Zeichen darf daneben nicht auftauchen, wenn Bilder da sind …
|
||||||
|
# (Doch: andere Einträge könnten bildlos sein — hier nur einer.)
|
||||||
|
|
||||||
|
# Mobil-Kartenansicht und Desktop-Tabelle sind beide im Markup.
|
||||||
|
assert 'md:hidden' in text
|
||||||
|
assert 'hidden md:block' in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_neuheitenliste_rendert_platzhalter_ohne_bild(app, client):
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
db.add(Neuheit(titel="Ohne Bild", status="neuheit"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/neuheiten")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
liste = antwort.text.split('<div id="neuheiten-liste"')[1].split("</main>")[0]
|
||||||
|
assert "<img" not in liste
|
||||||
|
assert "🎲" in antwort.text # Spielicon-Zeichen im Platzhalter-Div
|
||||||
|
|
||||||
|
|
||||||
|
def test_planungsliste_zeigt_cover(app, client):
|
||||||
|
from redaktionskern.auth.models import User
|
||||||
|
|
||||||
|
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
benutzer = db.query(User).filter_by(username="admin").first()
|
||||||
|
db.add(
|
||||||
|
Planungseintrag(
|
||||||
|
titel="Cascadia",
|
||||||
|
rezensent_id=benutzer.id,
|
||||||
|
status="offen",
|
||||||
|
bild_url="https://cf.geekdo-images.com/cascadia.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/planung")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
assert 'src="https://cf.geekdo-images.com/cascadia.jpg"' in antwort.text
|
||||||
|
assert 'loading="lazy"' in antwort.text
|
||||||
|
assert 'decoding="async"' in antwort.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_archiv_zeigt_cover(app, client):
|
||||||
|
ArchivNeuheit = plugin_modul(app, "archiv").ArchivNeuheit
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
db.add(
|
||||||
|
ArchivNeuheit(
|
||||||
|
quell_id=1, titel="Altspiel",
|
||||||
|
bild_url="https://cf.geekdo-images.com/altspiel.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/archiv")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
assert 'src="https://cf.geekdo-images.com/altspiel.jpg"' in antwort.text
|
||||||
|
assert 'loading="lazy"' in antwort.text
|
||||||
@@ -389,3 +389,25 @@ def test_bgg_pruefclient_toleroert_fehler():
|
|||||||
assert client.vorgaenger(99) == []
|
assert client.vorgaenger(99) == []
|
||||||
finally:
|
finally:
|
||||||
client.schliessen()
|
client.schliessen()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bgg_pruefclient_sendet_token_header():
|
||||||
|
from plugins.dedup.bgg import BggPruefClient
|
||||||
|
|
||||||
|
gesehen: list[httpx.Request] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
gesehen.append(request)
|
||||||
|
return httpx.Response(200, content=THING_XML.encode())
|
||||||
|
|
||||||
|
client = BggPruefClient(
|
||||||
|
mindestabstand_sekunden=0.0,
|
||||||
|
token="mein-token",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert client.alternativen(99) == ["Catan Expansion", "Siedler-Erweiterung"]
|
||||||
|
finally:
|
||||||
|
client.schliessen()
|
||||||
|
assert gesehen
|
||||||
|
assert gesehen[0].headers["Authorization"] == "Bearer mein-token"
|
||||||
|
|||||||
544
tests/test_neuheiten_ki_crawler.py
Normal file
544
tests/test_neuheiten_ki_crawler.py
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
"""Tests: Optionale LLM-Hilfsstufe des Web-Quellen-Crawlers (neuheiten).
|
||||||
|
|
||||||
|
Alle HTTP- und LLM-Antworten sind gemockt (httpx.MockTransport bzw.
|
||||||
|
Fake-Transports) — es gibt niemals echte Netzwerk- oder LLM-Aufrufe.
|
||||||
|
Abgedeckt: Fallback-Fälle (aktiv=0, fehlender Key, Timeout mit genau einem
|
||||||
|
Retry, kaputtes JSON), Konfidenz-Schwelle, Struktur-Erkennung nur mit
|
||||||
|
gleiche-Domain-URLs (max. 20), Feld-Korrektur ausschließlich unsicherer
|
||||||
|
Treffer (URL bleibt fix) und die Audit-Protokollierung der KI-Eingriffe.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests._neuheiten import (
|
||||||
|
MAX_FOLGE_URLS,
|
||||||
|
KiHilfe,
|
||||||
|
KiKonfiguration,
|
||||||
|
Neuheit,
|
||||||
|
QuellenFehler,
|
||||||
|
QuellenTreffer,
|
||||||
|
SpielboxQuelle,
|
||||||
|
SammelErgebnis,
|
||||||
|
WebQuellenClient,
|
||||||
|
WebQuellenSyncService,
|
||||||
|
extrahiere_ki_json,
|
||||||
|
lade_ki_konfiguration,
|
||||||
|
struktur_verdaechtig,
|
||||||
|
titel_ist_verdaechtig,
|
||||||
|
treffer_ist_unsicher,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASIS_URL = "https://www.spielbox.de/neuheiten-neue-spiele"
|
||||||
|
KONFIG = dict(
|
||||||
|
aktiv=True,
|
||||||
|
basis_url="https://llm.example.test/v1",
|
||||||
|
api_key="test-key",
|
||||||
|
modell="test-modell",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUhr:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.zeit = 0.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.zeit
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Gemockte Seiten ----------------
|
||||||
|
|
||||||
|
SEITE_LEER_VIELE_LINKS = (
|
||||||
|
"<html><body><nav>"
|
||||||
|
+ "".join(
|
||||||
|
f'<a href="/rubrik/{i}">Rubrik {i}</a> ' for i in range(40)
|
||||||
|
)
|
||||||
|
+ "</nav><p>Keine Neuheiten erkannt.</p></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
SEITE_MIT_TREFFERN = """
|
||||||
|
<div class="item">
|
||||||
|
<div class="page-header"><h2><a href="/x/1">MICROMACRO Kids</a></h2></div>
|
||||||
|
<p><strong>02.03.2026</strong> - kurz.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _antwort(inhalt: bytes | str) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
content=inhalt.encode("utf-8") if isinstance(inhalt, str) else inhalt,
|
||||||
|
request=httpx.Request("GET", BASIS_URL),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _crawler_client(seiten: dict[str, bytes | str]) -> tuple[WebQuellenClient, KartenTransport, FakeUhr]:
|
||||||
|
uhr = FakeUhr()
|
||||||
|
transport = KartenTransport({u: _antwort(i) for u, i in seiten.items()}, uhr)
|
||||||
|
client = WebQuellenClient(
|
||||||
|
transport=transport, schlaf=lambda s: None, uhr=uhr
|
||||||
|
)
|
||||||
|
return client, transport, uhr
|
||||||
|
|
||||||
|
|
||||||
|
class KartenTransport(httpx.BaseTransport):
|
||||||
|
"""Antworten je URL; zeichnet angefragte URLs auf."""
|
||||||
|
|
||||||
|
def __init__(self, antworten: dict[str, httpx.Response], uhr: FakeUhr):
|
||||||
|
self.antworten = antworten
|
||||||
|
self.anfragen: list[str] = []
|
||||||
|
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
self.anfragen.append(url)
|
||||||
|
antwort = self.antworten.get(url)
|
||||||
|
if antwort is None and "?" in url:
|
||||||
|
antwort = self.antworten.get(url.split("?")[0])
|
||||||
|
if antwort is None:
|
||||||
|
raise QuellenFehler(f"Keine gemockte Antwort für {url}")
|
||||||
|
return antwort
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- LLM-Mocks ----------------
|
||||||
|
|
||||||
|
def llm_antwort(objekt: dict, status: int = 200) -> httpx.Response:
|
||||||
|
body = {"choices": [{"message": {"role": "assistant", "content": json.dumps(objekt)}}]}
|
||||||
|
return httpx.Response(status, content=json.dumps(body).encode())
|
||||||
|
|
||||||
|
|
||||||
|
class LlmTransport(httpx.BaseTransport):
|
||||||
|
"""Gestaffelte Antworten für Chat-Completions; zählt Aufrufe.
|
||||||
|
|
||||||
|
Elemente: dict → JSON-Inhalt, int → HTTP-Status, Exception → wird geworfen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, antworten: list):
|
||||||
|
self.antworten = list(antworten)
|
||||||
|
self.aufrufe = 0
|
||||||
|
self.prompts: list[dict] = []
|
||||||
|
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
index = min(self.aufrufe, len(self.antworten) - 1)
|
||||||
|
spezifikation = self.antworten[index]
|
||||||
|
self.aufrufe += 1
|
||||||
|
self.prompts.append(json.loads(request.content.decode()))
|
||||||
|
if isinstance(spezifikation, Exception):
|
||||||
|
raise spezifikation
|
||||||
|
if isinstance(spezifikation, int):
|
||||||
|
return httpx.Response(spezifikation, json={"error": "kaputt"})
|
||||||
|
return llm_antwort(spezifikation)
|
||||||
|
|
||||||
|
|
||||||
|
def ki_hilfe(transport: httpx.BaseTransport, konfidenz_min: float = 0.7) -> KiHilfe:
|
||||||
|
return KiHilfe(
|
||||||
|
KiKonfiguration(konfidenz_min=konfidenz_min, **KONFIG),
|
||||||
|
transport=transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TREFFER_UNSICHER = lambda: QuellenTreffer( # noqa: E731
|
||||||
|
titel="Pegasus Spiele: Next Station Berlin",
|
||||||
|
quellen_url="https://www.spielbox.de/x/1",
|
||||||
|
)
|
||||||
|
TREFFER_SICHER = lambda: QuellenTreffer( # noqa: E731
|
||||||
|
titel="Galactic Cruise", verlag="Dranda Games",
|
||||||
|
quellen_url="https://www.spielbox.de/x/2",
|
||||||
|
)
|
||||||
|
|
||||||
|
GUTE_FELDANTWORT = {
|
||||||
|
"titel": "Next Station Berlin",
|
||||||
|
"verlag": "Pegasus Spiele",
|
||||||
|
"autor": None,
|
||||||
|
"konfidenz": 0.9,
|
||||||
|
"begruendung": "Verlag stand als Präfix im Titel.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Heuristiken ----------------
|
||||||
|
|
||||||
|
def test_struktur_verdaechtig_ohne_treffer_immmer():
|
||||||
|
assert struktur_verdaechtig(0, "<p>kurz</p>") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_struktur_verdaechtig_wenige_treffer_nur_bei_linkreicher_seite():
|
||||||
|
viele_links = "".join('<a href="/x">x</a>' for _ in range(40))
|
||||||
|
assert struktur_verdaechtig(2, viele_links) is True
|
||||||
|
assert struktur_verdaechtig(2, "<a href='/x'>x</a>") is False
|
||||||
|
assert struktur_verdaechtig(5, viele_links) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsichere_treffer_fehlender_verlag_oder_generischer_titel():
|
||||||
|
assert treffer_ist_unsicher(QuellenTreffer(titel="X", quellen_url="u")) is True
|
||||||
|
assert treffer_ist_unsicher(TREFFER_UNSICHER()) is True # kein Verlag
|
||||||
|
assert treffer_ist_unsicher(TREFFER_SICHER()) is False
|
||||||
|
assert titel_ist_verdaechtig("Mehr") is True
|
||||||
|
assert titel_ist_verdaechtig("ab") is True
|
||||||
|
assert titel_ist_verdaechtig("Fadenfroh") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_extrahiere_json_toleriert_code_fences():
|
||||||
|
text = "Antwort:\n```json\n{\"folge_urls\": []}\n```\nEnde."
|
||||||
|
assert extrahiere_ki_json(text) == {"folge_urls": []}
|
||||||
|
assert extrahiere_ki_json("völlig kaputt") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Konfiguration & Fallback ----------------
|
||||||
|
|
||||||
|
def test_konfiguration_standard_aus():
|
||||||
|
k = lade_ki_konfiguration({})
|
||||||
|
assert not k.aktiv and not k.vollstaendig
|
||||||
|
assert k.konfidenz_min == 0.7
|
||||||
|
assert k.timeout >= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_ohne_konfiguration_baut_keinen_ki_client(session_factory, monkeypatch):
|
||||||
|
"""Fallback aktiv=0: kein KI-Client, exakt der klassische Crawler."""
|
||||||
|
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_AKTIV", "0")
|
||||||
|
|
||||||
|
class Streng:
|
||||||
|
def __call__(self): # pragma: no cover — darf nie aufgerufen werden
|
||||||
|
raise AssertionError("KI-Client ohne Aktivierung gebaut")
|
||||||
|
def schliessen(self): pass
|
||||||
|
|
||||||
|
quelle = StatischeQuelle("spielbox", [TREFFER_SICHER()])
|
||||||
|
service = WebQuellenSyncService(
|
||||||
|
session_factory, adapter_klassen=(quelle,),
|
||||||
|
client_fabrik=lambda v: _ohne_netz_client(),
|
||||||
|
ki_fabrik=Streng(),
|
||||||
|
)
|
||||||
|
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].neu == 1 and zeilen[0].fehlermeldung is None
|
||||||
|
with session_factory() as db:
|
||||||
|
assert db.query(Neuheit).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_mit_aktiv_ohne_key_laeuft_klassisch(session_factory, monkeypatch):
|
||||||
|
"""Fallback: AKTIV=1, aber Key fehlt → vollständige Konfiguration fehlt."""
|
||||||
|
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_AKTIV", "1")
|
||||||
|
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_BASIS_URL", "https://llm.example.test/v1")
|
||||||
|
monkeypatch.delenv("SPIELE_NEUHEITEN_KI_API_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_MODELL", "test-modell")
|
||||||
|
|
||||||
|
class Streng:
|
||||||
|
def __call__(self): # pragma: no cover
|
||||||
|
raise AssertionError("KI-Client ohne Key gebaut")
|
||||||
|
def schliessen(self): pass
|
||||||
|
|
||||||
|
quelle = StatischeQuelle("spielbox", [TREFFER_SICHER()])
|
||||||
|
service = WebQuellenSyncService(
|
||||||
|
session_factory, adapter_klassen=(quelle,),
|
||||||
|
client_fabrik=lambda v: _ohne_netz_client(),
|
||||||
|
ki_fabrik=Streng(),
|
||||||
|
)
|
||||||
|
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].neu == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_genau_ein_retry_dann_klassischer_befund():
|
||||||
|
"""Timeout: insgesamt zwei Versuche, danach klassischer Treffer unverändert."""
|
||||||
|
transport = LlmTransport([httpx.ReadTimeout("zu lang"), httpx.ReadTimeout("nochmal")])
|
||||||
|
hilfe = ki_hilfe(transport)
|
||||||
|
|
||||||
|
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
|
||||||
|
|
||||||
|
assert transport.aufrufe == 2 # erster Versuch + genau ein Retry
|
||||||
|
assert eingriffe == []
|
||||||
|
original = treffer_liste[0]
|
||||||
|
assert original.verlag is None # klassischer Befund bleibt
|
||||||
|
assert original.quellen_url == "https://www.spielbox.de/x/1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaputtes_json_kein_retry_kein_eingriff():
|
||||||
|
transport = LlmTransport(["das ist {kaputtes JSON ohne Sinn"])
|
||||||
|
hilfe = ki_hilfe(transport)
|
||||||
|
|
||||||
|
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
|
||||||
|
|
||||||
|
assert transport.aufrufe == 1 # Parsing-Fehler werden nicht wiederholt
|
||||||
|
assert eingriffe == []
|
||||||
|
assert treffer_liste[0].verlag is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_serverfehler_nach_retry_faellt_zurueck():
|
||||||
|
transport = LlmTransport([503, 503])
|
||||||
|
hilfe = ki_hilfe(transport)
|
||||||
|
|
||||||
|
_, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
|
||||||
|
folge_urls = hilfe.folge_urls_bei_bedarf(0, SEITE_LEER_VIELE_LINKS, BASIS_URL, set())
|
||||||
|
|
||||||
|
assert transport.aufrufe == 4 # je Aufrufart zwei Versuche
|
||||||
|
assert eingriffe == [] and folge_urls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Konfidenz-Schwelle ----------------
|
||||||
|
|
||||||
|
def test_feldkorrektur_unter_konfidenz_min_wird_verworfen():
|
||||||
|
schwach = dict(GUTE_FELDANTWORT, konfidenz=0.5)
|
||||||
|
transport = LlmTransport([schwach])
|
||||||
|
hilfe = ki_hilfe(transport, konfidenz_min=0.7)
|
||||||
|
|
||||||
|
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
|
||||||
|
|
||||||
|
assert transport.aufrufe == 1
|
||||||
|
assert eingriffe == []
|
||||||
|
assert treffer_liste[0].titel == "Pegasus Spiele: Next Station Berlin"
|
||||||
|
assert treffer_liste[0].verlag is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_vorschlaege_unter_konfidenz_min_werden_verworfen():
|
||||||
|
vorschlaege = {
|
||||||
|
"folge_urls": ["/neuheiten-neue-spiele?start=6"],
|
||||||
|
"konfidenz": 0.3,
|
||||||
|
}
|
||||||
|
transport = LlmTransport([vorschlaege])
|
||||||
|
hilfe = ki_hilfe(transport)
|
||||||
|
|
||||||
|
urls = hilfe.folge_urls_bei_bedarf(0, SEITE_LEER_VIELE_LINKS, BASIS_URL, set())
|
||||||
|
|
||||||
|
assert urls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Struktur-Erkennung ----------------
|
||||||
|
|
||||||
|
def test_struktur_erkennung_nur_gleiche_domain_urls_werden_verfolgt():
|
||||||
|
"""Fremde Domains und Nicht-HTTP-Schemata werden hart herausgefiltert."""
|
||||||
|
gemischt = {
|
||||||
|
"folge_urls": [
|
||||||
|
"/neuheiten-neue-spiele?start=6", # gleiche Domain ✓
|
||||||
|
"https://www.spielbox.de/neuheiten?page=2", # gleiche Domain ✓
|
||||||
|
"https://boese.example.org/neuheiten", # fremde Domain ✗
|
||||||
|
"ftp://www.spielbox.de/liste", # Schema ✗
|
||||||
|
"/neuheiten-neue-spiele?start=6#oben", # Duplikat ✗
|
||||||
|
"javascript:zeigeListe()", # Schema ✗
|
||||||
|
],
|
||||||
|
"konfidenz": 0.9,
|
||||||
|
}
|
||||||
|
crawler_client, transport, _ = _crawler_client({BASIS_URL: SEITE_LEER_VIELE_LINKS})
|
||||||
|
transport.antworten["https://www.spielbox.de/neuheiten-neue-spiele?start=6"] = _antwort(SEITE_MIT_TREFFERN)
|
||||||
|
transport.antworten["https://www.spielbox.de/neuheiten?page=2"] = _antwort(SEITE_MIT_TREFFERN)
|
||||||
|
adapter = SpielboxQuelle(crawler_client)
|
||||||
|
adapter.ki_hilfe = ki_hilfe(LlmTransport([gemischt]))
|
||||||
|
|
||||||
|
ergebnis = adapter.sammle()
|
||||||
|
|
||||||
|
gefolgt = [u for u in transport.anfragen if u != BASIS_URL]
|
||||||
|
assert "https://www.spielbox.de/neuheiten-neue-spiele?start=6" in gefolgt
|
||||||
|
assert all(u.startswith("https://www.spielbox.de") for u in gefolgt)
|
||||||
|
assert not any("boese" in u or u.startswith("ftp") for u in gefolgt)
|
||||||
|
assert len(ergebnis.treffer) == 2 # aus den beiden nachgeschobenen Seiten
|
||||||
|
assert ergebnis.ki_folge_urls == gefolgt
|
||||||
|
|
||||||
|
|
||||||
|
def test_struktur_erkennung_maximal_20_folge_urls():
|
||||||
|
"""Rate-Limit-Schutz: auch bei 30 Vorschlägen werden höchstens 20 verfolgt."""
|
||||||
|
vorschlaege = {
|
||||||
|
"folge_urls": [f"/liste?seite={i}" for i in range(30)],
|
||||||
|
"konfidenz": 0.9,
|
||||||
|
}
|
||||||
|
crawler_client, transport, _ = _crawler_client({BASIS_URL: SEITE_LEER_VIELE_LINKS})
|
||||||
|
for i in range(30):
|
||||||
|
transport.antworten[f"https://www.spielbox.de/liste?seite={i}"] = _antwort(SEITE_MIT_TREFFERN)
|
||||||
|
adapter = SpielboxQuelle(crawler_client)
|
||||||
|
adapter.ki_hilfe = ki_hilfe(LlmTransport([vorschlaege]))
|
||||||
|
|
||||||
|
adapter.sammle()
|
||||||
|
|
||||||
|
gefolgt = [u for u in transport.anfragen if u != BASIS_URL]
|
||||||
|
assert len(gefolgt) <= MAX_FOLGE_URLS
|
||||||
|
assert len(gefolgt) == 20
|
||||||
|
assert "https://www.spielbox.de/liste?seite=25" not in gefolgt
|
||||||
|
|
||||||
|
|
||||||
|
def test_struktur_erkennung_greift_nicht_bei_normalen_ergebnissen():
|
||||||
|
"""Drei sichere Treffer → keine Verdachts-Heuristik, kein LLM-Aufruf."""
|
||||||
|
seite = SEITE_MIT_TREFFERN * 3
|
||||||
|
crawler_client, transport, _ = _crawler_client({BASIS_URL: seite})
|
||||||
|
adapter = SpielboxQuelle(crawler_client)
|
||||||
|
adapter.ki_hilfe = ki_hilfe(LlmTransport([])) # würde bei Aufruf zählen
|
||||||
|
|
||||||
|
ergebnis = adapter.sammle()
|
||||||
|
|
||||||
|
assert len(ergebnis.treffer) == 3
|
||||||
|
assert len(transport.anfragen) == 1 # nur die Startseite
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Feld-Korrektur ----------------
|
||||||
|
|
||||||
|
def test_feldkorrektur_nur_bei_unsicheren_treffern_url_bleibt_fix():
|
||||||
|
"""Sichere Treffer kosten kein Token; korrigiert wird nie die URL."""
|
||||||
|
transport = LlmTransport([GUTE_FELDANTWORT])
|
||||||
|
hilfe = ki_hilfe(transport)
|
||||||
|
sicher = TREFFER_SICHER()
|
||||||
|
unsicher = TREFFER_UNSICHER()
|
||||||
|
|
||||||
|
treffer_liste, eingriffe = hilfe.treffer_nachbessern([sicher, unsicher])
|
||||||
|
|
||||||
|
assert transport.aufrufe == 1 # nur der unsichere Treffer ging ans LLM
|
||||||
|
assert treffer_liste[0] is sicher # unangetastet
|
||||||
|
korrigiert = treffer_liste[1]
|
||||||
|
assert korrigiert.titel == "Next Station Berlin"
|
||||||
|
assert korrigiert.verlag == "Pegasus Spiele"
|
||||||
|
assert korrigiert.quellen_url == unsicher.quellen_url # URL bleibt fix
|
||||||
|
assert len(eingriffe) == 1
|
||||||
|
protokoll = eingriffe[0]
|
||||||
|
assert protokoll["modell"] == "test-modell"
|
||||||
|
assert protokoll["konfidenz"] == 0.9
|
||||||
|
assert protokoll["korrigiert"] == {
|
||||||
|
"titel": "Next Station Berlin", "verlag": "Pegasus Spiele",
|
||||||
|
}
|
||||||
|
assert protokoll["vorher"]["titel"] == "Pegasus Spiele: Next Station Berlin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feldkorrektur_ohne_aenderung_erzeugt_keinen_eingriff():
|
||||||
|
identisch = {
|
||||||
|
"titel": "Pegasus Spiele: Next Station Berlin",
|
||||||
|
"verlag": None, "autor": None, "konfidenz": 0.9, "begruendung": "passt",
|
||||||
|
}
|
||||||
|
transport = LlmTransport([identisch])
|
||||||
|
hilfe = ki_hilfe(transport)
|
||||||
|
|
||||||
|
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
|
||||||
|
|
||||||
|
assert eingriffe == []
|
||||||
|
assert treffer_liste[0].quellen_url == "https://www.spielbox.de/x/1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Sync-Integration + Audit ----------------
|
||||||
|
|
||||||
|
class StatischeQuelle:
|
||||||
|
"""Adapter-Ersatz mit fester Trefferliste (Service-Tests ohne Netz)."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "spielbox", treffer: list[QuellenTreffer] | None = None):
|
||||||
|
self.name = name
|
||||||
|
self.anzeigename = name.title()
|
||||||
|
self.start_urls = (f"https://{name}.example.org/liste",)
|
||||||
|
self._treffer = treffer if treffer is not None else []
|
||||||
|
|
||||||
|
def __call__(self, client):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def sammle(self) -> SammelErgebnis:
|
||||||
|
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _ohne_netz_client() -> WebQuellenClient:
|
||||||
|
return WebQuellenClient(transport=None, schlaf=lambda s: None, uhr=FakeUhr())
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAudit:
|
||||||
|
def __init__(self):
|
||||||
|
self.eintraege: list[tuple] = []
|
||||||
|
|
||||||
|
def log_sync(self, actor, action, objekt_typ, objekt_id, details, **kwargs):
|
||||||
|
self.eintraege.append((actor, action, objekt_typ, objekt_id, details))
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_auditert_ki_eingriffe_mit_modell_und_konfidenz():
|
||||||
|
"""End-to-End: Crawler-Seite mit unsicherem Treffer → KI-Korrektur → Audit.
|
||||||
|
|
||||||
|
„Ringträger“ ohne Doppelpunkt: der Parser findet keinen Verlag → Treffer
|
||||||
|
ist unsicher → das LLM darf titel/verlag korrigieren.
|
||||||
|
"""
|
||||||
|
seite = (
|
||||||
|
'<div class="item"><div class="page-header"><h2>'
|
||||||
|
'<a href="/x/1">Ringträger</a></h2></div>'
|
||||||
|
"<p><strong>15.08.2026</strong> - Text.</p></div>"
|
||||||
|
)
|
||||||
|
crawler_client, _, _ = _crawler_client({BASIS_URL: seite})
|
||||||
|
audit = FakeAudit()
|
||||||
|
fabrik = _session_factory()
|
||||||
|
service = WebQuellenSyncService(
|
||||||
|
fabrik,
|
||||||
|
adapter_klassen=(SpielboxQuelle,),
|
||||||
|
client_fabrik=lambda validatoren: crawler_client,
|
||||||
|
ki_fabrik=lambda: ki_hilfe(LlmTransport([GUTE_FELDANTWORT])),
|
||||||
|
umgebung={
|
||||||
|
"SPIELE_NEUHEITEN_KI_AKTIV": "1",
|
||||||
|
"SPIELE_NEUHEITEN_KI_BASIS_URL": "https://llm.example.test/v1",
|
||||||
|
"SPIELE_NEUHEITEN_KI_API_KEY": "k",
|
||||||
|
"SPIELE_NEUHEITEN_KI_MODELL": "m",
|
||||||
|
},
|
||||||
|
audit=audit,
|
||||||
|
)
|
||||||
|
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].neu == 1
|
||||||
|
with fabrik() as db:
|
||||||
|
eintrag = db.query(Neuheit).one()
|
||||||
|
assert eintrag.titel == "Next Station Berlin"
|
||||||
|
assert eintrag.verlag == "Pegasus Spiele"
|
||||||
|
assert eintrag.erscheinungsjahr == 2026
|
||||||
|
assert len(audit.eintraege) == 1
|
||||||
|
actor, action, objekt_typ, objekt_id, details = audit.eintraege[0]
|
||||||
|
assert action == "korrigiert" and objekt_typ == "neuheiten_ki_crawler"
|
||||||
|
assert objekt_id == "spielbox"
|
||||||
|
assert details["modell"] == "test-modell"
|
||||||
|
assert details["eingriffe"][0]["konfidenz"] == 0.9
|
||||||
|
assert details["eingriffe"][0]["korrigiert"]["verlag"] == "Pegasus Spiele"
|
||||||
|
|
||||||
|
|
||||||
|
def _session_factory():
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from redaktionskern.db import Base
|
||||||
|
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
|
||||||
|
fabrik.engine_zum_aufraeumen = engine # type: ignore[attr-defined]
|
||||||
|
return fabrik
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_ohne_ki_eingriff_schreibt_kein_audit():
|
||||||
|
audit = FakeAudit()
|
||||||
|
service = WebQuellenSyncService(
|
||||||
|
_session_factory(),
|
||||||
|
adapter_klassen=(StatischeQuelle("spielbox", [TREFFER_SICHER()]),),
|
||||||
|
client_fabrik=lambda v: _ohne_netz_client(),
|
||||||
|
ki_fabrik=None,
|
||||||
|
audit=audit,
|
||||||
|
)
|
||||||
|
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].neu == 1
|
||||||
|
assert audit.eintraege == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ki_ausfall_blockiert_den_sync_nie():
|
||||||
|
"""KI-Fabrik crasht → Sync läuft trotzdem komplett klassisch."""
|
||||||
|
def kaputte_fabrik():
|
||||||
|
raise RuntimeError("LLM nicht erreichbar")
|
||||||
|
|
||||||
|
service = WebQuellenSyncService(
|
||||||
|
_session_factory(),
|
||||||
|
adapter_klassen=(StatischeQuelle("spielbox", [TREFFER_SICHER()]),),
|
||||||
|
client_fabrik=lambda v: _ohne_netz_client(),
|
||||||
|
ki_fabrik=kaputte_fabrik,
|
||||||
|
umgebung={
|
||||||
|
"SPIELE_NEUHEITEN_KI_AKTIV": "1",
|
||||||
|
"SPIELE_NEUHEITEN_KI_BASIS_URL": "u",
|
||||||
|
"SPIELE_NEUHEITEN_KI_API_KEY": "k",
|
||||||
|
"SPIELE_NEUHEITEN_KI_MODELL": "m",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].neu == 1 and zeilen[0].fehlermeldung is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_factory():
|
||||||
|
fabrik = _session_factory()
|
||||||
|
yield fabrik
|
||||||
|
fabrik.engine_zum_aufraeumen.dispose() # type: ignore[attr-defined]
|
||||||
900
tests/test_neuheiten_quellen.py
Normal file
900
tests/test_neuheiten_quellen.py
Normal file
@@ -0,0 +1,900 @@
|
|||||||
|
"""Tests für die Web-Quellen-Erweiterung des Neuheiten-Plugins.
|
||||||
|
|
||||||
|
Alle HTTP-Antworten sind gemockt (Fake-Transport / Client-Fabrik) — es
|
||||||
|
gibt keine echten Netzwerkaufrufe. Abgedeckt: Parser-Extraktion je
|
||||||
|
Quelle, Pagination-Durchlauf, 304-Behandlung (If-None-Match), Rate-
|
||||||
|
Limit, Fehler-Isolation pro Quelle, Duplikatprüfung gegen BGG-Titel,
|
||||||
|
Env-Schalter und Sync-Übersicht im UI.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests._neuheiten import (
|
||||||
|
ADAPTER_KLASSEN,
|
||||||
|
BrettspielboxQuelle,
|
||||||
|
CliquenabendQuelle,
|
||||||
|
Neuheit,
|
||||||
|
QuellenFehler,
|
||||||
|
QuellenStatus,
|
||||||
|
QuellenTreffer,
|
||||||
|
QuellenLaufZeile,
|
||||||
|
SammelErgebnis,
|
||||||
|
SpielEssenQuelle,
|
||||||
|
SpielboxQuelle,
|
||||||
|
USER_AGENT,
|
||||||
|
WebQuellenClient,
|
||||||
|
WebQuellenSyncService,
|
||||||
|
jahr_aus_datumsangabe,
|
||||||
|
quelle_aktiv,
|
||||||
|
titel_aehnlichkeit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- Gemockte Seiten (realistische Struktur-Anleihen der Quellen) ----
|
||||||
|
|
||||||
|
SPIELBOX_SEITE_1 = b"""
|
||||||
|
<div class="blog">
|
||||||
|
<div class="item column-1">
|
||||||
|
<div class="page-header"><h2 itemprop="name">
|
||||||
|
<a href="/neuheiten-neue-spiele/1369-pegasus-next-station-berlin">Pegasus Spiele: Next Station Berlin</a>
|
||||||
|
</h2></div>
|
||||||
|
<p><strong>24.07.2026</strong> - <em>Next Station Berlin</em> von Matthew Dunstan, das neu bei Pegasus Spiele erschienen ist.</p>
|
||||||
|
</div>
|
||||||
|
<div class="item column-2">
|
||||||
|
<div class="page-header"><h2><a href="/neuheiten-neue-spiele/1247-micromacro-kids">MICROMACRO Kids</a></h2></div>
|
||||||
|
<p><strong>02.03.2026</strong> - Neu im März.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="pagination"><li><a href="/neuheiten-neue-spiele?start=6">2</a></li></ul>
|
||||||
|
"""
|
||||||
|
|
||||||
|
SPIELBOX_SEITE_2 = b"""
|
||||||
|
<div class="item column-1">
|
||||||
|
<div class="page-header"><h2><a href="/neuheiten-neue-spiele/1123-schmidt-ringtraeger">Schmidt Spiele: Ringträger</a></h2></div>
|
||||||
|
<p><strong>15.08.2026</strong> - Neue Titel von Schmidt Spiele.</p>
|
||||||
|
</div>
|
||||||
|
<ul class="pagination"></ul>
|
||||||
|
"""
|
||||||
|
|
||||||
|
BRETTSPIELBOX_SEITE = """
|
||||||
|
<div class="wp-block-columns is-layout-flex">
|
||||||
|
<div class="wp-block-column">
|
||||||
|
<p>0-9</p>
|
||||||
|
<ul>
|
||||||
|
<li>-60 Grad (dlp games)</li>
|
||||||
|
<li>5Pairs (<a href="https://brettspielbox.de/vorschau-bsl/">BSL</a>)</li>
|
||||||
|
</ul>
|
||||||
|
<p>A</p>
|
||||||
|
<ul><li>Abaku (Albi)</li></ul>
|
||||||
|
</div>
|
||||||
|
<div class="wp-block-column">
|
||||||
|
<p>M</p>
|
||||||
|
<ul>
|
||||||
|
<li>Machu Pichu (HUCH!)</li>
|
||||||
|
<li>Titel ohne Verlag</li>
|
||||||
|
<li>Fadenfroh (1 More Time Games)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
SPIELESSEN_PRODUKTE = {
|
||||||
|
"path": "static/spiel26/x/logos/",
|
||||||
|
"picturePath": "static/spiel26/x/products/",
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"ID": "188",
|
||||||
|
"TITEL": "-60°",
|
||||||
|
"UNTERTITEL": "Nanox Games",
|
||||||
|
"FIRMA_ID": "436",
|
||||||
|
"INFO": (
|
||||||
|
"<table><tr><td>Autor: </td><td>Alexander Pfister & Peter Prinz</td></tr>"
|
||||||
|
"<tr><td>Verlag: </td><td>Nanox Games</td></tr>"
|
||||||
|
"<tr><td>Vertrieb: </td><td>dlp games Verlag GmbH</td></tr>"
|
||||||
|
"<tr><td>Erscheinungsdatum:</td><td>10/2026</td></tr>"
|
||||||
|
"<tr><td>Spielalter:</td><td>ab 12 Jahren</td></tr></table>"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"ID": "189", "TITEL": "", "INFO": ""},
|
||||||
|
{"ID": "190", "TITEL": "Nur Aussteller-Match", "UNTERTITEL": "", "FIRMA_ID": "777", "INFO": ""},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SPIELESSEN_AUSSTELLER = {"exhibitors": [{"ID": "777", "NAME": "Mysterium Verlag"}]}
|
||||||
|
|
||||||
|
CLIQUENABEND_SEITE = """
|
||||||
|
<table>
|
||||||
|
<tr><td>Update</td><td>Verlag</td><td>Stand</td><td>Spiel</td><td>Autor</td></tr>
|
||||||
|
<tr>
|
||||||
|
<td>15.10.25</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight:bold;">Dranda Games</div>
|
||||||
|
<a class="grey" href="/profile/067900.html">Profil</a>,
|
||||||
|
<div style="font-weight:bold;">PD Verlag</div>
|
||||||
|
<a class="grey" href="/profile/152000.html">Profil</a>,
|
||||||
|
</td>
|
||||||
|
<td>3-H400</td>
|
||||||
|
<td><div><a href="/spiele/790610-Galactic-Cruise.html" target="_blank"><b style="font-size:1.1em;">Galactic Cruise</b></a>
|
||||||
|
<div class="gameinfos">100,- €</div></div></td>
|
||||||
|
<td>T.K. King und Koltin Thompson</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td>26.09.25</td><td>Piatnik Profil ,</td><td>6-G200</td>
|
||||||
|
<td><div><a href="/spiele/790700-Elixirus.html"><b>Elixirus</b></a></div></td>
|
||||||
|
<td>Dickie Chapin</td></tr>
|
||||||
|
<tr><td>01.10.25</td><td>ohne Link</td><td>—</td><td>Nur Texttitel</td><td>Unbekannt</td></tr>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
|
||||||
|
CLIQUENABEND_DETAIL_1 = """
|
||||||
|
<html><body>
|
||||||
|
<h1>Galactic Cruise</h1>
|
||||||
|
<div class="right_bar"><center>
|
||||||
|
<a href="/spiele/790610-Galactic-Cruise.html"><img src="/images/db/62452_360x240.jpg" border="0"></a><br>
|
||||||
|
</center></div>
|
||||||
|
<b>Release:</b><br>
|
||||||
|
2025 <br>
|
||||||
|
<b>Anzahl der Spieler:</b><br>
|
||||||
|
1 bis 4 Spieler<br>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
CLIQUENABEND_DETAIL_FEHLT = "<html><body><p>Seite ohne Release-Feld</p></body></html>"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUhr:
|
||||||
|
"""Steuerbare Monoton-Uhr."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.zeit = 0.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.zeit
|
||||||
|
|
||||||
|
|
||||||
|
class KartenTransport(httpx.BaseTransport):
|
||||||
|
"""Antworten je URL; zeichnet Requests (URL, Header, Uhrzeit) auf."""
|
||||||
|
|
||||||
|
def __init__(self, antworten: dict[str, httpx.Response], uhr: FakeUhr):
|
||||||
|
self.antworten = antworten
|
||||||
|
self.uhr = uhr
|
||||||
|
self.anfragen: list[tuple[str, float]] = []
|
||||||
|
self.header: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
self.anfragen.append((url, self.uhr()))
|
||||||
|
self.header.append(dict(request.headers))
|
||||||
|
# 1) exakte URL, 2) URL ohne Query (dynamische Parameter wie
|
||||||
|
# ?columns=… bei JSON-Endpunkten). URLs mit Query fallen NIE auf
|
||||||
|
# eine längere gemockte Basis-URL zurück (?start=N wäre sonst
|
||||||
|
# ein Präfix der Basis-URL und würde endlos Seite 1 liefern).
|
||||||
|
antwort = self.antworten.get(url)
|
||||||
|
if antwort is None and "?" in url:
|
||||||
|
antwort = self.antworten.get(url.split("?")[0])
|
||||||
|
if antwort is None and "?" not in url:
|
||||||
|
antwort = self.antworten.get(url.split("?")[0])
|
||||||
|
if antwort is None or isinstance(antwort, Exception):
|
||||||
|
raise QuellenFehler(f"Keine gemockte Antwort für {url}")
|
||||||
|
return antwort
|
||||||
|
|
||||||
|
|
||||||
|
def _antwort(inhalt: bytes | str, etag: str | None = None) -> httpx.Response:
|
||||||
|
header = {}
|
||||||
|
if etag:
|
||||||
|
header["ETag"] = etag
|
||||||
|
return httpx.Response(
|
||||||
|
200, content=inhalt.encode("utf-8") if isinstance(inhalt, str) else inhalt,
|
||||||
|
headers=header, request=httpx.Request("GET", "https://example.org/mock"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _client(transport, uhr, schlaf=None, validatoren=None) -> WebQuellenClient:
|
||||||
|
return WebQuellenClient(
|
||||||
|
transport=transport,
|
||||||
|
mindestabstand_sekunden=1.0,
|
||||||
|
schlaf=schlaf or (lambda s: None),
|
||||||
|
uhr=uhr,
|
||||||
|
validatoren=validatoren,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_factory():
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from redaktionskern.db import Base
|
||||||
|
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
|
||||||
|
yield fabrik
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter(klasse, seiten: dict[str, bytes | str], etag: str | None = None):
|
||||||
|
uhr = FakeUhr()
|
||||||
|
transport = KartenTransport(
|
||||||
|
{u: _antwort(i, etag) for u, i in seiten.items()}, uhr
|
||||||
|
)
|
||||||
|
return klasse(_client(transport, uhr)), transport
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Hilfsfunktionen ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_jahr_aus_datumsangabe():
|
||||||
|
assert jahr_aus_datumsangabe("24.07.2026") == 2026
|
||||||
|
assert jahr_aus_datumsangabe("10/2026") == 2026
|
||||||
|
assert jahr_aus_datumsangabe("Herbst 2026") == 2026
|
||||||
|
assert jahr_aus_datumsangabe("Q3 2026") == 2026
|
||||||
|
assert jahr_aus_datumsangabe(None) is None
|
||||||
|
assert jahr_aus_datumsangabe("ohne Jahr") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_titel_aehnlichkeit_erkennt_duplikate_keine_erweiterungen():
|
||||||
|
# Reale BGG-Duplikatfälle werden erkannt …
|
||||||
|
assert titel_aehnlichkeit("Die Siedler von Catan", "Catan") >= 92
|
||||||
|
assert titel_aehnlichkeit("Fadenfroh!", "Fadenfroh") >= 92
|
||||||
|
assert titel_aehnlichkeit("Machu Pichu", "Machu Picchu") >= 92
|
||||||
|
# … echte Erweiterungen/andere Spiele nicht.
|
||||||
|
assert titel_aehnlichkeit("Catan", "Catan: Erweiterung") < 92
|
||||||
|
assert titel_aehnlichkeit("Azul", "Azul: Summer Pavilion") < 92
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Parser je Quelle ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_spielbox_extrahiert_titel_verlag_datum_url():
|
||||||
|
# Ohne Pagination-Link — der Parser-Test interessiert nur Seite 1.
|
||||||
|
seite_ohne_pagination = SPIELBOX_SEITE_1.replace(
|
||||||
|
b'<ul class="pagination"><li><a href="/neuheiten-neue-spiele?start=6">2</a></li></ul>',
|
||||||
|
b'<ul class="pagination"></ul>',
|
||||||
|
)
|
||||||
|
adapter, _ = _adapter(SpielboxQuelle, {"https://www.spielbox.de/neuheiten-neue-spiele": seite_ohne_pagination})
|
||||||
|
ergebnis = adapter.sammle()
|
||||||
|
|
||||||
|
assert len(ergebnis.treffer) == 2
|
||||||
|
erster = ergebnis.treffer[0]
|
||||||
|
assert erster.titel == "Pegasus Spiele: Next Station Berlin"
|
||||||
|
assert erster.verlag == "Pegasus Spiele"
|
||||||
|
assert erster.erscheinungsdatum_oder_quartal == "24.07.2026"
|
||||||
|
assert erster.quellen_url == (
|
||||||
|
"https://www.spielbox.de/neuheiten-neue-spiele/1369-pegasus-next-station-berlin"
|
||||||
|
)
|
||||||
|
assert ergebnis.treffer[1].verlag is None # kein Doppelpunkt im Titel
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_brettspielbox_zerlegt_titel_verlag_und_links():
|
||||||
|
adapter, _ = _adapter(
|
||||||
|
BrettspielboxQuelle,
|
||||||
|
{"https://brettspielbox.de/brettspiel-neuheiten-spiel-2026-herbst-2026-a-z/": BRETTSPIELBOX_SEITE},
|
||||||
|
)
|
||||||
|
ergebnis = adapter.sammle()
|
||||||
|
|
||||||
|
titel_verlage = {(t.titel, t.verlag) for t in ergebnis.treffer}
|
||||||
|
assert ("-60 Grad", "dlp games") in titel_verlage
|
||||||
|
assert ("5Pairs", "BSL") in titel_verlage
|
||||||
|
assert ("Abaku", "Albi") in titel_verlage
|
||||||
|
assert ("Titel ohne Verlag", None) in titel_verlage
|
||||||
|
# Eintrag mit Vorstellungs-Link nutzt diesen als quellen_url …
|
||||||
|
fuenf = next(t for t in ergebnis.treffer if t.titel == "5Pairs")
|
||||||
|
assert fuenf.quellen_url == "https://brettspielbox.de/vorschau-bsl/"
|
||||||
|
# … alle anderen die Listenseite.
|
||||||
|
ohne = next(t for t in ergebnis.treffer if t.titel == "Abaku")
|
||||||
|
assert ohne.quellen_url.endswith("a-z/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_spielessen_nutzt_json_api_endpunkt():
|
||||||
|
adapter, transport = _adapter(
|
||||||
|
lambda client: SpielEssenQuelle(client, jahr=2026),
|
||||||
|
{}, # Endpunkte werden dynamisch gebaut → direkt setzen
|
||||||
|
)
|
||||||
|
transport.antworten.update(
|
||||||
|
{
|
||||||
|
# Der Adapter hängt ?columns=… an — Mock matcht per Präfix.
|
||||||
|
"https://maps.eyeled-services.de/de/spiel26/products": _antwort(
|
||||||
|
json.dumps(SPIELESSEN_PRODUKTE)
|
||||||
|
),
|
||||||
|
"https://maps.eyeled-services.de/de/spiel26/exhibitors": _antwort(
|
||||||
|
json.dumps(SPIELESSEN_AUSSTELLER)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ergebnis = adapter.sammle()
|
||||||
|
|
||||||
|
assert len(ergebnis.treffer) == 2 # leerer TITEL wird übersprungen
|
||||||
|
erster = ergebnis.treffer[0]
|
||||||
|
assert erster.titel == "-60°"
|
||||||
|
assert erster.verlag == "Nanox Games"
|
||||||
|
assert erster.autor == "Alexander Pfister & Peter Prinz"
|
||||||
|
assert erster.erscheinungsdatum_oder_quartal == "10/2026"
|
||||||
|
assert erster.quellen_url == "https://spiel-essen.de/de/die-spiel/neuheiten"
|
||||||
|
# Aussteller-Fallback für Produkte ohne INFO-Verlag:
|
||||||
|
assert ergebnis.treffer[1].verlag == "Mysterium Verlag"
|
||||||
|
|
||||||
|
|
||||||
|
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/spiele/790610-Galactic-Cruise.html": CLIQUENABEND_DETAIL_1,
|
||||||
|
"https://www.cliquenabend.de/spiele/790700-Elixirus.html": CLIQUENABEND_DETAIL_FEHLT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ergebnis = adapter.sammle()
|
||||||
|
|
||||||
|
assert [t.titel for t in ergebnis.treffer] == ["Galactic Cruise", "Elixirus"]
|
||||||
|
erster = ergebnis.treffer[0]
|
||||||
|
assert erster.verlag == "Dranda Games, PD Verlag"
|
||||||
|
assert erster.autor == "T.K. King und Koltin Thompson"
|
||||||
|
# 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"
|
||||||
|
# Cover-Bild aus der Detailseite (statt Messe-Foto):
|
||||||
|
assert erster.bild_url == "https://www.cliquenabend.de/images/db/62452_360x240.jpg"
|
||||||
|
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():
|
||||||
|
adapter, _ = _adapter(
|
||||||
|
CliquenabendQuelle,
|
||||||
|
{"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": "<html><body><p>Wartung</p></body></html>"},
|
||||||
|
)
|
||||||
|
ergebnis = adapter.sammle()
|
||||||
|
assert ergebnis.treffer == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Pagination ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_spielbox_durchlaeuft_alle_pagination_seiten():
|
||||||
|
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
|
||||||
|
adapter, transport = _adapter(
|
||||||
|
SpielboxQuelle,
|
||||||
|
{basis_url: SPIELBOX_SEITE_1, f"{basis_url}?start=6": SPIELBOX_SEITE_2},
|
||||||
|
)
|
||||||
|
|
||||||
|
ergebnis = adapter.sammle()
|
||||||
|
|
||||||
|
assert len(ergebnis.treffer) == 3 # 2 + 1 über die zweite Seite
|
||||||
|
angefragt = [u for u, _ in transport.anfragen]
|
||||||
|
assert angefragt.count(basis_url) == 1
|
||||||
|
assert f"{basis_url}?start=6" in angefragt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Rate-Limit & User-Agent ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_mindestens_eine_sekunde_zwischen_requests():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
wartezeiten: list[float] = []
|
||||||
|
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
|
||||||
|
transport = KartenTransport(
|
||||||
|
{basis_url: _antwort(SPIELBOX_SEITE_1), f"{basis_url}?start=6": _antwort(SPIELBOX_SEITE_2)},
|
||||||
|
uhr,
|
||||||
|
)
|
||||||
|
|
||||||
|
def schlaf(sekunden: float) -> None:
|
||||||
|
wartezeiten.append(sekunden)
|
||||||
|
uhr.zeit += sekunden
|
||||||
|
|
||||||
|
client = WebQuellenClient(transport=transport, mindestabstand_sekunden=1.0, schlaf=schlaf, uhr=uhr)
|
||||||
|
SpielboxQuelle(client).sammle()
|
||||||
|
|
||||||
|
assert len(transport.anfragen) == 2
|
||||||
|
erste_zeit = transport.anfragen[0][1]
|
||||||
|
zweite_zeit = transport.anfragen[1][1]
|
||||||
|
assert zweite_zeit - erste_zeit >= 1.0
|
||||||
|
assert wartezeiten and wartezeiten[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_sendet_redaktions_user_agent():
|
||||||
|
uhr = FakeUhr()
|
||||||
|
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
|
||||||
|
transport = KartenTransport({basis_url: _antwort(SPIELBOX_SEITE_1)}, uhr)
|
||||||
|
client = WebQuellenClient(transport=transport, schlaf=lambda s: None, uhr=uhr)
|
||||||
|
client.hole(basis_url)
|
||||||
|
assert transport.header[0]["user-agent"] == USER_AGENT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- ETag / 304 ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_etag_wird_gesendet_und_304_ueberspringt_die_quelle():
|
||||||
|
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
|
||||||
|
uhr = FakeUhr()
|
||||||
|
transport = KartenTransport(
|
||||||
|
{basis_url: httpx.Response(
|
||||||
|
304, request=httpx.Request("GET", basis_url)
|
||||||
|
)},
|
||||||
|
uhr,
|
||||||
|
)
|
||||||
|
client = WebQuellenClient(
|
||||||
|
transport=transport, schlaf=lambda s: None, uhr=uhr,
|
||||||
|
validatoren={basis_url: ('"abc123"', None)},
|
||||||
|
)
|
||||||
|
|
||||||
|
ergebnis = SpielboxQuelle(client).sammle()
|
||||||
|
|
||||||
|
assert transport.header[0].get("if-none-match") == '"abc123"'
|
||||||
|
assert ergebnis.unveraendert is True
|
||||||
|
assert ergebnis.treffer == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_etag_wird_aus_antwort_gespeichert():
|
||||||
|
basis_url = "https://www.spielbox.de/neuheiten-neue-spiele"
|
||||||
|
adapter, _ = _adapter(SpielboxQuelle, {basis_url: SPIELBOX_SEITE_1}, etag='"etag-42"')
|
||||||
|
adapter.client.hole(basis_url)
|
||||||
|
assert adapter.client.validatoren[basis_url][0] == '"etag-42"'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Sync-Orchestrierung ----------
|
||||||
|
|
||||||
|
|
||||||
|
class StatischerAdapter:
|
||||||
|
"""Adapter-Ersatz mit fester Trefferliste (für Service-Tests)."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, treffer: list[QuellenTreffer]):
|
||||||
|
self.name = name
|
||||||
|
self.anzeigename = name.title()
|
||||||
|
self.start_urls = (f"https://{name}.example.org/liste",)
|
||||||
|
self._treffer = treffer
|
||||||
|
|
||||||
|
def __call__(self, client):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def sammle(self) -> SammelErgebnis:
|
||||||
|
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
|
||||||
|
|
||||||
|
|
||||||
|
class KaputterAdapter:
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
self.anzeigename = name.title()
|
||||||
|
self.start_urls = (f"https://{name}.example.org/liste",)
|
||||||
|
|
||||||
|
def __call__(self, client):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def sammle(self) -> SammelErgebnis:
|
||||||
|
raise QuellenFehler("Seite umgebaut")
|
||||||
|
|
||||||
|
|
||||||
|
def _service(session_factory, klassen, dedup=None):
|
||||||
|
return WebQuellenSyncService(
|
||||||
|
session_factory, adapter_klassen=klassen, dedup=dedup,
|
||||||
|
client_fabrik=lambda validatoren: WebQuellenClient(
|
||||||
|
schlaf=lambda s: None, uhr=FakeUhr(), validatoren=validatoren
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_legt_web_eintraege_mit_quelle_an(session_factory):
|
||||||
|
quelle = StatischerAdapter(
|
||||||
|
"spielbox",
|
||||||
|
[QuellenTreffer(titel="Ringträger", verlag="Schmidt Spiele",
|
||||||
|
autor=None, erscheinungsdatum_oder_quartal="15.08.2026",
|
||||||
|
quellen_url="https://www.spielbox.de/x")],
|
||||||
|
)
|
||||||
|
service = _service(session_factory, (quelle,))
|
||||||
|
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].neu == 1
|
||||||
|
assert zeilen[0].fehlermeldung is None
|
||||||
|
with session_factory() as db:
|
||||||
|
eintrag = db.query(Neuheit).one()
|
||||||
|
assert eintrag.quelle == "spielbox"
|
||||||
|
assert eintrag.bgg_id is None
|
||||||
|
assert eintrag.status == "neuheit"
|
||||||
|
assert eintrag.erscheinungsjahr == 2026
|
||||||
|
statuszeile = db_status(session_factory, "spielbox")
|
||||||
|
assert statuszeile.anzahl_neu == 1
|
||||||
|
assert statuszeile.letzte_laufzeit is not None
|
||||||
|
|
||||||
|
|
||||||
|
def db_status(session_factory, quelle: str) -> QuellenStatus:
|
||||||
|
with session_factory() as db:
|
||||||
|
zeile = db.query(QuellenStatus).filter_by(quelle=quelle).one()
|
||||||
|
db.expunge(zeile)
|
||||||
|
return zeile
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_filtert_duplikate_gegen_bggtitel(session_factory):
|
||||||
|
with session_factory() as db:
|
||||||
|
db.add(Neuheit(titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
|
||||||
|
erscheinungsjahr=1995, bgg_id=13, quelle="boardgamegeek"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
quelle = StatischerAdapter(
|
||||||
|
"spielbox",
|
||||||
|
[
|
||||||
|
QuellenTreffer(titel="Die Siedler von Catan", verlag="KOSMOS",
|
||||||
|
quellen_url="https://www.spielbox.de/catan"),
|
||||||
|
QuellenTreffer(titel="Ein ganz neues Spiel", verlag="Anderer Verlag",
|
||||||
|
quellen_url="https://www.spielbox.de/neu"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
service = _service(session_factory, (quelle,))
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].gefiltert == 1 # BGG-Duplikat per Titel-Match
|
||||||
|
assert zeilen[0].neu == 1
|
||||||
|
with session_factory() as db:
|
||||||
|
titel = {e.titel for e in db.query(Neuheit).all()}
|
||||||
|
assert titel == {"Catan", "Ein ganz neues Spiel"}
|
||||||
|
neu = db.query(Neuheit).filter_by(titel="Ein ganz neues Spiel").one()
|
||||||
|
assert neu.bgg_id is None and neu.quelle == "spielbox"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zweiter_lauf_aktualisiert_statt_duplikat(session_factory):
|
||||||
|
quelle = StatischerAdapter(
|
||||||
|
"brettspielbox",
|
||||||
|
[QuellenTreffer(titel="Fadenfroh", verlag="1 More Time Games",
|
||||||
|
quellen_url="https://brettspielbox.de/fadenfroh")],
|
||||||
|
)
|
||||||
|
service = _service(session_factory, (quelle,))
|
||||||
|
service.laufe()
|
||||||
|
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].aktualisiert == 1
|
||||||
|
assert zeilen[0].neu == 0
|
||||||
|
with session_factory() as db:
|
||||||
|
assert db.query(Neuheit).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_konflikt_wird_gefiltert_registry_api(session_factory):
|
||||||
|
class FakeDedup:
|
||||||
|
async def check_titel(self, titel, verlag, bgg_id, **kwargs):
|
||||||
|
class Ergebnis:
|
||||||
|
hat_konflikte = titel.startswith("Konflikt")
|
||||||
|
|
||||||
|
return Ergebnis()
|
||||||
|
|
||||||
|
quelle = StatischerAdapter(
|
||||||
|
"cliquenabend",
|
||||||
|
[
|
||||||
|
QuellenTreffer(titel="Konfliktfall", verlag="V", quellen_url="u"),
|
||||||
|
QuellenTreffer(titel="Saubere Ware", verlag="V", quellen_url="u"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
service = _service(session_factory, (quelle,), dedup=FakeDedup())
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].gefiltert == 1
|
||||||
|
assert zeilen[0].neu == 1
|
||||||
|
assert [e.titel for e in alle(session_factory)] == ["Saubere Ware"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_ausfall_blockiert_nicht(session_factory):
|
||||||
|
class KaputterDedup:
|
||||||
|
async def check_titel(self, titel, verlag, bgg_id, **kwargs):
|
||||||
|
raise RuntimeError("dedup nicht erreichbar")
|
||||||
|
|
||||||
|
quelle = StatischerAdapter(
|
||||||
|
"cliquenabend",
|
||||||
|
[QuellenTreffer(titel="Läuft trotzdem", verlag="V", quellen_url="u")],
|
||||||
|
)
|
||||||
|
service = _service(session_factory, (quelle,), dedup=KaputterDedup())
|
||||||
|
zeilen = service.laufe()
|
||||||
|
assert zeilen[0].neu == 1
|
||||||
|
|
||||||
|
|
||||||
|
def alle(session_factory):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with session_factory() as db:
|
||||||
|
return list(db.scalars(select(Neuheit)).all())
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehler_isolation_eine_quelle_crasht_andere_laeuft_weiter(session_factory):
|
||||||
|
kaputt = KaputterAdapter("spielbox")
|
||||||
|
gut = StatischerAdapter(
|
||||||
|
"cliquenabend",
|
||||||
|
[QuellenTreffer(titel="Galactic Cruise", verlag="Dranda Games",
|
||||||
|
autor="T.K. King", erscheinungsdatum_oder_quartal="15.10.25",
|
||||||
|
quellen_url="https://www.cliquenabend.de/spiele/x")],
|
||||||
|
)
|
||||||
|
service = _service(session_factory, (kaputt, gut))
|
||||||
|
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].fehleranzahl == 1
|
||||||
|
assert "Seite umgebaut" in zeilen[0].fehlermeldung
|
||||||
|
assert zeilen[1].neu == 1 # andere Quelle lief normal weiter
|
||||||
|
status_kaputt = db_status(session_factory, "spielbox")
|
||||||
|
assert status_kaputt.fehlermeldung and status_kaputt.anzahl_fehler == 1
|
||||||
|
status_gut = db_status(session_factory, "cliquenabend")
|
||||||
|
assert status_gut.anzahl_neu == 1 and not status_gut.fehlermeldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_schalter_deaktiviert_einzelne_quelle(session_factory, monkeypatch):
|
||||||
|
monkeypatch.setenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", "0")
|
||||||
|
|
||||||
|
aus = StatischerAdapter("spielbox", [QuellenTreffer(titel="X", quellen_url="u")])
|
||||||
|
an = StatischerAdapter("cliquenabend", [QuellenTreffer(titel="Y", quellen_url="u")])
|
||||||
|
|
||||||
|
class BeobachtenderService(WebQuellenSyncService):
|
||||||
|
aufgerufen = []
|
||||||
|
|
||||||
|
def _client_fabrik_guard(self): # pragma: no cover - nur Signal
|
||||||
|
raise AssertionError("ausgeschaltete Quelle darf keinen Client bauen")
|
||||||
|
|
||||||
|
service = _service(session_factory, (aus, an))
|
||||||
|
zeilen = service.laufe()
|
||||||
|
|
||||||
|
assert zeilen[0].aktiv is False
|
||||||
|
assert zeilen[0].neu == 0
|
||||||
|
assert zeilen[1].neu == 1 # angeschaltete Quelle läuft
|
||||||
|
assert quelle_aktiv("spielbox") is False
|
||||||
|
assert quelle_aktiv("brettspielbox") is True # Standard „1“
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_schalter_uebersteuert_env(session_factory, monkeypatch):
|
||||||
|
"""Der DB-Schalter schaltet eine per Env aktive Quelle ab (und umgekehrt)."""
|
||||||
|
monkeypatch.delenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", raising=False)
|
||||||
|
|
||||||
|
spielbox = StatischerAdapter("spielbox", [QuellenTreffer(titel="X", quellen_url="u")])
|
||||||
|
service = _service(session_factory, (spielbox,))
|
||||||
|
|
||||||
|
# Standard: an
|
||||||
|
assert service.quelle_laeuft("spielbox") is True
|
||||||
|
|
||||||
|
# Schalter „aus“ → Sync überspringt die Quelle
|
||||||
|
service.schalter_gesetzt("spielbox", False)
|
||||||
|
assert service.quelle_laeuft("spielbox") is False
|
||||||
|
zeilen = service.laufe()
|
||||||
|
assert zeilen[0].aktiv is False and zeilen[0].neu == 0
|
||||||
|
|
||||||
|
# Schalter „an“ → läuft wieder
|
||||||
|
service.schalter_gesetzt("spielbox", True)
|
||||||
|
assert service.quelle_laeuft("spielbox") is True
|
||||||
|
zeilen = service.laufe()
|
||||||
|
assert zeilen[0].aktiv is True and zeilen[0].neu == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_einzellauf_ignoriert_schalter(session_factory, monkeypatch):
|
||||||
|
"""`laufе(nur_quellen=…)` läuft auch bei ausgeschalteter Quelle."""
|
||||||
|
monkeypatch.delenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", raising=False)
|
||||||
|
|
||||||
|
spielbox = StatischerAdapter("spielbox", [QuellenTreffer(titel="X", quellen_url="u")])
|
||||||
|
andere = StatischerAdapter("cliquenabend", [QuellenTreffer(titel="Y", quellen_url="u")])
|
||||||
|
service = _service(session_factory, (spielbox, andere))
|
||||||
|
|
||||||
|
service.schalter_gesetzt("spielbox", False)
|
||||||
|
|
||||||
|
zeilen = service.laufe(nur_quellen={"spielbox"})
|
||||||
|
assert len(zeilen) == 1
|
||||||
|
assert zeilen[0].quelle == "spielbox"
|
||||||
|
assert zeilen[0].aktiv is True
|
||||||
|
assert zeilen[0].neu == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_validatoren_werden_persistiert_und_wieder_verwendet(session_factory):
|
||||||
|
"""304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet."""
|
||||||
|
import tests._neuheiten as h
|
||||||
|
|
||||||
|
basis_url = "https://www.spielbox.de/neuheiten-neuheiten"
|
||||||
|
uhr = FakeUhr()
|
||||||
|
|
||||||
|
class ValidatorenTransport(httpx.BaseTransport):
|
||||||
|
def __init__(self):
|
||||||
|
self.etag_gesendet: list[str | None] = []
|
||||||
|
self.runde = 0
|
||||||
|
|
||||||
|
def handle_request(self, request):
|
||||||
|
self.etag_gesendet.append(request.headers.get("if-none-match"))
|
||||||
|
self.runde += 1
|
||||||
|
if self.runde == 1:
|
||||||
|
return httpx.Response(200, content=b"<div class='item'></div>",
|
||||||
|
headers={"ETag": '"runde-1"'},
|
||||||
|
request=httpx.Request("GET", basis_url))
|
||||||
|
return httpx.Response(304, request=httpx.Request("GET", basis_url))
|
||||||
|
|
||||||
|
transport = ValidatorenTransport()
|
||||||
|
|
||||||
|
def fabrik(validatoren):
|
||||||
|
return WebQuellenClient(transport=transport, schlaf=lambda s: None,
|
||||||
|
uhr=uhr, validatoren=validatoren)
|
||||||
|
|
||||||
|
quelle = type("SpielboxPersistenz", (), {
|
||||||
|
"name": "spielbox",
|
||||||
|
"anzeigename": "Spielbox",
|
||||||
|
"start_urls": (basis_url,),
|
||||||
|
})
|
||||||
|
# Adapter-Klasse so bauen, dass sie den Transport-Parser nutzt:
|
||||||
|
from tests._neuheiten import SpielboxQuelle as SB
|
||||||
|
|
||||||
|
class PersistenteSpielbox(SB):
|
||||||
|
pass
|
||||||
|
|
||||||
|
PersistenteSpielbox.name = "spielbox"
|
||||||
|
PersistenteSpielbox.start_urls = (basis_url,)
|
||||||
|
|
||||||
|
service = WebQuellenSyncService(
|
||||||
|
session_factory, adapter_klassen=(PersistenteSpielbox,),
|
||||||
|
dedup=None, client_fabrik=fabrik,
|
||||||
|
)
|
||||||
|
|
||||||
|
erste = service.laufe()
|
||||||
|
zweite = service.laufe()
|
||||||
|
|
||||||
|
assert erste[0].unveraendert is False
|
||||||
|
assert transport.etag_gesendet[0] is None
|
||||||
|
assert zweite[0].unveraendert is True # 304 → Quelle übersprungen
|
||||||
|
assert transport.etag_gesendet[1] == '"runde-1"' # gespeicherter Validator
|
||||||
|
statuszeile = db_status(session_factory, "spielbox")
|
||||||
|
assert statuszeile.validatoren[basis_url][0] == '"runde-1"'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- UI-Übersicht ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _test_app_und_client(monkeypatch, tmp_path):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from redaktionskern.app import create_app
|
||||||
|
from redaktionskern.config import Settings
|
||||||
|
from tests.conftest import ADMIN_PASSWORD, PROJEKT_WURZEL
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
database_url=f"sqlite:///{tmp_path/'q.db'}",
|
||||||
|
session_secret="test-secret",
|
||||||
|
initial_admin_password=ADMIN_PASSWORD,
|
||||||
|
plugins_dir=PROJEKT_WURZEL / "plugins",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
client = TestClient(app)
|
||||||
|
antwort = client.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD},
|
||||||
|
follow_redirects=False)
|
||||||
|
assert antwort.status_code == 303
|
||||||
|
return app, client
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_zeigt_sync_uebersicht_der_quellen(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", "0")
|
||||||
|
app, client = _test_app_und_client(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
antwort = client.get("/neuheiten")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
for text in ("Web-Quellen", "spielbox.de Neuheiten", "Brettspielbox Messevorschau",
|
||||||
|
"SPIEL Essen Neuheiten", "Cliquenabend Messe-Übersicht"):
|
||||||
|
assert text in antwort.text, f"Fehlt: {text!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manueller_quellen_sync_endpunkt_mit_fakes(monkeypatch, tmp_path):
|
||||||
|
app, client = _test_app_und_client(monkeypatch, tmp_path)
|
||||||
|
plugin = app.state.registry.get("neuheiten")
|
||||||
|
|
||||||
|
fake_zeile = QuellenLaufZeile("spielbox", "spielbox.de Neuheiten")
|
||||||
|
fake_zeile.neu = 3
|
||||||
|
monkeypatch.setattr(plugin, "_quellen_sync_ausfuehren", lambda: [fake_zeile])
|
||||||
|
|
||||||
|
antwort = client.post("/neuheiten/quellen-sync", follow_redirects=False)
|
||||||
|
assert antwort.status_code == 303
|
||||||
|
assert "/neuheiten" in antwort.headers["location"]
|
||||||
|
# Meldung steckt im Redirect-Ziel — der Folge-Request muss die
|
||||||
|
# Location (inkl. ?meldung=…) abrufen, nicht die nackte Liste.
|
||||||
|
folge = client.get(antwort.headers["location"])
|
||||||
|
assert "Web-Quellen-Sync abgeschlossen" in folge.text
|
||||||
|
assert "3 neu" in folge.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_rezensent_kann_quellen_sync_nicht_starten(monkeypatch, tmp_path):
|
||||||
|
from tests.conftest import lege_benutzer_an, melde_an
|
||||||
|
|
||||||
|
app, client = _test_app_und_client(monkeypatch, tmp_path)
|
||||||
|
lege_benutzer_an(app, "resi", "rezensent")
|
||||||
|
|
||||||
|
melde_an(client, username="resi", password="test-12345678")
|
||||||
|
antwort = client.post("/neuheiten/quellen-sync", follow_redirects=False)
|
||||||
|
assert antwort.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Migration: bgg_id nullable ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_0002_macht_bgg_id_nullable_erhaelt_daten(tmp_path):
|
||||||
|
"""Bestehende DB mit NOT NULL-bgg_id wird migriert, Daten bleiben erhalten."""
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
|
||||||
|
from redaktionskern.migrationen import run_migrations
|
||||||
|
from tests._neuheiten import models as m
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{tmp_path/'mig.db'}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql("""
|
||||||
|
CREATE TABLE neuheiten (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
titel VARCHAR(300) NOT NULL,
|
||||||
|
verlag VARCHAR(300),
|
||||||
|
autor VARCHAR(300),
|
||||||
|
erscheinungsjahr INTEGER,
|
||||||
|
bgg_id INTEGER NOT NULL,
|
||||||
|
status VARCHAR(50),
|
||||||
|
quelle VARCHAR(100),
|
||||||
|
erstellt_am DATETIME DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
aktualisiert_am DATETIME DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT uq_bgg UNIQUE (bgg_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"INSERT INTO neuheiten (titel, bgg_id, status, quelle) "
|
||||||
|
"VALUES ('Catan', 13, 'neuheit', 'boardgamegeek')"
|
||||||
|
)
|
||||||
|
plugin_name = "neuheiten"
|
||||||
|
|
||||||
|
plugin_instanz = _plugin_instance()
|
||||||
|
migrationen = plugin_instanz.migrations()
|
||||||
|
nur_alt = [migr for migr in migrationen if migr.version == "0001_neuheiten_tabelle"]
|
||||||
|
with engine.begin() as conn:
|
||||||
|
run_migrations(conn, plugin_name, nur_alt) # Stand: alte Version
|
||||||
|
with engine.begin() as conn:
|
||||||
|
neue = run_migrations(conn, plugin_name, migrationen)
|
||||||
|
|
||||||
|
assert "0002_bgg_id_nullable" in neue
|
||||||
|
inspektor = inspect(engine)
|
||||||
|
spalten = {c["name"]: c for c in inspektor.get_columns("neuheiten")}
|
||||||
|
assert spalten["bgg_id"]["nullable"] is True
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeile = conn.execute(text("SELECT titel, bgg_id FROM neuheiten")).one()
|
||||||
|
assert zeile == ("Catan", 13)
|
||||||
|
# NULL-bgg_id ist mehrfach erlaubt:
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO neuheiten (titel, bgg_id, status, quelle) "
|
||||||
|
"VALUES ('Web-Titel A', NULL, 'neuheit', 'spielbox')"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO neuheiten (titel, bgg_id, status, quelle) "
|
||||||
|
"VALUES ('Web-Titel B', NULL, 'neuheit', 'spielbox')"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_instance():
|
||||||
|
from tests._neuheiten import sys_modules_plugin
|
||||||
|
|
||||||
|
return sys_modules_plugin()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_0003_legt_quellen_status_tabelle_an(tmp_path):
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
from redaktionskern.migrationen import run_migrations
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{tmp_path/'mig3.db'}")
|
||||||
|
plugin_instanz = _plugin_instance()
|
||||||
|
with engine.begin() as conn:
|
||||||
|
run_migrations(conn, "neuheiten", plugin_instanz.migrations())
|
||||||
|
inspektor = inspect(engine)
|
||||||
|
assert "neuheiten_quellen_status" in inspektor.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Registrierung ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_alle_vier_quellen_registriert():
|
||||||
|
namen = {klasse.name for klasse in ADAPTER_KLASSEN}
|
||||||
|
assert namen == {"spielbox", "brettspielbox", "spielessen", "cliquenabend"}
|
||||||
@@ -94,7 +94,9 @@ def test_sync_legt_eintraege_an(session_factory):
|
|||||||
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||||
details_pro_id={13: _spiel()},
|
details_pro_id={13: _spiel()},
|
||||||
)
|
)
|
||||||
service = SyncService(session_factory, client)
|
service = SyncService(
|
||||||
|
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
|
||||||
|
)
|
||||||
|
|
||||||
ergebnis = service.synchronisiere(["catan"])
|
ergebnis = service.synchronisiere(["catan"])
|
||||||
|
|
||||||
@@ -117,7 +119,9 @@ def test_sync_aktualisiert_bestehende_eintraege_statt_duplikate(session_factory)
|
|||||||
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||||
details_pro_id={13: _spiel()},
|
details_pro_id={13: _spiel()},
|
||||||
)
|
)
|
||||||
service = SyncService(session_factory, client)
|
service = SyncService(
|
||||||
|
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
|
||||||
|
)
|
||||||
|
|
||||||
service.synchronisiere(["catan"])
|
service.synchronisiere(["catan"])
|
||||||
|
|
||||||
@@ -142,7 +146,9 @@ def test_sync_veraenderter_titel_gleiche_bgg_id_kein_duplikat(session_factory):
|
|||||||
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||||
details_pro_id={13: _spiel()},
|
details_pro_id={13: _spiel()},
|
||||||
)
|
)
|
||||||
service = SyncService(session_factory, client)
|
service = SyncService(
|
||||||
|
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
|
||||||
|
)
|
||||||
service.synchronisiere(["catan"])
|
service.synchronisiere(["catan"])
|
||||||
|
|
||||||
client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)")
|
client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)")
|
||||||
@@ -176,7 +182,9 @@ def test_sync_filtert_erweiterungen(session_factory):
|
|||||||
55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"),
|
55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
service = SyncService(session_factory, client)
|
service = SyncService(
|
||||||
|
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
|
||||||
|
)
|
||||||
|
|
||||||
ergebnis = service.synchronisiere(["catan"])
|
ergebnis = service.synchronisiere(["catan"])
|
||||||
|
|
||||||
@@ -200,7 +208,9 @@ def test_sync_filtert_prototypen(session_factory):
|
|||||||
78: _spiel(78, "My Prototype Game"),
|
78: _spiel(78, "My Prototype Game"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
service = SyncService(session_factory, client)
|
service = SyncService(
|
||||||
|
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
|
||||||
|
)
|
||||||
|
|
||||||
ergebnis = service.synchronisiere(["essen"])
|
ergebnis = service.synchronisiere(["essen"])
|
||||||
|
|
||||||
@@ -216,6 +226,61 @@ def test_prototyp_heuristik():
|
|||||||
assert not ist_moeglicher_prototyp("Die Burgen von Burgund")
|
assert not ist_moeglicher_prototyp("Die Burgen von Burgund")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Filter: Jahr und deutscher Titel ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_filtert_nach_jahr_und_deutschem_titel(session_factory):
|
||||||
|
aktuelles = 2026
|
||||||
|
client = FakeBggClient(
|
||||||
|
suchergebnisse={
|
||||||
|
"mix": [
|
||||||
|
SuchTreffer(13, "Catan", aktuelles - 1),
|
||||||
|
SuchTreffer(42, "Die neuen Siedler", aktuelles),
|
||||||
|
SuchTreffer(99, "Future Game", aktuelles + 1),
|
||||||
|
SuchTreffer(88, "The Old English Game", aktuelles),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
details_pro_id={
|
||||||
|
13: _spiel(13, "Catan", erscheinungsjahr=aktuelles - 1),
|
||||||
|
42: _spiel(42, "Die neuen Siedler", erscheinungsjahr=aktuelles),
|
||||||
|
99: _spiel(99, "Future Game", erscheinungsjahr=aktuelles + 1),
|
||||||
|
88: _spiel(88, "The Old English Game", erscheinungsjahr=aktuelles),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = SyncService(session_factory, client)
|
||||||
|
service._min_jahr = aktuelles
|
||||||
|
|
||||||
|
ergebnis = service.synchronisiere(["mix"])
|
||||||
|
|
||||||
|
assert ergebnis.neu == 1 # nur "Die neuen Siedler" überlebt beide Filter
|
||||||
|
assert ergebnis.gefiltert == 3 # 1 zu alt + 1 zukünftig aber englisch + 1 aktuell aber englisch
|
||||||
|
eintraege = _alle_eintraege(session_factory)
|
||||||
|
assert [e.bgg_id for e in eintraege] == [42]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_ohne_filter_behält_alle_jahre_und_sprachen(session_factory):
|
||||||
|
client = FakeBggClient(
|
||||||
|
suchergebnisse={
|
||||||
|
"mix": [
|
||||||
|
SuchTreffer(13, "Catan", 1995),
|
||||||
|
SuchTreffer(88, "The English Game", 2026),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
details_pro_id={
|
||||||
|
13: _spiel(13, "Catan", erscheinungsjahr=1995),
|
||||||
|
88: _spiel(88, "The English Game", erscheinungsjahr=2026),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = SyncService(
|
||||||
|
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
|
||||||
|
)
|
||||||
|
|
||||||
|
ergebnis = service.synchronisiere(["mix"])
|
||||||
|
|
||||||
|
assert ergebnis.neu == 2
|
||||||
|
assert ergebnis.gefiltert == 0
|
||||||
|
|
||||||
|
|
||||||
# ---------- Robustheit ----------
|
# ---------- Robustheit ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -233,7 +298,9 @@ def test_401_bricht_sync_ab_mit_klarer_meldung(session_factory):
|
|||||||
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
|
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
|
||||||
details_pro_id={13: _spiel()},
|
details_pro_id={13: _spiel()},
|
||||||
)
|
)
|
||||||
service = SyncService(session_factory, client)
|
service = SyncService(
|
||||||
|
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
|
||||||
|
)
|
||||||
|
|
||||||
ergebnis = service.synchronisiere(["gut", "abgelehnt", "dritter"])
|
ergebnis = service.synchronisiere(["gut", "abgelehnt", "dritter"])
|
||||||
|
|
||||||
@@ -255,7 +322,9 @@ def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory):
|
|||||||
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
|
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
|
||||||
details_pro_id={13: _spiel()},
|
details_pro_id={13: _spiel()},
|
||||||
)
|
)
|
||||||
service = SyncService(session_factory, client)
|
service = SyncService(
|
||||||
|
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
|
||||||
|
)
|
||||||
|
|
||||||
ergebnis = service.synchronisiere(["kaputt", "gut"])
|
ergebnis = service.synchronisiere(["kaputt", "gut"])
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Alle BGG-Antworten sind gemockt (FakeBggClient), kein echter Netzwerkverkehr.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from redaktionskern.app import create_app
|
from redaktionskern.app import create_app
|
||||||
@@ -77,7 +79,7 @@ def test_liste_zeigt_eintraege_und_deutsche_beschriftung(app, client):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
melde_an(client)
|
melde_an(client)
|
||||||
antwort = client.get("/neuheiten")
|
antwort = client.get("/neuheiten?jahr=1990")
|
||||||
|
|
||||||
assert antwort.status_code == 200
|
assert antwort.status_code == 200
|
||||||
for text in (
|
for text in (
|
||||||
@@ -135,12 +137,31 @@ def test_sortierung_nach_jahr_abwaerts(app, client):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
melde_an(client)
|
melde_an(client)
|
||||||
antwort = client.get("/neuheiten?sort=jahr&richtung=ab")
|
antwort = client.get("/neuheiten?sort=jahr&richtung=ab&jahr=1990")
|
||||||
reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile]
|
reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile]
|
||||||
position = [antwort.text.index(titel) for titel in reihenfolge]
|
position = [antwort.text.index(titel) for titel in reihenfolge]
|
||||||
assert position == sorted(position)
|
assert position == sorted(position)
|
||||||
|
|
||||||
|
|
||||||
|
def test_standard_jahr_filter_zeigt_nur_aktuelles_jahr_und_neuer(app, client):
|
||||||
|
"""Ohne jahr-Parameter: nur aktuelles Jahr und neuer (plus ohne Jahr)."""
|
||||||
|
with app.state.session_factory() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Neuheit(titel="Alt", bgg_id=1, erscheinungsjahr=1990),
|
||||||
|
Neuheit(titel="Aktuell", bgg_id=2, erscheinungsjahr=datetime.now(timezone.utc).year),
|
||||||
|
Neuheit(titel="Ohne Jahr", bgg_id=3),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
melde_an(client)
|
||||||
|
antwort = client.get("/neuheiten")
|
||||||
|
assert "Aktuell" in antwort.text
|
||||||
|
assert "Ohne Jahr" in antwort.text
|
||||||
|
assert ">Alt<" not in antwort.text
|
||||||
|
|
||||||
|
|
||||||
def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client):
|
def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client):
|
||||||
melde_an(client)
|
melde_an(client)
|
||||||
antwort = client.get("/neuheiten?sort=hack&richtung=auf")
|
antwort = client.get("/neuheiten?sort=hack&richtung=auf")
|
||||||
@@ -173,6 +194,8 @@ def test_sync_endpunkt_rollen_pruefung(app, settings):
|
|||||||
def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
|
def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("SPIELE_BGG_JAHR_FILTER", "0")
|
||||||
|
monkeypatch.setenv("SPIELE_BGG_DEUTSCHE_TITEL_FILTER", "0")
|
||||||
fake = FakeBggClient(
|
fake = FakeBggClient(
|
||||||
treffer=[SuchTreffer(13, "Catan", 1995)],
|
treffer=[SuchTreffer(13, "Catan", 1995)],
|
||||||
spiel_details=[
|
spiel_details=[
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ def test_migration_legt_planungsliste_an(app):
|
|||||||
text("SELECT version FROM schema_migrations WHERE plugin='planung'")
|
text("SELECT version FROM schema_migrations WHERE plugin='planung'")
|
||||||
).fetchall()
|
).fetchall()
|
||||||
assert "planungsliste" in tabellen
|
assert "planungsliste" in tabellen
|
||||||
assert [z[0] for z in protokoll] == ["0001_planungsliste"]
|
# 0002_bild_url + 0003_quellen_url: Coverbild- und Quellen-Link-Spalten.
|
||||||
|
assert [z[0] for z in protokoll] == [
|
||||||
|
"0001_planungsliste", "0002_bild_url", "0003_quellen_url",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_anonym_wird_zum_login_umgeleitet(client):
|
def test_anonym_wird_zum_login_umgeleitet(client):
|
||||||
|
|||||||
59
uv.lock
generated
59
uv.lock
generated
@@ -109,6 +109,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -395,6 +408,18 @@ woff = [
|
|||||||
{ name = "zopfli" },
|
{ name = "zopfli" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "freezegun"
|
||||||
|
version = "1.5.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.5.5"
|
version = "3.5.5"
|
||||||
@@ -923,6 +948,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -1075,6 +1112,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.9.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spiele-redaktion"
|
name = "spiele-redaktion"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1082,6 +1137,7 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
@@ -1095,6 +1151,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "freezegun" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
]
|
]
|
||||||
@@ -1103,6 +1160,7 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "apscheduler", specifier = ">=3.11,<4" },
|
{ name = "apscheduler", specifier = ">=3.11,<4" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||||
|
{ name = "beautifulsoup4", specifier = ">=4.12" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "itsdangerous", specifier = ">=2.1" },
|
{ name = "itsdangerous", specifier = ">=2.1" },
|
||||||
@@ -1116,6 +1174,7 @@ requires-dist = [
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "freezegun", specifier = ">=1.5.5" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "pytest", specifier = ">=8.0" },
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user