feat: HKG Stopover Variante — Multi-City FRA→HKG→KTI

- db.py: jobs Tabelle um via/stopover_min_h/stopover_max_h erweitert
- db.py: 2 neue kayak_multicity Jobs (CX + alle Airlines) via HKG
- db.py: KI-Prompt um HKG_STOPOVER Vergleichsfeld ergänzt
- worker.py: scrape_kayak_multicity() Funktion implementiert
- worker.py: Kayak Multi-City URL FRA→HKG/D1→HKG→KTI/D2→KTI→FRA/D3
- agent.py: via/stopover_min_h/stopover_max_h Parameter entgegengenommen
- scheduler.py: neue Parameter an Node-Agent weitergegeben
- web.py: Multi-City Einträge im Dashboard lila hervorgehoben (+~150€ Hotel-Hinweis)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Cursor 2026-02-25 16:59:53 +07:00
parent 639e6a4fc3
commit 2ce7d02bc5
5 changed files with 156 additions and 24 deletions

View file

@ -42,6 +42,9 @@ def init_db():
layover_max INTEGER DEFAULT 300,
max_flugzeit_h INTEGER DEFAULT 22,
max_stops INTEGER DEFAULT 2,
via TEXT DEFAULT '',
stopover_min_h INTEGER DEFAULT 0,
stopover_max_h INTEGER DEFAULT 0,
intervall TEXT DEFAULT 'daily',
aktiv INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
@ -57,6 +60,9 @@ def init_db():
"ALTER TABLE jobs ADD COLUMN layover_max INTEGER DEFAULT 300",
"ALTER TABLE jobs ADD COLUMN max_flugzeit_h INTEGER DEFAULT 22",
"ALTER TABLE jobs ADD COLUMN max_stops INTEGER DEFAULT 2",
"ALTER TABLE jobs ADD COLUMN via TEXT DEFAULT ''",
"ALTER TABLE jobs ADD COLUMN stopover_min_h INTEGER DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN stopover_max_h INTEGER DEFAULT 0",
]:
try:
c.execute(col_sql)
@ -150,7 +156,8 @@ BESTER_PREIS: [Anbieter + Airline + Preis + Node]
BESTE_AIRLINE: [welche der 4 Airlines gerade am guenstigsten]
TREND: [STEIGEND / FALLEND / STABIL]
GEO_UNTERSCHIED: [DE-Scanner vs. KH-Scanner Preisdifferenz]
PLAUSI_CHECK: [Preise unter 1000 EUR einzeln einordnen - was stimmt da nicht]"""
PLAUSI_CHECK: [Preise unter 1000 EUR einzeln einordnen - was stimmt da nicht]
HKG_STOPOVER: [Vergleich: Direktverbindung vs. FRA-HKG-KTI Multi-City lohnt sich der HKG-Tag? Preis + ca. 150 EUR Hotel HKG einrechnen]"""
c.execute("INSERT OR IGNORE INTO prompts (name, inhalt) VALUES (?, ?)",
("ki_auswertung", PROMPT_TEXT))
@ -169,14 +176,17 @@ PLAUSI_CHECK: [Preise unter 1000 EUR einzeln einordnen - was stimmt da nicht]"""
c.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)
airline_filter, layover_min, layover_max, max_flugzeit_h, max_stops,
via, stopover_min_h, stopover_max_h, intervall)
VALUES
('kayak','FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','', 120,300,22,2,'daily'),
('trip', 'FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','', 120,300,22,2,'daily'),
('kayak','FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','CZ',120,300,22,2,'daily'),
('kayak','FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','CX',120,300,22,2,'daily'),
('kayak','FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','SQ',120,300,22,2,'daily'),
('kayak','FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','TG',120,300,22,2,'daily')
('kayak', 'FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','', 120,300,22,2,'', 0, 0,'daily'),
('trip', 'FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','', 120,300,22,2,'', 0, 0,'daily'),
('kayak', 'FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','CZ',120,300,22,2,'', 0, 0,'daily'),
('kayak', 'FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','CX',120,300,22,2,'', 0, 0,'daily'),
('kayak', 'FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','SQ',120,300,22,2,'', 0, 0,'daily'),
('kayak', 'FRA','KTI',30,60,'roundtrip','premium_economy','1koffer+handgepaeck','TG',120,300,22,2,'', 0, 0,'daily'),
('kayak_multicity','FRA','KTI',30,60,'multicity', 'premium_economy','1koffer+handgepaeck','CX',120,300,22,2,'HKG',20,30,'daily'),
('kayak_multicity','FRA','KTI',30,60,'multicity', 'premium_economy','1koffer+handgepaeck','', 120,300,22,2,'HKG',20,30,'daily')
""")
conn.commit()

View file

@ -65,6 +65,9 @@ def dispatch_job(node, job, tage_override=None):
"layover_max": job.get("layover_max", 300),
"max_flugzeit_h": job.get("max_flugzeit_h", 22),
"max_stops": job.get("max_stops", 2),
"via": job.get("via", ""),
"stopover_min_h": job.get("stopover_min_h", 20),
"stopover_max_h": job.get("stopover_max_h", 30),
}
try:
r = requests.post(
@ -74,8 +77,10 @@ def dispatch_job(node, job, tage_override=None):
)
if r.status_code == 200:
results = r.json().get("results", [])
via_label = f" via {job.get('via','')}" if job.get('via') 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"{' +'+str(tage_override)+'T' if tage_override else ''}")
speichere_preise(results, node["name"], job)
return True

View file

@ -78,7 +78,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</span>
<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 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">
@ -244,7 +244,9 @@ 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 => {
const isMulticity = p.scanner === 'kayak_multicity';
const warn = p.preis < PLAUSI_GRENZE;
const plausi = warn
? '<span style="background:#451a03;color:#fbbf24;padding:0.15rem 0.5rem;border-radius:4px;font-size:0.75rem">⚠ bitte prüfen</span>'
@ -252,10 +254,16 @@ async function ladeUebersicht() {
const buchBtn = p.booking_url
? `<a href="${p.booking_url}" target="_blank" class="btn btn-sm" style="text-decoration:none">Öffnen </a>`
: '';
return `<tr>
<td>${p.scanner}</td>
const scannerLabel = isMulticity
? `<strong style="color:#818cf8">🇭🇰 HKG Stopover</strong><br><span style="font-size:0.72rem;color:#64748b">+~${HOTEL_HKG} Hotel</span>`
: p.scanner;
const gesamtHtml = isMulticity
? `<strong style="color:${warn?'#fbbf24':'#a78bfa'}">${p.preis} </strong><br><span style="font-size:0.75rem;color:#64748b"> ~${Math.round(p.preis)+HOTEL_HKG} inkl. Hotel</span>`
: `<strong style="color:${warn?'#fbbf24':'#34d399'}">${p.preis} </strong>`;
return `<tr${isMulticity?' style="background:rgba(99,102,241,0.06);border-left:3px solid #6366f1"':''}>
<td>${scannerLabel}</td>
<td style="font-size:0.8rem;color:#64748b">${p.node}</td>
<td><strong style="color:${warn?'#fbbf24':'#34d399'}">${p.preis} </strong></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>

View file

@ -22,20 +22,24 @@ def job():
trip_type = data.get("trip_type", "roundtrip")
kabine = data.get("kabine", "premium_economy")
gepaeck = data.get("gepaeck", "1koffer+handgepaeck")
airline_filter = data.get("airline_filter", "")
layover_min = data.get("layover_min", 120)
layover_max = data.get("layover_max", 300)
max_flugzeit_h = data.get("max_flugzeit_h", 22)
max_stops = data.get("max_stops", 2)
airline_filter = data.get("airline_filter", "")
layover_min = data.get("layover_min", 120)
layover_max = data.get("layover_max", 300)
max_flugzeit_h = data.get("max_flugzeit_h", 22)
max_stops = data.get("max_stops", 2)
via = data.get("via", "")
stopover_min_h = data.get("stopover_min_h", 20)
stopover_max_h = data.get("stopover_max_h", 30)
airline_label = f" [{airline_filter}]" if airline_filter else ""
print(f"[{NODE_NAME}] Job: {scanner}{airline_label} {von}{nach} | {kabine} | "
via_label = f" via {via}" if via else ""
print(f"[{NODE_NAME}] Job: {scanner}{airline_label}{via_label} {von}{nach} | {kabine} | "
f"Umstieg {layover_min}-{layover_max}min | max {max_flugzeit_h}h/{max_stops} Stopps")
try:
results = scrape(scanner, von, nach, tage, aufenthalt, trip_type, kabine,
gepaeck, airline_filter, layover_min, layover_max,
max_flugzeit_h, max_stops)
max_flugzeit_h, max_stops, via, stopover_min_h, stopover_max_h)
print(f"[{NODE_NAME}] {len(results)} Preise gefunden")
return jsonify({
"results": results,

View file

@ -7,16 +7,21 @@ def scrape(scanner, von, nach, tage=30, aufenthalt_tage=60,
trip_type="roundtrip", kabine="premium_economy",
gepaeck="1koffer+handgepaeck", airline_filter="",
layover_min=120, layover_max=300,
max_flugzeit_h=22, max_stops=2):
max_flugzeit_h=22, max_stops=2,
via="", stopover_min_h=20, stopover_max_h=30):
dispatcher = {
"google_flights": scrape_google_flights,
"kayak": scrape_kayak,
"skyscanner": scrape_skyscanner,
"trip": scrape_trip,
"google_flights": scrape_google_flights,
"kayak": scrape_kayak,
"kayak_multicity": scrape_kayak_multicity,
"skyscanner": scrape_skyscanner,
"trip": scrape_trip,
}
fn = dispatcher.get(scanner)
if not fn:
raise ValueError(f"Unbekannter Scanner: {scanner}")
if scanner == "kayak_multicity":
return fn(von, nach, tage, aufenthalt_tage, kabine, gepaeck,
airline_filter, via, stopover_min_h, stopover_max_h)
return fn(von, nach, tage, aufenthalt_tage, trip_type, kabine, gepaeck,
airline_filter, layover_min, layover_max, max_flugzeit_h, max_stops)
@ -386,6 +391,106 @@ def scrape_trip(von, nach, tage=30, aufenthalt_tage=60,
return results[:10]
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
"""
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 ""
# Kayak Multi-City Format: /flights/FRA-HKG/DATE/HKG-KTI/DATE/KTI-FRA/DATE
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",
airline_filter="",
via="HKG", stopover_min_h=20, stopover_max_h=30):
"""
Multi-City Suche: FRA HKG (1 Tag Aufenthalt) KTI FRA
Nutzt Cathay Pacific (CX) oder alle Airlines wenn airline_filter leer.
"""
abflug = (datetime.now() + timedelta(days=tage)).strftime("%Y-%m-%d")
via_datum = (datetime.now() + timedelta(days=tage + 1)).strftime("%Y-%m-%d")
rueck = (datetime.now() + timedelta(days=tage + 1 + aufenthalt_tage)).strftime("%Y-%m-%d")
kc = KABINE_KAYAK.get(kabine, "w")
bags = 1 if "koffer" in gepaeck else 0
airline_label = f" [{airline_filter}]" if airline_filter else ""
booking_url = _booking_url_kayak_multicity(von, nach, via, abflug, via_datum, rueck,
kc, bags, airline_filter)
booking_url_raw = _booking_url_kayak_multicity(von, nach, via, abflug, via_datum, rueck,
kc, 0, airline_filter)
print(f"[MC{airline_label}] Multi-City via {via}: {abflug} → +1T → {rueck}")
print(f"[MC{airline_label}] URL: {booking_url}")
results = []
with SB(uc=True, headless=True, chromium_arg="--no-sandbox --disable-dev-shm-usage") as sb:
sb.open(booking_url)
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',
'span[class*="price"]', '.Iqt3', 'div.nrc6-price', '.price']:
try:
elems = sb.find_elements(sel, timeout=2)
if elems:
for e in elems[:15]:
p = _parse_preis(e.text)
if p and p > 600:
results.append({
"scanner": "kayak_multicity",
"preis": p,
"waehrung": "EUR",
"airline": airline_filter or via,
"abflug": abflug,
"ankunft": rueck,
"booking_url": booking_url,
})
if results:
break
except Exception:
pass
if not results:
for r in _preise_aus_body(body, "kayak_multicity", abflug):
if r["preis"] > 600:
r["ankunft"] = rueck
r["booking_url"] = booking_url
r["airline"] = airline_filter or via
results.append(r)
# Fallback ohne Bags-Filter
if not results and bags > 0:
print(f"[MC] Kein Ergebnis mit Bags — Fallback ohne Bags-Filter")
sb.open(booking_url_raw)
sb.sleep(12)
body2 = sb.get_text("body")
for r in _preise_aus_body(body2, "kayak_multicity", abflug):
if r["preis"] > 600:
r["ankunft"] = rueck
r["booking_url"] = booking_url_raw
r["airline"] = airline_filter or via
results.append(r)
print(f"[MC{airline_label}] Ergebnis: {[r['preis'] for r in results[:5]]}")
return results[:10]
def scrape_skyscanner(von, nach, tage=30, aufenthalt_tage=60,
trip_type="roundtrip", kabine="premium_economy",
gepaeck="1koffer+handgepaeck", airline_filter="",