Бот для поиска туров на Python: LLM разбирает запрос, а API туроператоров подкидывают сюрпризы. ai.. ai. API.. ai. API. asyncio.. ai. API. asyncio. llm.. ai. API. asyncio. llm. ml.. ai. API. asyncio. llm. ml. python.. ai. API. asyncio. llm. ml. python. искусственный интеллект.. ai. API. asyncio. llm. ml. python. искусственный интеллект. серверная разработка.
Бот для поиска туров на Python: LLM разбирает запрос, а API туроператоров подкидывают сюрпризы - 1

Я сделал бота для Telegram и MAX. Ему пишут обычной фразой: «Египет в ноябре с 10-го ±3 дня на 8 ночей, двое взрослых и ребёнок 7 лет, 4–5 звёзд». Бот находит туры, билеты и отели, а по кнопке следит за ценой и пишет, когда она падает.

В статье — техническая часть: как языковая модель превращает фразу в строгий запрос, как один код обслуживает два мессенджера, как устроено слежение за ценами и какие грабли нашлись в API Слетать.ру и Aviasales. Код на Python 3.12, asyncio, httpx, pydantic, SQLite; живёт в Docker на VPS.

Путь одного запроса: сообщение → LLM-парсер → источники → ответ, слежение, сайты

Путь одного запроса: сообщение → LLM-парсер → источники → ответ, слежение, сайты

## 1. Разбор фразы: LLM со схемой вместо регулярок

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

Фраза пользователя и SearchRequest, который из неё получается

Фраза пользователя и 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: бот уточняет возраст ребёнка и отвечает одинаково

Один и тот же запрос в 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 None

ref — последняя цена, о которой бот сообщил пользователю, а не первая цена подписки. Иначе после одного падения бот писал бы на каждой проверке.

Ещё два случая, которые всплыли в бою:

– Ошибка источника — не «туров нет». Если провайдер упал, а предложений ноль, бот молчит и ждёт следующей проверки. Иначе пользователь получает «туры закончились», а через 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}nn<i>{lbl}</i>" if lbl else text

mark() вызывают оба адаптера в 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)

Автор: Maxnxi

Источник