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:
131
README.md
Normal file
131
README.md
Normal 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 (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) |
|
||||
1
adapters/__init__.py
Normal file
1
adapters/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Flight price adapters
|
||||
42
adapters/base.py
Normal file
42
adapters/base.py
Normal 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
134
adapters/kiwi.py
Normal 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
15
config.json
Normal 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
22
cron-install.sh
Executable 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
225
monitor.py
Normal 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
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
requests>=2.31
|
||||
Reference in New Issue
Block a user