Compare commits
11 Commits
c6f9b19f6b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929cea1887 | ||
|
|
d91402881a | ||
|
|
ca6d94938c | ||
|
|
667196a4ba | ||
|
|
8b38bc6c89 | ||
|
|
199f8b080d | ||
|
|
aea52d1563 | ||
|
|
fa453518d4 | ||
|
|
96d6af8163 | ||
|
|
20b84a8e1a | ||
|
|
f27940db8e |
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
|
||||||
|
|||||||
4
TODO.md
4
TODO.md
@@ -1,6 +1,6 @@
|
|||||||
# TODO — Web-Quellen im Neuheiten-Plugin (Branch wt-quellen)
|
# TODO — Web-Quellen im Neuheiten-Plugin (Branch wt-quellen)
|
||||||
|
|
||||||
> Live-Status, wird laufend aktualisiert.
|
> Abgeschlossen.
|
||||||
|
|
||||||
- [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py
|
- [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py
|
||||||
- [x] Adapter spiel-essen
|
- [x] Adapter spiel-essen
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
- [x] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified
|
- [x] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified
|
||||||
- [x] Dedup-Gate vor Übernahme in Neuheitenliste
|
- [x] Dedup-Gate vor Übernahme in Neuheitenliste
|
||||||
- [x] APScheduler-Job + Env-Schalter je Quelle (_AKTIV)
|
- [x] APScheduler-Job + Env-Schalter je Quelle (_AKTIV)
|
||||||
- [ ] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) → folgt als eigener Nachtrag
|
- [x] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) → folgt als eigener Nachtrag
|
||||||
- [x] Sync-Übersicht im UI (/neuheiten)
|
- [x] Sync-Übersicht im UI (/neuheiten)
|
||||||
- [x] Tests (gemocktes HTML)
|
- [x] Tests (gemocktes HTML)
|
||||||
- [x] README.md aktualisieren
|
- [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:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ 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
|
||||||
|
|
||||||
@@ -87,6 +87,8 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
self._quellen_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(
|
||||||
@@ -94,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)
|
||||||
@@ -116,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(
|
||||||
@@ -126,12 +154,16 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
quellen_zeilen = self._quellen_uebersicht(db)
|
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,
|
||||||
@@ -143,7 +175,9 @@ 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_zeilen": quellen_zeilen,
|
||||||
"quellen_sync_aktiv": (
|
"quellen_sync_aktiv": (
|
||||||
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip()
|
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip()
|
||||||
@@ -184,12 +218,41 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
@self.router.post("/neuheiten/quellen-sync")
|
@self.router.post("/neuheiten/quellen-sync")
|
||||||
def quellen_jetzt_synchronisieren(
|
def quellen_jetzt_synchronisieren(
|
||||||
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
|
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()
|
zeilen = self._quellen_sync_ausfuehren()
|
||||||
text = " · ".join(z.als_text() for z in zeilen)
|
text = " · ".join(z.als_text() for z in zeilen)
|
||||||
ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}"
|
ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}"
|
||||||
return RedirectResponse(ziel[:2000], status_code=303)
|
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]:
|
def _quellen_uebersicht(self, db) -> list[dict]:
|
||||||
"""Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle."""
|
"""Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle."""
|
||||||
status_nach_quelle = {
|
status_nach_quelle = {
|
||||||
@@ -199,12 +262,18 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
ansichten = []
|
ansichten = []
|
||||||
for klasse in ADAPTER_KLASSEN:
|
for klasse in ADAPTER_KLASSEN:
|
||||||
status = status_nach_quelle.get(klasse.name)
|
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(
|
ansichten.append(
|
||||||
{
|
{
|
||||||
"name": klasse.name,
|
"name": klasse.name,
|
||||||
"anzeigename": klasse.anzeigename,
|
"anzeigename": klasse.anzeigename,
|
||||||
"url": klasse.start_urls[0] if klasse.start_urls else "",
|
"url": klasse.start_urls[0] if klasse.start_urls else "",
|
||||||
"aktiv": quelle_aktiv(klasse.name),
|
"aktiv": aktiv,
|
||||||
"letzte_laufzeit": status.letzte_laufzeit if status else None,
|
"letzte_laufzeit": status.letzte_laufzeit if status else None,
|
||||||
"dauer_sekunden": status.dauer_sekunden if status else None,
|
"dauer_sekunden": status.dauer_sekunden if status else None,
|
||||||
"neu": status.anzahl_neu if status else 0,
|
"neu": status.anzahl_neu if status else 0,
|
||||||
@@ -255,10 +324,71 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
def quellen_status_tabelle(conn) -> None:
|
def quellen_status_tabelle(conn) -> None:
|
||||||
QuellenStatus.__table__.create(conn, checkfirst=True)
|
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 [
|
return [
|
||||||
Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle),
|
Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle),
|
||||||
Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable),
|
Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable),
|
||||||
Migration(version="0003_quellen_status", up=quellen_status_tabelle),
|
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]:
|
||||||
@@ -268,13 +398,23 @@ 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"))
|
||||||
@@ -317,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:
|
||||||
@@ -338,15 +480,25 @@ class NeuheitenPlugin(BasePlugin):
|
|||||||
finally:
|
finally:
|
||||||
self._sync_sperre.release()
|
self._sync_sperre.release()
|
||||||
|
|
||||||
def _quellen_sync_ausfuehren(self) -> list[QuellenLaufZeile]:
|
def _quellen_sync_ausfuehren(
|
||||||
"""Ein Lauf über alle aktiven Web-Quellen (Fehler pro Quelle isoliert)."""
|
self, nur_quellen: set[str] | None = None
|
||||||
dedup = (
|
) -> list[QuellenLaufZeile]:
|
||||||
self.context.registry.get("dedup")
|
"""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
|
if self.context is not None and self.context.registry is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
service = WebQuellenSyncService(self.context.session_factory, dedup=dedup)
|
dedup = registry.get("dedup") if registry is not None else None
|
||||||
return service.laufe()
|
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:
|
def _quellen_sync_job(self) -> None:
|
||||||
"""Hintergrund-Job: holt regelmäßig die Web-Quellen ab."""
|
"""Hintergrund-Job: holt regelmäßig die Web-Quellen ab."""
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ class Neuheit(Base):
|
|||||||
# BGG-ID und speichern NULL (Migration 0002 macht die Spalte nullable,
|
# BGG-ID und speichern NULL (Migration 0002 macht die Spalte nullable,
|
||||||
# Duplikate werden dort über den Titel gematcht statt über die ID).
|
# Duplikate werden dort über den Titel gematcht statt über die ID).
|
||||||
bgg_id: Mapped[int | None] = mapped_column(Integer, unique=True, index=True)
|
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())
|
||||||
@@ -63,6 +67,9 @@ class QuellenStatus(Base):
|
|||||||
anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0)
|
anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
fehlermeldung: Mapped[str | None] = mapped_column(Text)
|
fehlermeldung: Mapped[str | None] = mapped_column(Text)
|
||||||
unveraendert: Mapped[bool] = mapped_column(Boolean, default=False)
|
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]
|
#: JSON-Objekt URL → [etag, last_modified]
|
||||||
validatoren_json: Mapped[str | None] = mapped_column(Text)
|
validatoren_json: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .basis import (
|
|||||||
jahr_aus_datumsangabe,
|
jahr_aus_datumsangabe,
|
||||||
titel_aehnlichkeit,
|
titel_aehnlichkeit,
|
||||||
titel_normalisieren,
|
titel_normalisieren,
|
||||||
|
bild_aus_element,
|
||||||
)
|
)
|
||||||
from .brettspielbox import BrettspielboxQuelle
|
from .brettspielbox import BrettspielboxQuelle
|
||||||
from .cliquenabend import CliquenabendQuelle
|
from .cliquenabend import CliquenabendQuelle
|
||||||
@@ -50,6 +51,7 @@ __all__ = [
|
|||||||
"jahr_aus_datumsangabe",
|
"jahr_aus_datumsangabe",
|
||||||
"titel_aehnlichkeit",
|
"titel_aehnlichkeit",
|
||||||
"titel_normalisieren",
|
"titel_normalisieren",
|
||||||
|
"bild_aus_element",
|
||||||
"SpielboxQuelle",
|
"SpielboxQuelle",
|
||||||
"BrettspielboxQuelle",
|
"BrettspielboxQuelle",
|
||||||
"SpielEssenQuelle",
|
"SpielEssenQuelle",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ die Testsuite führt keine echten Netzwerkaufrufe durch.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree # noqa: F401 (dokumentiert: keine XML-Nutzung hier)
|
import xml.etree.ElementTree # noqa: F401 (dokumentiert: keine XML-Nutzung hier)
|
||||||
@@ -31,6 +32,10 @@ from typing import ClassVar
|
|||||||
import httpx
|
import httpx
|
||||||
from rapidfuzz import fuzz
|
from rapidfuzz import fuzz
|
||||||
|
|
||||||
|
from .ki_hilfe import KiHilfe
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
#: Freundlicher User-Agent für alle Redaktions-Crawler (Courtesy-Regeln).
|
#: Freundlicher User-Agent für alle Redaktions-Crawler (Courtesy-Regeln).
|
||||||
USER_AGENT = "SpieleRedaktionBot/1.0 (Redaktions-Tool; Kontakt siehe Repo)"
|
USER_AGENT = "SpieleRedaktionBot/1.0 (Redaktions-Tool; Kontakt siehe Repo)"
|
||||||
|
|
||||||
@@ -55,6 +60,52 @@ class QuellenTreffer:
|
|||||||
verlag: str | None = None
|
verlag: str | None = None
|
||||||
autor: str | None = None
|
autor: str | None = None
|
||||||
erscheinungsdatum_oder_quartal: 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
|
@dataclass
|
||||||
@@ -76,6 +127,12 @@ class SammelErgebnis:
|
|||||||
#: True = mindestens eine Seite kam per 304 als unverändert zurück;
|
#: True = mindestens eine Seite kam per 304 als unverändert zurück;
|
||||||
#: der Lauf wurde dann abgekürzt (Quelle übersprungen, kein Fehler).
|
#: der Lauf wurde dann abgekürzt (Quelle übersprungen, kein Fehler).
|
||||||
unveraendert: bool = False
|
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:
|
def jahr_aus_datumsangabe(angabe: str | None) -> int | None:
|
||||||
@@ -236,18 +293,40 @@ class QuellenAdapter:
|
|||||||
|
|
||||||
def __init__(self, client: WebQuellenClient) -> None:
|
def __init__(self, client: WebQuellenClient) -> None:
|
||||||
self.client = client
|
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:
|
def sammle(self) -> SammelErgebnis:
|
||||||
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS)."""
|
"""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()
|
ergebnis = SammelErgebnis()
|
||||||
warteschlange = list(self.start_urls)
|
warteschlange = list(self.start_urls)
|
||||||
besucht: set[str] = set()
|
besucht: set[str] = set()
|
||||||
|
ki_vorschlaege: set[str] = set()
|
||||||
while warteschlange and len(besucht) < self.max_seiten:
|
while warteschlange and len(besucht) < self.max_seiten:
|
||||||
url = warteschlange.pop(0)
|
url = warteschlange.pop(0)
|
||||||
if url in besucht:
|
if url in besucht:
|
||||||
continue
|
continue
|
||||||
besucht.add(url)
|
besucht.add(url)
|
||||||
|
try:
|
||||||
antwort = self.client.hole(url)
|
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:
|
if antwort.status_code == 304:
|
||||||
# Unverändert → Quelle überspringen (kein Fehler).
|
# Unverändert → Quelle überspringen (kein Fehler).
|
||||||
ergebnis.unveraendert = True
|
ergebnis.unveraendert = True
|
||||||
@@ -258,8 +337,48 @@ class QuellenAdapter:
|
|||||||
for link in folge_links:
|
for link in folge_links:
|
||||||
if link not in besucht:
|
if link not in besucht:
|
||||||
warteschlange.append(link)
|
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
|
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(
|
def seite_verarbeiten(
|
||||||
self, inhalt: bytes, url: str
|
self, inhalt: bytes, url: str
|
||||||
) -> tuple[list[QuellenTreffer], list[str]]:
|
) -> tuple[list[QuellenTreffer], list[str]]:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import re
|
|||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from .basis import QuellenAdapter, QuellenTreffer
|
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
|
||||||
|
|
||||||
TITEL_VERLAG_MUSTER = re.compile(r"^\s*(.+?)\s*\(([^()]*)\)\s*$")
|
TITEL_VERLAG_MUSTER = re.compile(r"^\s*(.+?)\s*\(([^()]*)\)\s*$")
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ class BrettspielboxQuelle(QuellenAdapter):
|
|||||||
autor=None,
|
autor=None,
|
||||||
erscheinungsdatum_oder_quartal=None,
|
erscheinungsdatum_oder_quartal=None,
|
||||||
quellen_url=quellen_url,
|
quellen_url=quellen_url,
|
||||||
|
bild_url=bild_aus_element(eintrag, url),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return treffer, []
|
return treffer, []
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ Die Seite ist nicht paginiert.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from bs4.element import Tag
|
||||||
|
|
||||||
from .basis import QuellenAdapter, QuellenTreffer
|
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DATUM_MUSTER = re.compile(r"\d{2}\.\d{2}\.(\d{2})\b")
|
DATUM_MUSTER = re.compile(r"\d{2}\.\d{2}\.(\d{2})\b")
|
||||||
BASIS_URL = "https://www.cliquenabend.de"
|
BASIS_URL = "https://www.cliquenabend.de"
|
||||||
@@ -40,6 +45,74 @@ class CliquenabendQuelle(QuellenAdapter):
|
|||||||
treffer.append(self._zeile(zellen, url))
|
treffer.append(self._zeile(zellen, url))
|
||||||
return [t for t in treffer if t is not None], []
|
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:
|
def _zeile(self, zellen, url: str) -> QuellenTreffer | None:
|
||||||
spiel_zelle = zellen[3]
|
spiel_zelle = zellen[3]
|
||||||
titel_link = spiel_zelle.find("a", href=re.compile(r"^/spiele/"))
|
titel_link = spiel_zelle.find("a", href=re.compile(r"^/spiele/"))
|
||||||
@@ -50,8 +123,6 @@ class CliquenabendQuelle(QuellenAdapter):
|
|||||||
titel = (fett or titel_link).get_text(" ", strip=True)
|
titel = (fett or titel_link).get_text(" ", strip=True)
|
||||||
href = titel_link.get("href")
|
href = titel_link.get("href")
|
||||||
if href:
|
if href:
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
quellen_url = urljoin(BASIS_URL, href)
|
quellen_url = urljoin(BASIS_URL, href)
|
||||||
if not titel:
|
if not titel:
|
||||||
return None
|
return None
|
||||||
@@ -60,18 +131,36 @@ class CliquenabendQuelle(QuellenAdapter):
|
|||||||
autor = zellen[4].get_text(" ", strip=True) or None
|
autor = zellen[4].get_text(" ", strip=True) or None
|
||||||
|
|
||||||
datum = None
|
datum = None
|
||||||
|
bild_url = bild_aus_element(spiel_zelle, quellen_url)
|
||||||
jahr_text = zellen[0].get_text(strip=True)
|
jahr_text = zellen[0].get_text(strip=True)
|
||||||
fund = DATUM_MUSTER.search(jahr_text)
|
fund = DATUM_MUSTER.search(jahr_text)
|
||||||
if fund:
|
if fund:
|
||||||
# Datum so übernehmen, wie es in der Quelle steht (z. B. „15.10.25“).
|
# Datum so übernehmen, wie es in der Quelle steht (z. B. „15.10.25“).
|
||||||
datum = fund.group(0)
|
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(
|
return QuellenTreffer(
|
||||||
titel=titel,
|
titel=titel,
|
||||||
verlag=verlag,
|
verlag=verlag,
|
||||||
autor=autor,
|
autor=autor,
|
||||||
erscheinungsdatum_oder_quartal=datum,
|
erscheinungsdatum_oder_quartal=datum,
|
||||||
quellen_url=quellen_url,
|
quellen_url=quellen_url,
|
||||||
|
bild_url=bild_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -17,7 +17,7 @@ import re
|
|||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from .basis import QuellenAdapter, QuellenTreffer
|
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
|
||||||
|
|
||||||
DATUM_MUSTER = re.compile(r"(\d{2}\.\d{2}\.\d{4})")
|
DATUM_MUSTER = re.compile(r"(\d{2}\.\d{2}\.\d{4})")
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ class SpielboxQuelle(QuellenAdapter):
|
|||||||
autor=None,
|
autor=None,
|
||||||
erscheinungsdatum_oder_quartal=datum,
|
erscheinungsdatum_oder_quartal=datum,
|
||||||
quellen_url=quellen_url,
|
quellen_url=quellen_url,
|
||||||
|
bild_url=bild_aus_element(element, url),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -121,9 +121,52 @@ class SpielEssenQuelle(QuellenAdapter):
|
|||||||
autor=felder.get("Autor"),
|
autor=felder.get("Autor"),
|
||||||
erscheinungsdatum_oder_quartal=felder.get("Erscheinungsdatum"),
|
erscheinungsdatum_oder_quartal=felder.get("Erscheinungsdatum"),
|
||||||
quellen_url=OEFFENTLICHE_SEITE,
|
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]:
|
def _parse_info(info_html: str) -> dict[str, str]:
|
||||||
"""Liest die INFO-Html-Tabelle (Zeilen „Feld: Wert“) tolerant aus."""
|
"""Liest die INFO-Html-Tabelle (Zeilen „Feld: Wert“) tolerant aus."""
|
||||||
felder: dict[str, str] = {}
|
felder: dict[str, str] = {}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from typing import Callable
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import sessionmaker
|
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 .models import STATUS_NEUHEIT, Neuheit, QuellenStatus
|
||||||
from .quellen import (
|
from .quellen import (
|
||||||
ADAPTER_KLASSEN,
|
ADAPTER_KLASSEN,
|
||||||
@@ -108,20 +109,38 @@ class WebQuellenSyncService:
|
|||||||
dedup=None,
|
dedup=None,
|
||||||
client_fabrik: Callable[[dict], WebQuellenClient] | None = None,
|
client_fabrik: Callable[[dict], WebQuellenClient] | None = None,
|
||||||
umgebung=None,
|
umgebung=None,
|
||||||
|
ki_fabrik: Callable[[], KiHilfe] | None = None,
|
||||||
|
audit=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self.adapter_klassen = adapter_klassen
|
self.adapter_klassen = adapter_klassen
|
||||||
self.dedup = dedup
|
self.dedup = dedup
|
||||||
|
self.audit = audit
|
||||||
self.umgebung = umgebung if umgebung is not None else os.environ
|
self.umgebung = umgebung if umgebung is not None else os.environ
|
||||||
self._client_fabrik = client_fabrik or self._standard_client_fabrik
|
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 ----------
|
# ---------- Gesamtdurchlauf ----------
|
||||||
|
|
||||||
def laufe(self) -> list[QuellenLaufZeile]:
|
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] = []
|
zeilen: list[QuellenLaufZeile] = []
|
||||||
|
ki = self._ki_hilfe()
|
||||||
for klasse in self.adapter_klassen:
|
for klasse in self.adapter_klassen:
|
||||||
|
if nur_quellen is not None and klasse.name not in nur_quellen:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
zeile = self._eine_quelle(klasse)
|
zeile = self._eine_quelle(
|
||||||
|
klasse, ki, einzellauf=nur_quellen is not None
|
||||||
|
)
|
||||||
except Exception as exc: # letzte Verteidigungslinie pro Quelle
|
except Exception as exc: # letzte Verteidigungslinie pro Quelle
|
||||||
_logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name)
|
_logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name)
|
||||||
zeile = QuellenLaufZeile(
|
zeile = QuellenLaufZeile(
|
||||||
@@ -134,6 +153,32 @@ class WebQuellenSyncService:
|
|||||||
zeilen.append(zeile)
|
zeilen.append(zeile)
|
||||||
return zeilen
|
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:
|
def gesamt_text(self, zeilen: list[QuellenLaufZeile]) -> str:
|
||||||
"""Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll."""
|
"""Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll."""
|
||||||
teile = [zeile.als_text() for zeile in zeilen]
|
teile = [zeile.als_text() for zeile in zeilen]
|
||||||
@@ -141,10 +186,35 @@ class WebQuellenSyncService:
|
|||||||
|
|
||||||
# ---------- Eine Quelle ----------
|
# ---------- Eine Quelle ----------
|
||||||
|
|
||||||
def _eine_quelle(self, klasse: type[QuellenAdapter]) -> QuellenLaufZeile:
|
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 = QuellenLaufZeile(klasse.name, klasse.anzeigename)
|
||||||
zeile.url = klasse.start_urls[0] if klasse.start_urls else None
|
zeile.url = klasse.start_urls[0] if klasse.start_urls else None
|
||||||
if not quelle_aktiv(klasse.name, self.umgebung):
|
# Einzellauf (nur_quellen) läuft immer; sonst entscheidet der Schalter.
|
||||||
|
if not einzellauf and not self.quelle_laeuft(klasse.name):
|
||||||
zeile.aktiv = False
|
zeile.aktiv = False
|
||||||
return zeile
|
return zeile
|
||||||
|
|
||||||
@@ -152,6 +222,7 @@ class WebQuellenSyncService:
|
|||||||
client = self._client_fabrik(self._validatoren_laden(klasse.name))
|
client = self._client_fabrik(self._validatoren_laden(klasse.name))
|
||||||
try:
|
try:
|
||||||
adapter = klasse(client)
|
adapter = klasse(client)
|
||||||
|
adapter.ki_hilfe = ki # None = rein regelbasiert (Fallback-Pflicht)
|
||||||
try:
|
try:
|
||||||
ergebnis = adapter.sammle()
|
ergebnis = adapter.sammle()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -160,6 +231,7 @@ class WebQuellenSyncService:
|
|||||||
zeile.fehlermeldung = str(exc)[:300]
|
zeile.fehlermeldung = str(exc)[:300]
|
||||||
return zeile
|
return zeile
|
||||||
zeile.seiten = ergebnis.seiten
|
zeile.seiten = ergebnis.seiten
|
||||||
|
self._ki_auditieren(klasse.name, ergebnis)
|
||||||
if ergebnis.unveraendert:
|
if ergebnis.unveraendert:
|
||||||
zeile.unveraendert = True
|
zeile.unveraendert = True
|
||||||
else:
|
else:
|
||||||
@@ -202,6 +274,8 @@ class WebQuellenSyncService:
|
|||||||
einzel.erscheinungsdatum_oder_quartal
|
einzel.erscheinungsdatum_oder_quartal
|
||||||
),
|
),
|
||||||
bgg_id=None,
|
bgg_id=None,
|
||||||
|
bild_url=einzel.bild_url,
|
||||||
|
quellen_url=einzel.quellen_url,
|
||||||
status=STATUS_NEUHEIT,
|
status=STATUS_NEUHEIT,
|
||||||
quelle=quelle,
|
quelle=quelle,
|
||||||
)
|
)
|
||||||
@@ -227,6 +301,12 @@ class WebQuellenSyncService:
|
|||||||
eintrag.erscheinungsjahr = jahr_aus_datumsangabe(
|
eintrag.erscheinungsjahr = jahr_aus_datumsangabe(
|
||||||
treffer.erscheinungsdatum_oder_quartal
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -263,6 +343,37 @@ class WebQuellenSyncService:
|
|||||||
return True
|
return True
|
||||||
return not ergebnis.hat_konflikte
|
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 ----------
|
# ---------- Statusprotokoll ----------
|
||||||
|
|
||||||
def _status_speichern(self, zeile: QuellenLaufZeile) -> None:
|
def _status_speichern(self, zeile: QuellenLaufZeile) -> None:
|
||||||
|
|||||||
@@ -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,13 +73,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 "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 %}
|
{% 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' %}
|
{% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %}
|
||||||
<span class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 align-super">{{ eintrag.quelle }}</span>
|
<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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
|
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
|
||||||
@@ -43,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>
|
||||||
@@ -60,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>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
{% if ist_redaktion %}
|
{% if ist_redaktion %}
|
||||||
<form method="post" action="/neuheiten/quellen-sync">
|
<form method="post" action="/neuheiten/quellen-sync">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="bg-sky-600 hover:bg-sky-700 text-white font-medium px-3 py-1.5 rounded-lg text-xs whitespace-nowrap">
|
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
|
↻ Web-Quellen synchronisieren
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -75,8 +75,24 @@
|
|||||||
<span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span>
|
<span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1.5">
|
<td class="px-2 py-1.5">
|
||||||
{% if quelle.aktiv %}<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700">an</span>
|
<form method="post" action="/neuheiten/quellen/{{ quelle.name }}/schalter" class="inline-flex items-center gap-2">
|
||||||
{% else %}<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-400">aus</span>{% endif %}
|
{% 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>
|
||||||
<td class="px-2 py-1.5 whitespace-nowrap text-slate-500">
|
<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 '—' }}
|
{{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }}
|
||||||
@@ -105,18 +121,25 @@
|
|||||||
|
|
||||||
<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() %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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>
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ jahr_aus_datumsangabe = quellen_basis.jahr_aus_datumsangabe
|
|||||||
titel_aehnlichkeit = quellen_basis.titel_aehnlichkeit
|
titel_aehnlichkeit = quellen_basis.titel_aehnlichkeit
|
||||||
titel_normalisieren = quellen_basis.titel_normalisieren
|
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
|
SpielboxQuelle = _neuheiten.quellen.spielbox.SpielboxQuelle
|
||||||
BrettspielboxQuelle = _neuheiten.quellen.brettspielbox.BrettspielboxQuelle
|
BrettspielboxQuelle = _neuheiten.quellen.brettspielbox.BrettspielboxQuelle
|
||||||
SpielEssenQuelle = _neuheiten.quellen.spielessen.SpielEssenQuelle
|
SpielEssenQuelle = _neuheiten.quellen.spielessen.SpielEssenQuelle
|
||||||
|
|||||||
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]
|
||||||
@@ -124,10 +124,25 @@ CLIQUENABEND_SEITE = """
|
|||||||
<tr><td>26.09.25</td><td>Piatnik Profil ,</td><td>6-G200</td>
|
<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><div><a href="/spiele/790700-Elixirus.html"><b>Elixirus</b></a></div></td>
|
||||||
<td>Dickie Chapin</td></tr>
|
<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>
|
<tr><td>01.10.25</td><td>ohne Link</td><td>—</td><td>Nur Texttitel</td><td>Unbekannt</td></tr>
|
||||||
</table>
|
</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:
|
class FakeUhr:
|
||||||
"""Steuerbare Monoton-Uhr."""
|
"""Steuerbare Monoton-Uhr."""
|
||||||
@@ -306,7 +321,11 @@ def test_parser_spielessen_nutzt_json_api_endpunkt():
|
|||||||
def test_parser_cliquenabend_liest_tabellenspalten():
|
def test_parser_cliquenabend_liest_tabellenspalten():
|
||||||
adapter, _ = _adapter(
|
adapter, _ = _adapter(
|
||||||
CliquenabendQuelle,
|
CliquenabendQuelle,
|
||||||
{"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE},
|
{
|
||||||
|
"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE,
|
||||||
|
"https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html": CLIQUENABEND_DETAIL_1,
|
||||||
|
"https://www.cliquenabend.de/spiele/790700-Elixirus.html": CLIQUENABEND_DETAIL_FEHLT,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
ergebnis = adapter.sammle()
|
ergebnis = adapter.sammle()
|
||||||
|
|
||||||
@@ -314,9 +333,28 @@ def test_parser_cliquenabend_liest_tabellenspalten():
|
|||||||
erster = ergebnis.treffer[0]
|
erster = ergebnis.treffer[0]
|
||||||
assert erster.verlag == "Dranda Games, PD Verlag"
|
assert erster.verlag == "Dranda Games, PD Verlag"
|
||||||
assert erster.autor == "T.K. King und Koltin Thompson"
|
assert erster.autor == "T.K. King und Koltin Thompson"
|
||||||
assert erster.erscheinungsdatum_oder_quartal == "15.10.25"
|
# Release-Feld der Detailseite schlägt das Update-Datum der Tabelle:
|
||||||
|
assert erster.erscheinungsdatum_oder_quartal == "2025"
|
||||||
assert erster.quellen_url == "https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html"
|
assert 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"
|
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():
|
def test_parser_cliquenabend_toleriert_fehlende_tabelle():
|
||||||
@@ -615,6 +653,46 @@ def test_env_schalter_deaktiviert_einzelne_quelle(session_factory, monkeypatch):
|
|||||||
assert quelle_aktiv("brettspielbox") is True # Standard „1“
|
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):
|
def test_validatoren_werden_persistiert_und_wieder_verwendet(session_factory):
|
||||||
"""304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet."""
|
"""304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet."""
|
||||||
import tests._neuheiten as h
|
import tests._neuheiten as h
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user