Я сделал бота для Telegram и MAX. Ему пишут обычной фразой: «Египет в ноябре с 10-го ±3 дня на 8 ночей, двое взрослых и ребёнок 7 лет, 4–5 звёзд». Бот находит туры, билеты и отели, а по кнопке следит за ценой и пишет, когда она падает.В статье — техническая часть: как языковая модель превращает фразу в строгий запрос, как один код обслуживает два мессенджера, как устроено слежение за ценами и какие грабли нашлись в API Слетать.ру и Aviasales. Код на Python 3.12, asyncio, httpx, pydantic, SQLite; живёт в Docker на VPS. Читать далее
Уровень сложностиСредний
Время на прочтение9 мин
Охват и читатели1.2K
Кейс

Я сделал бота для Telegram и MAX. Ему пишут обычной фразой: «Египет в ноябре с 10-го ±3 дня на 8 ночей, двое взрослых и ребёнок 7 лет, 4–5 звёзд». Бот находит туры, билеты и отели, а по кнопке следит за ценой и пишет, когда она падает.
В статье — техническая часть: как языковая модель превращает фразу в строгий запрос, как один код обслуживает два мессенджера, как устроено слежение за ценами и какие грабли нашлись в API Слетать.ру и Aviasales. Код на Python 3.12, asyncio, httpx, pydantic, SQLite; живёт в Docker на VPS.

Путь одного запроса: сообщение → LLM-парсер → источники → ответ, слежение, сайты
## 1. Разбор фразы: LLM со схемой вместо регулярок
Первая версия разбирала запрос регулярками. Через пару дней стало ясно, что это тупик: «с 21 октября плюс-минус 4 дня», «на недельку», «двое и ребёнок», «без пересадок», «в Пекин, обратно из Шанхая» — вариантов бесконечно много.

Фраза пользователя и SearchRequest, который из неё получается
Сейчас фразу разбирает языковая модель, а ответ сразу валидируется pydantic-схемой:
class _Parsed(BaseModel):
clarification: str | None # вопрос пользователю, если чего-то не хватает
interest: str | None = None # «хочу увидеть северное сияние» — нет направления
kind: Kind # tours | flights | both | hotels
departure: Place
country: str
resorts: list[Place]
date_from: str = Field(description="YYYY-MM-DD")
date_to: str = Field(description="YYYY-MM-DD")
nights_min: int
nights_max: int
adults: int
kids_ages: list[int]
stars: list[int]
hotel_chains: list[str]
hotel_names: list[str]
max_transfers: int | None = None # 0 = только прямые
...В бою работает DeepSeek в JSON-режиме, запасной вариант — Claude со structured outputs. Разбор одного запроса стоит доли копейки. Для DeepSeek схема просто вставляется в системный промпт, а если модель вернула невалидный JSON, ошибка pydantic уходит ей обратно:
try:
return schema.model_validate_json(content)
except ValidationError as e:
messages += [
{"role": "assistant", "content": content},
{"role": "user", "content": f"That JSON doesn't match the schema: {e}. Reply with the corrected JSON only."},
]Со второй попытки модель почти всегда исправляется.
Что оказалось важным в промпте:
- Сегодняшняя дата в каждом запросе. Без неё «в ноябре» превращается в ноябрь прошлого года.
- Явные правила для дат. «С 21 октября ±4 дня» — это окно вылета 17–25 октября; «на 8 ночей» — nights_min = nights_max = 8; если названы дни, то ночей на одну меньше.
- Границы между полями. Модель любит превращать «отель с хорошим аквапарком» в «интерес» и предлагать идею поездки вместо поиска тура. Помогло правило: если названа страна, город или даты, interest обязан быть null, а пожелание уходит в wishes.
- Предыдущий поиск в контексте. Тогда работают «а на 24-е», «а без детей», «теперь в Турцию»: модель меняет только то, что сказано, остальное берёт из прошлого запроса.
- Уточнение вместо догадки. Нет страны или возраста детей — модель заполняет clarification вопросом на языке пользователя, и бот спрашивает.
После модели всё равно стоит обычный код: дата не раньше сегодняшней, nights_min <= nights_max, не больше трёх аэропортов. Модели я доверяю понимание языка, но не инварианты.
Отдельный слой — сети отелей. Пользователь пишет «Pickalbatros», а у туроператоров отели называются «Jungle Aqua Park» или «Neverland» без названия сети. Это словарь в коде, сравнение без регистра, пробелов и пунктуации:
def normalize(s: str) -> str:
"""'Pick Albatros' и 'pickalbatros' совпадают."""
return re.sub(r"[\W_]+", "", s.lower())## 2. Один бот, два мессенджера
Бот работает в Telegram и в MAX. Чтобы логика не раздвоилась, вся она живёт в классе App, а мессенджер спрятан за протоколом:
class Messenger(Protocol):
platform: str # "tg" | "max"
async def send(self, chat_id: str, text: str, buttons: Keyboard | None = None) -> str: ...
async def delete(self, chat_id: str, message_id: str) -> None: ...
async def typing(self, chat_id: str) -> None: ...
async def answer_callback(self, callback_id: str, text: str | None = None, alert: bool = False) -> None: ...
async def start_link(self, payload: str) -> str: ...
def share_link(self, url: str, text: str) -> str | None: ...
Один и тот же запрос в MAX и Telegram: бот уточняет возраст ребёнка и отвечает одинаково
Каждое входящее событие превращается в Ctx — кто написал, в какой чат и через какой адаптер отвечать. Пользователь хранится как tg:123 или max:456, поэтому одна таблица обслуживает оба мессенджера. Адаптер Telegram — тонкая обёртка над aiogram, адаптер MAX — около 190 строк на чистом httpx.
Что пришлось учесть в MAX:
- Сертификат. TLS-сертификат API MAX выпущен российским УЦ Минцифры, которого нет в certifi. Добавлять его в системное хранилище не хотелось, поэтому он подгружается только в клиент для MAX:
def max_http_client() -> httpx.AsyncClient:
ctx = ssl.create_default_context(cafile=certifi.where())
ctx.load_verify_locations(cafile=str(RU_ROOT_CA))
return httpx.AsyncClient(verify=ctx, timeout=30)- Long polling через GET /updates с marker — по сути то же, что getUpdates в Telegram. Каждое обновление обрабатывается отдельной задачей, чтобы медленный поиск туров одного пользователя не задерживал остальных.
- Нет метода «список моих чатов». ID канала бот узнаёт из события bot_added, когда его добавляют в канал, и сохраняет у себя.
- Нет диалога «поделиться». В Telegram есть t.me/share/url?url=...&text=..., в MAX аналога нет. Поэтому share_link возвращает None, и кнопка «поделиться» в MAX просто не показывается.
- Username бота в MAX выдаётся автоматически, вида id772878808103_bot. Его нельзя хардкодить: ссылки строятся из ответа /me.
Создать бота в MAX может только юрлицо, ИП или самозанятый с подтверждением через business.max.ru. В остальном API оказался простым: HTML-разметка, inline-кнопки и deep link ?start= работают как в Telegram.
## 3. Источники: что не написано в документации
Источники опрашиваются параллельно через asyncio.gather(..., return_exceptions=True): упал один — остальные всё равно отвечают, а ошибка попадает в лог, а не пользователю. Одинаковые запросы от разных пользователей и от слежения за ценой склеиваются: результат кэшируется на 4 минуты, а параллельные одинаковые запросы ждут одного вызова через asyncio.Lock на ключ запроса.
### Слетать.ру: туры от туроператоров
У Слетать есть JSON-шлюз поиска туров. Он платный, с двухнедельным пробным периодом, лицензия привязана к домену или IP сервера. Поиск асинхронный:
1. GetTours(requestId=0) запускает поиск и возвращает requestId;
2. GetLoadState(requestId) опрашивается не чаще раза в 1,5 секунды, пока все туроператоры не ответят IsProcessed;
3. GetTours(requestId, updateResult=1) отдаёт строки списками: цена — row[42], отель — row[7], курорт — row[19] и так далее по индексам из документации.
Грабли, на которые я наступил:
- Без Referer нет результатов. Даже с лицензией по IP шлюз отвечает IsError с текстом про HTTP REFERER. Помогает любой Referer.
- Цены в 4 раза ниже реальных. Первая выдача выглядела слишком хорошо: туры примерно в 4 раза дешевле, чем на сайтах. Оказалось, в выдачу попадали пакеты без перелёта. Нужны флаги s_hasTickets=true, s_ticketsIncluded=true, includeOilTaxesAndVisa=1 (топливный сбор и виза в цене) и s_hotelIsNotInStop=true. Зато s_ticketsIncluded=false оказался полезен для запросов «только отель»: это настоящие цены проживания для тех, кто летит сам.
- Фильтр по курортам игнорируется. Запрос на Хургаду возвращает и Марса-Алам. Курорты фильтруются уже у себя, а размер страницы поднят до 300 строк, чтобы после фильтра что-то осталось. Квота считается по запущенным поискам, а не по страницам, поэтому это бесплатно.
- Поиск отелей по подстроке. GetHotels с фильтром horus возвращает отели Bosphorus, поэтому совпадение проверяется ещё раз у себя.
- Ссылка на бронь в выдаче ведёт в кабинет агента. В строке есть ссылка sletat.ru/book/..., но она открывает логин туроператора для агентств, а не страницу для туриста. Рабочий формат подсказала поддержка: sletat.ru/checkout/{operator}-{price_id}-{request_id}. Он открывает выбор перелёта для конкретного тура и живёт дольше 15 минут, отведённых поиску. Проверял в браузере, а не curl'ом: сайт — JS-приложение, и curl там ничего не показывает.
Из выдачи остаётся по одному самому дешёвому варианту на отель, дату и число ночей: у туроператоров один отель встречается десятки раз с разными номерами.
#### Цена в поиске ≠ цена при бронировании
Самый неприятный сюрприз пришёл в первый же день после публикации, от читателя: «Хрень, цена не соответствует». Он искал тур во Вьетнам на Новый год: 10 ночей, двое взрослых. Бот показал около 222 тысяч рублей. На странице бронирования Слетать перепроверил тур у оператора, и цена стала 405 775 ₽, на 183 тысячи больше.

Цена в поиске, на рынке и при бронировании
Я повторил поиск через шлюз и проверил три гипотезы:
- Топливный сбор не включён. В документации есть флаг calcFullPrice=1, «включает топливные сборы в цену». С ним и без него цены в выдаче совпали до рубля.
- Цена лежит в другом поле. По документации aaData[88] — «полная цена со всеми доплатами оператора». В реальной выдаче там 0 у всех 300 строк. Хорошо, что проверил до деплоя: код, который берёт «полную цену», если она есть, превратил бы все туры в бесплатные и отбросил их.
- Уточнять цену перед показом. Для этого есть метод ActualizePrice: он перепроверяет у оператора места, перелёт и доплаты. В документации пример ответа идёт 13,7 секунды, у меня он не уложился и в 30. Вызывать его на каждый из пяти показанных туров, пока пользователь ждёт ответа, нельзя.
К этому времени самый дешёвый тур на те же даты стоил уже от 316 тысяч, а на Фукуоке — от 384 тысяч. То есть 222 тысячи были устаревшей промо-ценой, которую оператор ещё отдавал в поиск. С этим же сталкивается любой, кто ищет на сайте агрегатора: цена в выдаче и цена в корзине иногда расходятся, особенно на праздничные даты.
Первая мысль была отсекать цены, которые сильно ниже медианы выдачи. Я от неё отказался: такой фильтр срезал бы и настоящие горящие предложения, а ради них бот и сделан. В итоге бот показывает то, что отдают операторы, а под турами теперь стоит строка: «Цены туров — от туроператоров на момент поиска. При бронировании оператор подтверждает цену и наличие мест: цена может измениться». Не фикс, а честность. Если знаете способ получше — расскажите в комментариях.
### Aviasales: билеты
Билеты идут через Data API Travelpayouts (prices_for_dates). Это кэш поисков Aviasales: цены за одного взрослого, возрастом до недели. Для мониторинга трендов этого достаточно, финальная цена подтверждается на сайте.
Тонкости:
- Слежение идёт по цене одного билета, а не всей компании. В кэше цена за одного взрослого, и если умножать её на состав туристов, то при смене возраста ребёнка «падение цены» появится на ровном месте.
- Ссылка на компанию. Для одного взрослого ссылка ведёт на конкретный билет из кэша. Для компании — на живой поиск в коротком формате MOW1011AYT1711211: даты как DDMM, затем число взрослых, детей и младенцев. Формат /search/flights?... с параметрами открывался в бизнес-классе, какой бы trip_class ни передавался.
## 4. Слежение за ценами
Подписки лежат в SQLite. Каждые 30 секунд монитор выбирает подписки, у которых подошло время проверки (раз в 10 или 30 минут), и проверяет не больше трёх параллельно через семафор.
Решение «писать или нет» — чистая функция без I/O, её легко покрыть тестами:
def evaluate(ref, current, drop_pct, rise_pct, target=None) -> Alert | None:
if current is None:
return Alert("gone", ref, None) if ref is not None else None
if ref is None:
return Alert("back", None, current)
if target and current <= target < ref:
return Alert("target", ref, current)
if current <= ref * (1 - drop_pct / 100):
return Alert("drop", ref, current)
if current >= ref * (1 + rise_pct / 100):
return Alert("rise", ref, current)
return Noneref — последняя цена, о которой бот сообщил пользователю, а не первая цена подписки. Иначе после одного падения бот писал бы на каждой проверке.
Ещё два случая, которые всплыли в бою:
- Ошибка источника — не «туров нет». Если провайдер упал, а предложений ноль, бот молчит и ждёт следующей проверки. Иначе пользователь получает «туры закончились», а через 10 минут «туры снова есть».
- Дешевле, но хуже. Пользователь следил за прямым рейсом, а прямые закончились, и самым дешёвым стал вариант с двумя пересадками. Формально цена упала, по сути маршрут другой. Поэтому сравниваются только варианты с числом пересадок не больше, чем у того, о котором бот сообщил последним. Если таких не осталось, бот молча переходит на новый маршрут, без уведомления «цена упала».
## 5. Маркировка рекламы автоматически
Ссылки в боте партнёрские, а по закону о рекламе (ст. 18.1 38-ФЗ) такое сообщение нужно маркировать: «Реклама», рекламодатель и токен erid. Travelpayouts регистрирует креативы в ЕРИР сам и вшивает erid в партнёрскую ссылку вида https://aviasales.tpo.li/AbCd?erid=2Vtzqv.... Значит, подпись можно собрать из самих ссылок:
PARTNER_LINK = re.compile(r"https://([a-z0-9-]+)\.tpo\.li/[A-Za-z0-9]+(?:\?erid=([A-Za-z0-9]+))?")
def mark(text: str, buttons: Keyboard | None = None) -> str:
lbl = label(text, buttons) # «Реклама. Aviasales (aviasales.ru), erid: …; Слетать.ру (sletat.ru), erid: …»
return f"{text}\n\n<i>{lbl}</i>" if lbl else textmark() вызывают оба адаптера в send(), поэтому ни одно сообщение с партнёрской ссылкой не уходит без маркировки, где бы в коде его ни собрали.
## 6. Статические сайты из тех же данных
Бот каждый день копит цены, и это единственное по-настоящему уникальное, что у него есть. Раз в сутки из них собираются два статических сайта: страницы «Билеты Москва — Хургада в ноябре» с ценами по дням и графиком из своей истории, и страницы идей «куда поехать в ноябре». Это HTML из шаблонов, sitemap и robots.txt; отдаёт их nginx в соседнем контейнере.

Страница «Билеты Москва — Хургада в ноябре»
Два решения, которые пришли не сразу:
- Страницы не удаляются. Прошедший месяц остаётся архивом с последними ценами и ссылками на актуальные месяцы. Поисковику не нравятся страницы, которые то появляются, то исчезают.
- Два домена — разный контент. Был соблазн показывать один сайт на двух доменах. Но Яндекс склеит их как зеркала, а одинаковые партнёрские страницы рискуют попасть под фильтр. Поэтому на одном домене цены на билеты, на другом — идеи поездок со ссылками друг на друга; nginx выбирает сайт по Host.
## Что получилось и что дальше
Сейчас это около 5 тысяч строк на Python: два адаптера мессенджеров, четыре источника, монитор, генератор сайтов и ежедневные подборки в канал. Всё работает в одном процессе на asyncio, база — SQLite, деплой — скрипт из трёх команд: pytest, rsync и docker compose up -d --build.

Запрос без направления: «хочу увидеть северное сияние»
Дальше в планах: цены туров на страницах идей, больше городов вылета и прокси для зарубежных API.
Если хотите проверить парсер на прочность, напишите боту запрос позаковыристее. А в комментариях расскажите, какой он не понял.
- Бот в Telegram: [t.me/tours_flights_search_bot](https://t.me/tours_flights_search_bot?start=habr)
- Бот в MAX: [max.ru/id772878808103_bot](https://max.ru/id772878808103_bot?start=habr)
- Цены по направлениям: [kudadeshevle.ru](https://kudadeshevle.ru)
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Как составить маршрут путешествия с помощью ИИ: сделали свой сервис, получили 5000 визитов и 0 продаж | -1 | 6.87 | 12-10-2026 |
| 2 | Как сделать RAG-чат для сайта, который не выдумывает цены: пять решений из моего бота | 0 | 8.78 | 13-10-2026 |
| 3 | Как желание быстрее читать чужой код превратилось в войну с недетерминизмом LLM | 0 | 5 | 28-06-2026 |
| 4 | Почему LLM никогда не пройдут тест Тьюринга в Tinder: изнанка дейтинг-аутстаффа | 0 | 9.37 | 22-09-2026 |
| 5 | Как заставить LLM выбирать осмысленные фрагменты из часовой расшифровки: почему «найди интересные моменты» не работает | 0 | 5 | 28-06-2026 |
| 6 | ✈️ Туркарта недели: разные страны, реальные цены 13–19 июля | ... | 0 | 7 | 07-07-2026 |
| 7 | МЫ УЧИМСЯ ЛЮБИТЬ ТОГО, КОГО НЕТ. И ЭТО ПУГАЕТ. 🤖💔 ... | 0 | 4.78 | 10-08-2026 |
| 8 | 🔥 БРОНИРУЙТЕ СЕЙЧАС — ПОТОМ БУДЕТ ДОРОЖЕ! Друзья, ловите момент, ... | 0 | 19.57 | 04-08-2026 |
| 9 | Хочу поделиться с вами нашими успехами 🔥 только за 10 ... | 7 | 6 | 13-07-2026 |
| 10 | Оптимизируем отпуск как инженеры: искусство продавать выходные | 0 | 10.62 | 20-10-2026 |