149 lines
4.7 KiB
Python
Executable File
149 lines
4.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Silent watchdog for Chiapas flight prices.
|
||
|
||
Checks the user's preferred KAYAK search URL and only prints an alert when a
|
||
meaningful price drop is detected. Empty stdout means no WhatsApp message.
|
||
"""
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
URL = "https://www.kayak.com/flights/MIA,MCO-TGZ/2026-09-21/2026-10-09/2adults/children-5-8?ucs=uwkx32&sort=bestflight_a"
|
||
STATE_PATH = Path.home() / ".hermes" / "state" / "chiapas_flight_monitor.json"
|
||
CHROMIUM = "/snap/bin/chromium"
|
||
|
||
# Alert policy: establish a baseline silently, then alert only if the best price
|
||
# is materially better than what we have seen before.
|
||
DROP_PERCENT = 0.20 # at least 20% lower than baseline/best seen
|
||
DROP_ABSOLUTE = 300 # or at least $300 lower for the searched party
|
||
VERY_GOOD_TOTAL = 450 # always alert at or below this displayed fare
|
||
MIN_REASONABLE = 400 # ignore UI noise like baggage/filter slider prices
|
||
MAX_REASONABLE = 8000 # ignore unrelated large numbers
|
||
|
||
|
||
def load_state():
|
||
if STATE_PATH.exists():
|
||
try:
|
||
return json.loads(STATE_PATH.read_text())
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
|
||
def save_state(state):
|
||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
STATE_PATH.write_text(json.dumps(state, indent=2, sort_keys=True))
|
||
|
||
|
||
def fetch_html():
|
||
if not Path(CHROMIUM).exists():
|
||
return ""
|
||
with tempfile.NamedTemporaryFile(prefix="chiapas_kayak_", suffix=".html", delete=False) as tmp:
|
||
out_path = tmp.name
|
||
try:
|
||
cmd = [
|
||
CHROMIUM,
|
||
"--headless",
|
||
"--disable-gpu",
|
||
"--no-sandbox",
|
||
"--disable-dev-shm-usage",
|
||
"--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36",
|
||
"--virtual-time-budget=60000",
|
||
"--dump-dom",
|
||
URL,
|
||
]
|
||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=120)
|
||
return result.stdout or ""
|
||
except Exception:
|
||
return ""
|
||
finally:
|
||
try:
|
||
os.unlink(out_path)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def extract_prices(html):
|
||
# KAYAK may render prices as visible text or escaped JSON. Keep this simple
|
||
# and conservative: only currency-looking amounts in a plausible full-party range.
|
||
raw = re.findall(r"\$\s*([0-9][0-9,]{2,5})", html)
|
||
prices = []
|
||
for s in raw:
|
||
try:
|
||
val = int(s.replace(",", ""))
|
||
except ValueError:
|
||
continue
|
||
if MIN_REASONABLE <= val <= MAX_REASONABLE:
|
||
prices.append(val)
|
||
return sorted(set(prices))
|
||
|
||
|
||
def main():
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
state = load_state()
|
||
html = fetch_html()
|
||
prices = extract_prices(html)
|
||
|
||
state["last_checked_utc"] = now
|
||
state["url"] = URL
|
||
|
||
if not prices:
|
||
state["last_status"] = "no_parseable_prices"
|
||
save_state(state)
|
||
return 0
|
||
|
||
current = min(prices)
|
||
state["last_prices_seen"] = prices[:10]
|
||
state["last_lowest"] = current
|
||
|
||
baseline = state.get("baseline_lowest")
|
||
best_seen = state.get("best_seen")
|
||
|
||
if baseline is None:
|
||
state["baseline_lowest"] = current
|
||
state["best_seen"] = current
|
||
state["last_status"] = "baseline_set_silent"
|
||
save_state(state)
|
||
return 0
|
||
|
||
if best_seen is None or current < best_seen:
|
||
state["best_seen"] = current
|
||
|
||
threshold_from_baseline = int(baseline * (1 - DROP_PERCENT))
|
||
threshold_from_best = int(best_seen * (1 - DROP_PERCENT)) if best_seen else threshold_from_baseline
|
||
absolute_threshold = max(0, baseline - DROP_ABSOLUTE)
|
||
|
||
should_alert = (
|
||
current <= VERY_GOOD_TOTAL
|
||
or current <= threshold_from_baseline
|
||
or current <= threshold_from_best
|
||
or current <= absolute_threshold
|
||
)
|
||
|
||
already_alerted_at = state.get("last_alert_price")
|
||
if should_alert and (already_alerted_at is None or current < already_alerted_at):
|
||
state["last_alert_price"] = current
|
||
state["last_status"] = "alerted"
|
||
save_state(state)
|
||
print(
|
||
"Chiapas flight price alert: KAYAK is showing a notably better fare.\n"
|
||
f"Route/search: MIA or MCO → TGZ, Sept 21–Oct 9, 2026, family search.\n"
|
||
f"Lowest price I could parse today: ${current:,}.\n"
|
||
f"Baseline: ${baseline:,}. Best previously seen: ${best_seen:,}.\n"
|
||
f"Check/book here: {URL}"
|
||
)
|
||
return 0
|
||
|
||
state["last_status"] = "checked_no_alert"
|
||
save_state(state)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|