From 30ee5fbd858f882cf24df2399254c29d68b5f378 Mon Sep 17 00:00:00 2001 From: Murray Date: Sat, 21 Mar 2026 23:10:25 +0000 Subject: [PATCH] feat: initial flight monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 14 ++ README.md | 206 +++++++++++++++++- adapters/__init__.py | 7 + adapters/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 358 bytes adapters/__pycache__/base.cpython-312.pyc | Bin 0 -> 2103 bytes adapters/__pycache__/kiwi.cpython-312.pyc | Bin 0 -> 4948 bytes adapters/base.py | 54 +++++ adapters/kiwi.py | 120 ++++++++++ config.json | 14 ++ monitor.log | 3 + monitor.py | 170 +++++++++++++++ requirements.txt | 2 + 12 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 adapters/__init__.py create mode 100644 adapters/__pycache__/__init__.cpython-312.pyc create mode 100644 adapters/__pycache__/base.cpython-312.pyc create mode 100644 adapters/__pycache__/kiwi.cpython-312.pyc create mode 100644 adapters/base.py create mode 100644 adapters/kiwi.py create mode 100644 config.json create mode 100644 monitor.log create mode 100644 monitor.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20b9bdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ +.env +.venv +venv/ +monitor.log diff --git a/README.md b/README.md index c026133..b99cf65 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,205 @@ -# flight-monitor +# Flight Price Monitor -Flight price monitor — alerts when BER→EZE drops below threshold \ No newline at end of file +Daily flight price monitor that checks configured routes and alerts when prices drop below threshold. + +## Features + +- ✈️ Monitor multiple flight routes (BER→EZE, etc.) +- 🔔 OpenClaw alerts when prices drop below threshold +- 📊 Extensible adapter pattern for multiple price sources +- 📝 Full logging of all checks (hits and misses) +- 🔧 Cron-friendly one-shot execution + +## Setup + +### 1. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Get a Kiwi API Key + +1. Sign up at https://tequila.kiwi.com/portal/login +2. Create an API key +3. Set environment variable: + +```bash +export KIWI_API_KEY="your-api-key-here" +``` + +Or create a `.env` file: + +```bash +KIWI_API_KEY=your-api-key-here +``` + +### 3. Configure Routes + +Edit `config.json`: + +```json +{ + "routes": [ + { + "origin": "BER", + "destination": "EZE", + "trip_type": "round_trip", + "threshold_eur": 700, + "duration_weeks": [2, 3, 4], + "preferred_months": [12, 1, 2, 3], + "monitor_year_round": true + } + ], + "check_interval": "daily" +} +``` + +**Config Fields:** + +- `origin`, `destination`: IATA airport codes +- `threshold_eur`: Alert if price drops below this (in EUR) +- `duration_weeks`: Trip lengths to search (in weeks) +- `preferred_months`: Months to prioritize (1-12) +- `monitor_year_round`: `true` to search all months, `false` to only search `preferred_months` + +You can add multiple routes — the monitor will check them all. + +### 4. Run + +```bash +python monitor.py +``` + +Logs are written to `monitor.log` and stdout. + +## Scheduling (Cron) + +To run daily at 6 AM: + +```bash +crontab -e +``` + +Add: + +``` +0 6 * * * cd /path/to/flight-monitor && /usr/bin/python3 monitor.py +``` + +Or use OpenClaw cron (recommended): + +```bash +openclaw cron add \ + --schedule '0 6 * * *' \ + --command 'cd /path/to/flight-monitor && python monitor.py' \ + --name 'Flight Price Monitor' +``` + +## Architecture + +### Adapter Pattern + +The monitor uses an adapter pattern to support multiple price sources: + +``` +adapters/ + base.py # Abstract FlightAdapter interface + kiwi.py # Kiwi/Tequila API implementation +``` + +Each adapter implements: + +- `search_round_trip()` — returns normalized results +- `get_name()` — adapter name + +All results follow this schema: + +```python +{ + "price_eur": float, + "airline": str, + "departure": datetime, + "return": datetime, + "link": str, # Booking URL + "source": str # Adapter name +} +``` + +### Adding a New Adapter + +1. Create `adapters/my_adapter.py`: + +```python +from adapters.base import FlightAdapter + +class MyAdapter(FlightAdapter): + def get_name(self) -> str: + return "MySource" + + def search_round_trip(self, origin, destination, departure_date, return_date, **kwargs): + # Your implementation here + # Must return list of normalized results (see schema above) + pass +``` + +2. Register in `adapters/__init__.py`: + +```python +from adapters.my_adapter import MyAdapter +__all__ = [..., "MyAdapter"] +``` + +3. Update `monitor.py` to use your adapter: + +```python +adapter = MyAdapter() +``` + +You can also make the adapter configurable via `config.json` if needed. + +## Alerting + +When a price drop is detected, the monitor sends an alert via OpenClaw: + +```bash +openclaw system event --text "✈️ BER→EZE €650 on 2026-04-15 → 2026-04-29 (Lufthansa) — below €700! https://..." --mode now +``` + +This routes the alert to your configured OpenClaw channels (Telegram, Discord, Signal, etc.). + +## Logs + +All checks are logged to `monitor.log` with timestamps: + +``` +2026-03-21 18:00:00 [INFO] === Flight Monitor Starting === +2026-03-21 18:00:00 [INFO] Checking route BER→EZE (threshold: €700) +2026-03-21 18:00:01 [INFO] 2026-04-15 → 2026-04-29: €850 (Lufthansa) +2026-03-21 18:00:02 [INFO] 2026-05-01 → 2026-05-15: €620 (Air France) +2026-03-21 18:00:02 [INFO] Alert sent via OpenClaw +2026-03-21 18:00:03 [INFO] === Flight Monitor Complete === +``` + +## Troubleshooting + +**"KIWI_API_KEY environment variable not set"** + +- Set the `KIWI_API_KEY` env var (see Setup step 2) +- The monitor will exit gracefully (not crash) if the key is missing + +**No results returned** + +- Check `monitor.log` for API errors +- Verify your IATA codes are correct (e.g., "BER" not "Berlin") +- Try a broader date range (increase `duration_weeks` or `preferred_months`) + +**OpenClaw alert not sent** + +- Verify `openclaw` CLI is in PATH +- Check OpenClaw is running: `openclaw status` +- Look for error messages in `monitor.log` + +## License + +MIT diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..09b25ad --- /dev/null +++ b/adapters/__init__.py @@ -0,0 +1,7 @@ +""" +Flight price adapters package. +""" +from adapters.base import FlightAdapter +from adapters.kiwi import KiwiAdapter + +__all__ = ["FlightAdapter", "KiwiAdapter"] diff --git a/adapters/__pycache__/__init__.cpython-312.pyc b/adapters/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20edc3a78a06e60af9a3ef09f3fae5452b179dc5 GIT binary patch literal 358 zcmX@j%ge<81fi8JuP?VXRs*sqHSWuE$RIE^tn4F!Mo~p<75@fI@ z<1Jo@I!A~Cpa8dbW_c!*Tf_`hRKx-#{508avB$@!#`30$Y$vKJT`sMjW*~LI7rRwIFWEShEft{?I zo1d3il3%0`vqe8XJ`*SsAFo$X`HRCQH$SB`C)KWq2WTWC5Esh;i4V+-jEo5~U ZvN14f++|RGz%4by_X3wzBYP1jcmZRQWF!Cp literal 0 HcmV?d00001 diff --git a/adapters/__pycache__/base.cpython-312.pyc b/adapters/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0d249193be8d7910498cad90c417d1311ead7af GIT binary patch literal 2103 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 literal 0 HcmV?d00001 diff --git a/adapters/base.py b/adapters/base.py new file mode 100644 index 0000000..3f03bfe --- /dev/null +++ b/adapters/base.py @@ -0,0 +1,54 @@ +""" +Base adapter interface for flight price sources. +""" +from abc import ABC, abstractmethod +from datetime import date +from typing import List, Dict, Any + + +class FlightAdapter(ABC): + """ + Abstract base class for flight price adapters. + + Each adapter implements a specific price source (Kiwi, Google Flights, etc.) + and returns results in a normalized format. + """ + + @abstractmethod + def search_round_trip( + self, + origin: str, + destination: str, + departure_date: date, + return_date: date, + **kwargs + ) -> List[Dict[str, Any]]: + """ + Search for round-trip flights. + + Args: + origin: IATA code of origin airport (e.g., "BER") + destination: IATA code of destination airport (e.g., "EZE") + departure_date: Departure date + return_date: Return date + **kwargs: Adapter-specific parameters + + Returns: + List of results, each with: + { + "price_eur": float, + "airline": str, + "departure": datetime, + "return": datetime, + "link": str (booking URL), + "source": str (adapter name) + } + + Returns empty list if no results found. + """ + pass + + @abstractmethod + def get_name(self) -> str: + """Return the name of this adapter.""" + pass diff --git a/adapters/kiwi.py b/adapters/kiwi.py new file mode 100644 index 0000000..5b7b4a0 --- /dev/null +++ b/adapters/kiwi.py @@ -0,0 +1,120 @@ +""" +Kiwi.com / Tequila API adapter. +""" +import os +import logging +from datetime import date, datetime +from typing import List, Dict, Any +import requests + +from adapters.base import FlightAdapter + + +logger = logging.getLogger(__name__) + + +class KiwiAdapter(FlightAdapter): + """ + Adapter for Kiwi.com Tequila API. + + API docs: https://tequila.kiwi.com/docs/search/ + """ + + API_BASE = "https://api.tequila.kiwi.com" + + def __init__(self): + """Initialize the adapter. Requires KIWI_API_KEY environment variable.""" + self.api_key = os.getenv("KIWI_API_KEY") + if not self.api_key: + raise ValueError( + "KIWI_API_KEY environment variable not set. " + "Get your API key from https://tequila.kiwi.com/portal/login" + ) + logger.info("Kiwi adapter initialized") + + def get_name(self) -> str: + return "Kiwi/Tequila" + + def search_round_trip( + self, + origin: str, + destination: str, + departure_date: date, + return_date: date, + **kwargs + ) -> List[Dict[str, Any]]: + """ + Search for round-trip flights via Kiwi API. + + Args: + origin: IATA code (e.g., "BER") + destination: IATA code (e.g., "EZE") + departure_date: Departure date + return_date: Return date + + Returns: + List of normalized flight results. + """ + url = f"{self.API_BASE}/v2/search" + + params = { + "fly_from": origin, + "fly_to": destination, + "date_from": departure_date.strftime("%d/%m/%Y"), + "date_to": departure_date.strftime("%d/%m/%Y"), + "return_from": return_date.strftime("%d/%m/%Y"), + "return_to": return_date.strftime("%d/%m/%Y"), + "flight_type": "round", + "adults": 1, + "curr": "EUR", + "limit": 5, + "sort": "price" + } + + headers = { + "apikey": self.api_key, + "accept": "application/json" + } + + try: + logger.debug(f"Kiwi API request: {origin}→{destination} {departure_date} → {return_date}") + response = requests.get(url, params=params, headers=headers, timeout=30) + response.raise_for_status() + + data = response.json() + results = [] + + for item in data.get("data", []): + # Extract airline(s) - may be multiple for multi-leg flights + airlines = set() + for route in item.get("route", []): + airline = route.get("airline") + if airline: + airlines.add(airline) + airline_str = ", ".join(sorted(airlines)) if airlines else "Unknown" + + # Parse timestamps + departure_time = datetime.fromtimestamp(item.get("dTime", 0)) + return_time = datetime.fromtimestamp(item.get("aTime", 0)) + + # Build deep link + deep_link = item.get("deep_link", "") + + results.append({ + "price_eur": float(item.get("price", 0)), + "airline": airline_str, + "departure": departure_time, + "return": return_time, + "link": deep_link, + "source": self.get_name() + }) + + logger.debug(f"Kiwi API returned {len(results)} results") + return results + + except requests.exceptions.RequestException as e: + logger.error(f"Kiwi API request failed: {e}") + return [] + except Exception as e: + logger.error(f"Error parsing Kiwi API response: {e}") + return [] diff --git a/config.json b/config.json new file mode 100644 index 0000000..8684faf --- /dev/null +++ b/config.json @@ -0,0 +1,14 @@ +{ + "routes": [ + { + "origin": "BER", + "destination": "EZE", + "trip_type": "round_trip", + "threshold_eur": 700, + "duration_weeks": [2, 3, 4], + "preferred_months": [12, 1, 2, 3], + "monitor_year_round": true + } + ], + "check_interval": "daily" +} diff --git a/monitor.log b/monitor.log new file mode 100644 index 0000000..ad53dbf --- /dev/null +++ b/monitor.log @@ -0,0 +1,3 @@ +2026-03-21 23:10:16,010 [INFO] === Flight Monitor Starting === +2026-03-21 23:10:16,011 [ERROR] Adapter initialization failed: KIWI_API_KEY environment variable not set. Get your API key from https://tequila.kiwi.com/portal/login +2026-03-21 23:10:16,011 [INFO] Set KIWI_API_KEY environment variable to enable monitoring diff --git a/monitor.py b/monitor.py new file mode 100644 index 0000000..5520b09 --- /dev/null +++ b/monitor.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2b1be2a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +python-dotenv>=1.0.0