feat: 4x täglich (06/11/18/23h) + Flex-Lauf Di/Mi 23:30 mit ±3 Tage Datumsfenster
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f85c049aca
commit
4ba004cc76
2 changed files with 107 additions and 46 deletions
|
|
@ -1,9 +1,9 @@
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import json
|
import threading
|
||||||
import requests
|
import requests
|
||||||
import schedule
|
import schedule
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from db import init_db, get_conn, log
|
from db import init_db, get_conn, log
|
||||||
from ki import auswerten
|
from ki import auswerten
|
||||||
|
|
||||||
|
|
@ -17,21 +17,20 @@ def get_nodes():
|
||||||
return [dict(n) for n in nodes]
|
return [dict(n) for n in nodes]
|
||||||
|
|
||||||
|
|
||||||
def get_aktive_jobs():
|
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
|
||||||
|
"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
jobs = conn.execute(
|
jobs = conn.execute("SELECT * FROM jobs WHERE aktiv = 1").fetchall()
|
||||||
"SELECT * FROM jobs WHERE aktiv = 1"
|
|
||||||
).fetchall()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return [dict(j) for j in jobs]
|
return [dict(j) for j in jobs]
|
||||||
|
|
||||||
|
|
||||||
def node_ping(node):
|
def node_ping(node):
|
||||||
try:
|
try:
|
||||||
r = requests.get(
|
r = requests.get(f"http://{node['tailscale_ip']}:5010/status", timeout=5)
|
||||||
f"http://{node['tailscale_ip']}:5010/status",
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
return r.status_code == 200
|
return r.status_code == 200
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
@ -47,23 +46,22 @@ def update_node_status(name, status):
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def dispatch_job(node, job):
|
def dispatch_job(node, job, tage_override=None):
|
||||||
payload = {
|
payload = {
|
||||||
"scanner": job["scanner"],
|
"scanner": job["scanner"],
|
||||||
"von": job["von"],
|
"von": job["von"],
|
||||||
"nach": job["nach"],
|
"nach": job["nach"],
|
||||||
"tage": job["tage"],
|
"tage": tage_override if tage_override is not None else job["tage"],
|
||||||
"aufenthalt_tage": job.get("aufenthalt_tage", 60),
|
"aufenthalt_tage": job.get("aufenthalt_tage", 60),
|
||||||
"trip_type": job.get("trip_type", "roundtrip"),
|
"trip_type": job.get("trip_type", "roundtrip"),
|
||||||
"kabine": job.get("kabine", "premium_economy"),
|
"kabine": job.get("kabine", "premium_economy"),
|
||||||
"gepaeck": job.get("gepaeck", "1koffer+handgepaeck"),
|
"gepaeck": job.get("gepaeck", "1koffer+handgepaeck"),
|
||||||
"airline_filter": job.get("airline_filter", ""),
|
"airline_filter": job.get("airline_filter", ""),
|
||||||
"layover_min": job.get("layover_min", 120),
|
"layover_min": job.get("layover_min", 120),
|
||||||
"layover_max": job.get("layover_max", 300),
|
"layover_max": job.get("layover_max", 300),
|
||||||
"max_flugzeit_h": job.get("max_flugzeit_h", 22),
|
"max_flugzeit_h": job.get("max_flugzeit_h", 22),
|
||||||
"max_stops": job.get("max_stops", 2),
|
"max_stops": job.get("max_stops", 2),
|
||||||
}
|
}
|
||||||
log(f"Job an {node['name']} ({node['tailscale_ip']}): {payload}")
|
|
||||||
try:
|
try:
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"http://{node['tailscale_ip']}:5010/job",
|
f"http://{node['tailscale_ip']}:5010/job",
|
||||||
|
|
@ -72,11 +70,13 @@ def dispatch_job(node, job):
|
||||||
)
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
results = r.json().get("results", [])
|
results = r.json().get("results", [])
|
||||||
log(f"{node['name']}: {len(results)} Preise erhalten")
|
log(f"{node['name']}: {len(results)} Preise ← {job['scanner']}"
|
||||||
|
f"{' ['+job.get('airline_filter','')+']' if job.get('airline_filter') else ''}"
|
||||||
|
f"{' +'+str(tage_override)+'T' if tage_override else ''}")
|
||||||
speichere_preise(results, node["name"], job)
|
speichere_preise(results, node["name"], job)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
log(f"{node['name']}: Fehler {r.status_code}", "ERROR")
|
log(f"{node['name']}: Fehler {r.status_code} bei {job['scanner']}", "ERROR")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"{node['name']}: Exception {e}", "ERROR")
|
log(f"{node['name']}: Exception {e}", "ERROR")
|
||||||
|
|
@ -102,37 +102,75 @@ def speichere_preise(results, node_name, job):
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def taeglich_scrapen():
|
def scraping_lauf(label="Standard", flex_tage_liste=None):
|
||||||
log("=== Täglicher Scraping-Lauf gestartet ===")
|
"""
|
||||||
|
Führt alle aktiven Jobs auf allen Nodes aus.
|
||||||
|
flex_tage_liste: Liste von 'tage'-Werten für Datums-Flexibilität (z.B. [27,28,30,32,33])
|
||||||
|
"""
|
||||||
|
log(f"=== Scraping-Lauf [{label}] gestartet ===")
|
||||||
nodes = get_nodes()
|
nodes = get_nodes()
|
||||||
jobs = get_aktive_jobs()
|
jobs = get_aktive_jobs()
|
||||||
|
|
||||||
if not nodes:
|
if not nodes:
|
||||||
log("Keine aktiven Nodes konfiguriert", "WARN")
|
log("Keine aktiven Nodes konfiguriert", "WARN")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
tage_varianten = flex_tage_liste or [None] # None = Job-Default verwenden
|
||||||
|
|
||||||
|
online = 0
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
if node_ping(node):
|
if node_ping(node):
|
||||||
update_node_status(node["name"], "online")
|
update_node_status(node["name"], "online")
|
||||||
|
online += 1
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
dispatch_job(node, job)
|
for tage_var in tage_varianten:
|
||||||
|
dispatch_job(node, job, tage_override=tage_var)
|
||||||
else:
|
else:
|
||||||
log(f"Node {node['name']} nicht erreichbar", "WARN")
|
log(f"Node {node['name']} nicht erreichbar", "WARN")
|
||||||
update_node_status(node["name"], "offline")
|
update_node_status(node["name"], "offline")
|
||||||
|
|
||||||
log("Scraping abgeschlossen — KI-Auswertung läuft")
|
log(f"Scraping [{label}] abgeschlossen — {online}/{len(nodes)} Nodes online")
|
||||||
auswerten()
|
auswerten()
|
||||||
log("=== Lauf beendet ===")
|
log(f"=== Lauf [{label}] beendet ===")
|
||||||
|
|
||||||
|
|
||||||
|
def standard_lauf():
|
||||||
|
"""Normaler 4×-täglich Lauf mit Standard-Datum."""
|
||||||
|
scraping_lauf(label=datetime.now().strftime("%H:%M"))
|
||||||
|
|
||||||
|
|
||||||
|
def flex_lauf():
|
||||||
|
"""
|
||||||
|
Di/Mi Nacht: ±3 Tage Datums-Flexibilität.
|
||||||
|
Sucht Abflug in 27-33 Tagen statt nur 30 Tagen.
|
||||||
|
"""
|
||||||
|
wochentag = datetime.now().weekday() # 0=Mo, 1=Di, 2=Mi
|
||||||
|
if wochentag not in (1, 2):
|
||||||
|
log("Flex-Lauf: heute kein Di/Mi — übersprungen")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 7 Varianten: 30±3 Tage
|
||||||
|
basis = 30
|
||||||
|
flex_varianten = list(range(basis - 3, basis + 4)) # [27,28,29,30,31,32,33]
|
||||||
|
log(f"=== Flex-Lauf Di/Mi ±3 Tage: {flex_varianten} ===")
|
||||||
|
scraping_lauf(label="Flex-Di/Mi", flex_tage_liste=flex_varianten)
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
init_db()
|
init_db()
|
||||||
log("Scheduler gestartet")
|
log("Scheduler gestartet — 4× täglich + Flex Di/Mi")
|
||||||
|
|
||||||
# Täglich um 06:00
|
# 4× täglich Standard-Scan
|
||||||
schedule.every().day.at("06:00").do(taeglich_scrapen)
|
schedule.every().day.at("06:00").do(standard_lauf)
|
||||||
|
schedule.every().day.at("11:00").do(standard_lauf)
|
||||||
|
schedule.every().day.at("18:00").do(standard_lauf)
|
||||||
|
schedule.every().day.at("23:00").do(standard_lauf)
|
||||||
|
|
||||||
log("Nächster Lauf: täglich 06:00 Uhr")
|
# Di + Mi um 23:30: erweiterter Flex-Lauf mit ±3 Tage Datumsfenster
|
||||||
|
schedule.every().day.at("23:30").do(flex_lauf)
|
||||||
|
|
||||||
|
naechste = [str(j.next_run)[:16] for j in schedule.jobs[:3]]
|
||||||
|
log(f"Nächste Läufe: {', '.join(naechste)} ...")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
schedule.run_pending()
|
schedule.run_pending()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
from flask import Flask, jsonify, request, render_template_string
|
from flask import Flask, jsonify, request, render_template_string
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from db import init_db, get_conn, log
|
from db import init_db, get_conn, log
|
||||||
from scheduler import taeglich_scrapen
|
from scheduler import scraping_lauf
|
||||||
import schedule
|
|
||||||
import time
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
@ -78,9 +77,12 @@ BASE_HTML = """<!DOCTYPE html>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
OVERVIEW_HTML = BASE_HTML.replace("{% block content %}{% endblock %}", """
|
OVERVIEW_HTML = BASE_HTML.replace("{% block content %}{% endblock %}", """
|
||||||
<div style="background:#451a03;border:1px solid#92400e;border-radius:8px;padding:0.6rem 1rem;margin-bottom:1.2rem;font-size:0.85rem;color:#fcd34d">
|
<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">
|
||||||
✈️ <strong>FRA → KTI</strong> · Roundtrip · Premium Economy · 1 Aufgabekoffer + Handgepäck · ~2 Monate Aufenthalt
|
<span>✈️ <strong>FRA → KTI</strong> · Roundtrip · Premium Economy · 1 Koffer + Handgepäck · ~2 Monate · max 22h / 2 Stopps / Umstieg 2-5h</span>
|
||||||
· <span style="color:#f87171;font-weight:600">⚠ Preise unter 1.000 € bitte manuell prüfen</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">
|
||||||
|
⚠ <strong>Preise unter 1.000 €</strong> bitte im Dashboard manuell prüfen — wahrscheinlich Economy, kein Koffer oder falsches Segment
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid-3" style="margin-bottom:1.5rem">
|
<div class="grid-3" style="margin-bottom:1.5rem">
|
||||||
|
|
@ -167,14 +169,20 @@ function preisZelle(preis, booking_url) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ladeUebersicht() {
|
async function ladeUebersicht() {
|
||||||
const [stats, ki, preise, nodes, vergleich] = await Promise.all([
|
const [stats, ki, preise, nodes, vergleich, sched] = await Promise.all([
|
||||||
fetch('/api/stats').then(r=>r.json()),
|
fetch('/api/stats').then(r=>r.json()),
|
||||||
fetch('/api/ki/latest').then(r=>r.json()),
|
fetch('/api/ki/latest').then(r=>r.json()),
|
||||||
fetch('/api/preise/heute').then(r=>r.json()),
|
fetch('/api/preise/heute').then(r=>r.json()),
|
||||||
fetch('/api/nodes').then(r=>r.json()),
|
fetch('/api/nodes').then(r=>r.json()),
|
||||||
fetch('/api/preise/vergleich').then(r=>r.json())
|
fetch('/api/preise/vergleich').then(r=>r.json()),
|
||||||
|
fetch('/api/schedule').then(r=>r.json())
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Zeitplan-Anzeige
|
||||||
|
const flexHeute = sched.flex_heute ? ' 🔍 Flex-Lauf heute 23:30' : '';
|
||||||
|
document.getElementById('schedule-info').textContent =
|
||||||
|
`Scans: ${sched.taeglich.join(' · ')} Uhr${flexHeute} — nächster Flex: ${sched.naechster_flex}`;
|
||||||
|
|
||||||
const minHeute = stats.min_heute;
|
const minHeute = stats.min_heute;
|
||||||
document.getElementById('min-preis').textContent = minHeute ? Math.round(minHeute) : '—';
|
document.getElementById('min-preis').textContent = minHeute ? Math.round(minHeute) : '—';
|
||||||
document.getElementById('min-preis').style.color = (minHeute && minHeute < PLAUSI_GRENZE) ? '#fbbf24' : '#38bdf8';
|
document.getElementById('min-preis').style.color = (minHeute && minHeute < PLAUSI_GRENZE) ? '#fbbf24' : '#38bdf8';
|
||||||
|
|
@ -466,9 +474,28 @@ def api_logs():
|
||||||
return jsonify([dict(r) for r in rows])
|
return jsonify([dict(r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/schedule")
|
||||||
|
def api_schedule():
|
||||||
|
"""Zeigt nächste geplante Läufe."""
|
||||||
|
heute = datetime.now().strftime("%A") # Wochentag
|
||||||
|
return jsonify({
|
||||||
|
"taeglich": ["06:00", "11:00", "18:00", "23:00"],
|
||||||
|
"flex_lauf": "Di + Mi um 23:30 (±3 Tage Datumsfenster)",
|
||||||
|
"heute_ist": heute,
|
||||||
|
"flex_heute": datetime.now().weekday() in (1, 2),
|
||||||
|
"naechster_flex": "heute 23:30" if datetime.now().weekday() in (1, 2) else
|
||||||
|
("Di " if datetime.now().weekday() < 1 else
|
||||||
|
"nächsten Di") + " 23:30"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/scrape/now", methods=["POST"])
|
@app.route("/api/scrape/now", methods=["POST"])
|
||||||
def api_scrape_now():
|
def api_scrape_now():
|
||||||
threading.Thread(target=taeglich_scrapen, daemon=True).start()
|
threading.Thread(
|
||||||
|
target=scraping_lauf,
|
||||||
|
kwargs={"label": "Manuell"},
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
return jsonify({"message": "Scraping gestartet — läuft im Hintergrund"})
|
return jsonify({"message": "Scraping gestartet — läuft im Hintergrund"})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -640,13 +667,9 @@ if __name__ == "__main__":
|
||||||
init_db()
|
init_db()
|
||||||
log("Flugpreisscanner Hub gestartet")
|
log("Flugpreisscanner Hub gestartet")
|
||||||
|
|
||||||
# Scheduler in eigenem Thread
|
# Scheduler läuft als eigener Container (scheduler.py)
|
||||||
def run_schedule():
|
def run_schedule():
|
||||||
import schedule as s
|
pass # Scheduler-Container übernimmt die Zeitplanung
|
||||||
from scheduler import taeglich_scrapen
|
|
||||||
s.every().day.at("06:00").do(taeglich_scrapen)
|
|
||||||
while True:
|
|
||||||
s.run_pending()
|
|
||||||
time.sleep(30)
|
time.sleep(30)
|
||||||
|
|
||||||
threading.Thread(target=run_schedule, daemon=True).start()
|
threading.Thread(target=run_schedule, daemon=True).start()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue