Logo Craft Homelab Docs Нейросети Хостинг Обучение Контакты
Бот для поиска туров на Python: LLM-парсер, два мессенджера и грабли туристических API Трендовые github проекты в нашем телеграм канале. Подпишись →
24 октября 2026 г.

Фраза «Египет в ноябре на 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, от которого зависят деньги, стоит проверить на реальной выдаче до деплоя.