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