#!/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())