Files
flight-monitor/monitor.py

175 lines
5.4 KiB
Python

#!/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 dotenv import load_dotenv
from adapters.base import FlightAdapter
from adapters.kiwi import KiwiAdapter
# Load environment variables from .env if present
load_dotenv()
# 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()