From 97ec0e5eeca81a1cddb8e0333d854b208c55f400 Mon Sep 17 00:00:00 2001 From: Otis Date: Thu, 19 Mar 2026 07:52:54 +0000 Subject: [PATCH] feat: flight price monitor with Kiwi adapter stub and cron setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.json: BER→EZE route, €700 threshold, 14-28 nights, Dec-Mar preferred - monitor.py: config-driven, multi-route, windowed date search, openclaw alerts - adapters/base.py: abstract FlightAdapter interface - adapters/kiwi.py: Kiwi/Tequila v2 adapter (stub until KIWI_API_KEY is set) - requirements.txt, cron-install.sh (daily 08:00 UTC), README.md --- README.md | 131 +++++++++++++++++++++++++ adapters/__init__.py | 1 + adapters/base.py | 42 ++++++++ adapters/kiwi.py | 134 ++++++++++++++++++++++++++ config.json | 15 +++ cron-install.sh | 22 +++++ monitor.py | 225 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 8 files changed, 571 insertions(+) create mode 100644 README.md create mode 100644 adapters/__init__.py create mode 100644 adapters/base.py create mode 100644 adapters/kiwi.py create mode 100644 config.json create mode 100755 cron-install.sh create mode 100644 monitor.py create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..4412c3d --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# ✈️ Flight Price Monitor + +Monitors round-trip flight prices and fires alerts via OpenClaw when prices drop below configured thresholds. + +## Features + +- Config-driven: add as many routes as you like in `config.json` +- Swappable price adapters (currently: Kiwi/Tequila) +- Alerts via `openclaw send` when price < threshold +- Logs every check run to `monitor.log` +- Daily cron job, one-command install + +## Setup + +### 1. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Get a Kiwi API key (free) + +1. Sign up at https://tequila.kiwi.com/ +2. Create a solution and copy your API key +3. Set the environment variable: + +```bash +export KIWI_API_KEY=your_key_here +``` + +Add it to your shell profile or a `.env` file to persist it. + +> **Without a key:** the monitor still runs — it just returns no results and logs a reminder. No errors, no crashes. + +### 3. Configure routes + +Edit `config.json`: + +```json +{ + "routes": [ + { + "id": "ber-eze", + "origin": "BER", + "destination": "EZE", + "threshold_eur": 700, + "min_nights": 14, + "max_nights": 28, + "preferred_months": [12, 1, 2, 3], + "monitor_year_round": true + } + ] +} +``` + +| Field | Description | +|---|---| +| `id` | Unique route identifier | +| `origin` / `destination` | IATA airport or city codes | +| `threshold_eur` | Alert fires when price is below this | +| `min_nights` / `max_nights` | Stay duration range | +| `preferred_months` | Months to prioritise (1–12) | +| `monitor_year_round` | If `true`, searches all 12 months | + +### 4. Add more routes + +Just add another object to the `routes` array. Example — Berlin to NYC: + +```json +{ + "id": "ber-jfk", + "origin": "BER", + "destination": "JFK", + "threshold_eur": 400, + "min_nights": 7, + "max_nights": 14, + "preferred_months": [6, 7, 8], + "monitor_year_round": false +} +``` + +## Running manually + +```bash +python monitor.py +# or explicitly choose adapter: +python monitor.py --adapter kiwi +``` + +Logs are written to `monitor.log` and stdout. + +## Install the daily cron job + +```bash +bash cron-install.sh +``` + +Installs: `0 8 * * * python /path/to/monitor.py` (08:00 UTC daily). +Run again to check if already installed — it won't duplicate. + +## Alerts + +When a price hits below threshold, OpenClaw receives: + +``` +✈️ Flight alert: BER→EZE €650 on 2026-12-15–2027-01-05! https://... +``` + +Sent via: `openclaw send --text "..."` + +## Adapter architecture + +``` +adapters/ + base.py ← abstract FlightAdapter interface + kiwi.py ← Kiwi/Tequila v2 implementation +``` + +To add a new source: subclass `FlightAdapter`, implement `search()`, register in `ADAPTER_MAP` in `monitor.py`. + +## Files + +| File | Purpose | +|---|---| +| `config.json` | Route configuration | +| `monitor.py` | Main script | +| `adapters/base.py` | Abstract adapter interface | +| `adapters/kiwi.py` | Kiwi/Tequila adapter | +| `requirements.txt` | Python dependencies | +| `cron-install.sh` | Cron job installer | +| `monitor.log` | Runtime log (created on first run) | diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..6e1ee8f --- /dev/null +++ b/adapters/__init__.py @@ -0,0 +1 @@ +# Flight price adapters diff --git a/adapters/base.py b/adapters/base.py new file mode 100644 index 0000000..3e0d1a9 --- /dev/null +++ b/adapters/base.py @@ -0,0 +1,42 @@ +"""Base adapter interface for flight price sources.""" + +from abc import ABC, abstractmethod +from typing import Optional + + +class FlightAdapter(ABC): + """Abstract base class for flight search adapters. + + All adapters must return a list of flight result dicts with keys: + price_eur (float) - total round-trip price in EUR + outbound_date (str) - departure date YYYY-MM-DD + return_date (str) - return date YYYY-MM-DD + deep_link (str) - booking URL + carrier (str) - airline name or IATA code + """ + + @abstractmethod + def search( + self, + origin: str, + destination: str, + date_from: str, + date_to: str, + min_nights: int, + max_nights: int, + ) -> list[dict]: + """Search for round-trip flights. + + Args: + origin: IATA airport/city code (e.g. "BER") + destination: IATA airport/city code (e.g. "EZE") + date_from: Earliest outbound date, YYYY-MM-DD + date_to: Latest outbound date, YYYY-MM-DD + min_nights: Minimum stay duration + max_nights: Maximum stay duration + + Returns: + List of result dicts (may be empty). Sorted by price ascending + is preferred but not required. + """ + ... diff --git a/adapters/kiwi.py b/adapters/kiwi.py new file mode 100644 index 0000000..12f38ca --- /dev/null +++ b/adapters/kiwi.py @@ -0,0 +1,134 @@ +"""Kiwi/Tequila flight search adapter. + +Requires a free API key from https://tequila.kiwi.com/ +Set it via the KIWI_API_KEY environment variable. + +Without a key this adapter acts as a stub: it logs instructions and returns +an empty list so the rest of the monitor pipeline still runs cleanly. +""" + +import logging +import os +from datetime import datetime + +import requests + +from .base import FlightAdapter + +logger = logging.getLogger(__name__) + +KIWI_API_URL = "https://api.tequila.kiwi.com/v2/search" + + +class KiwiAdapter(FlightAdapter): + """Adapter for the Kiwi/Tequila v2 flight search API.""" + + def __init__(self): + self.api_key = os.environ.get("KIWI_API_KEY", "").strip() + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def search( + self, + origin: str, + destination: str, + date_from: str, + date_to: str, + min_nights: int, + max_nights: int, + ) -> list[dict]: + """Search Kiwi for round-trip flights. + + Returns an empty list (with a log warning) when no API key is set. + """ + if not self.api_key: + logger.warning( + "Kiwi adapter requires API key — set KIWI_API_KEY env var. " + "Get a free key at https://tequila.kiwi.com/" + ) + return [] + + return self._fetch( + origin=origin, + destination=destination, + date_from=date_from, + date_to=date_to, + min_nights=min_nights, + max_nights=max_nights, + ) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _fetch( + self, + origin: str, + destination: str, + date_from: str, + date_to: str, + min_nights: int, + max_nights: int, + ) -> list[dict]: + params = { + "fly_from": origin, + "fly_to": destination, + "date_from": self._kiwi_date(date_from), + "date_to": self._kiwi_date(date_to), + "nights_in_dst_from": min_nights, + "nights_in_dst_to": max_nights, + "flight_type": "round", + "curr": "EUR", + "limit": 5, + "sort": "price", + } + headers = {"apikey": self.api_key} + + try: + resp = requests.get(KIWI_API_URL, params=params, headers=headers, timeout=20) + resp.raise_for_status() + data = resp.json() + except requests.RequestException as exc: + logger.error("Kiwi API request failed: %s", exc) + return [] + + results = [] + for item in data.get("data", []): + try: + results.append(self._parse(item)) + except (KeyError, TypeError, ValueError) as exc: + logger.debug("Skipping malformed Kiwi result: %s", exc) + + return results + + @staticmethod + def _parse(item: dict) -> dict: + """Normalise a Kiwi result dict into the standard adapter format.""" + # Kiwi returns Unix timestamps; convert to YYYY-MM-DD + outbound_ts = item["dTime"] + return_ts = item["aTimeReturn"] if "aTimeReturn" in item else item["dTimeReturn"] + + outbound_date = datetime.utcfromtimestamp(outbound_ts).strftime("%Y-%m-%d") + return_date = datetime.utcfromtimestamp(return_ts).strftime("%Y-%m-%d") + + # Carrier: prefer marketing airline of first leg + try: + carrier = item["airlines"][0] + except (KeyError, IndexError): + carrier = "unknown" + + return { + "price_eur": float(item["price"]), + "outbound_date": outbound_date, + "return_date": return_date, + "deep_link": item.get("deep_link", ""), + "carrier": carrier, + } + + @staticmethod + def _kiwi_date(iso_date: str) -> str: + """Convert YYYY-MM-DD → DD/MM/YYYY (Kiwi format).""" + dt = datetime.strptime(iso_date, "%Y-%m-%d") + return dt.strftime("%d/%m/%Y") diff --git a/config.json b/config.json new file mode 100644 index 0000000..3b73dd1 --- /dev/null +++ b/config.json @@ -0,0 +1,15 @@ +{ + "routes": [ + { + "id": "ber-eze", + "origin": "BER", + "destination": "EZE", + "threshold_eur": 700, + "min_nights": 14, + "max_nights": 28, + "preferred_months": [12, 1, 2, 3], + "monitor_year_round": true + } + ], + "check_interval_hours": 24 +} diff --git a/cron-install.sh b/cron-install.sh new file mode 100755 index 0000000..3aead7c --- /dev/null +++ b/cron-install.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Install a daily cron job to run the flight monitor at 08:00 UTC. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON="$(command -v python3 || command -v python)" +CRON_CMD="0 8 * * * $PYTHON $SCRIPT_DIR/monitor.py >> $SCRIPT_DIR/monitor.log 2>&1" +MARKER="flight-monitor" + +# Check if already installed +if crontab -l 2>/dev/null | grep -q "$MARKER"; then + echo "✅ Cron job already installed (contains '$MARKER'). Nothing changed." + exit 0 +fi + +# Append new entry (preserve existing crontab) +(crontab -l 2>/dev/null || true; echo "# $MARKER"; echo "$CRON_CMD") | crontab - + +echo "✅ Cron job installed:" +echo " $CRON_CMD" +echo "" +echo "Runs daily at 08:00 UTC. Logs → $SCRIPT_DIR/monitor.log" diff --git a/monitor.py b/monitor.py new file mode 100644 index 0000000..fe74106 --- /dev/null +++ b/monitor.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Flight price monitor. + +Reads routes from config.json, searches for round-trip flights via the +selected adapter, and fires an openclaw alert when prices drop below the +configured threshold. + +Usage: + python monitor.py [--adapter kiwi] [--config config.json] +""" + +import argparse +import importlib +import json +import logging +import subprocess +import sys +from datetime import date, timedelta +from pathlib import Path + +# --------------------------------------------------------------------------- +# Logging — append to monitor.log in same directory as this script +# --------------------------------------------------------------------------- +LOG_FILE = Path(__file__).parent / "monitor.log" + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[ + logging.FileHandler(LOG_FILE, encoding="utf-8"), + logging.StreamHandler(sys.stdout), + ], +) +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Adapter loader +# --------------------------------------------------------------------------- + +ADAPTER_MAP = { + "kiwi": "adapters.kiwi.KiwiAdapter", +} + + +def load_adapter(name: str): + """Dynamically load an adapter class by short name.""" + if name not in ADAPTER_MAP: + raise ValueError( + f"Unknown adapter '{name}'. Available: {', '.join(ADAPTER_MAP)}" + ) + module_path, class_name = ADAPTER_MAP[name].rsplit(".", 1) + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + return cls() + + +# --------------------------------------------------------------------------- +# Date window generation +# --------------------------------------------------------------------------- + +def generate_windows(route: dict, horizon_days: int = 365) -> list[tuple[str, str]]: + """Generate (date_from, date_to) pairs to search. + + Produces one window per fortnight for the next `horizon_days` days. + Each window spans 2 weeks of possible departure dates. + """ + today = date.today() + preferred = set(route.get("preferred_months", [])) + monitor_year_round = route.get("monitor_year_round", True) + + windows = [] + step = timedelta(weeks=2) + window_span = timedelta(weeks=2) + + cursor = today + timedelta(days=7) # start one week out + end_horizon = today + timedelta(days=horizon_days) + + while cursor < end_horizon: + month = cursor.month + if monitor_year_round or month in preferred: + date_from = cursor.strftime("%Y-%m-%d") + date_to = (cursor + window_span).strftime("%Y-%m-%d") + windows.append((date_from, date_to)) + cursor += step + + return windows + + +# --------------------------------------------------------------------------- +# Alerting +# --------------------------------------------------------------------------- + +def send_alert(route: dict, result: dict): + """Fire an openclaw alert for a price hit.""" + msg = ( + f"✈️ Flight alert: {route['origin']}→{route['destination']} " + f"€{result['price_eur']:.0f} on " + f"{result['outbound_date']}–{result['return_date']}! " + f"{result['deep_link']}" + ) + cmd = ["openclaw", "send", "--text", msg] + try: + subprocess.run(cmd, check=True, timeout=30) + logger.info("ALERT SENT: %s", msg) + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as exc: + logger.error("Failed to send alert: %s | command: %s", exc, " ".join(cmd)) + + +# --------------------------------------------------------------------------- +# Main monitor loop +# --------------------------------------------------------------------------- + +def check_route(route: dict, adapter) -> int: + """Check a single route. Returns number of alerts fired.""" + route_id = route["id"] + origin = route["origin"] + destination = route["destination"] + threshold = route["threshold_eur"] + min_nights = route["min_nights"] + max_nights = route["max_nights"] + + logger.info( + "Checking %s: %s→%s threshold=€%s min=%d max=%d nights", + route_id, origin, destination, threshold, min_nights, max_nights, + ) + + windows = generate_windows(route) + logger.info("Generated %d search windows for %s", len(windows), route_id) + + all_results: list[dict] = [] + + for date_from, date_to in windows: + try: + results = adapter.search( + origin=origin, + destination=destination, + date_from=date_from, + date_to=date_to, + min_nights=min_nights, + max_nights=max_nights, + ) + except Exception as exc: + logger.error( + "Adapter error for %s window %s–%s: %s", + route_id, date_from, date_to, exc, + ) + results = [] + + logger.debug( + "Window %s–%s: %d results", date_from, date_to, len(results) + ) + all_results.extend(results) + + alerts_fired = 0 + + if not all_results: + logger.info("Route %s: no results returned (adapter stub or no flights found)", route_id) + return 0 + + # Sort by price + all_results.sort(key=lambda r: r["price_eur"]) + best = all_results[0] + logger.info( + "Route %s: %d results, best price €%.0f on %s–%s", + route_id, len(all_results), best["price_eur"], best["outbound_date"], best["return_date"], + ) + + for result in all_results: + if result["price_eur"] < threshold: + send_alert(route, result) + alerts_fired += 1 + + if alerts_fired == 0: + logger.info( + "Route %s: best price €%.0f is above threshold €%s — no alert", + route_id, best["price_eur"], threshold, + ) + + return alerts_fired + + +def main(): + parser = argparse.ArgumentParser(description="Flight price monitor") + parser.add_argument( + "--adapter", default="kiwi", + choices=list(ADAPTER_MAP.keys()), + help="Price adapter to use (default: kiwi)", + ) + parser.add_argument( + "--config", default=Path(__file__).parent / "config.json", + help="Path to config file (default: config.json)", + ) + args = parser.parse_args() + + config_path = Path(args.config) + if not config_path.exists(): + logger.error("Config file not found: %s", config_path) + sys.exit(1) + + with config_path.open() as f: + config = json.load(f) + + routes = config.get("routes", []) + if not routes: + logger.warning("No routes defined in config — nothing to do.") + return + + try: + adapter = load_adapter(args.adapter) + except ValueError as exc: + logger.error(str(exc)) + sys.exit(1) + + logger.info("=== Flight monitor run start | adapter=%s | routes=%d ===", args.adapter, len(routes)) + + total_alerts = 0 + for route in routes: + total_alerts += check_route(route, adapter) + + logger.info("=== Run complete | total alerts=%d ===", total_alerts) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests>=2.31