Я собираю афишу города в одну карту из пяти источников. Два из них — Яндекс.Афиша и afisha.ru — собирать себя не хотят. И режут не так, как ждешь: ни логина, ни капчи на входе, ни «подтвердите, что вы не робот». Мой requests.get(...) ловит 403 — даже когда заголовки скопированы из Chrome один в один.

Первая мысль была дурацкая: где-то не хватает куки или очередного sec-ch-ua. Оказалось — проблема раньше. К тому моменту, когда сервер читает мой браузерный User-Agent, он уже успел посмотреть, как именно клиент открыл защищенное соединение. Питоновский клиент палится ровно тем, как он здоровается.


Почему браузерный User-Agent не делает Python браузером

HTTPS-соединение начинается с TLS-рукопожатия. В первом же сообщении — ClientHello — клиент выкладывает серверу:

  • поддерживаемые версии TLS;

  • список шифров и их порядок;

  • расширения;

  • эллиптические кривые;

  • ALPN;

  • алгоритмы подписи.

Вместе это — устойчивый профиль TLS-стека и его настроек (зависит не только от либы, но и от ее версии и сборки). По нему строят TLS-фингерпринт: известные форматы — JA3 (постарше) и JA4 (поновее). Какой именно считает конкретный антибот, я не знаю — внутренностей чужого edge у меня нет. Но клиента там различают уже здесь, еще до анализа тела запроса.

В чем фокус: обычные Python-клиенты в типовой сборке ходят через питоновский ssl-модуль поверх OpenSSL (в моем эксперименте это requests), а Chrome — через BoringSSL (форк OpenSSL от Google). В итоге отличается почти все ниже HTTP:

  • набор и порядок шифров;

  • TLS-расширения и GREASE;

  • ALPN;

  • версия HTTP;

  • HTTP/2 SETTINGS;

  • порядок псевдозаголовков.

Поэтому строка User-Agent: Mozilla/5.0 ... сама по себе не доказывает ничего: к этому моменту у edge уже есть сильный сигнал транспортного уровня — типичный OpenSSL-профиль, не обязательно питоновский, но на Chrome такой клиент не похож.

Что показал эксперимент

Сравнил несколько конфигураций клиента, не меняя IP и URL:

Клиент

Заголовки

impersonate

HTTP

Ответ

requests

стандартные

нет

1.1

403

requests

скопированы из Chrome

нет

1.1

403

curl_cffi

браузерные

chrome

2

200

Браузерные заголовки не сдвинули ничего. Сдвинула browser impersonation.

Только не будем приписывать тесту больше, чем он показал. curl_cffi меняет не один ClientHello — вместе с TLS-профилем едут HTTP/2-поведение, ALPN и часть стандартных заголовков. Так что доказано тут скромное:

одних браузерных HTTP-заголовков мало; блокировка держалась на сетевом профиле под ними — TLS, HTTP/2 или их сумме.

Что весило больше, JA4 или HTTP/2-профиль, этим тестом я не разделил. Для чистоты надо было независимо переключать TLS-профиль, версию HTTP и порядок заголовков.

curl_cffi: воспроизводим транспортный профиль Chrome

Помогла библиотека curl_cffi — обертка над curl-impersonate. Она воспроизводит браузерный транспортный профиль: TLS-параметры, ALPN и характерное HTTP/2-поведение конкретной версии браузера. Коннекторы Яндекс.Афиши и afisha.ru у меня оба на ней.

from curl_cffi.requests import AsyncSession

# afisha.ru rejects the default transport profile of plain Python clients.
# curl_cffi reproduces a Chrome-like TLS and HTTP/2 profile
# that was accepted in our tests.
_IMPERSONATE = "chrome"


def _session(self) -> AsyncSession:
    return AsyncSession(
        impersonate=_IMPERSONATE,
        proxies=self._proxies,
    )

Одна строчка impersonate="chrome" — и в моем случае 403 пропал. curl_cffi в моей версии, с дефолтными заголовками, сам подставляет согласованные с профилем User-Agent / sec-ch-ua / accept, поверх которых мой код добавляет только специфичные для запроса. Клиент выглядит цельно, а не «браузерный UA поверх питоновского рукопожатия» — вот это был бы еще более явный палеж.

Универсальной кнопки «пройти антибот» тут нет: сайт может дополнительно смотреть на IP и ASN, куки, историю сессии, JS-challenge, частоту и поведение. Именно это и случилось дальше.

Соединение приняли, но POST все еще не похож на запрос фронтенда

Дальше интереснее. Нужные мне данные фронтенд Яндекс.Афиши тянет через GraphQL-эндпоинт. Рукопожатие прошло, HTTP/2 тоже, но POST продолжает получать 405. Transport impersonation открыла дверь, но сам POST все еще не был похож на запрос фронтенда. Дальнейшие тесты показали, что проблема в его форме:

def _headers(self) -> dict[str, str]:
    return {
        "content-type": "application/json",

        # В моих тестах без пустого x-csrf-token
        # сервер отвечал 405.
        "x-csrf-token": "",

        "x-force-cors-preflight": "1",
        "origin": "https://afisha.yandex.ru",
        "referer": f"https://afisha.yandex.ru/{self.city}",
        "accept-language": "ru-RU,ru;q=0.9",

        # Принимался любой синтаксически корректный.
        "cookie": "yandexuid=<any-valid-id>",
    }

Самое неочевидное — пустой x-csrf-token. В моих тестах валидное значение токена не требовалось: сервер принимал заголовок с пустой строкой. На момент разбора без него отвечал 405, с ним — 200. Плюс понадобились именованный GraphQL-оператор, корректное тело, origin, referer и yandexuid.

Что там крутится внутри middleware или edge — я не вижу и не додумываю. Может, запрос уходит в другую ветку обработки, может, проверяется схема фронтенд-запроса. Я утверждаю только наблюдаемое: без набора условий — 405, с ним — 200.

Не угадывай слой по номеру статуса

Соблазнительно вывести простое правило: 403 — антибот, 405 — приложение, 429 — лимит. Так нельзя. HTTP-статус говорит результат, но не говорит, какие сигналы участвовали в решении. Тот же 403 может учитывать и TLS-отпечаток, и HTTP/2-профиль, и репутацию IP, и куки, и заголовки, и WAF-правило, и частоту.

Полезнее спросить другое: дошло ли вообще до HTTP-ответа?

Практическая шпаргалка, по которой развожу симптомы:

Симптом

С чего начать

Что смотреть

TLS alert или reset до HTTP-ответа

TCP/TLS

версия TLS, cipher suites, SNI, ALPN

HTTP 403

WAF или антибот

TLS/h2-профиль, IP, куки, заголовки

HTTP 400 / 405

приложение, middleware или WAF

метод, эндпоинт, GraphQL-оператор, тело

CAPTCHA

challenge-система

IP, куки, JS-стейт, сессия

HTTP 429

rate-limit

частота, concurrency, backoff

Когда браузерного профиля мало: репутация IP

С afisha.ru был еще один слой. Browser impersonation проходит, но листинг с моего egress в VK Cloud отдает капчу. Причина, судя по опытам, уже не в транспортном профиле — дело в репутации диапазона: датацентр сам по себе «грязный».

Гнать весь трафик сервиса через общий VPN — грубо и незачем. Нужно завернуть ровно один хост. Решение — WireGuard split-туннель с выходом через GCP. Тонкость неприятная: WireGuard знает только IP-префиксы — домены ему не скормишь. Так написать нельзя:

AllowedIPs = afisha.ru

Приходится резолвить домен, брать текущие IPv4/IPv6, класть их в маршрутизацию и следить за изменениями. А если сайт за CDN, один IP может обслуживать чужие домены — часть постороннего трафика тоже уедет в туннель. Так что «туннель на один хост» на деле — «туннель на адреса, в которые хост резолвится прямо сейчас». В конфиге причина записана прямо:

# afisha.ru challenges our default VK Cloud egress by IP reputation.
# Production routes afisha.ru addresses through a separate GCP egress,
# which was not challenged at the time of testing.
afisha_enabled: bool = Field(
    default=True,
    alias="AFISHA_ENABLED",
)

И тут важно не приукрасить: GCP — тоже датацентр, не residential. Домашним IP я не стал. Я просто ушел с диапазона, который получал challenge, на диапазон с репутацией получше. Итого два независимых слоя: curl_cffi чинит несовпадающий транспортный профиль, а split-туннель — «твой IP из плохого района».

Лучший обход — иногда вообще не обходить

Самое обидное во всей истории. Пока я воевал с капчей на www.afisha.ru, рядом все это время лежал GraphQL-эндпоинт graph.afisha.ru/graphql — тот самый, откуда фронтенд берет даты сеансов. На момент разбора он:

  • отвечал с датацентр-IP без всякого challenge;

  • возвращал компактный JSON на 1–2 КБ вместо тяжелой HTML-страницы;

  • одним запросом отдавал все сеансы события — многодневный прогон или тур целиком, без отдельного запроса на каждую дату.

# Event sessions are read from the GraphQL endpoint used by the schedule UI.
# At the time of testing it returned compact JSON from a datacenter IP
# without the challenge shown by the HTML listing.
_GRAPHQL_URL = "https://graph.afisha.ru/graphql"

Единственная плата — persisted-queries: вместо полного запроса фронтенд шлет заранее известный sha256-хеш операции, и после обновления сайта старый хеш отвечает PersistedQueryNotFound — тогда снова лезешь в Network за актуальной операцией. Все равно это в разы легче, чем дергать HTML на каждое событие.

Поэтому и краулер листинга ведет себя вежливо: пауза между страницами ~2.5с + джиттер, ретраи на 429/5xx с бэкоффом, полный скан раз в сутки, инкремент — одна страница раз в несколько минут. Смысл скрейпа — взять минимум нужного самым легким путем. Не выжать источник досуха.

Про серую зону — без самообмана

Страницы публичные, логина не требуют, пейволл я не обхожу и приватного не трогаю. Но антибот все равно обхожу против воли владельца — это серая зона. И публичность страницы сама по себе не дает права на автоматический сбор и переиспользование: остаются ToS, права на базу данных, авторские права на тексты и картинки, правила непубличного API. Так что ниже — про инженерную практику; юридической оценки я не даю.

Что я себе позволяю и чего нет:

  • беру только реально нужные поля (список + даты);

  • предпочитаю легкий JSON тяжелому HTML;

  • держу паузы и бэкофф, один суточный полный проход;

  • ничего приватного и никакой нагрузки, близкой к DoS;

  • заранее считаю источник нестабильным.

Последнее важнее всего. Пустой x-csrf-token работает сегодня — завтра поменяется middleware, послезавтра протухнет persisted-хеш, через месяц подвинут TLS-политику. Если строишь на этом продукт, держи фолбэк (у меня при отвале API берутся грубые Min/Max из листинга). Белой это историю не делает. Хотя бы честной. И да — все конкретные детали выше это снимок на лето 2026.

По другую сторону баррикад: как я сам ловлю ботов

Забавно, что в другом своем проекте — чат-виджете, где каждый запрос стоит денег через внешнее API, — я по другую сторону этой же войны. Однажды по одному клиенту прошла координированная волна: около 130 IP из датацентр-подсетей, из них 91 подняли активность в пределах 42 секунд ровно в полночь, все с одним headless-профилем.

Такой узкий кластер по времени сильно указывал на координированный запуск. Доказать конкретный управляющий сервер по одним логам нельзя, мотив тоже остается догадкой — но по времени и повадкам это выглядело как попытка разом сжечь API-кредиты и испортить продуктовую статистику. И тут пригодились ровно те сигналы, которые я только что обходил как скрейпер.

Не блок, а fraud-score

Главный принцип защиты: ни один сигнал не выносит приговор в одиночку. Датацентр-IP подозрителен, но живые тоже заходят из AWS, GCP, VPN и корпоративных сетей. Поэтому он только добавляет вес:

if _is_datacenter(ip):
    add(DATACENTER_IP_WEIGHT, "datacenter_ip")

Дальше вес копится из JA4, HTTP/2-отпечатка, ASN и типа сети, velocity, клиентской подписи, PoW и поведения. Блокирует не «плохой IP», а сумма. Конкретные пороги и веса я скрыл: читателю они ничего не добавляют, а атакующему экономят пару проб. Реальной оставил только сложность PoW — на ней дальше строится оценка стоимости.

Та же ошибка — только с другой стороны

На краю прокси терминирует TLS, считает JA4 и HTTP/2-отпечаток и передает в бэкенд. JA4 — читаемая строка вида:

t13d1516h2_<cipher-hash>_<extension-hash>

В моей системе доверие устроено в два пути. Часть стабильных cipher-кластеров по накопленной статистике почти не встречалась в мошенническом трафике — им хватало доверия по одному cipher-hash. А для массово имитируемого Chrome-профиля дополнительно проверялся extension-hash: совпал cipher, но расширения незнакомые — уже «подозрительный». Важно: сам cipher-hash лишь коррелирует с семейством TLS-стека и ничего не доказывает про конкретный браузер.

И вот тут, пока я писал статью, вылезла неприятная вещь. В коде доверенный fingerprint полностью отключал дальнейшие проверки:

if fingerprint in BROWSER_WHITELIST:
    return None

То есть логика была: профиль похож на браузер — можно не считать score и не смотреть поведение. А первая половина статьи ровно про то, что этот профиль воспроизводится через impersonation. Прямое противоречие: в теории ни один сигнал не абсолютен, на практике один fingerprint давал полный bypass. Для Chrome-профиля меня отчасти прикрывает второй хеш (совпасть еще и по расширениям умеет не всякий impersonate), но кластеры попроще доверялись по одному cipher — и там скип безусловный.

Правильно тут не безусловное доверие, а отрицательный вес. Пока писал статью, этот кусок и переписал: знакомый браузерный профиль теперь снижает подозрительность, но score и поведенческие проверки не отключает.

# TRUSTED_BROWSER_DISCOUNT < 0 — снижает подозрительность, но проверки не отключает
if is_known_browser_fingerprint(ja4, h2_fp):
    add(TRUSTED_BROWSER_DISCOUNT, "known_browser_profile")

Теперь даже идеально воспроизведенный транспортный профиль не выключает анализ поведения, velocity и подписи. Такой вот побочный эффект: пишешь разбор своей же системы — и находишь в ней багу.

Отдельные грабли, за которые заплатил полднем: локальный compute_ja4 расходился с edge-версией (регистр hex в списке шифров), и whitelist, собранный по данным edge, надо сверять именно с edge-значением. Иначе валидный Chrome не попадает в свой же whitelist.

Velocity с поправкой на тип сети

Наивное «много UUID с одной /16 = ботнет» быстро ломается о реальность: за /16 мобильного оператора через NAT сидят тысячи живых людей. Поэтому крупные подсети нельзя мерить одной линейкой:

UUID_THRESHOLD_DATACENTER     = LOW_THRESHOLD      # реальные значения скрыл
SUBNET_THRESHOLD              = MEDIUM_THRESHOLD
SUBNET16_DATACENTER_THRESHOLD = LOW_THRESHOLD

Порог по /16 применяется только к датацентр-подсетям. Порог по /24 — тоже слабое место, честно: за одним диапазоном бывает офис, универ, публичный Wi-Fi или операторский NAT. Поэтому и /24 — это вес с поправкой на ASN; самостоятельным основанием для блока он быть не должен.

Клиентская подпись — полезная, но не секретная

Виджет мой, поэтому я могу требовать дополнительные признаки штатного клиента: HMAC-подпись запроса per-company секретом, короткоживущий session-токен (HMAC-подписанный на серверном ключе, живет 30 минут, несет в себе PoW-challenge) и сам Proof-of-Work.

Но врать себе тут нельзя: секрет, уехавший в браузер, — уже не секрет. Ключ лежит в JS-бандле, значит, его можно вытащить и подписывать запросы правильно. Поэтому подпись не доказывает подлинность клиента — она лишь поднимает стоимость: надо скачать бандл конкретной компании, найти ключ, восстановить схему и повторять после каждой ротации. Массовому универсальному скрипту это лишняя возня, целевого атакующего под одну компанию — не остановит. Так что подпись — тоже лишь один вес в сумме. Даже с украденным секретом бот спотыкается о поведение (ноль mouse/key, отправка через 300 мс после mount, ровные интервалы).

Оговорюсь и тут: поведение — не судья в одиночку. На мобильном нет mouse-событий, у screen-reader свои паттерны, кто-то жмет заранее готовую кнопку. Поэтому сигналы читаются с поправкой на платформу. «Нет мыши → бот» — так нельзя.

Proof-of-Work тоже не панацея

Виджет получает challenge и ищет nonce:

POW_DIFFICULTY_BITS = 18

То есть подобрать значение, у которого SHA-256 начинается с 18 нулевых бит. В браузере это ~50–150 мс, пользователь не замечает. Но без сказок про экономику: это ~2^18 хешей, и нативный код, параллелизм и GPU считают их куда быстрее браузерного JS. PoW не останавливает атаку сам по себе — он делает каждый запрос чуть дороже. Работает только в связке: rate-limit, короткий TTL challenge, защита от replay, fraud-score, поведение.

Кстати про stateless: сам session-токен проверяется без похода в хранилище, а вот anti-replay по nonce уже требует короткоживущего серверного состояния — кэша использованных nonce. То есть «stateless» тут про токен, не про всю проверку.

Почему tarpit может ударить по самому сервису

Рядом — rate-limit, который не банит, а тормозит:

CANARY_CHAT_RATE_LIMIT = LOW_RATE_LIMIT   # реальное значение скрыл

Превышение — tarpit текущего запроса, без persistent ban: живой восстановится через минуту. Но tarpit легко превратить в само-DoS. Даже если задержка на краю и не занимает application-воркер, во время паузы висят TCP-соединение, HTTP/2-stream, память, дескрипторы. Так что число одновременно придержанных запросов тоже надо лимитировать. Часто честный 429 Too Many Requests с Retry-After дешевле и безопаснее.

Синхронность оказалась не главным сигналом

Сначала я полез строить отдельный режим «под атакой»: считать rpm на компанию и ужесточать пороги на всплеске. Начал — и бросил: для той волны он почти ничего не добавлял. Запросы и без него были на одно лицо — датацентр-IP, один headless-профиль, ноль ожидаемого поведения, отправка сразу после загрузки, ровные интервалы. Каждый адрес набирал score сам, синхронно они стартовали или с разбежкой в минуты — неважно. Синхронность помогла понять, что волна координированная. Но ловилось поштучно — по тому, что все клиенты одинаковые.

Мораль симметрии: что подделывается дешево (UA, IP через прокси, TLS через impersonate), защита либо читает глубже (JA4, h2, поведение), либо делает дорогим (PoW, подпись), либо не банит сразу, а копит в score. Выигрывает тот, кто поднимает СТОИМОСТЬ атаки. Не тот, кто городит очередной жесткий блок — так только живых ловишь заодно.

Что вынес

  1. Браузерного User-Agent мало. Сильные сигналы появляются уже в ClientHello (JA3/JA4) и дополняются HTTP/2-профилем; помогла согласованная имитация транспорта через curl_cffi — подмена заголовков не спасала.

  2. Номер статуса не выдает слой. Оборвалось до ответа — копай в TCP/TLS; пришел 403/405 — соединение живо, а решение могло опереться и на TLS, и на IP, и на заголовки.

  3. Репутация IP — независимый сигнал. Impersonation не чинит плохую репутацию датацентр-диапазона; лечится это маршрутом (split-туннель на текущие адреса хоста), имитация тут бессильна.

  4. Лучший обход — поискать легкий путь рядом. Часто у того же сайта есть компактный API, который и источник щадит, и тебе жизнь упрощает.

  5. Fingerprint — не доказательство. То, что воспроизводится через impersonation, нельзя превращать в абсолютное доверие — ни при обходе, ни при защите.

  6. Защита поднимает стоимость атаки. Fraud-score, rate-limit, короткоживущие токены и PoW работают только вместе.

Так и живу: с утра обхожу чужой антибот ради афиш, вечером строю свой против ботов. Один и тот же отпечаток — по разные стороны экрана.

А карта, ради которой вся эта возня, — Окрест.`

Ссылки