Как устроен поиск Wildberries изнутри: шарды, TLS-фингерпринты и 50 запросов в минуту

Я неделю ковырял API Wildberries, чтобы написать бота для проверки позиций. Рассказываю, что нашел внутри и почему `requests` банят за секунду, а `curl` — нет.

Мне нужно было проверить, на какой позиции мой товар в поиске Wildberries. Задача на пять минут, если делать руками. И на три недели — если хочешь автоматизировать.

За это время я нашел 26 поисковых шардов, которые возвращают одинаковые данные, разобрался почему WB банит одни HTTP-клиенты и пропускает другие, и в итоге написал Telegram-бота, который проверяет позиции по 48 городам России.

Ниже — все технические находки. Без воды.

Зачем вообще проверять позиции

63% продаж на WB — из внутреннего поиска. Покупатель вводит «кроссовки мужские» и выбирает с первой страницы. Если ваш товар на пятой — его не существует.

Проблема в том, что позиция зависит от региона. В Москве ваш товар на 8-й позиции, в Краснодаре — на 40-й. WB ранжирует по скорости доставки (30–40% веса алгоритма), поэтому если ваш склад в Подмосковье — в Сибири вы проигрываете конкурентам с местных складов.

Проверять руками — лениво. Автоматизировать — интересно. Я пошел по второму пути.

requests.get() и мгновенный бан

Поисковый API Wildberries — не секрет. Если открыть DevTools при поиске на сайте, видно куда уходят запросы:

https://search.wb.ru/exactmatch/ru/common/v18/search?query=кроссовки&dest=-1257786&page=1&resultset=catalog&sort=popular

Endpoint публичный, авторизации нет, формат JSON. Казалось бы — бери и парси.

import requests resp = requests.get(url, params=params)

Результат: 403. С первого запроса. Без предупреждений, без капчи, просто глухая стена.

Окей, может нужен User-Agent? Добавил — 403. Может куки? Скопировал из браузера — 403. Может прокси? Попробовал три разных — 403, 403, 403.

В этот момент я начал подозревать, что дело не в том, что я отправляю, а в том, как.

Ключевое открытие: TLS-фингерпринт

Когда браузер устанавливает HTTPS-соединение, он отправляет серверу «рукопожатие» — TLS Client Hello. В нем: список поддерживаемых шифров, расширения, порядок алгоритмов. У каждого браузера этот набор уникальный. У Python-библиотеки requests — тоже уникальный. И WB прекрасно их различает.

Это называется TLS-фингерпринтинг. Wildberries не смотрит на ваш User-Agent. Ему неважно, что вы написали «Chrome/126.0» в заголовке. Он смотрит на TLS handshake и видит: это Python, а не браузер. Бан.

Причем бан жесткий — каскадный, retry его только продлевает. Я проверил: requests, aiohttp, urllib3 — все банятся одинаково. Общий TLS-стек Python их выдает.

Решение нашлось в библиотеке curl_cffi. Она оборачивает настоящий curl и умеет эмулировать TLS-фингерпринт конкретного браузера:

from curl_cffi.requests import AsyncSession async with AsyncSession(impersonate="chrome") as session: resp = await session.get(url, params=params)

Параметр impersonate="chrome" — и сервер видит TLS handshake настоящего Chrome. Ответ: 200. Данные. JSON с товарами.

Ни прокси, ни ротация IP — ничего из этого не было нужно. Проблема была исключительно в TLS.

Что внутри API

Получив рабочий доступ, я начал разбираться что к чему.

Endpoint: /exactmatch/ru/{shard}/v18/search

Важный момент: во многих старых гайдах фигурирует /catalog/. Он мертв, возвращает 404. Актуален только /exactmatch/.

Параметры запроса:

Параметр: query. Пример: кроссовки мужские. Что делает: Поисковый запрос

Параметр: dest. Пример: -1257786. Что делает: Регион (Москва)

Параметр: page. Пример: 1. Что делает: Страница (100 товаров)

Параметр: resultset. Пример: catalog. Что делает: Тип выборки

Параметр: sort. Пример: popular. Что делает: Сортировка

Параметр: spp. Пример: 30. Что делает: Скидка СПП

Ответ — JSON с массивом products. Каждый товар содержит id, name, brand, rating, feedbacks и цену в копейках.

Пагинация простая: page=1, page=2 и так далее. На каждой странице 100 товаров. Когда products возвращает пустой массив — товары кончились.

26 шардов, которые возвращают одинаковые данные

В URL есть параметр shard. На проде я нашел 26+ рабочих:

common, male, female, msk, blackhole, preset, similar, sport, beauty, kids, health, food, books, auto, hobby, men_shoes, women_shoes, electronic, office, toys, pet, household, dom, furniture, garden, zoo

Логично предположить, что male возвращает мужские товары, beauty — косметику, sport — спорт. Так вот, нет. Все шарды возвращают одинаковые результаты. Один и тот же запрос на common и на blackhole дает один и тот же список товаров.

Зачем WB держит 26 одинаковых эндпоинтов — я не знаю. Может, исторически они были разными. Может, это балансировка нагрузки. Но для парсера это подарок: если один шард вернул 429, можно сразу пойти на другой.

429 — это не бан

Это было вторым важным открытием. Когда начинаешь отправлять запросы с частотой 1–2 в секунду (~50 в минуту), периодически приходят ответы 429 Too Many Requests. Примерно 6% запросов.

Интуитивная реакция: стоп, меня банят, надо снижать скорость. Но на самом деле эти 429 — не бан. Это мягкий рейт-лимит, и он работает иначе, чем кажется.

Что я выяснил тестами:

Получил 429 на common, сразу иду на preset — 200 OK

Получил 429, жду 2 секунды, повторяю — 83% — 200 OK

Получил 5 подряд 429 — Тогда да, надо подождать 30 секунд

Устойчивый поток 50 req/min — Работает стабильно, 94% успеха

Главное: 429 не каскадный. Один неудачный запрос не запускает цепочку банов. Retry через другой шард почти всегда помогает. Это принципиально отличается от TLS-бана, где каждый retry только ухудшает ситуацию.

Итоговая стратегия: 1. Пауза 1–2 секунды между запросами (рандомная) 2. Шарды по кругу (round-robin) 3. При 429 — ждем 2 секунды, пробуем другой шард (до 2 попыток) 4. При 5+ подряд 429 — бэкофф 30 секунд

Результат: 99% запросов проходят успешно. На одном IP, без прокси.

Basket API: бэкдор для проверки товаров

Отдельная находка — Basket API. Это CDN Wildberries, через который раздаются карточки товаров. Структура URL:

https://basket-{NN}.wbbasket.ru/vol{VOL}/part{PART}/{ARTICLE}/info/ru/card.json

Где VOL = article_id // 100000, PART = article_id // 1000, а номер бакета (01–18) определяется по таблице диапазонов.

Этот API: - Не требует TLS-имперсонации - Не ставит рейт-лимиты - Отдает полную информацию о товаре (название, бренд, характеристики)

Я использую его для проверки существования артикула. Если пользователь отправил номер и товар не нашелся в поиске — прежде чем сказать «не найден», стоит проверить, существует ли он вообще. Может, просто выпал из индекса.

dest-параметры: хаос вместо системы

Для проверки позиций по регионам нужен параметр dest — идентификатор города. Вот несколько примеров:

Москва — -1257786

Санкт-Петербург — -1221148

Краснодар — -1255563

Новосибирск — -364763

Владивосток — 123587791

Нижний Новгород — 12358579

Никакой логики. Отрицательные, положительные, разная длина. Нельзя вычислить dest по названию города — только захардкодить. Я собрал 48 городов через эндпоинт user-geo-data.wildberries.ru/get-geo-info и положил в справочник.

Что из этого получилось

Весь этот реверс-инжиниринг лег в основу Telegram-бота. Отправляешь артикул и ключевой запрос — получаешь позицию. Можно выбрать любой из 48 городов, поставить товар на мониторинг — бот проверяет 3 раза в день и присылает алерт, если позиция изменилась больше чем на 5 мест. Плюс ежедневный отчет.

Бот бесплатный: @WBposition762bot

Весь стек: Python, python-telegram-bot, curl_cffi, SQLite, APScheduler. Крутится в Docker на VPS.

Что не получилось и честные ограничения

Поисковая выдача WB в 2026 — почти полностью рекламная. В конкурентных нишах первая страница забита промо. Органические позиции начинаются со второй-третьей страницы. Бот показывает «честную» позицию, но покупатель видит другое — с рекламными вставками. Это нужно понимать.

WB предупреждает: парсерам могут показываться рандомизированные результаты. На практике я этого не заметил — данные консистентны между шардами и сессиями. Но гарантий нет.

Один IP — потолок. 50 запросов в минуту хватает на 20–30 пользователей с 15 отслеживаниями каждый. Если нужно больше — нужны прокси или Cloudflare Workers (у меня подготовлены, но пока не понадобились).

Итого

Главные технические находки:

  1. Защита WB — TLS-фингерпринт, не IP. requests и aiohttp банятся жестко. curl_cffi с impersonate="chrome" проходит. Прокси не нужны.
  2. 26+ шардов с одинаковыми данными. Все варианты (common, male, blackhole…) возвращают одно и то же. Полезны для ротации при 429.
  3. 429 — мягкий лимит, а не бан. Retry через другой шард работает в 83% случаев. Каскадного бана нет. 50 req/min — устойчиво.
  4. Endpoint /catalog/ мертв. Работает только /exactmatch/. Многие гайды и парсеры ссылаются на старый URL — он давно 404.
  5. Basket API — бэкдор без рейт-лимитов. Данные о товарах можно получить через wbbasket.ru без каких-либо ограничений.
  6. dest-параметры — хаос. Нет формулы, только справочник. Москва = -1257786, Владивосток = 123587791.

Если делаете что-то похожее — надеюсь, это сэкономит вам пару дней тупления в 403-е.

Бот: @WBposition762bot — бесплатный, без регистрации. Проверяйте позиции, ставьте на мониторинг.

Вопросы, критика, дополнения — пишите в комментариях. Особенно интересно, если кто-то нашел другие рабочие эндпоинты WB или разобрался с логикой dest.