feat: initial flight monitor
- Python flight price monitor for BER↔EZE and other routes - Adapter pattern for extensibility (Kiwi/Tequila implemented) - OpenClaw alerting integration - Cron-friendly one-shot execution - Full logging of all checks - Graceful handling when API key not set Implements monkey-island/flight-monitor#2
This commit is contained in:
170
monitor.py
Normal file
170
monitor.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user