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:
parent
639e6a4fc3
commit
2ce7d02bc5
5 changed files with 156 additions and 24 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> · Roundtrip · Premium Economy · 1 Koffer + Handgepäck · ~2 Monate · max 22h / 2 Stopps / Umstieg 2-5h</span>
|
||||
<span>✈️ <strong>FRA → KTI</strong> · Roundtrip · Premium Economy · 1 Koffer + Handgepäck · ~2 Monate · max 22h / 2 Stopps / Umstieg 2-5h · 🇭🇰 <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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: FRA→HKG/DATE1 → HKG→KTI/DATE2 → KTI→FRA/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}¤cy=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="",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue