
Я собираю афишу города в одну карту из пяти источников. Два из них — Яндекс.Афиша и 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 | Ответ |
|---|---|---|---|---|
| стандартные | нет | 1.1 | 403 |
| скопированы из Chrome | нет | 1.1 | 403 |
| браузерные | 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. Выигрывает тот, кто поднимает СТОИМОСТЬ атаки. Не тот, кто городит очередной жесткий блок — так только живых ловишь заодно.
Что вынес
Браузерного
User-Agentмало. Сильные сигналы появляются уже вClientHello(JA3/JA4) и дополняются HTTP/2-профилем; помогла согласованная имитация транспорта через curl_cffi — подмена заголовков не спасала.Номер статуса не выдает слой. Оборвалось до ответа — копай в TCP/TLS; пришел 403/405 — соединение живо, а решение могло опереться и на TLS, и на IP, и на заголовки.
Репутация IP — независимый сигнал. Impersonation не чинит плохую репутацию датацентр-диапазона; лечится это маршрутом (split-туннель на текущие адреса хоста), имитация тут бессильна.
Лучший обход — поискать легкий путь рядом. Часто у того же сайта есть компактный API, который и источник щадит, и тебе жизнь упрощает.
Fingerprint — не доказательство. То, что воспроизводится через impersonation, нельзя превращать в абсолютное доверие — ни при обходе, ни при защите.
Защита поднимает стоимость атаки. Fraud-score, rate-limit, короткоживущие токены и PoW работают только вместе.
Так и живу: с утра обхожу чужой антибот ради афиш, вечером строю свой против ботов. Один и тот же отпечаток — по разные стороны экрана.
А карта, ради которой вся эта возня, — Окрест.`
