Skip to content

Repository files navigation

Flight Deal Radar

Свой мониторинг дешёвых авиабилетов — потому что готовые «клубы» вроде Triips или Going не покрывают вылеты из Израиля.

Сервис следит за ценами, копит историю по каждому маршруту и присылает в Telegram или Pushover только те тарифы, которые статистически аномальны для этого направления — включая ошибочные (error fares).

🔴 Похоже на ошибочный тариф
Тель-Авив (TLV) → Бангкок (BKK)

💸 $187  ~$642~  −71%
📅 12 окт — 26 окт
✈️ 14 ноч. · пересадок: 1 · TK

на 71% ниже обычной цены маршрута ($642, 47 наблюдений за 21 день)

🔗 Открыть на Aviasales
🔎 Проверить в Google Flights

⚠️ Ошибочные тарифы живут часы. Бронировать сразу, отели и планы —
только после подтверждения от авиакомпании.

Как это работает

Ровно та же механика, что у платных сервисов, без «AI» в маркетинговом смысле:

  1. Сбор. Данные берутся из Aviasales/Travelpayouts Data API — там отличное покрытие израильских вылетов и бесплатный токен. Основной режим — один запрос city-directions, возвращающий самые дешёвые билеты из TLV во все направления сразу. Именно он находит поездки, которые вы не искали.

  2. История. Каждое наблюдение пишется в локальный SQLite. Отдельные корзины для round-trip и one-way, и отдельные — для каждой валюты: сравнивать их между собой бессмысленно.

  3. Детекция. Медиана и MAD вместо среднего и стандартного отклонения — один ошибочный тариф в окне сместил бы среднее настолько, что скрыл бы следующий. Тариф считается аномальным, только если он одновременно:

    • ниже медианы маршрута на MIN_DROP_PCT (по умолчанию 35%),
    • ниже её на MIN_Z_SCORE собственных «сигм» маршрута (3.0),
    • и не выше 10-го перцентиля всего, что мы видели.

    Три условия вместе — это то, что не даёт волатильному маршруту звонить постоянно: если направление и так скачет на 40% каждую неделю, у него широкий MAD, и очередные −40% дают низкий z-score и остаются молчать.

  4. Холодный старт. Пока истории нет, работает грубая оценка «сколько вообще должен стоить перелёт на такое расстояние» по большой окружности. Она намеренно консервативна (срабатывает примерно от половины ожидаемой цены), и каждый такой алерт помечен как оценка без истории.

  5. Антиспам. Отпечаток дела (маршрут + месяц + ценовая корзина 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 необязателен; если задан, он подставляется в ссылки на бронирование.

Telegram

Бот через @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, иначе второй параллельный запуск затёр бы наблюдения первого при выгрузке базы.

Ресурсы в AWS

Что Идентификатор
функция 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

CI

Каждый пуш в 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

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages