- BrainTools - https://www.braintools.ru -

Бот для поиска туров на Python: LLM разбирает запрос, а API туроператоров подкидывают сюрпризы

Бот для поиска туров на 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, ошибка [1] 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. Чтобы логика [2] не раздвоилась, вся она живёт в классе 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=... [3], в MAX аналога нет. Поэтому share_link возвращает None, и кнопка «поделиться» в MAX просто не показывается.

– Username бота в MAX выдаётся автоматически, вида id772878808103_bot. Его нельзя хардкодить: ссылки строятся из ответа /me.

Создать бота в MAX может только юрлицо, ИП или самозанятый с подтверждением через business.max.ru [4]. В остальном 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/... [5], но она открывает логин туроператора для агентств, а не страницу для туриста. Рабочий формат подсказала поддержка: sletat.ru/checkout/{operator}-{price_id}-{request_id} [6]. Он открывает выбор перелёта для конкретного тура и живёт дольше 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... [7]. Значит, подпись можно собрать из самих ссылок:

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) [8]

– Бот в MAX: [max.ru/id772878808103_bot](https://max.ru/id772878808103_bot?start=habr) [9]

– Цены по направлениям: [kudadeshevle.ru [10]](https://kudadeshevle.ru [11])

Автор: Maxnxi

Источник [12]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/37424

URLs in this post:

[1] ошибка: http://www.braintools.ru/article/4192

[2] логика: http://www.braintools.ru/article/7640

[3] t.me/share/url?url=...&text=...: http://t.me/share/url?url=...&text=...%60

[4] business.max.ru: http://business.max.ru

[5] sletat.ru/book/...: http://sletat.ru/book/...%60

[6] sletat.ru/checkout/{operator}-{price_id}-{request_id}: http://sletat.ru/checkout/%7Boperator%7D-%7Bprice_id%7D-%7Brequest_id%7D%60

[7] https://aviasales.tpo.li/AbCd?erid=2Vtzqv...: https://aviasales.tpo.li/AbCd?erid=2Vtzqv...%60

[8] t.me/tours_flights_search_bot](https://t.me/tours_flights_search_bot?start=habr): https://www.braintools.ruhttp://t.me/tours_flights_search_bot%5D(https://t.me/tours_flights_search_bot?start=habr)

[9] max.ru/id772878808103_bot](https://max.ru/id772878808103_bot?start=habr): https://www.braintools.ruhttp://max.ru/id772878808103_bot%5D(https://max.ru/id772878808103_bot?start=habr)

[10] kudadeshevle.ru: http://kudadeshevle.ru

[11] https://kudadeshevle.ru: https://kudadeshevle.ru

[12] Источник: https://habr.com/ru/articles/1098622/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1098622

www.BrainTools.ru

Rambler's Top100