Свой мониторинг дешёвых авиабилетов — потому что готовые «клубы» вроде Triips или Going не покрывают вылеты из Израиля.
Сервис следит за ценами, копит историю по каждому маршруту и присылает в Telegram или Pushover только те тарифы, которые статистически аномальны для этого направления — включая ошибочные (error fares).
🔴 Похоже на ошибочный тариф
Тель-Авив (TLV) → Бангкок (BKK)
💸 $187 ~$642~ −71%
📅 12 окт — 26 окт
✈️ 14 ноч. · пересадок: 1 · TK
на 71% ниже обычной цены маршрута ($642, 47 наблюдений за 21 день)
🔗 Открыть на Aviasales
🔎 Проверить в Google Flights
⚠️ Ошибочные тарифы живут часы. Бронировать сразу, отели и планы —
только после подтверждения от авиакомпании.
Ровно та же механика, что у платных сервисов, без «AI» в маркетинговом смысле:
-
Сбор. Данные берутся из Aviasales/Travelpayouts Data API — там отличное покрытие израильских вылетов и бесплатный токен. Основной режим — один запрос
city-directions, возвращающий самые дешёвые билеты из TLV во все направления сразу. Именно он находит поездки, которые вы не искали. -
История. Каждое наблюдение пишется в локальный SQLite. Отдельные корзины для round-trip и one-way, и отдельные — для каждой валюты: сравнивать их между собой бессмысленно.
-
Детекция. Медиана и MAD вместо среднего и стандартного отклонения — один ошибочный тариф в окне сместил бы среднее настолько, что скрыл бы следующий. Тариф считается аномальным, только если он одновременно:
- ниже медианы маршрута на
MIN_DROP_PCT(по умолчанию 35%), - ниже её на
MIN_Z_SCOREсобственных «сигм» маршрута (3.0), - и не выше 10-го перцентиля всего, что мы видели.
Три условия вместе — это то, что не даёт волатильному маршруту звонить постоянно: если направление и так скачет на 40% каждую неделю, у него широкий MAD, и очередные −40% дают низкий z-score и остаются молчать.
- ниже медианы маршрута на
-
Холодный старт. Пока истории нет, работает грубая оценка «сколько вообще должен стоить перелёт на такое расстояние» по большой окружности. Она намеренно консервативна (срабатывает примерно от половины ожидаемой цены), и каждый такой алерт помечен как оценка без истории.
-
Антиспам. Отпечаток дела (маршрут + месяц + ценовая корзина 5%) плюс кулдаун на маршрут: повторный алерт проходит, только если цена упала ещё минимум на 15%.
git clone https://github.com/levitanda/cheapest_flights
cd cheapest_flights
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # вписать токен и Telegram
cp watchlist.example.toml watchlist.toml
python -m flight_radar doctor # проверить, что всё живо
python -m flight_radar scan --dry-run # прогон без отправки
python -m flight_radar watch # рабочий режимБесплатный: travelpayouts.com → регистрация →
Developers → API tokens. Партнёрский marker необязателен; если задан, он
подставляется в ссылки на бронирование.
Бот через @BotFather, затем TELEGRAM_CHAT_ID —
свой numeric id (например, у @userinfobot).
| Команда | Что делает |
|---|---|
scan |
один проход по списку наблюдения |
scan --dry-run |
то же, но только печатает в консоль, ничего не шлёт и не пишет в лог алертов |
watch |
бесконечный цикл с интервалом SCAN_INTERVAL_MINUTES и джиттером |
doctor |
проверяет токен, ответ API, справочник аэропортов и каналы уведомлений |
stats |
что накопилось в базе: маршруты, наблюдения, последние алерты |
test-notify |
шлёт одно синтетическое уведомление во все настроенные каналы |
watchlist.toml, два режима:
[defaults]
max_nights = 21
# Один запрос — все направления из аэропорта. Основной режим.
[[watch]]
origin = "TLV"
mode = "discover"
min_nights = 2
# Помесячная детализация по конкретному маршруту: дороже по запросам,
# но именно она копит плотную историю для статистического детектора.
[[watch]]
origin = "TLV"
destination = "BKK"
months_ahead = 6Фильтры: min_nights, max_nights, max_price, direct_only, exclude,
include_countries. Секция [defaults] применяется ко всем записям.
Без файла сервис работает с дефолтом — сплошной обзор из TLV.
Всё в .env. Дефолты подобраны симуляцией: 60 направлений, 60 дней чистого
ценового шума без единой настоящей аномалии — сколько алертов дойдёт до
пользователя.
MIN_Z_SCORE |
фоновых алертов в неделю |
|---|---|
| 2.5 | ~7 — шумно |
| 3.0 | ~3 — дефолт |
| 3.5 | ~1.4 — можно пропустить хорошее |
Главная ручка громкости — именно MIN_Z_SCORE; MIN_DROP_PCT на объём почти
не влияет (при любом z разброс между 0.35 и 0.50 — меньше двух алертов в
неделю), он работает как нижний порог абсолютной осмысленности скидки.
| Переменная | Смысл |
|---|---|
MIN_DROP_PCT |
насколько ниже медианы (0.35 = −35%) |
MIN_Z_SCORE |
во сколько собственных «сигм» маршрута |
MIN_OBSERVATIONS / MIN_DISTINCT_DAYS |
когда истории уже можно доверять |
COLD_START_RATIO |
порог эвристики до накопления истории |
ERROR_FARE_DROP_PCT |
с какой скидки считать тариф ошибочным |
MAX_ALERTS_PER_SCAN |
потолок на один проход |
ALERT_COOLDOWN_HOURS / ALERT_IMPROVE_PCT |
антиспам по маршруту |
Слишком шумно — поднимите MIN_Z_SCORE до 3.5. Слишком тихо в первые недели —
опустите MIN_OBSERVATIONS, но учтите, что на редкой истории статистика врёт.
Первые дни детектор работает почти вслепую: истории нет, спасает только эвристика. Через 2–3 недели непрерывного сбора появляются нормальные базовые линии, и качество алертов заметно растёт. Поэтому имеет смысл держать сервис запущенным постоянно, а не запускать руками.
Сервера нет: сканер живёт в AWS Lambda, расписание даёт EventBridge, а история цен — единственное, ради чего вообще нужен был постоянный хост — синхронизируется с S3 в начале и в конце каждого запуска.
EventBridge (rate 3 hours)
└─> Lambda flight-radar-scan
├── pull s3://flight-radar-state-…/state/ (SQLite + справочники)
├── scan Travelpayouts → детектор → Telegram
├── push s3://…-site-…/data/deals.json (данные для сайта)
└── push s3://flight-radar-state-…/state/ (обновлённая история)
Корректность держится на том, что писатель ровно один: у функции
ReservedConcurrentExecutions=1, иначе второй параллельный запуск затёр бы
наблюдения первого при выгрузке базы.
| Что | Идентификатор |
|---|---|
| функция | flight-radar-scan (python3.11, 512 МБ, таймаут 300 с) |
| расписание | правило flight-radar-schedule, rate(3 hours) |
| состояние | s3://flight-radar-state-654654296346/state/ (приватный) |
| сайт | s3://isr-cheap-flight-site-654654296346 за CloudFront EWANFAR53TRLY |
| роль | flight-radar-lambda-role |
История лежит в отдельном бакете от сайта: положи её рядом со статикой — и CloudFront отдавал бы базу по прямой ссылке.
Токены живут в переменных окружения Lambda, не в репозитории:
aws lambda update-function-configuration \
--function-name flight-radar-scan --region eu-central-1 \
--environment 'Variables={
DATA_DIR=/tmp/flight-radar,
STATE_BUCKET=flight-radar-state-654654296346,
STATE_PREFIX=state,
SITE_BUCKET=isr-cheap-flight-site-654654296346,
SITE_DATA_KEY=data/deals.json,
CURRENCY=usd,
TRAVELPAYOUTS_TOKEN=ваш_токен,
TELEGRAM_BOT_TOKEN=ваш_бот,
TELEGRAM_CHAT_ID=ваш_id}'Расписание создано выключенным — включённое без токена оно просто падало бы восемь раз в сутки. После настройки токенов:
aws events enable-rule --name flight-radar-schedule --region eu-central-1Каждый пуш в main прогоняет тесты, затем двумя независимыми задачами
выкладывает сайт и пересобирает функцию. update-function-code возвращает
успех даже для пакета, который не импортируется, поэтому после выкладки
воркфлоу один раз вызывает функцию и валит деплой на ImportModuleError.
Секреты репозитория: AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY от
пользователя flight-radar-site-deployer — он умеет только положить
index.html, обновить код функции и сбросить кэш CDN.
Режим watch никуда не делся: python -m flight_radar watch или контейнер из
Dockerfile — если захочется держать сканер у себя, а не в Lambda.
docker build -t flight-radar .
docker run -d --name flight-radar \
--env-file .env \
-v "$PWD/data:/app/data" \
-v "$PWD/watchlist.toml:/app/watchlist.toml:ro" \
flight-radarТом с data/ обязателен — в нём лежит вся накопленная история цен.
isr-cheap-flight.dalev.click — статическая страница со всеми находками: фильтр по городу, сортировка по скидке и цене, таблица наблюдаемых маршрутов.
Данные туда попадают не через API, а файлом: после каждого скана сервис
выгружает data/deals.json в S3, а страница его читает. Поэтому у сайта нет
ни бэкенда, ни открытого порта к базе с историей цен — только приватный бакет
за CloudFront с доступом по OAC.
scan → SQLite (на сервере) → data/deals.json → S3 ─┐
├─ CloudFront ─ isr-cheap-flight.dalev.click
site/index.html (из GitHub Actions) ──┘
Ресурсы в AWS: бакет isr-cheap-flight-site-654654296346 (приватный),
дистрибуция EWANFAR53TRLY, сертификат ACM в us-east-1, ALIAS-записи A/AAAA в
зоне dalev.click. Два узких IAM-пользователя вместо общего ключа:
flight-radar-publisher (только PutObject в data/*, лежит на сервере) и
flight-radar-site-deployer (выкладка index.html из CI).
Локально страницу можно посмотреть так:
python -m flight_radar publish --stdout > site/data/deals.json
python -m http.server -d site 8000- API отдаёт цены, найденные пользователями Aviasales за последние 48 часов. Это ближе к реальности, чем опубликованные тарифы, но всё равно кэш: цена может не подтвердиться при переходе. Поэтому в каждом уведомлении есть вторая ссылка на Google Flights для независимой проверки.
- Ошибочные тарифы живут часы, а рассылка не мгновенна. Часть алертов неизбежно окажется «уже неактуально» — это общая беда всех подобных сервисов, включая платные.
- Эвристика холодного старта грубая. Она годится, чтобы поймать явный выброс, и не годится, чтобы отличить хорошую цену от средней.
python -m pytest