Дисклеймер. Речь не о взломе и не о нагрузке на чужие серверы. Я работал с публичными страницами товаров — теми, что видит любой человек без логина, — и ходил туда раз в несколько часов. Это история о том, как я разбирался, почему меня блокируют. Рабочие параметры и точные обходы намеренно опущены: тут про механизмы, а не про готовый инструмент для взлома интернета.
С чего все началось
Хотел простую вещь: отслеживать цену на пару товаров, которые ждал со скидкой. Чтобы не заходить руками каждый день, а получить уведомление, когда подешевело.
Думал, это вечер работы. requests.get(), выдрать цену, сравнить, отправить в телегу (упрощенно, конечно).
Оказалось, парсинг цен на маркетплейсах — это не про HTTP‑запрос. Это про репутацию IP‑адресов, про то, как браузер выдает себя поведением, и — на финале — про пост‑квантовую криптографию в TLS‑рукопожатии. Три площадки (Ozon, WB, Яндекс.Маркет) дали мне три совершенно разных уровня защиты, и на каждом я застревал по‑своему.

Глава 1. Ozon: «датацентр здесь не пройдет»
Начал с Ozon. Самый наивный вариант — обычный HTTP‑запрос за страницей товара.
403. Сразу.
Окей, добавил заголовки как у настоящего браузера — User‑Agent, Accept, все как полагается. 403. Поменял User‑Agent еще раз. 403. Стало понятно, что дело не в заголовках — Ozon режет меня раньше, чем смотрит, что я там прислал.
Дело не в запросе, а в том, откуда он
Ключевой инсайт пришел, когда я попробовал тот же самый запрос с разных адресов. С сервера в дата‑центре — 403. С домашнего интернета — страница открывается. Запрос идентичный. Разница только в IP.
Вот тут до меня дошло, что антибот Ozon в первую очередь смотрит не на что я прислал, а откуда я пришел. И адреса для него делятся на касты:
— Дата‑центровые IP (облака, хостинги, VPS) — «ты бот, до свидания», 403.
— Мобильные IP — «подозрительно, докажи, что человек» → капча.
— Резидентные IP (обычные домашние провайдеры) — «свой, проходи» → полная страница.
Логика у них простая: настоящий покупатель сидит дома с провайдером вроде Ростелекома, а не шлет запросы из амазоновского облака. Поэтому весь мой красивый сервер был бесполезен — он палился самим фактом, что он сервер.
Решение: притвориться домашним
Выход — пускать запросы через резидентный прокси: трафик идет через реальный домашний IP обычного провайдера, и для Ozon я выгляжу как еще один человек, листающий товары с дивана.
И еще одно — саму цену даже не надо вытаскивать из JavaScript. Она лежит прямо в HTML страницы, в данных серверного рендеринга (то, что приходит еще до того, как отработают скрипты). То есть тяжелый JS‑движок можно не гонять — цена уже в первом же ответе сервера, надо только знать, где ее искать. Это сильно упростило жизнь.
Отдельно стоит сказать про то, как снимать готовый DOM. Самый ходовой способ управлять браузером из кода — подключиться к нему через CDP (Chrome DevTools Protocol). Но забегая вперед, в главе про Wildberries будет видно, что само это подключение оставляет следы, по которым антибот опознает автоматизацию. Поэтому отрендеренный HTML я снимал так, чтобы CDP‑канал вообще не открывался — браузер отдает готовый DOM и закрывается, без интерактивного управления через DevTools. Меньше точек, за которые можно зацепиться. Деталей опускаю, но принцип такой: если можно не открывать CDP — не надо)
А теперь — счет за трафик
Резидентный прокси — удовольствие платное, считается по гигабайтам. И когда я посмотрел, за что именно плачу, поплохело.
Открываю биллинг за первый день. Одна проверка цены одного товара весила десятки мегабайт. Браузер, который я запускал ради одной цифры, добросовестно тянул все: видеопревью товара, тяжелые JS‑бандлы, картинки, и в придачу свою собственную болтовню с серверами Google.
Дальше была война за трафик в три ступени.
Ступень первая: видео. Самым жирным в биллинге оказался домен видео‑CDN — в отдельных строках по 23–25 МБ. Браузер на каждую проверку цены качал видеопревью товара. То самое крутящееся видео, которое на карточке показывают. Мне нужна цена — цифра в пару байт, — а я качал мегабайты видеоролика. Зарезал загрузку видео‑доменов: десятки мегабайт превратились в единицы.
Ступень вторая: JavaScript. После видео самым тяжелым стал домен со скриптами — бандлы фронтенда, по 7–10 МБ на проверку. Но я ведь уже выяснил, что цена лежит в серверном HTML и JS для нее не нужен. Значит, и тянуть эти мегабайты скриптов незачем. Заблокировал загрузку бандлов: осталась почти голая страница.
Ступень третья: то, что осталось. После двух блокировок вес одной проверки упал примерно до 120 КБ — это сама HTML‑страница товара, и все.

С ~30 мегабайт до ~120 килобайт. Это срез примерно в двести раз. На дистанции — разница между «прокси съедает всю экономику проекта» и «проектом вообще можно заниматься».
Кстати, в биллинге до сих пор видно остаточную болтовню браузера с Google — синхронизация, токены, push‑сервисы. Десятки килобайт на проверку все еще утекают не в Ozon, а в Google, и я плачу за это как за резидентный трафик. Так что война, строго говоря, не закончена — просто вышла на приемлемый уровень. Браузер по умолчанию очень разговорчивый, и заставить его молчать — отдельный квест.
Глава 2. Wildberries: «headless выдает себя»
С Ozon разобрался, иду к Wildberries. Думал, будет проще — опыт есть, прокси настроен. Но WB подкинул проблему другого рода.
С резидентным IP за страницу пускали. А вот через headless‑браузер (без графического интерфейса, как обычно и парсят) я упирался в антибот‑челлендж: заголовки в порядке, IP резидентный — а вместо данных проверка, которую headless пройти не мог.
Стал разбираться, на что смотрит антибот, и открылся целый зоопарк сигналов, по которым headless палится — все публично описаны самими антибот‑вендорами. Когда Chrome запущен без интерфейса и под автоматизацией, он отличается от обычного десятком мелочей, и любую антибот проверяет одной строкой JavaScript: navigator.webdriver под автоматизацией возвращает true (у человека — false); список плагинов и window.chrome.runtime часто пустые или отсутствуют; в User‑Agent торчит HeadlessChrome; на сервере без видеокарты WebGL отдает software‑рендерер (SwiftShader) вместо реальной GPU, а enumerateDevices() — пустой список вместо камеры и микрофона живой машины. То есть headless — это не «почти Chrome», это Chrome с дюжиной торчащих наружу признаков, что он робот на сервере.
Почему «пропатчить признаки» не работает
Очевидная мысль — подменить эти признаки, на чем и построены стелс‑плагины. Я полез туда и быстро уперся в тупик по двум причинам.
Во‑первых, антибот проверяет не только значение признака, но и как оно получено. Переопределил navigator.webdriver через Object.defineProperty — это видно: по дескриптору свойства, по цепочке прототипов, по свежему iframe (отдает исходное значение раньше, чем применится патч), по Web Worker‑ам (наследуют непропатченное). Залатал на поверхности — из щелей торчит правда.
Во‑вторых, и это неприятнее, сам способ управления браузером оставляет следы. Puppeteer, Playwright, Selenium рулят Chrome через CDP (Chrome DevTools Protocol), а CDP неизбежно дает побочные эффекты — аномалии в stack trace ошибок, нестандартные дескрипторы, тайминговые следы от инъекции скриптов. Это присуще самой механике: нельзя управлять браузером через CDP и не создавать следов CDP. Стелс‑плагины латают JS‑уровень, но не меняют ни GPU, ни тайминги, ни окружение — да еще и сами добавляют узнаваемые артефакты. Гонка, в которой ты по определению на шаг позади.
(Гонка живая до сих пор. Знаменитый CDP‑сигнал через side‑effect при обращении к .stack объекта Error в 2025-м внезапно перестал работать — V8 изменил, как превьюит объекты. Сломались даже детекторы. Воюют все).
Проще быть настоящим браузером, чем притворяться
И тут пришла мысль, все упростившая: зачем притворяться настоящим браузером, если можно быть им? Корень почти всех сигналов — в том, что браузер headless и крутится на сервере без дисплея.
Решение — запускать полноценный Chrome с графическим интерфейсом, а не headless. Монитора на сервере нет, но это лечится виртуальным дисплеем: поднимаешь экран в памяти (Xvfb — X‑сервер, рисующий в буфер, которого никто не видит), и для браузера всё выглядит как нормальная машина с экраном. Браузер настоящий, headful, просто его картинка уходит в никуда. Это гасит целый класс векторов разом — нет software‑рендерера, правильный тайминг кадров — и не надо латать webdriver хитрыми патчами: браузер действительно не headless, ему нечего скрывать на этом уровне.
Конкретику опускаю. Важен принцип, и он красивый: вместо того чтобы все глубже зарываться в маскировку, иногда дешевле убрать саму причину — перестать быть headless, а не прятать, что ты headless.

Глава 3. Яндекс.Маркет: загадка одинаковых запросов
Самое интересное я оставил напоследок, потому что Яндекс задал мне загадку, над которой я завис всерьез.
К этому моменту я был уже наученный. Резидентный прокси — есть. Заголовки браузера — один в один как у настоящего Chrome. User‑Agent правильный. И вот я шлю запрос за страницей товара на Яндекс.Маркет, а в ответ — капча (Яндекс показывает свою SmartCaptcha). Ладно, думаю, бывает. Беру ровно те же заголовки, тот же IP, и проверяю отдельным маленьким клиентом — проходит, страница отдается.
Стоп. Запросы идентичные. Те же заголовки, тот же IP, тот же User‑Agent. Один ловит капчу, другой проходит. В чем разница?
Когда заголовки одинаковые, а запросы разные
Я сравнивал запросы построчно. Заголовки — совпадают. Порядок заголовков — совпадает. IP — один и тот же. По всему, что я привык считать «запросом», они были близнецами. Но сервер Яндекса их различал. Значит, он смотрит на что‑то, что я вообще не считал частью запроса.
И тут до меня дошло, где я не смотрел. До единого HTTP‑заголовка, до первого байта URL, есть еще TLS‑рукопожатие. Прежде чем отправить хоть что‑то зашифрованное, клиент и сервер договариваются о шифровании, и в самом первом сообщении этого рукопожатия — ClientHello — клиент выкладывает целую анкету о себе: какие версии TLS поддерживает, какие шифронаборы (cipher suites), какие расширения, какие эллиптические кривые. И эта анкета, как оказалось, у разных клиентов разная.
На этом построена техника под названием TLS‑фингерпринтинг. HTTP‑заголовки слишком легко подделать, а вот ClientHello отражает то, как устроена сетевая библиотека клиента под капотом, и подделать его куда сложнее.

JA3 и JA4: как из рукопожатия делают отпечаток
Конкретные алгоритмы, которыми из ClientHello делают отпечаток, называются JA3 и JA4.
JA3 (придумали в Salesforce) берет пять полей из ClientHello — версию TLS, список шифронаборов, список расширений, список эллиптических кривых и форматы точек, — склеивает их числовые значения в строку и хеширует через MD5. Получается короткий отпечаток клиента.
У JA3 есть забавная слабость, на которую я тоже наткнулся: его отпечаток нестабилен для современного Chrome. Дело в GREASE — это специальные случайные значения, которые браузеры намеренно подмешивают в ClientHello (чтобы серверы не закостеневали и умели игнорировать неизвестные значения). Из‑за GREASE и перемешивания порядка расширений JA3-хеш у того же самого Chrome скачет от соединения к соединению. Поэтому полагаться на голый JA3 как на «это точно бот» нельзя — честные браузеры тоже дают разный JA3 каждый раз.
JA4 — более новый и аккуратный наследник. Он, в отличие от JA3, сортирует список расширений перед хешированием (поэтому перемешивание и GREASE его не сбивают), считает хеш через SHA256 и в целом стабильнее. И формат у него человекочитаемый: t13d1516h2_... — это TCP, TLS 1.3, есть SNI, 15 шифронаборов, 16 расширений, ALPN h2 (HTTP/2), а дальше уже хеши. Именно JA4 сейчас — предпочтительный инструмент для такого анализа.
Но есть тонкость, которая мне потом и пригодилась: JA4 в свой отпечаток не закладывает список эллиптических кривых, а JA3 — закладывает.
Развязка: меня выдавала криптография
Когда я понял, куда смотреть, причина нашлась. Мои два клиента отличались не заголовками, а TLS‑анкетой — потому что собраны были на разных версиях Go, а ClientHello у Go зависит от версии.
Ниточкой оказался пост‑квантовый обмен ключами. Тут важна точность: Go и в старой версии слал пост‑квантовую группу — X25519Kyber768Draft00, черновой Kyber. А начиная с Go 1.24 стандартная библиотека по умолчанию переключилась на финальный X25519MLKEM768 — гибрид классического X25519 и пост‑квантового ML‑KEM, защита от «сохрани сейчас, расшифруй потом». Поменялось не «появился пост‑квант», а кодпоинт группы: в реестре у MLKEM768 ID 4588. Один мой клиент (на свежем Go) слал в анкете 4588, другой (на старом) — нет. На уровне TLS они перестали быть близнецами.
И вот тут я снял отпечатки сам, на стенде, чтобы не гадать. Взял один и тот же net/http и сравнил две сборки — с MLKEM по умолчанию и с ним, выключенным вручную. Результат оказался поучительнее, чем я ждал:

Убрал одну группу — JA3 поплыл, а JA4 остался прежним. Ровно потому, что JA3 кривые в хеш кладет, а JA4 — нет. Значит детектор, который различал мои два клиента по этой кривой, работал на JA3-уровне (или разбирал supported_groups напрямую), а не на ванильном JA4. Раз мой фикс — выключить MLKEM — в бою сработал, это и подтвердилось: ловили по группам.
И тут легко сделать неверный вывод — будто палит сам пост‑квантовый ключ. Не он. Chrome тоже шлет X25519MLKEM768 (с версии 131), так что наличие кривой как раз делает Go‑клиент похожим на современный браузер. Выдает не кривая, а форма всей анкеты вокруг нее. Это видно по тому же снимку. Мой Go по JA4 — t13d**1312**h2: 13 шифронаборов, 12 расширений. Реальный Chrome — t13d**1516**h2: 15 и 16. Go‑клиент торчит из ряда еще до всякого MLKEM — он предлагает меньше шифров, меньше расширений и, главное, не подмешивает GREASE, те самые случайные значения, которыми браузер намеренно «зашумляет» ClientHello. Антибот Яндекса смотрел на этот силуэт и видел: пришел не браузер, а Go‑программа, как бы она ни подделывала заголовки.
MLKEM был не приговором, а зацепкой — именно он развел два моих клиента по JA3 и заставил меня наконец посмотреть на слой, который я вообще не считал частью запроса. Маркером же была вся форма Go‑анкеты: меньше шифров и расширений, отсутствие GREASE, плюс характерный набор групп. Починка вышла на удивление короткой и почти комичной на фоне всей истории: одной строкой настройки заставить свою TLS‑библиотеку не выпячивать ту самую пост‑квантовую группу — и силуэт перестает цеплять глаз антибота.
Дополнение: этот же стенд я перезапустил уже на Go 1.26 — MLKEM по‑прежнему в дефолте (4588 так и стоит в группах). То есть само обновление Go проблему не лечит: явный CurvePreferences — не разовый костыль, а постоянная часть парсера.
Что в итоге
Три площадки — три слоя, на которых тебя проверяют, от самого внешнего к самому глубокому:
Площадка |
Слой |
На что смотрит |
Что выдавало |
Ozon |
IP |
репутация адреса |
датацентровый IP |
WB |
браузер |
признаки рантайма |
headless |
Яндекс |
TLS |
ClientHello |
пост‑квантовый ключ Go |
Закономерность одна: каждый раз я застревал, пока проверял слой выше нужного. На Ozon крутил заголовки, когда дело было в IP. На Яндексе крутил заголовки и IP, когда дело было в TLS‑рукопожатии, которое я вообще не считал частью запроса. Самый дорогой по времени баг — не «не работает», а «работает через раз по причине, которую ты не видишь в дампе».
И это движущаяся мишень. CDP‑сигнал со .stack сломался об апдейт V8, Go без спросу пометил свои клиенты новой кривой — завтра поедет что‑то еще. Поэтому из всей истории ценен не конкретный обход (протухнет за полгода), а один вопрос, который я теперь задаю первым: на каком слое нас сейчас видно.
Что из этого выросло
Из всей этой возни вырос рабочий продукт — телеграм‑бот (с костяком в miniApp — графики, удобный интерфейс и тд.), который следит за ценами и пишет, когда товар дешевеет. Ссылку не ставлю, чтобы не выглядело рекламой: если интересно посмотреть или поговорить про устройство — спрашивайте.

Писал я это, чтобы разложить по полочкам собственный путь. Если воевали с похожим и видите, где я мог сделать умнее (особенно по TLS и трафику) — критику принимаю ниже). Делал один, так что взгляд со стороны — то, что нужно. Но и в команде поработать есть желание — открыт к предложениям.