#!/usr/bin/env python3 """ Flight Price Monitor One-shot CLI tool to check configured flight routes and alert on price drops. """ import json import logging import os import sys from datetime import datetime, timedelta from pathlib import Path from typing import List, Dict, Any from adapters.base import FlightAdapter from adapters.kiwi import KiwiAdapter # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.FileHandler('monitor.log'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) def load_config(config_path: str = "config.json") -> Dict[str, Any]: """Load configuration from JSON file.""" try: with open(config_path, 'r') as f: return json.load(f) except FileNotFoundError: logger.error(f"Config file not found: {config_path}") sys.exit(1) except json.JSONDecodeError as e: logger.error(f"Invalid JSON in config file: {e}") sys.exit(1) def get_search_dates(route: Dict[str, Any]) -> List[tuple]: """Generate search date ranges based on route configuration.""" dates = [] today = datetime.now().date() # Look ahead for the next 6 months for offset_months in range(6): # Calculate target month target_date = today + timedelta(days=offset_months * 30) target_month = target_date.month # Check if this month should be searched if route.get("monitor_year_round") or target_month in route.get("preferred_months", []): for weeks in route.get("duration_weeks", [2]): departure = target_date return_date = departure + timedelta(weeks=weeks) dates.append((departure, return_date)) # Limit to 10 searches to avoid API spam return dates[:10] def send_openclaw_alert(message: str) -> bool: """Send alert via OpenClaw system event.""" try: import subprocess result = subprocess.run( ["openclaw", "system", "event", "--text", message, "--mode", "now"], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: logger.info("Alert sent via OpenClaw") return True else: logger.error(f"OpenClaw alert failed: {result.stderr}") return False except Exception as e: logger.error(f"Failed to send OpenClaw alert: {e}") return False def check_route(adapter: FlightAdapter, route: Dict[str, Any]) -> None: """Check a single route for price drops.""" origin = route["origin"] destination = route["destination"] threshold = route["threshold_eur"] logger.info(f"Checking route {origin}→{destination} (threshold: €{threshold})") search_dates = get_search_dates(route) if not search_dates: logger.warning(f"No search dates generated for {origin}→{destination}") return alerts_sent = 0 for departure, return_date in search_dates: try: results = adapter.search_round_trip( origin=origin, destination=destination, departure_date=departure, return_date=return_date ) if not results: logger.info(f" {departure} → {return_date}: No results") continue # Check if any result is below threshold for result in results[:3]: # Check top 3 results price = result["price_eur"] airline = result.get("airline", "Unknown") link = result.get("link", "") logger.info(f" {departure} → {return_date}: €{price} ({airline})") if price < threshold: message = ( f"✈️ {origin}→{destination} €{price} on " f"{departure.strftime('%Y-%m-%d')} → {return_date.strftime('%Y-%m-%d')} " f"({airline}) — below €{threshold}! {link}" ) send_openclaw_alert(message) alerts_sent += 1 except Exception as e: logger.error(f" Error searching {departure} → {return_date}: {e}") if alerts_sent == 0: logger.info(f"No prices below threshold for {origin}→{destination}") def main(): """Main entry point.""" logger.info("=== Flight Monitor Starting ===") # Load configuration config = load_config() # Initialize adapter # For now, hardcoded to Kiwi. Could be made configurable. try: adapter = KiwiAdapter() except ValueError as e: logger.error(f"Adapter initialization failed: {e}") logger.info("Set KIWI_API_KEY environment variable to enable monitoring") sys.exit(0) # Graceful exit, not a crash # Check each route routes = config.get("routes", []) if not routes: logger.warning("No routes configured") sys.exit(0) for route in routes: try: check_route(adapter, route) except Exception as e: logger.error(f"Error checking route: {e}") logger.info("=== Flight Monitor Complete ===") if __name__ == "__main__": main()