Как устроен поиск 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 (у меня подготовлены, но пока не понадобились).
Итого
Главные технические находки:
- Защита WB — TLS-фингерпринт, не IP. requests и aiohttp банятся жестко. curl_cffi с impersonate="chrome" проходит. Прокси не нужны.
- 26+ шардов с одинаковыми данными. Все варианты (common, male, blackhole…) возвращают одно и то же. Полезны для ротации при 429.
- 429 — мягкий лимит, а не бан. Retry через другой шард работает в 83% случаев. Каскадного бана нет. 50 req/min — устойчиво.
- Endpoint /catalog/ мертв. Работает только /exactmatch/. Многие гайды и парсеры ссылаются на старый URL — он давно 404.
- Basket API — бэкдор без рейт-лимитов. Данные о товарах можно получить через wbbasket.ru без каких-либо ограничений.
- dest-параметры — хаос. Нет формулы, только справочник. Москва = -1257786, Владивосток = 123587791.
Если делаете что-то похожее — надеюсь, это сэкономит вам пару дней тупления в 403-е.
Бот: @WBposition762bot — бесплатный, без регистрации. Проверяйте позиции, ставьте на мониторинг.
Вопросы, критика, дополнения — пишите в комментариях. Особенно интересно, если кто-то нашел другие рабочие эндпоинты WB или разобрался с логикой dest.