175 lines
5.4 KiB
Python
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()
|