feat: flight price monitor with Kiwi adapter stub and cron setup

- 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
This commit is contained in:
Otis
2026-03-19 07:52:54 +00:00
commit 97ec0e5eec
8 changed files with 571 additions and 0 deletions

131
README.md Normal file
View File

@@ -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 (112) |
| `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-152027-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) |

1
adapters/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Flight price adapters

42
adapters/base.py Normal file
View File

@@ -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.
"""
...

134
adapters/kiwi.py Normal file
View File

@@ -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")

15
config.json Normal file
View File

@@ -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
}

22
cron-install.sh Executable file
View File

@@ -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"

225
monitor.py Normal file
View File

@@ -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()

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
requests>=2.31