refactor: Economy-Fokus, CX via HKG, Telegram Bot, Anti-Bot

Komplette Neuausrichtung des Scanners:
- Premium Economy → Economy (CX via HKG als Hauptroute)
- Telegram Bot (@CX_HKG_Alert_bot) mit /preis, /best, /status
- SeleniumBase 4.34 → 4.47 (besserer UC/CDP Mode)
- Scrape-URL (.de) / Booking-URL (.com) Trennung
- GDPR-Consent-Handling für Kayak/Momondo
- NODE_SCANNER_SKIP: Geo-Block-Scanner pro Node konfigurierbar
- Alert-Zähler pro Node (kein Spam durch bekannte Geo-Blocks)
- .env Dateien aus Repo entfernt (Secrets)
- STATE.md mit aktuellem Stand

Made-with: Cursor
This commit is contained in:
root 2026-02-26 18:01:38 +07:00
parent 8c6eb7128a
commit a9cb83871c
12 changed files with 1492 additions and 339 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.env
*.env
.env.*
__pycache__/
*.pyc
*.bak
data/

243
STATE.md Normal file
View file

@ -0,0 +1,243 @@
# STATE: Flugpreisscanner
**Stand: 26.02.2026**
---
## Status
🚀 **In Betrieb — seit 25.02.2026**
| Komponente | Status |
|------------|--------|
| flugscanner-hub | ✅ Läuft (Docker: web + scheduler) |
| flugscanner-asia | ✅ Läuft (Docker: agent + noVNC) |
| flugscanner-mu | ✅ Läuft (Docker: agent + noVNC) |
| Forgejo-Repo | ✅ http://100.89.246.60:3000/orbitalo/flugpreisscanner |
| Dashboard | ✅ http://100.92.161.97:8080 |
| Telegram Bot | ✅ @CX_HKG_Alert_bot — Alerts + /preis + /best + /status |
---
## Kernidee
Täglich günstigste Flüge **FRA → KTI (Frankfurt → Phnom Penh)** automatisch finden.
Kabine: **Economy** · Gepäck: 1 Koffer + Handgepäck · Aufenthalt: ~2 Monate
Fokus: **Cathay Pacific (CX) via Hong Kong** — beste Preis-Leistung in Economy.
KI wertet aus: jetzt buchen oder warten?
Scraping läuft bewusst von Heimnetz-IPs — nicht von Hetzner (Datacenter-IPs werden geblockt).
**Route: 🇭🇰 HKG Stopover** — Multi-City FRA→HKG (12 Nächte) → KTI → FRA.
Realistischer Preis: **9001.050 EUR** Roundtrip Economy.
---
## Container
| CT | Name | Server | LAN-IP | Tailscale-IP | Aufgabe |
|----|------|--------|--------|--------------|---------|
| 115 | `flugscanner-hub` | pve-hetzner | 10.10.10.115 | 100.92.161.97 | Gehirn: Dashboard + Scheduler + KI-Auswertung (OpenRouter) + DB + Job-Koordination |
| 115 | `flugscanner-asia` | pve1 Kambodscha | 192.168.0.131 | 100.112.190.22 | Scraping-Node A: SeleniumBase CDP + noVNC, Heimnetz-IP Asien |
| 145 | `flugscanner-mu` | helmut-pve Muldenstein | 192.168.178.130 | 100.75.182.15 | Scraping-Node B: SeleniumBase CDP + noVNC, Heimnetz-IP Deutschland |
**Zugänge:**
- Hub (pve-hetzner): `ssh root@100.88.230.59` PW: Astral-Proxmox!2026 → `pct exec 115`
- Asia (pve1): `ssh root@192.168.0.197` PW: astral66 → `pct exec 115`
- Muldenstein: `ssh root@100.75.182.15` PW: astral66 (direkt, kein pct nötig)
- helmut-pve: `ssh root@100.87.235.11` PW: astral66
**Wichtig:**
- Scraping läuft NIE von CT 115 / Hetzner aus
- CT 115 koordiniert nur — die Nodes führen aus
- Muldenstein = deutsche IP (beste Ergebnisse für Kayak, Momondo)
- Kambodscha = asiatische IP (Momondo/Traveloka werden übersprungen — Geo-Block)
- **Tailscale auf allen Containern** — sichere Kommunikation über Tailnet
---
## CT 115 — Flugpreisscanner Hub
**Nur Koordination, Auswertung, Dashboard — KEIN Scraping, KEIN noVNC hier.**
### Dienste (Docker)
| Service | Container | Port | Aufgabe |
|---------|-----------|------|---------|
| web | `flugscanner-web` | 8080 | Flask Dashboard |
| scheduler | `flugscanner-scheduler` | — | Jobs verteilen, KI auslösen, Telegram Bot |
### Pfade
```
/opt/flugscanner/
├── hub/
│ ├── docker-compose.yml
│ ├── .env
│ ├── Dockerfile
│ ├── data/
│ │ └── flugscanner.db ← SQLite Datenbank
│ └── src/
│ ├── web.py ← Flask Dashboard + API
│ ├── scheduler.py ← Job-Koordination + Telegram Bot
│ ├── ki.py ← OpenRouter Auswertung + Plausibilität
│ ├── db.py ← DB-Zugriff + Init
│ └── requirements.txt
└── node/ ← (auf Nodes ausgecheckt)
```
---
## Scraping-Nodes (asia + mu)
### Dienste (Docker)
| Service | Container | Port | Aufgabe |
|---------|-----------|------|---------|
| agent | `flugscanner-agent` | 5010 | Jobs empfangen, Selenium starten |
| novnc | `flugscanner-novnc` | 6080 | Chrome live im Browser sehen |
### Pfade
```
/opt/flugscanner/node/
├── docker-compose.yml
├── .env ← NODE_NAME=flugscanner-asia/mu
├── Dockerfile
└── src/
├── agent.py ← Flask API (POST /job, GET /status)
├── worker.py ← SeleniumBase CDP Scraper
└── requirements.txt
```
### Kommunikation
```
Hub Scheduler → POST http://[Node-Tailscale-IP]:5010/job
{ "scanner": "kayak_multicity", "von": "FRA", "nach": "KTI", "kabine": "economy", ... }
Node antwortet:
{ "results": [...], "node": "flugscanner-mu", "count": 10, "screenshot_b64": "..." }
```
---
## Scanner
| Scanner | Status | Anmerkung |
|---------|--------|-----------|
| Kayak (Roundtrip) | ✅ Aktiv | Beste Datenquelle, GDPR-Consent automatisiert |
| **Kayak Multi-City CX via HKG** | ✅ Aktiv | Primärer Scanner — FRA→HKG→KTI→FRA |
| Trip.com | ✅ Aktiv | Gute Ergänzung, auch CX-Filter |
| Momondo | ✅ Aktiv | Nur auf Muldenstein (Geo-Block aus Asien) |
| Google Flights | ⚠ Eingeschränkt | Wenige Ergebnisse, Consent-Probleme |
| Traveloka | ⚠ Nur Muldenstein | Geo-Block aus Asien |
| Wego | ❌ Deaktiviert | |
| Skyscanner | ❌ Deaktiviert | Bot-Detection |
### Node-spezifische Einschränkungen
Momondo und Traveloka werden auf `flugscanner-asia` automatisch übersprungen (Geo-Block).
Konfiguration: `NODE_SCANNER_SKIP` in scheduler.py.
---
## Anti-Bot-Strategie
- Scan-Intervall: zufällig **2545 Minuten** (nicht regelmäßig)
- SeleniumBase **UC/CDP Mode** (undetected Chromium)
- GDPR-Consent automatisch wegklicken (Kayak, Momondo)
- **Zwei verschiedene Geo-Locations** (Kambodscha + Deutschland)
- Scrape-URL (.de) getrennt von Booking-URL (.com) — Nutzer sieht internationale Preise
---
## Telegram Bot
**Bot:** @CX_HKG_Alert_bot
**Token:** `8693839370:AAEPG0t2gA5jkLFH3J8UmstZMkHPdp0aTG4`
**Chat-ID:** 674951792
### Befehle
| Befehl | Funktion |
|--------|----------|
| /preis | Aktueller CX-Preis via HKG |
| /best | Top 3 günstigste heute |
| /status | Systemstatus (Nodes, letzte Scan-Zeit) |
### Automatische Nachrichten
| Wann | Was |
|------|-----|
| Täglich 07:00 | Morgenbericht mit Preisübersicht |
| Bei CX < 900 | Preis-Alert |
| Bei Anstieg > 50€ | Preisanstieg-Warnung |
| Nach 3x Null-Ergebnissen | Scanner-Problem-Alert (pro Node) |
---
## Datenbank (SQLite auf CT 115)
Pfad: `/opt/flugscanner/hub/data/flugscanner.db`
| Tabelle | Inhalt |
|---------|--------|
| jobs | Geplante Scraping-Jobs (Route, Anbieter, Intervall, Airline-Filter) |
| prices | Rohe Preisdaten (Preis, Datum, Anbieter, Node, Booking-URL, plausibel) |
| screenshots | Vision-AI Screenshots mit Kabinenklassen-Erkennung |
| analyses | KI-Auswertungen mit Timestamp |
| prompts | Editierbare KI-Prompts |
| nodes | Registrierte Scraping-Nodes + Status |
| logs | System-Logs |
---
## KI-Auswertung
- Läuft automatisch nach jedem Scraping-Durchlauf
- **Vision AI**: Screenshots werden per gpt-4o-mini klassifiziert (Economy/PE/Business)
- **Plausibilitätsprüfung**: Preise 70012.000€ für Economy Roundtrip
- **Marktanalyse**: Prompt editierbar im Dashboard
- OpenRouter Guthaben wird im Dashboard angezeigt
### OpenRouter
| Variable | Wert |
|----------|------|
| OPENROUTER_API_KEY | `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` |
| AI_MODEL | `openai/gpt-4o-mini` |
---
## Preiserwartung (Stand 26.02.2026)
**FRA → HKG → Phnom Penh → FRA — Cathay Pacific Economy Roundtrip**
| Metrik | Wert |
|--------|------|
| Günstigster | ~726 EUR |
| Realistischer Schnitt | **9001.050 EUR** |
| Gute Airlines (CX/SQ/TG) Durchschnitt | ~1.030 EUR |
| Zum Vergleich: Reisebüro VA PE | ~2.000 EUR |
---
## Repo
`git.orbitalo.net/orbitalo/flugpreisscanner`
API-Token (cursor-deploy-3): `a6dd1ee58e091c894169c5ae15f6b74bb9461c56`
---
## Änderungslog
| Datum | Was |
|-------|-----|
| 25.02.2026 | System live geschaltet |
| 25.02.2026 | Cookie-Banner-Fix + Screenshot-Verbesserungen |
| 26.02.2026 | Umstellung PE → Economy, CX via HKG als Hauptroute |
| 26.02.2026 | Telegram Bot @CX_HKG_Alert_bot eingerichtet |
| 26.02.2026 | SeleniumBase 4.34 → 4.47 (CDP-Verbesserungen) |
| 26.02.2026 | _scrape_url / _booking_url Trennung (Scrape .de, Booking .com) |
| 26.02.2026 | GDPR-Consent-Handling für Kayak/Momondo |
| 26.02.2026 | NODE_SCANNER_SKIP: Momondo/Traveloka auf Asia deaktiviert |
| 26.02.2026 | Alert-Zähler jetzt pro Node (kein Spam durch Geo-Blocks) |
| 26.02.2026 | SSH-Fix Muldenstein (PermitRootLogin yes) |
| 26.02.2026 | Doku in CT999 ergänzt (ct-145-flugscanner-mu.md + index.md) |

View file

@ -1,5 +0,0 @@
OPENROUTER_API_KEY=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
AI_MODEL=openai/gpt-4o-mini
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=674951792
DB_PATH=/data/flugscanner.db

View file

@ -29,7 +29,7 @@ services:
- AI_MODEL=${AI_MODEL}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
command: python /app/src/scheduler.py
command: python -u /app/src/scheduler.py
depends_on:
- web

View file

@ -1,120 +0,0 @@
"""
Einmalig ausführen um laufende DB zu migrieren.
docker exec flugscanner-web python3 /app/src/db_migrate.py
"""
import sqlite3, os
DB_PATH = os.environ.get("DB_PATH", "/data/flugscanner.db")
conn = sqlite3.connect(DB_PATH)
# 1. Neue Spalten nachrüsten
for sql, desc in [
("ALTER TABLE jobs ADD COLUMN airline_filter TEXT DEFAULT ''", "airline_filter"),
("ALTER TABLE jobs ADD COLUMN layover_min INTEGER DEFAULT 120", "layover_min"),
("ALTER TABLE jobs ADD COLUMN layover_max INTEGER DEFAULT 300", "layover_max"),
("ALTER TABLE jobs ADD COLUMN max_flugzeit_h INTEGER DEFAULT 22","max_flugzeit_h"),
("ALTER TABLE jobs ADD COLUMN max_stops INTEGER DEFAULT 2", "max_stops"),
]:
try:
conn.execute(sql)
print(f" ✓ Spalte hinzugefügt: {desc}")
except Exception:
print(f" — Spalte existiert: {desc}")
# 2. Bestehende Jobs mit vernünftigen Standardwerten befüllen
conn.execute("""
UPDATE jobs SET
layover_min = 120,
layover_max = 300,
max_flugzeit_h = 22,
max_stops = 2
WHERE layover_min IS NULL OR layover_min = 0
""")
conn.execute("UPDATE jobs SET airline_filter = '' WHERE airline_filter IS NULL")
print(" ✓ Bestehende Jobs aktualisiert")
# 3. Airline-spezifische Jobs anlegen (nur wenn noch nicht vorhanden)
airlines = [
("CZ", "China Southern"),
("CX", "Cathay Pacific"),
("SQ", "Singapore Airlines"),
("TG", "Thai Airways"),
]
for code, name in airlines:
exists = conn.execute(
"SELECT id FROM jobs WHERE scanner='kayak' AND airline_filter=?", (code,)
).fetchone()
if not exists:
conn.execute("""
INSERT INTO jobs
(scanner, von, nach, tage, aufenthalt_tage, trip_type, kabine, gepaeck,
airline_filter, layover_min, layover_max, max_flugzeit_h, max_stops, intervall)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", ("kayak","FRA","KTI",30,60,"roundtrip","premium_economy",
"1koffer+handgepaeck", code, 120, 300, 22, 2, "daily"))
print(f" ✓ Job angelegt: Kayak [{code}] {name}")
else:
print(f" — Job existiert: [{code}] {name}")
# 4. Prompt aktualisieren
PROMPT = """Du bist ein Flugpreis-Analyst. Analysiere Preisdaten für diesen Flug:
STRECKE: ROUNDTRIP Frankfurt (FRA) Phnom Penh Techo Airport (KTI)
KABINE: Premium Economy (echte PE-Sitze mit extra Beinfreiheit, NICHT Economy mit anderem Namen!)
GEPÄCK: 1 großer Aufgabekoffer + Handgepäck (zwingend inklusive)
AUFENTHALT: ca. 2 Monate
BEVORZUGTE AIRLINES (nach Preis-Leistung):
- China Southern (CZ): Umstieg Guangzhou (CAN) meist günstigste Option
- Cathay Pacific (CX): Umstieg Hongkong (HKG) sehr gutes PE-Produkt
- Singapore Airlines (SQ): Umstieg Singapur (SIN) bestes PE-Produkt
- Thai Airways (TG): Umstieg Bangkok (BKK) gutes Netz nach KTI
- Vietnam Airlines (VN): Umstieg Hanoi (HAN) direktester Weg nach KTI
HARTE FILTER (Flüge außerhalb dieser Grenzen ablehnen):
- Umstiegszeit an asiatischen Hubs: MUSS 25 Stunden sein (120300 Min)
Unter 2h = Gepäck-Transfer-Risiko / Über 5h = Hotelübernachtung nötig
- Gesamtreisezeit: MAX 22 Stunden (FRAKTI oder KTIFRA)
Flüge mit 30+ Stunden (z.B. mehrere Stopps mit langen Wartezeiten) ausschließen
- Maximale Stopps: 2 (idealerweise 1)
WICHTIG: Preise unter 1000 EUR für diesen Roundtrip sind fast immer unplausibel.
Mögliche Gründe: Economy statt PE, nur Hinflug, kein Freigepäck, falsche Route.
Aktuelle Preise (Anbieter | Node | Airline | Preis):
{preise_heute}
Preisverlauf letzte 30 Tage:
{preisverlauf}
Statistik: Ø {avg} EUR | Min {min} EUR | Max {max} EUR
Antworte auf Deutsch:
EMPFEHLUNG: [JETZT BUCHEN / WARTEN / NEUTRAL]
BEGRUENDUNG: [1-2 Sätze]
BESTER_PREIS: [Anbieter + Airline + Preis + Node]
BESTE_AIRLINE: [welche der Airlines aktuell am günstigsten und warum]
TREND: [STEIGEND / FALLEND / STABIL]
GEO_UNTERSCHIED: [DE-Scanner vs. KH-Scanner Preisdifferenz und Erklärung]
FILTER_WARNUNG: [Welche gefundenen Preise gegen Flugzeit/Stopps/Umstieg-Regeln verstoßen]
PLAUSI_CHECK: [Preise unter 1000 EUR einzeln einordnen was da nicht stimmt]"""
conn.execute("UPDATE prompts SET inhalt=?, updated_at=datetime('now') WHERE name='ki_auswertung'",
(PROMPT,))
print(" ✓ KI-Prompt aktualisiert")
conn.commit()
# 5. Status anzeigen
print("\n=== Aktuelle Jobs ===")
jobs = conn.execute("""
SELECT id, scanner, airline_filter, layover_min, layover_max,
max_flugzeit_h, max_stops, aktiv
FROM jobs ORDER BY id
""").fetchall()
for j in jobs:
al = f" [{j[2]}]" if j[2] else ""
print(f" #{j[0]} {j[1]}{al} | Umstieg {j[3]}-{j[4]}min | max {j[5]}h | {j[6]} Stopps | {'' if j[7] else ''}")
conn.close()
print("\n✅ Migration abgeschlossen")

View file

@ -1,5 +1,6 @@
import os
import json
import requests
from openai import OpenAI
from db import get_conn, log
@ -10,45 +11,39 @@ client = OpenAI(
MODEL = os.environ.get("AI_MODEL", "openai/gpt-4o-mini")
PLAUSI_PROMPT = """Du bist ein Flugpreis-Experte. Pruefe jeden der folgenden Preise auf Plausibilitaet.
PLAUSI_PROMPT = """Du bist ein Flugpreis-Experte. Prüfe jeden der folgenden Preise auf Plausibilität.
KONTEXT:
- Strecke: Roundtrip Frankfurt (FRA) Phnom Penh Techo (KTI), ca. 2 Monate Aufenthalt
- Kabinenklasse: PREMIUM ECONOMY (nicht Economy!)
- Gepaeck: 1 grosser Koffer + Handgepaeck MUSS inklusive sein
- Bevorzugte Airlines: China Southern (CZ), Cathay Pacific (CX), Singapore Airlines (SQ), Thai Airways (TG), Vietnam Airlines (VN)
- Strecke: Roundtrip Frankfurt (FRA) Phnom Penh/Siem Reap (KTI), ca. 2 Monate Aufenthalt
- Kabinenklasse: ECONOMY (normales Economy mit Gepäck)
- Gepäck: 1 großer Koffer + Handgepäck muss inklusive sein
- Ziel-Airlines: Cathay Pacific (CX), Singapore Airlines (SQ), Emirates (EK), Qatar Airways (QR)
PREISREFERENZ fuer Premium Economy Roundtrip FRA-KTI mit Gepaeck:
- Sehr guenstig: 900-1200 EUR (seltene Deals, plausibel wenn bekannte Airline)
- Normal: 1200-1800 EUR
- Teuer: 1800-2500 EUR
- Ueber 2500 EUR: zu teuer oder Business Class
- UNTER 700 EUR: fast sicher ECONOMY, nicht Premium Economy!
- 700-900 EUR: sehr verdaechtig, wahrscheinlich Economy oder ohne Gepaeck
PREISREFERENZ für Economy Roundtrip FRA-KTI mit Gepäck:
- Sehr günstig: 700-900 EUR (seltene Deals, plausibel wenn bekannte Airline)
- Normal: 900-1200 EUR
- Teuer: 1200-1600 EUR
- Über 1600 EUR: möglicherweise falsche Kabine oder Business
- Unter 500 EUR: fast sicher Economy Light (ohne Gepäck) NICHT PLAUSIBEL
- 500-700 EUR: verdächtig, wahrscheinlich ohne Gepäck
PRUEFREGELN:
1. Preis unter 700 EUR NICHT PLAUSIBEL (Economy ohne Gepaeck)
2. Preis 700-900 EUR VERDAECHTIG (pruefen ob Economy oder ohne Gepaeck)
3. Preis 900-2500 EUR mit bekannter Airline PLAUSIBEL
4. Preis ueber 2500 EUR VERDAECHTIG (eventuell Business Class)
5. Scanner "kayak_multicity" (HKG Stopover): Preise 100-200 EUR hoeher als Direkt ist normal
6. Wenn ein Scanner deutlich guenstigere Preise zeigt als alle anderen: VERDAECHTIG
PRÜFREGELN:
1. Preis unter 500 EUR NICHT PLAUSIBEL (Economy Light ohne Gepäck)
2. Preis 500-700 EUR VERDÄCHTIG (prüfen ob ohne Gepäck)
3. Preis 700-1600 EUR mit bekannter Airline PLAUSIBEL
4. Preis über 1600 EUR VERDÄCHTIG (möglicherweise Business oder falsche Kabine)
5. kayak_multicity (HKG Stopover): 50-150 EUR teurer als Direkt ist normal
6. Wenn ein Scanner deutlich günstiger als alle anderen: VERDÄCHTIG
PREISE ZU PRUEFEN:
PREISE ZU PRÜFEN:
{preise_liste}
Antworte NUR mit gueltigem JSON-Array. Fuer jeden Preis:
{{"id": <price_id>, "plausibel": true/false, "grund": "<kurze Begruendung auf Deutsch>"}}
Beispiel:
[
{{"id": 123, "plausibel": true, "grund": "1350 EUR fuer CX PE Roundtrip ist marktgerecht"}},
{{"id": 124, "plausibel": false, "grund": "436 EUR ist Economy-Preis, nicht PE mit Gepaeck"}}
]"""
Antworte NUR mit gültigem JSON-Array. Für jeden Preis:
{{"id": <price_id>, "plausibel": true/false, "grund": "<kurze Begründung auf Deutsch>"}}"""
def plausibilitaetspruefung(von="FRA", nach="KTI"):
"""Prüft alle ungeprüften Preise des aktuellen Laufs via KI."""
"""Prüft alle ungeprüften Economy-Preise des aktuellen Laufs via KI."""
log("KI-Plausibilitätsprüfung gestartet")
conn = get_conn()
@ -58,28 +53,29 @@ def plausibilitaetspruefung(von="FRA", nach="KTI"):
WHERE von=? AND nach=?
AND plausibel IS NULL
AND date(scraped_at) = date('now')
AND kabine_erkannt IN ('Economy', 'Economy Light', 'Unbekannt')
OR (von=? AND nach=? AND plausibel IS NULL
AND date(scraped_at) = date('now')
AND kabine_erkannt IS NULL)
ORDER BY preis ASC
""", (von, nach)).fetchall()
""", (von, nach, von, nach)).fetchall()
if not ungepruefte:
log("Keine ungeprüften Preise — Plausibilitätsprüfung übersprungen")
log("Keine ungeprüften Economy-Preise — Plausibilitätsprüfung übersprungen")
conn.close()
return
# In Batches aufteilen (max 25 Preise pro KI-Call)
BATCH_SIZE = 25
batches = [ungepruefte[i:i+BATCH_SIZE] for i in range(0, len(ungepruefte), BATCH_SIZE)]
plausibel_total = 0
verdaechtig_total = 0
plausibel_total = verdaechtig_total = 0
for batch_nr, batch in enumerate(batches):
preise_liste = "\n".join([
f" ID {p['id']}: {p['preis']:.0f} EUR — Scanner: {p['scanner']}"
f"Node: {p['node']}Airline: {p['airline'] or 'k.A.'} — Abflug: {p['abflug']}"
f"Airline: {p['airline'] or 'k.A.'} — Abflug: {p['abflug']}"
for p in batch
])
prompt = PLAUSI_PROMPT.format(preise_liste=preise_liste)
try:
@ -90,28 +86,22 @@ def plausibilitaetspruefung(von="FRA", nach="KTI"):
temperature=0.1,
)
antwort = response.choices[0].message.content.strip()
if "```" in antwort:
antwort = antwort.split("```")[1]
if antwort.startswith("json"):
antwort = antwort[4:]
ergebnisse = json.loads(antwort)
for e in ergebnisse:
pid = e.get("id")
pid = e.get("id")
ist_plausibel = 1 if e.get("plausibel") else 0
grund = e.get("grund", "")[:200]
grund = e.get("grund", "")[:200]
conn.execute(
"UPDATE prices SET plausibel=?, plausi_grund=? WHERE id=?",
(ist_plausibel, grund, pid)
)
if ist_plausibel:
plausibel_total += 1
else:
verdaechtig_total += 1
if ist_plausibel: plausibel_total += 1
else: verdaechtig_total += 1
conn.commit()
except json.JSONDecodeError as e:
@ -127,25 +117,52 @@ def plausibilitaetspruefung(von="FRA", nach="KTI"):
def _regelbasierte_plausi(conn, preise):
"""Fallback wenn KI nicht erreichbar: einfache Regeln."""
log("Regelbasierte Plausibilitätsprüfung als Fallback")
"""Fallback wenn KI nicht erreichbar: regelbasiert für Economy."""
log("Regelbasierte Plausibilitätsprüfung (Economy) als Fallback")
for p in preise:
preis = p["preis"]
if preis < 700:
if preis < 500:
conn.execute("UPDATE prices SET plausibel=0, plausi_grund=? WHERE id=?",
("Preis unter 700€ — sehr wahrscheinlich Economy", p["id"]))
elif preis < 900:
("Unter 500€ — wahrscheinlich Economy Light ohne Gepäck", p["id"]))
elif preis < 700:
conn.execute("UPDATE prices SET plausibel=0, plausi_grund=? WHERE id=?",
("Preis 700-900€ — verdächtig, wahrscheinlich Economy oder ohne Gepäck", p["id"]))
elif preis > 3000:
("500-700€ — verdächtig, wahrscheinlich ohne Gepäck", p["id"]))
elif preis > 1800:
conn.execute("UPDATE prices SET plausibel=0, plausi_grund=? WHERE id=?",
("Preis über 3000€ — möglicherweise Business Class", p["id"]))
("Über 1800€ — möglicherweise Business Class", p["id"]))
else:
conn.execute("UPDATE prices SET plausibel=1, plausi_grund=? WHERE id=?",
("Preis im erwarteten PE-Bereich", p["id"]))
("Preis im Economy-Roundtrip-Bereich", p["id"]))
conn.commit()
def get_openrouter_guthaben() -> dict:
"""Fragt OpenRouter-Guthaben ab."""
api_key = os.environ.get("OPENROUTER_API_KEY", "")
if not api_key:
return {"fehler": "Kein API-Key konfiguriert"}
try:
r = requests.get(
"https://openrouter.ai/api/v1/auth/key",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10
)
if r.status_code == 200:
d = r.json().get("data", {})
limit = d.get("limit")
usage = d.get("usage", 0)
verbleibend = round((limit - usage), 4) if limit else None
return {
"limit": limit,
"usage": round(usage, 4),
"verbleibend": verbleibend,
"is_free": d.get("is_free_tier", False),
}
return {"fehler": f"HTTP {r.status_code}"}
except Exception as e:
return {"fehler": str(e)}
def get_prompt():
conn = get_conn()
row = conn.execute(
@ -159,20 +176,34 @@ def auswerten(von="FRA", nach="KTI"):
log("KI-Auswertung gestartet")
conn = get_conn()
# Nur Economy-Preise die plausibel sind
preise_heute = conn.execute("""
SELECT scanner, node, preis, airline, abflug
SELECT scanner, node, preis, airline, abflug, kabine_erkannt
FROM prices
WHERE von=? AND nach=?
AND date(scraped_at) = date('now')
AND (plausibel = 1 OR plausibel IS NULL)
AND date(scraped_at) = date('now')
AND plausibel = 1
AND kabine_erkannt IN ('Economy', 'Economy Light', 'Unbekannt')
ORDER BY preis ASC
""", (von, nach)).fetchall()
qualitaet = conn.execute("""
SELECT
COUNT(*) as gesamt,
SUM(CASE WHEN kabine_erkannt='Economy' THEN 1 ELSE 0 END) as eco,
SUM(CASE WHEN kabine_erkannt='Economy Light' THEN 1 ELSE 0 END) as light,
SUM(CASE WHEN kabine_erkannt='Premium Economy' THEN 1 ELSE 0 END) as pe
FROM prices
WHERE von=? AND nach=? AND date(scraped_at) = date('now')
""", (von, nach)).fetchone()
preisverlauf = conn.execute("""
SELECT date(scraped_at) as tag, MIN(preis) as min_preis, AVG(preis) as avg_preis
FROM prices
WHERE von=? AND nach=?
AND scraped_at >= datetime('now', '-30 days')
AND scraped_at >= datetime('now', '-30 days')
AND kabine_erkannt IN ('Economy', 'Economy Light', 'Unbekannt')
AND plausibel = 1
GROUP BY date(scraped_at)
ORDER BY tag
""", (von, nach)).fetchall()
@ -181,31 +212,44 @@ def auswerten(von="FRA", nach="KTI"):
SELECT AVG(preis) as avg, MIN(preis) as min, MAX(preis) as max
FROM prices
WHERE von=? AND nach=?
AND scraped_at >= datetime('now', '-30 days')
AND scraped_at >= datetime('now', '-30 days')
AND kabine_erkannt IN ('Economy', 'Economy Light', 'Unbekannt')
AND plausibel = 1
""", (von, nach)).fetchone()
conn.close()
if not preise_heute:
log("Keine Preise für heute — KI-Auswertung übersprungen", "WARN")
log("Keine plausiblen Economy-Preise heute — KI-Auswertung übersprungen", "WARN")
return
qualitaet_hinweis = (
f"DATENQUALITÄT HEUTE: {qualitaet['eco'] or 0} Economy, "
f"{qualitaet['light'] or 0} Economy Light gescannt. "
f"Nur plausible Roundtrip-Preise mit Gepäck werden ausgewertet.\n"
)
preise_heute_str = "\n".join([
f" {p['scanner']} ({p['node']}): {p['preis']} EUR — {p['airline'] or 'k.A.'}"
f" {p['scanner']}: {p['preis']} EUR — {p['airline'] or 'k.A.'} "
f"({p['kabine_erkannt'] or '?'})"
for p in preise_heute
])
verlauf_str = "\n".join([
f" {p['tag']}: min {p['min_preis']:.0f} EUR, avg {p['avg_preis']:.0f} EUR"
for p in preisverlauf
])
]) or " (noch keine Verlaufsdaten)"
prompt_template = get_prompt()
prompt = prompt_template.format(
if not prompt_template:
log("Kein KI-Auswertungs-Prompt in DB — übersprungen", "WARN")
return
prompt = qualitaet_hinweis + "\n" + prompt_template.format(
preise_heute=preise_heute_str,
preisverlauf=verlauf_str,
avg=f"{stats['avg']:.0f}" if stats['avg'] else "?",
min=f"{stats['min']:.0f}" if stats['min'] else "?",
max=f"{stats['max']:.0f}" if stats['max'] else "?"
avg=f"{stats['avg']:.0f}" if stats and stats['avg'] else "?",
min=f"{stats['min']:.0f}" if stats and stats['min'] else "?",
max=f"{stats['max']:.0f}" if stats and stats['max'] else "?"
)
try:
@ -215,28 +259,19 @@ def auswerten(von="FRA", nach="KTI"):
max_tokens=500
)
analyse = response.choices[0].message.content
log(f"KI-Antwort erhalten: {analyse[:100]}...")
guenstigster = preise_heute[0]
empfehlung = ""
if "JETZT BUCHEN" in analyse:
empfehlung = "JETZT BUCHEN"
elif "WARTEN" in analyse:
empfehlung = "WARTEN"
else:
empfehlung = "NEUTRAL"
if "JETZT BUCHEN" in analyse: empfehlung = "JETZT BUCHEN"
elif "WARTEN" in analyse: empfehlung = "WARTEN"
else: empfehlung = "NEUTRAL"
conn = get_conn()
conn.execute("""
INSERT INTO analyses
(von, nach, guenstigster_preis, guenstigster_anbieter, ki_empfehlung, ki_analyse)
VALUES (?, ?, ?, ?, ?, ?)
""", (
von, nach,
guenstigster["preis"],
f"{guenstigster['scanner']} ({guenstigster['node']})",
empfehlung, analyse
))
""", (von, nach, guenstigster["preis"],
f"{guenstigster['scanner']}", empfehlung, analyse))
conn.commit()
conn.close()
log("KI-Auswertung gespeichert")

View file

@ -1,14 +1,121 @@
import os
import time
import random
import threading
import requests
import schedule
from datetime import datetime, timedelta
from db import init_db, get_conn, log
from ki import auswerten, plausibilitaetspruefung
from openai import OpenAI
# Verhindert dass zwei Läufe gleichzeitig laufen
_scan_lock = threading.Lock()
# ── OpenRouter Vision Client ──────────────────────────────────────────────────
_vision_client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ.get("OPENROUTER_API_KEY")
)
# ── Telegram ──────────────────────────────────────────────────────────────────
TELEGRAM_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
def telegram_send(msg: str):
"""Sendet Nachricht via Telegram. Fehler werden nur geloggt, nie geworfen."""
if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
return
try:
requests.post(
f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
json={"chat_id": TELEGRAM_CHAT_ID, "text": msg, "parse_mode": "HTML"},
timeout=10
)
except Exception as e:
log(f"Telegram-Fehler: {e}", "WARN")
# ── Zero-Result-Tracking (in-memory, pro Job-ID) ─────────────────────────────
_null_ergebnis_zaehler: dict[str, int] = {} # key = "node:job_id"
ALERT_NACH_N_NULLLAEUFEN = 3
# Scanner die aus Asien (Cambodia) nicht funktionieren - Geo-Block
NODE_SCANNER_SKIP = {
"flugscanner-asia": {"momondo", "traveloka"},
}
# ── Vision Prompt (angepasst für Economy) ────────────────────────────────────
VISION_PROMPT = """Du siehst einen Screenshot einer Flugsuche-Website (Kayak, Momondo etc.).
AUFGABE: Bestimme welche Kabinenklasse in den SUCHERGEBNISSEN gezeigt wird.
WICHTIG WAS ZÄHLT:
Die Kabinenklasse direkt UNTER den einzelnen Flügen in der Ergebnisliste
Der aktive Kabinenfilter-Button in der Suche
Labels neben dem Preis jedes Flugergebnisses
IGNORIERE:
Werbebanner
Empfehlungsboxen oben auf der Seite
Texte die nicht zu konkreten Flugergebnissen gehören
KLASSIFIZIERUNG:
- "Economy Light" "Economy Light", "Basic", "Light", "Nur Handgepäck", "Hand baggage"
- "Economy" "Economy" ohne "Premium" davor
- "Premium Economy" "Premium Economy" oder "W Class" bei Flugergebnissen
- "Business" "Business" bei Flugergebnissen
- "Unbekannt" Ladescreen, Captcha, Cookie-Banner, keine Ergebnisse
Antworte NUR mit dem einen passenden Begriff. Keine Erklärung."""
def klassifiziere_screenshot(screenshot_b64: str) -> str:
"""Vision-KI klassifiziert Kabine im Screenshot."""
if not screenshot_b64:
return "Unbekannt"
try:
response = _vision_client.chat.completions.create(
model="openai/gpt-4o-mini",
max_tokens=15,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": VISION_PROMPT},
{"type": "image_url", "image_url": {
"url": f"data:image/jpeg;base64,{screenshot_b64}"
}}
]
}]
)
antwort = response.choices[0].message.content.strip().lower()
if "premium" in antwort: return "Premium Economy"
if "light" in antwort or "basic" in antwort: return "Economy Light"
if "economy" in antwort: return "Economy"
if "business" in antwort: return "Business"
if "first" in antwort: return "First"
return "Unbekannt"
except Exception as e:
log(f"Vision-Klassifizierung fehlgeschlagen: {e}", "WARN")
return "Unbekannt"
# ── Cleanup ───────────────────────────────────────────────────────────────────
def cleanup_alte_screenshots(tage=30):
"""Löscht Screenshots die älter als `tage` Tage sind."""
try:
conn = get_conn()
cur = conn.execute("""
DELETE FROM screenshots
WHERE created_at < datetime('now', ?)
""", (f"-{tage} days",))
deleted = cur.rowcount
conn.commit()
conn.close()
if deleted > 0:
log(f"Cleanup: {deleted} Screenshots älter als {tage} Tage gelöscht")
except Exception as e:
log(f"Cleanup-Fehler: {e}", "WARN")
# ── Lock ──────────────────────────────────────────────────────────────────────
_scan_lock = threading.Lock()
_lauf_aktiv = False
@ -21,11 +128,7 @@ def get_nodes():
return [dict(n) for n in nodes]
def get_aktive_jobs(flex=False):
"""
flex=False normale Jobs (datum_flex IS NULL or 0)
flex=True alle Jobs, tage wird durch Aufrufer variiert
"""
def get_aktive_jobs():
conn = get_conn()
jobs = conn.execute("SELECT * FROM jobs WHERE aktiv = 1").fetchall()
conn.close()
@ -58,7 +161,7 @@ def dispatch_job(node, job, tage_override=None):
"tage": tage_override if tage_override is not None else job["tage"],
"aufenthalt_tage": job.get("aufenthalt_tage", 60),
"trip_type": job.get("trip_type", "roundtrip"),
"kabine": job.get("kabine", "premium_economy"),
"kabine": job.get("kabine", "economy"),
"gepaeck": job.get("gepaeck", "1koffer+handgepaeck"),
"airline_filter": job.get("airline_filter", ""),
"layover_min": job.get("layover_min", 120),
@ -69,6 +172,7 @@ def dispatch_job(node, job, tage_override=None):
"stopover_min_h": job.get("stopover_min_h", 20),
"stopover_max_h": job.get("stopover_max_h", 30),
}
job_id = job["id"]
try:
r = requests.post(
f"http://{node['tailscale_ip']}:5010/job",
@ -79,13 +183,45 @@ def dispatch_job(node, job, tage_override=None):
data = r.json()
results = data.get("results", [])
screenshot_b64 = data.get("screenshot_b64", "")
via_label = f" via {job.get('via','')}" if job.get('via') else ""
airline_label = f" [{job.get('airline_filter','')}]" if job.get('airline_filter') else ""
log(f"{node['name']}: {len(results)} Preise ← {job['scanner']}"
f"{' ['+job.get('airline_filter','')+']' if job.get('airline_filter') else ''}"
f"{via_label}"
f"{airline_label}{via_label}"
f"{' +'+str(tage_override)+'T' if tage_override else ''}")
screenshot_id = speichere_screenshot(screenshot_b64, node["name"], job)
speichere_preise(results, node["name"], job, screenshot_id)
# ── Zero-Result-Alert ─────────────────────────────────────────
if len(results) == 0:
zkey = f"{node['name']}:{job_id}"
_null_ergebnis_zaehler[zkey] = _null_ergebnis_zaehler.get(zkey, 0) + 1
zaehler = _null_ergebnis_zaehler[zkey]
log(f"{job['scanner']} liefert 0 Preise ({zaehler}/{ALERT_NACH_N_NULLLAEUFEN})", "WARN")
if zaehler >= ALERT_NACH_N_NULLLAEUFEN:
telegram_send(
f"⚠️ <b>Flugscanner-Alert</b>\n"
f"Scanner <b>{job['scanner']}</b> (Job #{job_id}) liefert "
f"seit {zaehler} Läufen <b>0 Preise</b>.\n"
f"Möglicherweise Anti-Bot-Erkennung oder Seite verändert."
)
else:
zkey = f"{node['name']}:{job_id}"
_null_ergebnis_zaehler[zkey] = 0 # Reset bei Erfolg
# ─────────────────────────────────────────────────────────────
screenshot_id = speichere_screenshot(screenshot_b64, node["name"], job)
# ── Vision-Wahrheitsfilter ────────────────────────────────────
kabine_erkannt = klassifiziere_screenshot(screenshot_b64)
log(f"{node['name']}/{job['scanner']}: Vision → {kabine_erkannt}")
# Für Economy-Suche: Business/First/PE sind Fehlklassifizierungen
FALSCHE_KABINEN = ("Premium Economy", "Business", "First")
if kabine_erkannt in FALSCHE_KABINEN:
log(f"⚠ Vision zeigt {kabine_erkannt} statt Economy — Preise markiert", "WARN")
# ─────────────────────────────────────────────────────────────
pruefe_preis_alert(results, job)
pruefe_preisanstieg(results, job)
speichere_preise(results, node["name"], job, screenshot_id, kabine_erkannt)
return True
else:
log(f"{node['name']}: Fehler {r.status_code} bei {job['scanner']}", "ERROR")
@ -97,7 +233,6 @@ def dispatch_job(node, job, tage_override=None):
def speichere_screenshot(screenshot_b64, node_name, job):
"""Speichert Screenshot in DB, gibt screenshot_id zurück (oder None)."""
if not screenshot_b64:
return None
try:
@ -115,13 +250,47 @@ def speichere_screenshot(screenshot_b64, node_name, job):
return None
def speichere_preise(results, node_name, job, screenshot_id=None):
ALERT_SCHWELLE_EUR = 900 # Telegram-Alert wenn CX via HKG unter diesen Preis fällt
def pruefe_preis_alert(results, job):
"""Sendet Telegram-Alert wenn kayak_multicity unter Schwelle fällt."""
if job.get("scanner") != "kayak_multicity":
return
for r in results:
if r.get("preis", 9999) < ALERT_SCHWELLE_EUR:
preis = r["preis"]
abflug = r.get("abflug", "?")
url = r.get("booking_url", "")
telegram_send(
f"✈️ <b>CX via HKG unter {ALERT_SCHWELLE_EUR}€!</b>\n\n"
f"💰 Preis: <b>{preis:.0f} EUR</b> Roundtrip\n"
f"📅 Abflug: {abflug}\n"
f"🔗 <a href='{url}'>Jetzt buchen</a>"
)
log(f"💰 PREIS-ALERT: {preis:.0f}EUR via HKG — Telegram gesendet")
break # Nur einmal pro Job-Lauf
def speichere_preise(results, node_name, job, screenshot_id=None, kabine_erkannt=None):
# Economy-Suche: PE/Business/First sind Fehlkabinen → disqualifizieren
FALSCHE_KABINEN = ("Premium Economy", "Business", "First")
ist_disqualifiziert = kabine_erkannt in FALSCHE_KABINEN
conn = get_conn()
for r in results:
plausibel_init = None
plausi_grund_init = ""
if ist_disqualifiziert:
plausibel_init = 0
plausi_grund_init = (
f"[Vision-Filter] Screenshot zeigt {kabine_erkannt} — kein Economy"
)
conn.execute("""
INSERT INTO prices
(job_id, scanner, node, preis, waehrung, airline, abflug, ankunft, von, nach, booking_url, screenshot_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(job_id, scanner, node, preis, waehrung, airline, abflug, ankunft,
von, nach, booking_url, screenshot_id, kabine_erkannt, plausibel, plausi_grund)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
job["id"], r.get("scanner", job["scanner"]), node_name,
r["preis"], r.get("waehrung", "EUR"), r.get("airline", ""),
@ -129,16 +298,15 @@ def speichere_preise(results, node_name, job, screenshot_id=None):
job["von"], job["nach"],
r.get("booking_url", ""),
screenshot_id,
kabine_erkannt,
plausibel_init,
plausi_grund_init,
))
conn.commit()
conn.close()
def scraping_lauf(label="Standard", flex_tage_liste=None):
"""
Führt alle aktiven Jobs auf allen Nodes aus.
Übersprungen wenn ein anderer Lauf noch aktiv ist (Lock-Schutz).
"""
global _lauf_aktiv
if not _scan_lock.acquire(blocking=False):
@ -158,16 +326,16 @@ def scraping_lauf(label="Standard", flex_tage_liste=None):
return
tage_varianten = flex_tage_liste or [None]
online = 0
fehler = 0
preise_gesamt = 0
online = fehler = 0
for node in nodes:
if node_ping(node):
update_node_status(node["name"], "online")
online += 1
for job in jobs:
skip_set = NODE_SCANNER_SKIP.get(node["name"], set())
if job["scanner"] in skip_set:
continue
for tage_var in tage_varianten:
try:
ok = dispatch_job(node, job, tage_override=tage_var)
@ -182,7 +350,7 @@ def scraping_lauf(label="Standard", flex_tage_liste=None):
dauer = round((datetime.now() - start).total_seconds())
log(f"Scraping [{label}] fertig — {online}/{len(nodes)} Nodes | "
f"{fehler} Fehler | {dauer}s Laufzeit")
f"{fehler} Fehler | {dauer}s")
try:
plausibilitaetspruefung()
@ -202,7 +370,6 @@ def scraping_lauf(label="Standard", flex_tage_liste=None):
def standard_lauf():
"""30-Minuten-Takt — in eigenem Thread damit der Scheduler nicht blockiert."""
threading.Thread(
target=scraping_lauf,
kwargs={"label": datetime.now().strftime("%H:%M")},
@ -211,7 +378,6 @@ def standard_lauf():
def flex_lauf():
"""Di/Mi 23:30 — ±3 Tage Datumsfenster."""
wochentag = datetime.now().weekday()
if wochentag not in (1, 2):
log("Flex-Lauf: heute kein Di/Mi — übersprungen")
@ -226,17 +392,269 @@ def flex_lauf():
).start()
def vorlauf_lauf():
"""Täglich 08:00 — scannt 45/60/84 Tage vorab für Buchungsvorlauf-Kurve."""
threading.Thread(
target=scraping_lauf,
kwargs={"label": "Vorlauf-84T", "flex_tage_liste": [45, 60, 84]},
daemon=True
).start()
def cleanup_lauf():
"""Tägliche Wartung: alte Screenshots löschen."""
cleanup_alte_screenshots(tage=30)
# ── Telegram Bot Befehle ──────────────────────────────────────────────────────
def _cx_preise_jetzt() -> dict:
"""Holt aktuellen CX-Multicity-Preis und Vergleichswerte aus DB."""
conn = get_conn()
cx = conn.execute("""
SELECT MIN(preis) as min_p, MAX(scraped_at) as zuletzt
FROM prices WHERE scanner='kayak_multicity'
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
AND scraped_at >= datetime('now','-3 hours')
""").fetchone()
direkt = conn.execute("""
SELECT MIN(preis) as min_p
FROM prices WHERE scanner='kayak'
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
AND scraped_at >= datetime('now','-3 hours')
""").fetchone()
gestern_cx = conn.execute("""
SELECT MIN(preis) as min_p
FROM prices WHERE scanner='kayak_multicity'
AND date(scraped_at) = date('now','-1 day')
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
""").fetchone()
ki = conn.execute("""
SELECT ki_empfehlung, ki_analyse FROM analyses
ORDER BY id DESC LIMIT 1
""").fetchone()
conn.close()
return {
"cx_min": cx["min_p"] if cx else None,
"cx_zuletzt": cx["zuletzt"][:16] if cx and cx["zuletzt"] else "?",
"direkt_min": direkt["min_p"] if direkt else None,
"gestern_cx": gestern_cx["min_p"] if gestern_cx else None,
"ki_empf": ki["ki_empfehlung"] if ki else "",
"ki_text": ki["ki_analyse"][:200] if ki else "",
}
def _top3_heute() -> list:
"""Top 3 günstigste Multicity-Treffer heute."""
conn = get_conn()
rows = conn.execute("""
SELECT preis, abflug, ankunft, booking_url
FROM prices WHERE scanner='kayak_multicity'
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
AND scraped_at >= datetime('now','-3 hours')
ORDER BY preis ASC LIMIT 3
""").fetchall()
conn.close()
return [dict(r) for r in rows]
def handle_bot_command(text: str, chat_id: str):
"""Verarbeitet eingehende Bot-Befehle."""
cmd = text.strip().lower().split()[0] if text.strip() else ""
d = _cx_preise_jetzt()
if cmd == "/preis":
cx = d["cx_min"]
direkt = d["direkt_min"]
gestern = d["gestern_cx"]
trend = ""
if cx and gestern:
diff = cx - gestern
trend = f"↗️ +{diff:.0f}€ vs. gestern" if diff > 0 else f"↘️ {diff:.0f}€ vs. gestern"
aufpreis = f"+{cx-direkt:.0f}€ vs. Direktflug" if cx and direkt else ""
msg = (
f"✈️ <b>CX via HKG — aktueller Preis</b>\n\n"
f"💰 <b>{cx:.0f} EUR</b> Roundtrip {trend}\n"
f"🔵 Direktflug: {direkt:.0f} EUR ({aufpreis})\n"
f"🕐 Letzter Scan: {d['cx_zuletzt']}\n\n"
f"KI: <b>{d['ki_empf']}</b>"
) if cx else "⏳ Noch keine Daten im aktuellen Scan-Fenster."
elif cmd == "/best":
top3 = _top3_heute()
if not top3:
msg = "⏳ Noch keine Treffer im aktuellen Scan-Fenster."
else:
zeilen = "\n".join([
f"{i+1}. <b>{r['preis']:.0f}€</b> — Abflug {r['abflug']} "
f"<a href='{r['booking_url']}'>buchen</a>"
for i, r in enumerate(top3)
])
msg = f"🏆 <b>Top 3 CX via HKG heute</b>\n\n{zeilen}"
elif cmd == "/status":
conn = get_conn()
nodes = conn.execute("SELECT name, status, last_seen FROM nodes").fetchall()
jobs_n = conn.execute("SELECT COUNT(*) FROM jobs WHERE aktiv=1").fetchone()[0]
letzter = conn.execute("SELECT MAX(scraped_at) FROM prices").fetchone()[0]
conn.close()
from ki import get_openrouter_guthaben
gut = get_openrouter_guthaben()
node_str = "\n".join([
f" {'🟢' if n['status']=='online' else '🔴'} {n['name']} ({n['last_seen'][:16] if n['last_seen'] else '?'})"
for n in nodes
])
msg = (
f"🖥️ <b>Flugscanner Status</b>\n\n"
f"<b>Nodes:</b>\n{node_str}\n\n"
f"<b>Aktive Jobs:</b> {jobs_n}\n"
f"<b>Letzter Scan:</b> {letzter[:16] if letzter else '?'}\n"
f"<b>OpenRouter:</b> {gut.get('verbleibend','?')} USD verbleibend"
)
else:
msg = (
"✈️ <b>CX HKG Alert Bot</b>\n\n"
"/preis — Aktueller CX-Preis + Trend\n"
"/best — Top 3 günstigste Treffer heute\n"
"/status — Nodes, Scans, Guthaben"
)
telegram_send_to(chat_id, msg)
def telegram_send_to(chat_id: str, msg: str):
"""Sendet an spezifische Chat-ID."""
if not TELEGRAM_TOKEN:
return
try:
requests.post(
f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
json={"chat_id": chat_id, "text": msg,
"parse_mode": "HTML", "disable_web_page_preview": True},
timeout=10
)
except Exception as e:
log(f"Telegram-Send-Fehler: {e}", "WARN")
def telegram_polling():
"""Background-Thread: empfängt Bot-Befehle via Long Polling."""
if not TELEGRAM_TOKEN:
log("Telegram-Token fehlt — Bot-Polling deaktiviert", "WARN")
return
log("Telegram Bot Polling gestartet")
offset = 0
while True:
try:
r = requests.get(
f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/getUpdates",
params={"offset": offset, "timeout": 30, "allowed_updates": ["message"]},
timeout=40
)
if r.status_code == 200:
updates = r.json().get("result", [])
for u in updates:
offset = u["update_id"] + 1
msg = u.get("message", {})
text = msg.get("text", "")
chat_id = str(msg.get("chat", {}).get("id", ""))
if text and chat_id:
log(f"Bot-Befehl von {chat_id}: {text[:30]}")
threading.Thread(
target=handle_bot_command,
args=(text, chat_id),
daemon=True
).start()
except Exception as e:
log(f"Polling-Fehler: {e}", "WARN")
time.sleep(5)
# ── Morgenbericht ─────────────────────────────────────────────────────────────
def morgenbericht():
"""Täglich 07:00: Tagesüberblick per Telegram."""
d = _cx_preise_jetzt()
top3 = _top3_heute()
cx = d["cx_min"]
gestern = d["gestern_cx"]
if not cx:
telegram_send("☀️ Guten Morgen — noch keine Scan-Daten für heute.")
return
trend_emoji = "📈" if (cx and gestern and cx > gestern) else "📉" if (cx and gestern and cx < gestern) else "➡️"
trend_str = f"{trend_emoji} {cx-gestern:+.0f}€ vs. gestern" if gestern else ""
empf_farbe = {"JETZT BUCHEN": "🟢", "WARTEN": "🔴", "NEUTRAL": "🟡"}.get(d["ki_empf"], "")
top_str = ""
if top3:
top_str = "\n🏆 <b>Beste Angebote:</b>\n" + "\n".join([
f" {i+1}. {r['preis']:.0f}€ — {r['abflug']} <a href='{r['booking_url']}'>buchen</a>"
for i, r in enumerate(top3)
])
msg = (
f"☀️ <b>Guten Morgen — CX via HKG</b>\n\n"
f"💰 Heute ab <b>{cx:.0f} EUR</b> {trend_str}\n"
f"{empf_farbe} KI-Empfehlung: <b>{d['ki_empf']}</b>\n"
f"{top_str}"
)
telegram_send(msg)
log("Morgenbericht gesendet")
# ── Preisanstieg-Alert ────────────────────────────────────────────────────────
_letzter_cx_preis: float = 0.0
def pruefe_preisanstieg(results, job):
"""Alert wenn CX via HKG um mehr als 50€ gestiegen ist."""
global _letzter_cx_preis
if job.get("scanner") != "kayak_multicity" or not results:
return
aktuell = min(r["preis"] for r in results)
if _letzter_cx_preis > 0 and aktuell > _letzter_cx_preis + 50:
diff = aktuell - _letzter_cx_preis
telegram_send(
f"📈 <b>Preisanstieg CX via HKG!</b>\n\n"
f"Vorher: {_letzter_cx_preis:.0f}€ → Jetzt: <b>{aktuell:.0f}€</b> "
f"(+{diff:.0f}€)\n\n"
f"Falls du buchen wolltest: jetzt könnte es teurer werden."
)
log(f"📈 Preisanstieg-Alert: {_letzter_cx_preis:.0f}{aktuell:.0f}EUR")
if aktuell > 0:
_letzter_cx_preis = aktuell
def run():
init_db()
log("Scheduler gestartet — alle 30 Minuten + Flex Di/Mi 23:30")
log("Scheduler gestartet")
# Alle 30 Minuten Standard-Scan
schedule.every(30).minutes.do(standard_lauf)
# Zufälliges Intervall 25-45 Minuten — Anti-Detection
schedule.every(25).to(45).minutes.do(standard_lauf)
# Di + Mi um 23:30: erweiterter Flex-Lauf mit ±3 Tage Datumsfenster
# Di + Mi 23:30: Flex-Lauf ±3 Tage
schedule.every().day.at("23:30").do(flex_lauf)
# Täglich 03:00: Screenshots aufräumen
schedule.every().day.at("03:00").do(cleanup_lauf)
# Täglich 08:00: Buchungsvorlauf-Scan 45/60/84 Tage
schedule.every().day.at("08:00").do(vorlauf_lauf)
log("Vorlauf-Scan: täglich 08:00 für 45/60/84 Tage vorab")
# Täglich 07:00: Morgenbericht
schedule.every().day.at("07:00").do(morgenbericht)
log("Morgenbericht: täglich 07:00 Uhr")
log(f"Nächster Lauf: {str(schedule.jobs[0].next_run)[:16]}")
log(f"Scan-Intervall: zufällig 25-45 Minuten (Anti-Bot)")
# Telegram Bot Polling in eigenem Thread
threading.Thread(target=telegram_polling, daemon=True).start()
while True:
schedule.run_pending()

View file

@ -45,6 +45,14 @@ BASE_HTML = """<!DOCTYPE html>
.btn-green:hover { background: #047857; }
.btn-red { background: #dc2626; }
.btn-red:hover { background: #b91c1c; }
.fb { background:#1e293b;border:1px solid #334155;color:#94a3b8;padding:0.25rem 0.7rem;border-radius:6px;cursor:pointer;font-size:0.78rem;transition:all .15s; }
.fb:hover { border-color:#60a5fa;color:#bfdbfe; }
.fb-active { background:#1e3a5f;border-color:#2563eb;color:#93c5fd; }
.sortable { cursor:pointer;user-select:none; }
.sortable:hover { background:rgba(255,255,255,0.05); }
.sortable.asc .sort-icon::after { content:""; }
.sortable.desc .sort-icon::after { content:""; }
.sort-icon { font-size:0.7rem;color:#475569;margin-left:3px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
.stat-box { text-align: center; }
@ -66,6 +74,7 @@ BASE_HTML = """<!DOCTYPE html>
<nav>
<h1> Flugpreisscanner</h1>
<a href="/" class="{{ 'active' if page == 'overview' else '' }}">Übersicht</a>
<a href="/markt" class="{{ 'active' if page == 'markt' else '' }}">🏷 Marktübersicht</a>
<a href="/quellen" class="{{ 'active' if page == 'quellen' else '' }}">Quellen</a>
<a href="/jobs" class="{{ 'active' if page == 'jobs' else '' }}">Jobs</a>
<a href="/prompts" class="{{ 'active' if page == 'prompts' else '' }}">Prompts</a>
@ -79,7 +88,7 @@ BASE_HTML = """<!DOCTYPE html>
OVERVIEW_HTML = BASE_HTML.replace("{% block content %}{% endblock %}", """
<div style="background:#0c1a3a;border:1px solid#1e40af;border-radius:8px;padding:0.6rem 1rem;margin-bottom:0.6rem;font-size:0.85rem;color:#93c5fd;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem">
<span> <strong>FRA KTI</strong> &nbsp;·&nbsp; Roundtrip &nbsp;·&nbsp; Premium Economy &nbsp;·&nbsp; 1 Koffer + Handgepäck &nbsp;·&nbsp; ~2 Monate &nbsp;·&nbsp; max 22h / 2 Stopps / Umstieg 2-5h &nbsp;·&nbsp; 🇭🇰 <strong>HKG Stopover</strong> Variante aktiv</span>
<span> <strong>FRA KTI</strong> &nbsp;·&nbsp; Roundtrip &nbsp;·&nbsp; Economy mit Gepäck &nbsp;·&nbsp; ~2 Monate &nbsp;·&nbsp; 🇭🇰 <strong>Cathay Pacific via HKG</strong> 2 Nächte Stopover</span>
<span id="schedule-info" style="font-size:0.78rem;color:#60a5fa">Lade Zeitplan...</span>
</div>
<div style="background:#451a03;border:1px solid#92400e;border-radius:8px;padding:0.5rem 1rem;margin-bottom:1.2rem;font-size:0.82rem;color:#fcd34d">
@ -89,8 +98,8 @@ OVERVIEW_HTML = BASE_HTML.replace("{% block content %}{% endblock %}", """
<div class="grid-3" style="margin-bottom:1.5rem">
<div class="card stat-box">
<div class="value" id="min-preis"></div>
<div class="label">Günstigster PE-Preis heute (EUR)</div>
<div id="min-preis-warnung" style="display:none;margin-top:0.4rem;font-size:0.75rem;color:#34d399"> KI-geprüft: nur plausible PE-Preise</div>
<div class="label">Günstigster Economy-Preis heute (EUR)</div>
<div id="min-preis-warnung" style="display:none;margin-top:0.4rem;font-size:0.75rem;color:#34d399"> KI-geprüft · Roundtrip mit Gepäck</div>
</div>
<div class="card stat-box">
<div class="value" id="avg-preis"></div>
@ -102,6 +111,22 @@ OVERVIEW_HTML = BASE_HTML.replace("{% block content %}{% endblock %}", """
</div>
</div>
<div class="card" style="margin-bottom:1.5rem;padding:0.9rem 1.5rem;display:flex;align-items:center;gap:2rem;flex-wrap:wrap">
<span style="font-size:0.8rem;color:#64748b;text-transform:uppercase;letter-spacing:0.05em">OpenRouter KI-Guthaben</span>
<span style="font-size:1.4rem;font-weight:700" id="or-guthaben"></span>
<span style="font-size:0.85rem;color:#64748b">von <span id="or-limit"></span> USD</span>
<div style="flex:1;min-width:120px;background:#0f172a;border-radius:6px;height:8px;overflow:hidden">
<div id="or-bar" style="height:100%;background:#38bdf8;border-radius:6px;width:0%;transition:width .5s"></div>
</div>
<span style="font-size:0.75rem;color:#475569">verbraucht: <span id="or-usage"></span> USD</span>
</div>
<div class="card" style="margin-bottom:1.5rem">
<h2>Wann buchen? Preis nach Buchungsvorlauf <span style="font-size:0.75rem;color:#475569;font-weight:400;text-transform:none">(Direkt vs. via HKG)</span></h2>
<div id="vorlauf-hinweis" style="color:#64748b;font-size:0.85rem;margin-bottom:0.5rem"></div>
<canvas id="vorlaufChart" height="120"></canvas>
</div>
<div class="grid-2">
<div class="card">
<h2>Preisverlauf (30 Tage)</h2>
@ -143,8 +168,27 @@ OVERVIEW_HTML = BASE_HTML.replace("{% block content %}{% endblock %}", """
<div class="card">
<h2>Alle Preise heute (Detail)</h2>
<table>
<thead><tr><th>Anbieter</th><th>Node</th><th>Preis</th><th>Plausibilität</th><th>Abflug</th><th>Rückflug</th><th>Buchen</th><th>Screenshot</th></tr></thead>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem;align-items:center">
<span style="color:#64748b;font-size:0.8rem">Filter Kabine:</span>
<button onclick="filterKabine('alle')" id="fb-alle" class="fb fb-active">Alle</button>
<button onclick="filterKabine('pe')" id="fb-pe" class="fb" style="display:none">PE</button>
<button onclick="filterKabine('eco')" id="fb-eco" class="fb"> Economy</button>
<button onclick="filterKabine('light')" id="fb-light" class="fb">🚫 Eco Light</button>
<button onclick="filterKabine('unk')" id="fb-unk" class="fb"> Unbekannt</button>
</div>
<table id="preise-table">
<thead><tr>
<th class="sortable" data-col="0">Anbieter <span class="sort-icon"></span></th>
<th class="sortable" data-col="1">Node <span class="sort-icon"></span></th>
<th class="sortable" data-col="2" data-type="num">Preis <span class="sort-icon"></span></th>
<th class="sortable" data-col="3">Kabine (KI) <span class="sort-icon"></span></th>
<th>Plausibilität</th>
<th class="sortable" data-col="5">Abflug <span class="sort-icon"></span></th>
<th>Rückflug</th>
<th class="sortable" data-col="7">Gescrapt um <span class="sort-icon"></span></th>
<th>Buchen</th>
<th>Screenshot</th>
</tr></thead>
<tbody id="preise-tbody"></tbody>
</table>
</div>
@ -204,6 +248,28 @@ async function ladeUebersicht() {
document.getElementById('avg-preis').textContent = stats.avg_30d ? Math.round(stats.avg_30d) : '';
document.getElementById('node-count').textContent = nodes.filter(n=>n.status==='online').length;
// OpenRouter Guthaben
try {
const or = await fetch('/api/openrouter/guthaben').then(r=>r.json());
if (or.fehler) {
document.getElementById('or-guthaben').textContent = 'Fehler';
document.getElementById('or-guthaben').style.color = '#f87171';
} else {
const verb = or.verbleibend != null ? or.verbleibend : '?';
const limit = or.limit || 20;
const pct = or.verbleibend != null ? Math.round((or.verbleibend / limit) * 100) : 0;
const farbe = pct > 50 ? '#34d399' : pct > 20 ? '#fbbf24' : '#f87171';
document.getElementById('or-guthaben').textContent = verb + ' USD';
document.getElementById('or-guthaben').style.color = farbe;
document.getElementById('or-limit').textContent = limit;
document.getElementById('or-usage').textContent = or.usage || '';
document.getElementById('or-bar').style.width = pct + '%';
document.getElementById('or-bar').style.background = farbe;
}
} catch(e) {
document.getElementById('or-guthaben').textContent = 'n/a';
}
if (ki.ki_empfehlung) {
const farben = {'JETZT BUCHEN':'#34d399','WARTEN':'#fbbf24','NEUTRAL':'#60a5fa'};
document.getElementById('ki-empfehlung').textContent = ki.ki_empfehlung;
@ -255,16 +321,15 @@ async function ladeUebersicht() {
}
// Detail-Tabelle
const tbody = document.getElementById('preise-tbody');
const HOTEL_HKG = 150; // geschätzte Hotel-Kosten HKG in EUR
tbody.innerHTML = preise.map(p => {
_allePreiszeilen = preise.map(p => {
const isMulticity = p.scanner === 'kayak_multicity';
// KI-Plausibilitätsstatus: 1=plausibel, 0=verdächtig, -1/null=ungeprüft
const ps = p.plausi_status !== undefined ? p.plausi_status : (p.plausibel !== undefined ? p.plausibel : -1);
const grund = p.plausi_info || p.plausi_grund || '';
let plausi;
if (ps === 1) {
plausi = `<span title="${grund}" style="background:#064e3b;color:#34d399;padding:0.15rem 0.5rem;border-radius:4px;font-size:0.75rem;cursor:help"> PE bestätigt</span>`;
plausi = `<span title="${grund}" style="background:#064e3b;color:#34d399;padding:0.15rem 0.5rem;border-radius:4px;font-size:0.75rem;cursor:help"> plausibel</span>`;
} else if (ps === 0) {
plausi = `<span title="${grund}" style="background:#7f1d1d;color:#fca5a5;padding:0.15rem 0.5rem;border-radius:4px;font-size:0.75rem;cursor:help"> ${grund.substring(0,40) || 'verdächtig'}</span>`;
} else {
@ -290,17 +355,42 @@ async function ladeUebersicht() {
const rowStyle = verdaechtig
? ' style="background:rgba(239,68,68,0.08);border-left:3px solid #ef4444;opacity:0.7"'
: (isMulticity ? ' style="background:rgba(99,102,241,0.06);border-left:3px solid #6366f1"' : '');
return `<tr${rowStyle}>
<td>${scannerLabel}</td>
<td style="font-size:0.8rem;color:#64748b">${p.node}</td>
<td>${gesamtHtml}</td>
<td>${plausi}</td>
<td style="font-size:0.85rem">${p.abflug||''}</td>
<td style="font-size:0.85rem">${p.ankunft||''}</td>
<td>${buchBtn}</td>
<td>${ssBtn}</td>
</tr>`;
}).join('') || '<tr><td colspan="8" style="color:#475569;text-align:center">Noch keine Daten heute</td></tr>';
const scrapedAt = p.scraped_at
? (() => { const d = new Date(p.scraped_at.replace(' ','T')+'Z');
return d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit',second:'2-digit',timeZone:'Europe/Berlin'}); })()
: '';
const kabine = p.kabine_erkannt || '';
const kabineBadge = (() => {
const k = (p.kabine_erkannt || '').toLowerCase();
if (k.includes('premium'))
return `<span style="background:#14532d;color:#86efac;padding:0.15rem 0.45rem;border-radius:4px;font-size:0.72rem;white-space:nowrap"> CX</span>`;
if (k.includes('light') || k.includes('basic'))
return `<span style="background:#7f1d1d;color:#fca5a5;padding:0.15rem 0.45rem;border-radius:4px;font-size:0.72rem;white-space:nowrap">🚫 Eco Light</span>`;
if (k.includes('economy'))
return `<span style="background:#78350f;color:#fcd34d;padding:0.15rem 0.45rem;border-radius:4px;font-size:0.72rem;white-space:nowrap"> Economy</span>`;
if (k.includes('business'))
return `<span style="background:#1e1b4b;color:#a5b4fc;padding:0.15rem 0.45rem;border-radius:4px;font-size:0.72rem;white-space:nowrap">💼 Business</span>`;
return `<span style="color:#475569;font-size:0.72rem"></span>`;
})();
return {
_html: `<tr${rowStyle}>
<td>${scannerLabel}</td>
<td style="font-size:0.8rem;color:#64748b">${p.node}</td>
<td>${gesamtHtml}</td>
<td>${kabineBadge}</td>
<td>${plausi}</td>
<td style="font-size:0.85rem">${p.abflug||''}</td>
<td style="font-size:0.85rem">${p.ankunft||''}</td>
<td style="font-size:0.78rem;color:#64748b;white-space:nowrap">${scrapedAt}</td>
<td>${buchBtn}</td>
<td>${ssBtn}</td>
</tr>`,
_kabine: p.kabine_erkannt || '',
_cells: [p.scanner||'', p.node||'', String(p.preis||0), p.kabine_erkannt||'', '',
p.abflug||'', p.ankunft||'', scrapedAt, '', ''],
};
});
renderPreisTabelle(_aktiveZeilen());
const ntbody = document.getElementById('nodes-tbody');
ntbody.innerHTML = nodes.map(n => `
@ -309,7 +399,7 @@ async function ladeUebersicht() {
<td>${n.last_seen||''}</td></tr>
`).join('');
// Chart
// Preisverlauf Chart (30 Tage)
const verlauf = await fetch('/api/preise/verlauf').then(r=>r.json());
const ctx = document.getElementById('priceChart').getContext('2d');
new Chart(ctx, {
@ -326,6 +416,71 @@ async function ladeUebersicht() {
scales: { x: { ticks: { color: '#64748b' }, grid: { color: '#1e293b' }},
y: { ticks: { color: '#64748b' }, grid: { color: '#1e293b' }}}}
});
// Buchungsvorlauf Chart
const vorlaufDaten = await fetch('/api/preise/vorlauf').then(r=>r.json());
const vCtx = document.getElementById('vorlaufChart').getContext('2d');
// Aufteilen in direkt und hkg
const direktMap = {}, hkgMap = {};
vorlaufDaten.forEach(d => {
if (d.typ === 'direkt') direktMap[d.tage_vorab] = d.min_preis;
else hkgMap[d.tage_vorab] = d.min_preis;
});
const alleLabels = [...new Set(vorlaufDaten.map(d=>d.tage_vorab))].sort((a,b)=>b-a);
if (alleLabels.length < 2) {
document.getElementById('vorlauf-hinweis').textContent =
'⏳ Noch zu wenig Daten — wird täglich befüllt. Erster aussagekräftiger Chart in ca. 1 Woche.';
} else {
document.getElementById('vorlauf-hinweis').textContent =
`${alleLabels.length} Vorlauf-Punkte erfasst (${alleLabels[alleLabels.length-1]}${alleLabels[0]} Tage vor Abflug)`;
}
new Chart(vCtx, {
type: 'line',
data: {
labels: alleLabels.map(t => t + 'T'),
datasets: [
{
label: 'Direkt FRA→KTI',
data: alleLabels.map(t => direktMap[t] || null),
borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.08)',
tension: 0.3, fill: false, spanGaps: true,
pointRadius: 4, pointHoverRadius: 6
},
{
label: 'Via HKG (Cathay)',
data: alleLabels.map(t => hkgMap[t] || null),
borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.08)',
tension: 0.3, fill: false, spanGaps: true,
pointRadius: 4, pointHoverRadius: 6, borderDash: [4,3]
}
]
},
options: {
plugins: {
legend: { labels: { color: '#94a3b8' }},
tooltip: {
callbacks: {
label: ctx => ctx.dataset.label + ': ' + Math.round(ctx.parsed.y) + ''
}
}
},
scales: {
x: {
reverse: true,
title: { display: true, text: '← früher buchen später buchen →', color: '#64748b' },
ticks: { color: '#64748b' }, grid: { color: '#1e293b' }
},
y: {
title: { display: true, text: 'EUR (Roundtrip)', color: '#64748b' },
ticks: { color: '#64748b' }, grid: { color: '#1e293b' }
}
}
}
});
}
async function pruefeScanStatus() {
@ -343,6 +498,73 @@ async function pruefeScanStatus() {
}
}
// Tabellen-Sortierung
let _sortCol = null, _sortAsc = true;
let _allePreiszeilen = [];
function renderPreisTabelle(zeilen) {
const tbody = document.getElementById('preise-tbody');
tbody.innerHTML = zeilen.length
? zeilen.map(z => z._html).join('')
: '<tr><td colspan="10" style="color:#475569;text-align:center">Keine Einträge für diesen Filter</td></tr>';
}
function sortiereTabelle(colIdx, isNum) {
// Header-Icons aktualisieren
document.querySelectorAll('#preise-table th.sortable').forEach(th => {
th.classList.remove('asc','desc');
th.querySelector('.sort-icon').textContent = '';
});
const th = document.querySelector(`#preise-table th[data-col="${colIdx}"]`);
if (_sortCol === colIdx) { _sortAsc = !_sortAsc; }
else { _sortCol = colIdx; _sortAsc = true; }
th.classList.add(_sortAsc ? 'asc' : 'desc');
th.querySelector('.sort-icon').textContent = '';
const aktiv = _aktiveZeilen();
aktiv.sort((a, b) => {
const va = a._cells[colIdx] || '', vb = b._cells[colIdx] || '';
if (isNum) return _sortAsc ? parseFloat(va||0)-parseFloat(vb||0) : parseFloat(vb||0)-parseFloat(va||0);
return _sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
});
renderPreisTabelle(aktiv);
}
let _kabineFilter = 'alle';
function _aktiveZeilen() {
return _allePreiszeilen.filter(z => {
const k = (z._kabine || '').toLowerCase();
if (_kabineFilter === 'pe') return k.includes('premium');
if (_kabineFilter === 'eco') return k.includes('economy') && !k.includes('light') && !k.includes('premium');
if (_kabineFilter === 'light') return k.includes('light') || k.includes('basic');
if (_kabineFilter === 'unk') return !k || k === 'unbekannt' || k === 'fehler';
return true;
});
}
function filterKabine(typ) {
_kabineFilter = typ;
document.querySelectorAll('.fb').forEach(b => b.classList.remove('fb-active'));
document.getElementById('fb-' + typ).classList.add('fb-active');
_sortCol = null; _sortAsc = true;
document.querySelectorAll('#preise-table th.sortable').forEach(th => {
th.classList.remove('asc','desc');
const si = th.querySelector('.sort-icon');
if (si) si.textContent = '';
});
renderPreisTabelle(_aktiveZeilen());
}
// Sortier-Listener auf Header setzen (einmalig nach DOM-ready)
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#preise-table th.sortable').forEach(th => {
th.addEventListener('click', () => {
sortiereTabelle(parseInt(th.dataset.col), th.dataset.type === 'num');
});
});
});
//
function zeigeScreenshot(id, label) {
const modal = document.getElementById('ss-modal');
const img = document.getElementById('ss-img');
@ -416,6 +638,7 @@ def api_preise_heute():
(SELECT MAX(scraped_at) FROM prices WHERE date(scraped_at) = date('now')),
'-20 minutes'
)
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
ORDER BY preis ASC LIMIT 100
""").fetchall()
if not rows:
@ -424,6 +647,7 @@ def api_preise_heute():
COALESCE(plausi_grund, '') as plausi_info
FROM prices
WHERE date(scraped_at) = date('now')
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
ORDER BY preis ASC LIMIT 100
""").fetchall()
conn.close()
@ -444,6 +668,7 @@ def api_preise_vergleich():
'-20 minutes'
)
AND (plausibel = 1 OR plausibel IS NULL)
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
GROUP BY scanner, node
ORDER BY scanner, preis
""").fetchall()
@ -453,6 +678,7 @@ def api_preise_vergleich():
FROM prices
WHERE date(scraped_at) = date('now')
AND (plausibel = 1 OR plausibel IS NULL)
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
GROUP BY scanner, node
ORDER BY scanner, preis
""").fetchall()
@ -493,6 +719,7 @@ def api_preise_verlauf():
rows = conn.execute("""
SELECT date(scraped_at) as tag, MIN(preis) as min_preis, AVG(preis) as avg_preis
FROM prices WHERE scraped_at >= datetime('now','-30 days')
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
GROUP BY date(scraped_at) ORDER BY tag
""").fetchall()
conn.close()
@ -629,8 +856,239 @@ def api_screenshot(screenshot_id):
headers={"Cache-Control": "public, max-age=3600"})
MARKT_HTML = """
<!-- Screenshot Lightbox (geteilt) -->
<div id="m-ss-modal" onclick="this.style.display='none'"
style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.93);z-index:9999;overflow:auto;cursor:zoom-out">
<div style="text-align:center;padding:1.5rem">
<button onclick="document.getElementById('m-ss-modal').style.display='none'"
style="background:#ef4444;color:white;border:none;padding:0.4rem 1.2rem;
border-radius:6px;cursor:pointer;margin-bottom:0.8rem">✕ Schließen</button>
<div id="m-ss-label" style="color:#94a3b8;font-size:0.85rem;margin-bottom:0.5rem"></div>
<img id="m-ss-img" src="" style="max-width:100%;border-radius:8px;
box-shadow:0 0 40px rgba(0,0,0,0.8)" onclick="event.stopPropagation()">
</div>
</div>
<div class="card" style="margin-bottom:1rem">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2 style="margin:0">🏷 Marktübersicht letzte 24h</h2>
<span id="markt-stand" style="font-size:0.8rem;color:#475569"></span>
</div>
<p style="color:#64748b;font-size:0.85rem;margin:0.4rem 0 0">
Top-3 günstigste Angebote pro Kabinenklasse · mit Screenshot zur direkten Verifikation
</p>
</div>
<div id="markt-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1.2rem;margin-bottom:1.5rem">
<div id="col-light" class="markt-col"></div>
<div id="col-eco" class="markt-col"></div>
<div id="col-pe" class="markt-col"></div>
</div>
<div class="card">
<h2>Preisdifferenz-Analyse</h2>
<div id="diff-analyse" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"></div>
</div>
<style>
.markt-col { background:#0f172a;border:1px solid #1e293b;border-radius:12px;overflow:hidden; }
.markt-header { padding:1rem 1.2rem 0.8rem;border-bottom:1px solid #1e293b; }
.markt-preis-card { padding:0.9rem 1.2rem;border-bottom:1px solid #0f172a;transition:background .15s; }
.markt-preis-card:hover { background:rgba(255,255,255,0.03); }
.markt-preis-card:last-child { border-bottom:none; }
.markt-empty { padding:2rem;text-align:center;color:#334155;font-size:0.85rem; }
.ss-verify-btn { background:#1e3a5f;border:1px solid #2563eb;color:#93c5fd;
padding:0.2rem 0.5rem;border-radius:5px;cursor:pointer;
font-size:0.75rem;margin-left:4px; }
.ss-verify-btn:hover { background:#1d4ed8; }
.rank-badge { display:inline-block;width:20px;height:20px;border-radius:50%;
text-align:center;line-height:20px;font-size:0.7rem;font-weight:700;
margin-right:6px; }
</style>
<script>
function zeigeMarktScreenshot(id, label) {
document.getElementById('m-ss-modal').style.display = 'block';
document.getElementById('m-ss-label').textContent = label;
const img = document.getElementById('m-ss-img');
img.src = '';
img.src = '/api/screenshot/' + id;
}
function preisKarte(p, rank) {
const rankColors = ['#fbbf24','#94a3b8','#b45309'];
const rankLabels = ['1','2','3'];
const airline = p.airline || 'k.A.';
const abflug = p.abflug || '';
const seit = p.scraped_at ? (() => {
const diff = Math.round((Date.now() - new Date(p.scraped_at.replace(' ','T')+'Z')) / 60000);
return diff < 60 ? `vor ${diff} Min` : `vor ${Math.floor(diff/60)}h`;
})() : '';
const ssBtn = p.screenshot_id
? `<button class="ss-verify-btn" onclick="zeigeMarktScreenshot(${p.screenshot_id},'${p.scanner} · ${p.kabine_erkannt} · ${abflug}')">📷 Prüfen</button>`
: '<span style="color:#334155;font-size:0.72rem">kein SS</span>';
const buchBtn = p.booking_url
? `<a href="${p.booking_url}" target="_blank"
style="background:#059669;color:white;padding:0.2rem 0.6rem;border-radius:5px;
font-size:0.75rem;text-decoration:none;margin-left:4px">↗ Buchen</a>`
: '';
const plausiIcon = p.plausibel === 1 ? '' : p.plausibel === 0 ? '' : '';
const plausiColor = p.plausibel === 1 ? '#34d399' : p.plausibel === 0 ? '#f87171' : '#fbbf24';
return `<div class="markt-preis-card">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<span class="rank-badge" style="background:${rankColors[rank]};color:#0f172a">${rankLabels[rank]}</span>
<strong style="font-size:1.15rem;color:#f1f5f9">${p.preis} </strong>
<span style="color:${plausiColor};font-size:0.72rem;margin-left:4px">${plausiIcon}</span>
</div>
<div style="text-align:right;font-size:0.72rem;color:#64748b">${seit}</div>
</div>
<div style="margin-top:0.4rem;font-size:0.8rem;color:#94a3b8">
<span style="color:#e2e8f0">${p.scanner}</span>
· ${p.node.replace('flugscanner-','')}
· ${airline}
</div>
<div style="margin-top:0.25rem;font-size:0.78rem;color:#64748b">
Abflug: <span style="color:#cbd5e1">${abflug}</span>
${p.ankunft ? `· Rück: <span style="color:#cbd5e1">${p.ankunft}</span>` : ''}
</div>
<div style="margin-top:0.5rem;display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${ssBtn}${buchBtn}
</div>
</div>`;
}
function marktKolumne(kabine, id, farbe, icon, beschreibung, eintraege) {
const header = `<div class="markt-header">
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:1rem;font-weight:700;color:${farbe}">${icon} ${kabine}</span>
<span style="font-size:0.72rem;background:${farbe}22;color:${farbe};
padding:0.1rem 0.5rem;border-radius:10px">${eintraege.length} Treffer</span>
</div>
<div style="font-size:0.75rem;color:#475569;margin-top:0.2rem">${beschreibung}</div>
${eintraege.length > 0
? `<div style="font-size:1.6rem;font-weight:800;color:${farbe};margin-top:0.5rem">
${eintraege[0].preis}
<span style="font-size:0.8rem;font-weight:400;color:#475569">günstigster</span>
</div>`
: ''}
</div>`;
const karten = eintraege.length > 0
? eintraege.map((p, i) => preisKarte(p, i)).join('')
: `<div class="markt-empty">Keine Daten letzte 24h</div>`;
return header + karten;
}
function diffAnalyse(daten) {
const pe = daten['Premium Economy']?.[0]?.preis;
const eco = daten['Economy']?.[0]?.preis;
const light = daten['Economy Light']?.[0]?.preis;
const cards = [];
if (pe && eco) {
const diff = Math.round(pe - eco);
cards.push(`<div class="card" style="margin:0">
<div style="font-size:0.8rem;color:#64748b">PE vs. Economy</div>
<div style="font-size:1.4rem;font-weight:700;color:#f59e0b">+${diff} </div>
<div style="font-size:0.78rem;color:#94a3b8">Aufpreis für breiteren Sitz + mehr Service</div>
</div>`);
}
if (pe && light) {
const diff = Math.round(pe - light);
cards.push(`<div class="card" style="margin:0">
<div style="font-size:0.8rem;color:#64748b">PE vs. Economy Light</div>
<div style="font-size:1.4rem;font-weight:700;color:#f59e0b">+${diff} </div>
<div style="font-size:0.78rem;color:#94a3b8">Inkl. Koffer (~50-100) + PE-Komfort</div>
</div>`);
}
if (eco && light) {
const diff = Math.round(eco - light);
cards.push(`<div class="card" style="margin:0">
<div style="font-size:0.8rem;color:#64748b">Economy vs. Economy Light</div>
<div style="font-size:1.4rem;font-weight:700;color:#60a5fa">+${diff} </div>
<div style="font-size:0.78rem;color:#94a3b8">Aufpreis für Koffer inklusive</div>
</div>`);
}
return cards.join('');
}
async function ladenMarkt() {
const daten = await fetch('/api/markt').then(r => r.json());
document.getElementById('col-light').innerHTML = marktKolumne(
'Economy Light', 'col-light', '#f87171', '🚫',
'Nur Handgepäck · kein Koffer inklusive',
daten['Economy Light'] || []
);
document.getElementById('col-eco').innerHTML = marktKolumne(
'Economy', 'col-eco', '#fbbf24', '',
'Koffer inklusive · Standard-Sitz',
daten['Economy'] || []
);
document.getElementById('col-pe').innerHTML = marktKolumne(
'Premium Economy', 'col-pe', '#34d399', '',
'Breiterer Sitz · mehr Beinfreiheit · Koffer inkl.',
daten['Premium Economy'] || []
);
document.getElementById('diff-analyse').innerHTML = diffAnalyse(daten);
const stats = daten['_stats'] || [];
const total = stats.reduce((s, r) => s + r.n, 0);
document.getElementById('markt-stand').textContent =
`${total} Scans letzte 24h · Stand: ${new Date().toLocaleTimeString('de-DE')}`;
}
ladenMarkt();
setInterval(ladenMarkt, 60000);
</script>
"""
# ─── Marktübersicht API ────────────────────────────────────────────────────────
@app.route("/api/markt")
def api_markt():
"""Top-3 günstigste Preise pro Kabinenklasse aus den letzten 24h."""
conn = get_conn()
result = {}
for kabine in ("Economy Light", "Economy", "Premium Economy"):
rows = conn.execute("""
SELECT p.id, p.preis, p.scanner, p.node, p.airline,
p.abflug, p.ankunft, p.booking_url, p.scraped_at,
p.screenshot_id, p.plausibel, p.kabine_erkannt
FROM prices p
WHERE p.kabine_erkannt = ?
AND p.scraped_at >= datetime('now', '-24 hours')
ORDER BY p.preis ASC
LIMIT 3
""", (kabine,)).fetchall()
result[kabine] = [dict(r) for r in rows]
# Gesamtstatistik letzte 24h
stats = conn.execute("""
SELECT kabine_erkannt, COUNT(*) as n, MIN(preis) as min_p
FROM prices
WHERE scraped_at >= datetime('now', '-24 hours')
AND kabine_erkannt IS NOT NULL
GROUP BY kabine_erkannt
""").fetchall()
result["_stats"] = [dict(r) for r in stats]
conn.close()
return jsonify(result)
# ─── Seiten ────────────────────────────────────────────────────────────────────
@app.route("/markt")
def markt():
return render_template_string(BASE_HTML.replace(
"{% block content %}{% endblock %}", MARKT_HTML
), page="markt")
@app.route("/")
def overview():
return render_template_string(OVERVIEW_HTML, page="overview")
@ -791,6 +1249,41 @@ setInterval(laden, 10000);
return render_template_string(html, page="logs")
# ─── OpenRouter Guthaben ───────────────────────────────────────────────────────
@app.route("/api/openrouter/guthaben")
def api_openrouter_guthaben():
from ki import get_openrouter_guthaben
return jsonify(get_openrouter_guthaben())
# ─── Buchungsvorlauf-Kurve ─────────────────────────────────────────────────────
@app.route("/api/preise/vorlauf")
def api_preise_vorlauf():
"""
Gibt pro Vorlauf-Fenster (tage_vorab) den günstigsten Preis zurück.
Separat für direkte Routen und Multicity (via HKG).
"""
conn = get_conn()
rows = conn.execute("""
SELECT
CAST(julianday(abflug) - julianday(date(scraped_at)) AS INT) as tage_vorab,
CASE WHEN scanner = 'kayak_multicity' THEN 'hkg' ELSE 'direkt' END as typ,
MIN(preis) as min_preis,
COUNT(*) as n
FROM prices
WHERE abflug != ''
AND (kabine_erkannt != 'Premium Economy' OR kabine_erkannt IS NULL)
AND plausibel != 0
GROUP BY tage_vorab, typ
HAVING tage_vorab BETWEEN 7 AND 100
ORDER BY tage_vorab DESC
""").fetchall()
conn.close()
return jsonify([dict(r) for r in rows])
# ─── Start ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":

View file

@ -1 +0,0 @@
NODE_NAME=flugscanner-asia

View file

@ -1 +0,0 @@
NODE_NAME=flugscanner-mu

View file

@ -1,2 +1,2 @@
flask==3.1.0
seleniumbase==4.34.4
seleniumbase>=4.46.5

View file

@ -53,7 +53,7 @@ def scrape(scanner, von, nach, tage=30, aufenthalt_tage=60,
screenshot_b64 = JPEG Full-Page Screenshot als base64-String (leer wenn Fehler)
"""
dispatcher = {
"google_flights": _scrape_disabled,
"google_flights": scrape_google_flights,
"kayak": scrape_kayak,
"kayak_multicity": scrape_kayak_multicity,
"momondo": scrape_momondo,
@ -96,23 +96,14 @@ def _take_screenshot(sb):
def _booking_url_google(von, nach, abflug, rueck, kc):
# Hash-Fragment wird von headless Chrome ignoriert → tfs-Parameter nutzen
if rueck:
return (f"https://www.google.com/travel/flights?hl=de&curr=EUR"
return (f"https://www.google.com/travel/flights?hl=en&curr=EUR"
f"#flt={von}.{nach}.{abflug}*{nach}.{von}.{rueck};c:EUR;e:1;sd:1;t:r;sc:{kc}")
return (f"https://www.google.com/travel/flights?hl=de&curr=EUR"
return (f"https://www.google.com/travel/flights?hl=en&curr=EUR"
f"#flt={von}.{nach}.{abflug};c:EUR;e:1;sd:1;t:f;sc:{kc}")
def _booking_url_kayak(von, nach, abflug, rueck, kc, bags=1,
layover_min=120, layover_max=300, airline="",
max_flugzeit_h=22, max_stops=2):
"""
Kayak fs-Filter:
bfc=1 min. 1 Freigepäck inklusive
ctr=120,300 Umstiegszeit 25 Stunden (Minuten)
duration=-1320 Max. Gesamtflugzeit (Minuten, hier 22h)
s=2 Max. 2 Stopps
airlines=XX Airline-Code (CZ, CX, SQ, TG )
"""
def _kayak_filters(bags, layover_min, layover_max, max_flugzeit_h, max_stops, airline):
"""Gemeinsame Filter-Logik für alle Kayak-URL-Funktionen."""
filters = []
if bags:
filters.append(f"bfc%3D{bags}")
@ -124,13 +115,47 @@ def _booking_url_kayak(von, nach, abflug, rueck, kc, bags=1,
filters.append(f"s%3D{max_stops}")
if airline:
filters.append(f"airlines%3D{airline}")
fs = ("&fs=" + "%3B".join(filters)) if filters else ""
return ("&fs=" + "%3B".join(filters)) if filters else ""
def _scrape_url_kayak(von, nach, abflug, rueck, kc, bags=1,
layover_min=120, layover_max=300, airline="",
max_flugzeit_h=22, max_stops=2):
"""Interne Scraping-URL (kayak.de — bekannte HTML-Struktur)."""
fs = _kayak_filters(bags, layover_min, layover_max, max_flugzeit_h, max_stops, airline)
base = f"https://www.kayak.de/flights/{von}-{nach}/{abflug}"
if rueck:
return f"{base}/{rueck}?sort=price_a&cabin={kc}&currency=EUR{fs}"
return f"{base}?sort=price_a&cabin={kc}&currency=EUR{fs}"
def _booking_url_kayak(von, nach, abflug, rueck, kc, bags=1,
layover_min=120, layover_max=300, airline="",
max_flugzeit_h=22, max_stops=2):
"""User-facing Booking-URL (kayak.com international, kein DE-Aufschlag)."""
fs = _kayak_filters(bags, layover_min, layover_max, max_flugzeit_h, max_stops, airline)
base = f"https://www.kayak.com/flights/{von}-{nach}/{abflug}"
if rueck:
return f"{base}/{rueck}?sort=price_a&cabin={kc}&currency=EUR{fs}"
return f"{base}?sort=price_a&cabin={kc}&currency=EUR{fs}"
def _consent_kayak(sb):
"""Kayak/Momondo GDPR-Consent wegklicken."""
for sel in ['#didomi-notice-agree-button', 'button[class*="accept"]',
'button[class*="agree"]', '[data-testid*="accept"]',
'button[id*="accept"]', '.RxNS-button-content',
'button[aria-label*="akzeptieren"]', 'button[aria-label*="Alle"]']:
try:
sb.find_element(sel, timeout=2).click()
print(f"[CONSENT] Kayak Consent geklickt: {sel}")
sb.sleep(3)
return True
except Exception:
pass
return False
def _booking_url_momondo(von, nach, abflug, rueck, kc, bags=1,
layover_min=120, layover_max=300, airline="",
max_flugzeit_h=22, max_stops=2):
@ -147,12 +172,28 @@ def _booking_url_momondo(von, nach, abflug, rueck, kc, bags=1,
if airline:
filters.append(f"airlines%3D{airline}")
fs = ("&fs=" + "%3B".join(filters)) if filters else ""
base = f"https://www.momondo.de/flight-search/{von}-{nach}/{abflug}"
base = f"https://www.momondo.com/flight-search/{von}-{nach}/{abflug}"
if rueck:
return f"{base}/{rueck}?sort=price_a&cabin={kc}&currency=EUR{fs}"
return f"{base}?sort=price_a&cabin={kc}&currency=EUR{fs}"
def _scrape_url_momondo(von, nach, abflug, rueck, kc, bags=1,
layover_min=120, layover_max=300, airline="",
max_flugzeit_h=22, max_stops=2):
filters = []
if bags: filters.append(f"bfc%3D{bags}")
if layover_min and layover_max: filters.append(f"ctr%3D{layover_min}%2C{layover_max}")
if max_flugzeit_h: filters.append(f"duration%3D-{max_flugzeit_h * 60}")
if max_stops is not None and max_stops < 10: filters.append(f"s%3D{max_stops}")
if airline: filters.append(f"airlines%3D{airline}")
fs = ("&fs=" + "%3B".join(filters)) if filters else ""
base = f"https://www.momondo.de/flight-search/{von}-{nach}/{abflug}"
if rueck:
return f"{base}/{rueck}?sort=price_a&cabin={kc}&currency=EUR{fs}"
return f"{base}?sort=price_a&cabin={kc}&currency=EUR{fs}"
def _booking_url_trip(von, nach, abflug_fmt, rueck_fmt, kc, von_name, nach_name):
if rueck_fmt:
return (f"https://www.trip.com/flights/{von_name}-to-{nach_name}/"
@ -189,8 +230,10 @@ def _parse_preis(text):
def _preise_aus_body(body, scanner, abflug):
results = []
seen = set()
for m in re.finditer(r'(\d[\d\s\.]{1,5})\s?€|€\s?(\d[\d\s\.]{1,5})', body):
raw = (m.group(1) or m.group(2)).replace(' ', '').replace('.', '')
# Normalisierung: thin/non-breaking spaces → reguläre Leerzeichen
body_norm = body.replace('\xa0', ' ').replace('\u202f', ' ').replace('\u00a0', ' ')
for m in re.finditer(r'(\d{1,2}[.,]\d{3}|\d[\d\s\.]{1,5})\s?€|€\s?(\d[\d\s\.]{1,5})', body_norm):
raw = (m.group(1) or m.group(2)).strip().replace(' ', '').replace('.', '').replace(',', '')
try:
v = float(raw)
if 300 < v < 12000 and v not in seen:
@ -207,10 +250,13 @@ def _preise_aus_body(body, scanner, abflug):
def _consent_google(sb):
"""Google Consent-Seite (DSGVO) behandeln."""
if "consent" in sb.get_current_url() or "Bevor Sie" in sb.get_title():
title = sb.get_title()
url = sb.get_current_url()
if "consent" in url or "Bevor Sie" in title or "Before you" in title:
print("[CONSENT] Google Consent erkannt")
for sel in ['form[action*="save"] button', 'button[jsname="tHlp8d"]',
'.lssxud button', 'button[aria-label*="kzeptieren"]']:
'.lssxud button', 'button[aria-label*="kzeptieren"]',
'button[aria-label*="Accept all"]', 'button[aria-label*="Accept"]']:
try:
sb.click(sel, timeout=3)
sb.sleep(4)
@ -277,52 +323,66 @@ def scrape_google_flights(von, nach, tage=30, aufenthalt_tage=60,
print(f"[GF] Suche: {von_name}{nach_name} {abflug_de}")
with SB(uc=True, headless=True, chromium_arg="--no-sandbox --disable-dev-shm-usage") as sb:
# ── Strategie 1: Direkte URL mit Datums-Parametern ─────────────────
# Google Flights verarbeitet den Hash-Fragment erst nach JS-Ausführung
# Hash-Fragment URL (wird nach Consent-Redirect verloren — daher 2-Schritt)
direct_url = (
f"https://www.google.com/travel/flights?hl=de&curr=EUR"
f"https://www.google.com/travel/flights?hl=en&curr=EUR"
f"#flt={von}.{nach}.{abflug}*{nach}.{von}.{rueck}"
f";c:EUR;e:1;sd:1;t:r;sc:w"
f";c:EUR;e:1;sd:1;t:r;sc:e"
) if rueck else (
f"https://www.google.com/travel/flights?hl=de&curr=EUR"
f"#flt={von}.{nach}.{abflug};c:EUR;e:1;sd:1;t:f;sc:w"
f"https://www.google.com/travel/flights?hl=en&curr=EUR"
f"#flt={von}.{nach}.{abflug};c:EUR;e:1;sd:1;t:f;sc:e"
)
# ── Schritt 1: Consent zuerst auf der Basis-URL akzeptieren ─────────
# Consent-Redirect von consent.google.com strippt den #-Fragment.
# Lösung: Consent einmal auf Basisseite akzeptieren, dann Hash-URL öffnen.
sb.open("https://www.google.com/travel/flights?hl=en&curr=EUR")
sb.sleep(6)
consented = _consent_google(sb)
if consented:
print("[GF] Consent akzeptiert — öffne jetzt Hash-URL")
sb.sleep(3)
# ── Schritt 2: Jetzt Hash-URL mit Suchparametern öffnen ─────────────
sb.open(direct_url)
sb.sleep(8)
_consent_google(sb)
sb.sleep(3)
sb.sleep(12)
title_direct = sb.get_title()
print(f"[GF] URL-Ansatz: {title_direct[:60]}")
url_now = sb.get_current_url()
print(f"[GF] Titel: {title_direct[:60]}")
print(f"[GF] URL: {url_now[:80]}")
# Wenn direkte URL Ergebnisse liefert (Titel enthält Städtenamen)
# Wenn Hash-Deeplink Ergebnisse liefert
url_erfolgreich = any(kw in title_direct for kw in
[von, nach, "FRA", "KTI", "Frankfurt", "Phnom", "Flüge"])
[von, nach, "FRA", "KTI", "Frankfurt", "Phnom", "Flights to", "Flüge"])
if not url_erfolgreich:
# ── Strategie 2: Startseite + Formular befüllen ─────────────────
print("[GF] Direktlink kein Ergebnis — wechsle zu Formular-Ansatz")
sb.open("https://www.google.com/travel/flights?hl=de&curr=EUR")
sb.sleep(5)
_consent_google(sb)
sb.sleep(2)
# ── Fallback: Formular manuell befüllen ─────────────────────────
print("[GF] Hash-URL kein Ergebnis — wechsle zu Formular-Ansatz")
sb.open("https://www.google.com/travel/flights?hl=en&curr=EUR")
sb.sleep(4)
# ── 1. Kabine auf "Premium Economy" setzen ──────────────────────────
# ── 1. Kabine auf Economy setzen (Standard — meist schon vorausgewählt) ──
# Economy = data-value="1" in Google Flights Dropdown
# Nur klicken falls aktuell etwas anderes ausgewählt ist
try:
# VfPpkd-Buttons: [0]=Hin+Rück [1]=Economy(Klasse)
btns = sb.find_elements('button[class*="VfPpkd"]')
if len(btns) >= 2:
btns[1].click()
sb.sleep(1)
# Option "Premium Economy" im Dropdown auswählen
for opt_sel in ['[data-value="2"]',
'li[class*="premium"]',
'[role="option"]:nth-child(3)']:
try:
sb.find_element(opt_sel, timeout=2).click()
sb.sleep(0.5)
print(f"[GF] Kabine gesetzt via {opt_sel}")
break
except Exception:
pass
cabin_btn = btns[1]
cabin_text = cabin_btn.text.lower()
if "economy" not in cabin_text or "premium" in cabin_text:
cabin_btn.click()
sb.sleep(1)
for opt_sel in ['[data-value="1"]',
'li[class*="economy"]:first-child',
'[role="option"]:nth-child(2)']:
try:
sb.find_element(opt_sel, timeout=2).click()
sb.sleep(0.5)
print(f"[GF] Economy gesetzt via {opt_sel}")
break
except Exception:
pass
else:
print("[GF] Economy bereits ausgewählt")
except Exception as e:
print(f"[GF] Kabine: {e}")
@ -505,23 +565,29 @@ def scrape_kayak(von, nach, tage=30, aufenthalt_tage=60,
rueck = (datetime.now() + timedelta(days=tage + aufenthalt_tage)).strftime("%Y-%m-%d") if trip_type == "roundtrip" else ""
kc = KABINE_KAYAK.get(kabine, "w")
bags = 1 if "koffer" in gepaeck else 0
scrape_url = _scrape_url_kayak(von, nach, abflug, rueck, kc, bags,
layover_min, layover_max, airline_filter,
max_flugzeit_h, max_stops)
booking_url = _booking_url_kayak(von, nach, abflug, rueck, kc, bags,
layover_min, layover_max, airline_filter,
max_flugzeit_h, max_stops)
airline_label = f" [{airline_filter}]" if airline_filter else ""
print(f"[KY{airline_label}] URL: {booking_url}")
print(f"[KY{airline_label}] Scrape: {scrape_url[:80]}")
results = []
with SB(uc=True, headless=True, chromium_arg="--no-sandbox --disable-dev-shm-usage") as sb:
sb.open(booking_url)
sb.open(scrape_url)
sb.sleep(8)
_consent_kayak(sb)
sb.sleep(15)
title = sb.get_title()
body = sb.get_text("body")
print(f"[KY] Title: {title[:80]}")
for sel in ['.price-text', '.f8F1-price-text', 'div[class*="price"] span',
for sel in ['div[class*="hYzH-price"]', 'div[class*="e2GB-price-text"]',
'.price-text', '.f8F1-price-text', 'div[class*="price"] span',
'span[class*="price"]', '.Iqt3', 'div.nrc6-price', '.price']:
try:
elems = sb.find_elements(sel, timeout=2)
@ -630,8 +696,15 @@ def scrape_trip(von, nach, tage=30, aufenthalt_tage=60,
def _booking_url_kayak_multicity(von, nach, via, abflug, via_datum, rueck, kc, bags=1, airline=""):
"""
Kayak Multi-City URL: FRAHKG/DATE1 HKGKTI/DATE2 KTIFRA/DATE3
Kabinen-Code: w=Premium Economy
Bei CX: direkt auf cathaypacific.com verlinken (günstiger, keine Aufschläge).
"""
if airline.upper() == "CX":
# Google Flights Multi-City mit CX-Filter — präziser Deeplink, kein Aufschlag
return (
f"https://www.google.com/travel/flights?hl=en&curr=EUR"
f"#flt={von}.{via}.{abflug}*{via}.{nach}.{via_datum}*{nach}.{von}.{rueck}"
f";c:EUR;e:1;sd:1;t:m;a:CX"
)
filters = []
if bags:
filters.append(f"bfc%3D{bags}")
@ -639,13 +712,25 @@ def _booking_url_kayak_multicity(von, nach, via, abflug, via_datum, rueck, kc, b
filters.append(f"airlines%3D{airline}")
fs = ("&fs=" + "%3B".join(filters)) if filters else ""
# Kayak Multi-City Format: /flights/FRA-HKG/DATE/HKG-KTI/DATE/KTI-FRA/DATE
return (f"https://www.kayak.de/flights"
return (f"https://www.kayak.com/flights"
f"/{von}-{via}/{abflug}"
f"/{via}-{nach}/{via_datum}"
f"/{nach}-{von}/{rueck}"
f"?sort=price_a&cabin={kc}&currency=EUR{fs}")
def _scrape_url_kayak_multicity(von, nach, via, abflug, via_datum, rueck, kc, bags=1, airline=""):
filters = []
if bags: filters.append(f"bfc%3D{bags}")
if airline: filters.append(f"airlines%3D{airline}")
fs = ("&fs=" + "%3B".join(filters)) if filters else ""
return (f"https://www.kayak.de/flights"
f"/{von}-{via}/{abflug}"
f"/{via}-{nach}/{via_datum}"
f"/{nach}-{von}/{rueck}"
f"?sort=price_a&cabin={kc}&currency=EUR{fs}")
def scrape_kayak_multicity(von, nach, tage=30, aufenthalt_tage=60,
kabine="premium_economy",
gepaeck="1koffer+handgepaeck",
@ -662,23 +747,28 @@ def scrape_kayak_multicity(von, nach, tage=30, aufenthalt_tage=60,
bags = 1 if "koffer" in gepaeck else 0
airline_label = f" [{airline_filter}]" if airline_filter else ""
scrape_url = _scrape_url_kayak_multicity(von, nach, via, abflug, via_datum, rueck,
kc, bags, airline_filter)
booking_url = _booking_url_kayak_multicity(von, nach, via, abflug, via_datum, rueck,
kc, bags, airline_filter)
kc, bags, airline_filter)
print(f"[MC{airline_label}] Multi-City via {via}: {abflug} → +1T → {rueck}")
print(f"[MC{airline_label}] URL: {booking_url}")
print(f"[MC{airline_label}] Scrape: {scrape_url[:80]}")
results = []
with SB(uc=True, headless=True, chromium_arg="--no-sandbox --disable-dev-shm-usage") as sb:
sb.open(booking_url)
sb.open(scrape_url)
sb.sleep(8)
_consent_kayak(sb)
sb.sleep(15)
title = sb.get_title()
body = sb.get_text("body")
print(f"[MC] Title: {title[:80]}")
for sel in ['.price-text', '.f8F1-price-text', 'div[class*="price"] span',
for sel in ['div[class*="hYzH-price"]', 'div[class*="e2GB-price-text"]',
'.price-text', '.f8F1-price-text', 'div[class*="price"] span',
'span[class*="price"]', '.Iqt3', 'div.nrc6-price', '.price']:
try:
elems = sb.find_elements(sel, timeout=2)
@ -725,38 +815,32 @@ def scrape_momondo(von, nach, tage=30, aufenthalt_tage=60,
if trip_type == "roundtrip" else ""
kc = KABINE_KAYAK.get(kabine, "w")
bags = 1 if "koffer" in gepaeck else 0
scrape_url = _scrape_url_momondo(von, nach, abflug, rueck, kc, bags,
layover_min, layover_max, airline_filter,
max_flugzeit_h, max_stops)
booking_url = _booking_url_momondo(von, nach, abflug, rueck, kc, bags,
layover_min, layover_max, airline_filter,
max_flugzeit_h, max_stops)
airline_label = f" [{airline_filter}]" if airline_filter else ""
print(f"[MO{airline_label}] URL: {booking_url}")
print(f"[MO{airline_label}] Scrape: {scrape_url[:80]}")
results = []
screenshot_b64 = ""
with SB(uc=True, headless=True, chromium_arg="--no-sandbox --disable-dev-shm-usage") as sb:
sb.open(booking_url)
sb.open(scrape_url)
sb.sleep(8)
_consent_kayak(sb)
# Momondo Cookie-Consent wegklicken
for sel in ['button[class*="accept"]', '.RxNS-button-content',
'#onetrust-accept-btn-handler', 'button[title*="akzeptieren"]',
'button[title*="Alle akzeptieren"]', '.evidon-banner-acceptbutton']:
try:
sb.find_element(sel, timeout=2).click()
print(f"[MO] Consent geklickt: {sel}")
sb.sleep(3)
break
except Exception:
pass
# Nach Consent: Seite muss neu laden / Ergebnisse warten
# Nach Consent: Ergebnisse laden lassen
sb.sleep(12)
title = sb.get_title()
body = sb.get_text("body")
print(f"[MO] Title: {title[:80]} | Body: {len(body)} chars")
for sel in ['.price-text', '.f8F1-price-text', 'div[class*="price"] span',
for sel in ['div[class*="hYzH-price"]', 'div[class*="e2GB-price-text"]',
'div[class*="ixMA-price"]',
'.price-text', '.f8F1-price-text', 'div[class*="price"] span',
'span[class*="price"]', '.Iqt3', 'div.nrc6-price', '.price',
'[class*="resultPrice"]', '.lowest-price']:
try: