Трендовые github проекты в нашем телеграм канале. Подпишись → Фраза «Египет в ноябре на 8 ночей» как строгий JSON и что с ним делать дальше
Задача выглядит просто: пользователь пишет боту «Египет в ноябре с 10-го ±3 дня на 8 ночей, двое взрослых и ребёнок 7 лет, 4–5 звёзд», бот находит туры, билеты и отели, а по кнопке следит за ценой и сообщает, когда она падает. Стек: Python 3.12, asyncio, httpx, pydantic, SQLite, всё в Docker на одном VPS. Самое интересное здесь начинается на стыке LLM с внешними API, у которых документация расходится с реальностью.
Разбор запроса: LLM со схемой
Регулярки сдаются за пару дней. «С 21 октября плюс-минус 4 дня», «на недельку», «двое и ребёнок», «без пересадок», «в Пекин, обратно из Шанхая» — вариантов бесконечно много. Рабочая схема: фразу разбирает языковая модель, ответ сразу валидируется 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и начинает предлагать идеи поездки. Правило: если названа страна, город или даты,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())
Один код для 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: ...
Каждое входящее событие превращается в Ctx: кто написал, в какой чат, через какой адаптер отвечать. Пользователь хранится как tg:123 или max:456, так что одна таблица обслуживает обе платформы. Адаптер Telegram — тонкая обёртка над aiogram, адаптер MAX — около 190 строк на чистом httpx.
Особенности MAX, которые пришлось обходить:
-
TLS-сертификат API выпущен УЦ Минцифры, которого нет в
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. Каждое обновление обрабатывается отдельной задачей, чтобы долгий поиск одного пользователя не тормозил остальных. -
Нет метода «список моих чатов». ID канала бот запоминает из события
bot_added. -
Нет диалога «поделиться».
share_linkвозвращаетNone, и кнопка в MAX просто не рисуется. -
Username бота генерируется (вида
id772878808103_bot), поэтому ссылки строятся из ответа/me, хардкодить его нельзя.
Создать бота в MAX может только юрлицо, ИП или самозанятый через business.max.ru. HTML-разметка, inline-кнопки и deep link ?start= работают так же, как в Telegram.
Источники данных и их недокументированное поведение
Источники опрашиваются параллельно через asyncio.gather(..., return_exceptions=True): падение одного не ломает ответ, ошибка уходит в лог. Одинаковые запросы от разных пользователей и от монитора цен кэшируются на 4 минуты, а параллельные одинаковые запросы ждут одного вызова через asyncio.Lock на ключ запроса.
Шлюз туров Слетать.ру
Шлюз платный, лицензия привязана к домену или IP. Поиск асинхронный: GetTours(requestId=0) запускает его, GetLoadState опрашивается не чаще раза в 1,5 секунды до IsProcessed у всех операторов, затем GetTours(requestId, updateResult=1) отдаёт строки массивами (цена в row[42], отель в row[7], курорт в row[19]).
Что всплыло на практике:
- Без заголовка
Refererрезультатов нет, даже при лицензии по IP. Подходит любое значение. - Цены в 4 раза ниже рыночных — в выдачу попадали пакеты без перелёта. Нужны
s_hasTickets=true,s_ticketsIncluded=true,includeOilTaxesAndVisa=1иs_hotelIsNotInStop=true. При этомs_ticketsIncluded=falseпригодился для режима «только отель». - Фильтр по курортам игнорируется: запрос на Хургаду возвращает Марса-Алам. Фильтровать приходится у себя, размер страницы поднят до 300 строк. Квота считается по запущенным поискам, страницы бесплатны.
GetHotelsищет по подстроке:horusнаходит Bosphorus. Совпадение перепроверяется локально.- Ссылка на бронь из выдачи ведёт в кабинет агента. Рабочий формат для туриста —
sletat.ru/checkout/{operator}-{price_id}-{request_id}, и он живёт дольше 15 минут, отведённых поиску. Проверять пришлось в браузере: сайт — JS-приложение, curl там ничего не покажет.
Из выдачи остаётся один самый дешёвый вариант на отель, дату и число ночей: один и тот же отель у операторов встречается десятки раз с разными номерами.
Цена в поиске и цена при бронировании
Самая неприятная история случилась сразу после запуска. Тур во Вьетнам на Новый год, 10 ночей, двое взрослых: бот показал около 222 тысяч рублей, а на странице бронирования оператор пересчитал и выдал 405 775 ₽.
Проверка гипотез дала такой результат:
- Флаг
calcFullPrice=1(«включает топливные сборы») не меняет цены ни на рубль. - Поле
aaData[88]по документации — «полная цена со всеми доплатами». В реальной выдаче там 0 у всех 300 строк. Код, который брал бы это поле при наличии, сделал бы все туры бесплатными и отбросил их. Хороший повод проверять поля на живых данных до деплоя. - Метод
ActualizePriceперепроверяет места, перелёт и доплаты у оператора, но в документации пример отвечает 13,7 секунды, а в реальности не уложился и в 30. Вызывать его для пяти туров, пока пользователь ждёт, нельзя.
К моменту проверки самый дешёвый тур на те же даты стоил от 316 тысяч: 222 тысячи были устаревшей промо-ценой, которую оператор всё ещё отдавал в поиск. Фильтр «отсекать цены сильно ниже медианы» отвергнут — он срезал бы и настоящие горящие туры. Под выдачей теперь стоит честная оговорка, что цена подтверждается оператором при бронировании и может измениться.
Билеты через Travelpayouts
Билеты берутся из Data API Travelpayouts (prices_for_dates) — это кэш поисков Aviasales с ценой за одного взрослого и возрастом данных до недели. Для слежения за трендом этого хватает.
- Мониторится цена одного билета. Если умножать её на состав компании, смена возраста ребёнка создаёт «падение цены» на ровном месте.
- Для компании ссылка ведёт на живой поиск в коротком формате
MOW1011AYT1711211: даты как DDMM, затем число взрослых, детей и младенцев. Формат/search/flights?...открывался в бизнес-классе независимо отtrip_class.
Слежение за ценами
Подписки лежат в 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 None
ref — последняя цена, о которой бот сообщил пользователю. Если сравнивать с первой ценой подписки, после одного падения уведомления посыпятся на каждой проверке.
Два случая, которые нашлись только в эксплуатации:
- Ошибка источника при нуле предложений не считается сигналом «туров нет». Бот молчит до следующей проверки, иначе пользователь получит «туры закончились», а через 10 минут «туры снова есть».
- Дешевле, но с пересадками. Прямые рейсы закончились, самым дешёвым стал вариант с двумя пересадками — формально цена упала. Поэтому сравниваются только варианты с числом пересадок не больше, чем у последнего сообщённого. Если таких нет, бот молча переключается на новый маршрут без уведомления о падении.
Что забрать в свой проект
Схема переносится на любой бот, который принимает свободный текст и ходит во внешние API: LLM разбирает язык, pydantic держит форму с повтором при ошибке валидации, обычный код проверяет инварианты. Мессенджер прячется за Protocol с префиксом платформы в ID пользователя. Логика уведомлений живёт в чистой функции и сравнивает с последним отправленным значением. Каждое поле внешнего API, от которого зависят деньги, стоит проверить на реальной выдаче до деплоя.