Массовый парсинг Wildberries через IPv6 прокси: как обходить rate-limit без капчи
Содержание
- Почему Wildberries вообще упирается в лимиты
- Чем IPv6 меняет расклад
- должен вернуть адрес вида 2a01:4f8:...
- Архитектура пула адресов
- Сколько адресов реально нужно
- Поведенческий профиль
- Когда IPv6 не спасает
- Кейс: каталог на 200 000 SKU
- Кейс: 429 при малом rps
- Кейс: пустые ответы вместо данных
- Прокси-слой: что важно
- Мониторинг и алерты
- Итог
Почему Wildberries вообще упирается в лимиты
Wildberries отдаёт каталог через публичный JSON API на `card.wb.ru` и `static-basket-01.wb.ru`. Формально это открытые endpoint'ы, авторизация не нужна. На практике при превышении порога запросов с одного IP начинается деградация: сначала растёт `latency`, потом прилетает `429`, потом `403` с залипшим баннером на 10-30 минут.
Порог не публикуется и плавает. По наблюдениям за 2024-2025 годы, с одного IPv4-адреса комфортно проходит 60-90 запросов в минуту к `/catalog`. Дальше — рандом. Иногда 200 запросов пролетают, иногда на 70-м уже `429`. Это не баг, а защита от скрейпинга, построенная на скользящем окне и репутации подсети.
Ключевой момент: Wildberries смотрит не только на частоту, но и на **разнообразие** запросов. Если с одного IP идут запросы только к `/catalog` без переходов на карточки товаров, без загрузки картинок — это палево. Нормальный пользователь так не делает.
Чем IPv6 меняет расклад
IPv4-адресов у провайдеров мало, они дорогие и их выдают блоками /29 или /28. IPv6-префикс /64 — это 18 квинтиллионов адресов. Один сервер с выделенным /64 может выставить миллиарды исходящих IP без NAT.
Для парсинга это означает: можно раскидать нагрузку по тысячам адресов, каждый из которых для Wildberries выглядит как отдельный пользователь. Rate-limit привязан к IP, а не к подсети — иначе они бы забанили половину мобильных операторов.
Проверить, что у вас реально работает IPv6:
```bash
curl -6 -s https://ifconfig.co
должен вернуть адрес вида 2a01:4f8:...
curl -6 -s -o /dev/null -w "%{http_code} %{time_total}\n" https://card.wb.ru/cards/v1/detail?appType=1\&curr=rub\&dest=-1257786\&nm=12345678
```
Если второй curl отдаёт `000` или таймаутит — у вас нет IPv6-маршрута, и вся затея мертва. Провайдеры вроде Hetzner, OVH, Vultr дают /64 бесплатно к любой VPS. Российские хостеры часто IPv6 не дают вообще.
Архитектура пула адресов
Схема простая: берём /64, разбиваем на адреса, каждый запрос уходит с нового source IP. Ядро Linux умеет это из коробки, если правильно настроить маршрутизацию.
```bash
ip -6 route add local 2a01:4f8:1c1c:1234::/64 dev lo
sysctl -w net.ipv6.ip_nonlocal_bind=1
```
Первая команда говорит ядру, что весь префикс локальный — можно биндиться на любой адрес из него. Вторая разрешает bind на адреса, которых физически нет на интерфейсе. Без неё `curl --interface` упадёт с `Cannot assign requested address`.
Дальше в Python выбираем случайный адрес из префикса:
```python
import random, ipaddress, httpx
PREFIX = ipaddress.IPv6Network("2a01:4f8:1c1c:1234::/64")
def rand_ip():
return str(PREFIX.network_address + random.randint(1, 2**64 - 2))
async def fetch(nm: int):
ip = rand_ip()
transport = httpx.AsyncHTTPTransport(local_address=ip)
async with httpx.AsyncClient(transport=transport, timeout=10.0) as client:
r = await client.get(
"https://card.wb.ru/cards/v1/detail",
params={"appType": 1, "curr": "rub", "dest": -1257786, "nm": nm},
)
return r.status_code, r.json() if r.status_code == 200 else None
```
`local_address` в httpx работает через bind на конкретный source IP. Это чище, чем мутить с `curl --interface`, и не форкает процесс на каждый запрос.
Сколько адресов реально нужно
Не гонитесь за миллионом. Wildberries не проверяет, что IP уникален глобально — важно, чтобы он не повторялся в пределах скользящего окна. Окно примерно 60 секунд.
Считаем: если вы хотите 500 запросов в секунду и не повторять IP в течение минуты, нужно 30 000 адресов. Это /112. Из /64 можно нарезать 65 536 таких блоков.
На практике 500 rps к Wildberries — это уже агрессивно. Они начнут замечать паттерн на уровне ASN: тысячи IP из одного /64, идущие строго в `card.wb.ru`. Поэтому пул адресов нужно разбавлять — часть трафика гнать через IPv4, часть через мобильные прокси, часть через residential.
Поведенческий профиль
Просто менять IP недостаточно. Wildberries смотрит на заголовки. Дефолтный `httpx` отправляет `User-Agent: python-httpx/0.27.0`, `Accept: */*`, `Accept-Encoding: gzip, deflate`. Это палево с первого запроса.
Нужен профиль мобильного клиента. Приложение Wildberries на Android шлёт примерно такое:
```python
HEADERS = {
"User-Agent": "Wildberries/6.2.1000 (Android 13; Pixel 6)",
"Accept": "application/json",
"Accept-Language": "ru-RU,ru;q=0.9",
"Accept-Encoding": "gzip",
"X-Request-Id": None,
"Device-Id": None,
}
```
`X-Request-Id` и `Device-Id` генерируются на каждый запрос. Формат `Device-Id` — hex-строка из 32 символов. Если их не отправлять, часть запросов отваливается с `400`, часть проходит. Если отправлять одинаковые — банят быстрее.
Ещё момент: порядок query-параметров. Wildberries не сортирует их, но мобильное приложение всегда шлёт в одном порядке. `httpx` с `params=` сортирует по алфавиту. Придётся собирать URL вручную.
Когда IPv6 не спасает
Wildberries в 2024 году начал раскатывать проверку на уровне TLS-fingerprint. Если у вас `httpx` с дефолтным OpenSSL — JA3-хэш не совпадает с мобильным клиентом. Тут IPv6 не поможет, нужен `curl_cffi` с имперсонацией:
```python
from curl_cffi import requests
r = requests.get(
"https://card.wb.ru/cards/v1/detail",
params={"appType": 1, "curr": "rub", "dest": -1257786, "nm": 12345678},
impersonate="chrome120",
proxies={"https": f"http://[{rand_ip()}]:3128"},
)
```
`impersonate="chrome120"` подделывает TLS-отпечаток под Chrome 120. Это работает лучше, чем `httpx`, но требует прокси, потому что `curl_cffi` не умеет `local_address` напрямую. Приходится поднимать локальный прокси-демон, который биндится на нужный IPv6.
Через lexic.ml это решается на уровне пула — прокси сам выбирает исходящий адрес, клиенту не нужно возиться с `local_address` и `ip_nonlocal_bind`. Для парсинга WB это удобно: один endpoint, ротация адресов на стороне прокси.
Кейс: каталог на 200 000 SKU
Сервер на Hetzner CX41, /64 префикс, задача — собрать цены по 200 000 SKU раз в час. Первая попытка: 50 потоков, один IPv4. На 3-й минуте — `429` на всё, бан на 40 минут.
Причина: 50 потоков давали ~300 rps с одного IP. Wildberries душит такое за секунды.
Решение: раскидали по 5 000 IPv6 из /64, ограничили 20 rps на адрес. Итого 100 000 rps теоретически, на практике упёрлись в CPU сервера — 4 vCPU не тянут 5 000 одновременных TLS-хендшейков. Снизили до 800 адресов и 40 rps на адрес, получили 32 000 rps. 200 000 SKU собрались за 7 секунд чистого времени, с учётом ретраев — 12 секунд.
Через сутки начались проблемы: Wildberries забанил весь /64 по ASN. Пришлось добавить второй префикс с другого хостера и чередовать.
Кейс: 429 при малом rps
Другой случай: 10 rps, один IPv6, всё равно `429` каждые 5 минут. Логи чистые, заголовки правильные.
Причина оказалась в `dest`. Параметр `dest=-1257786` — это код региона Москвы. Если слать всегда один и тот же `dest` с одного IP, Wildberries видит аномалию: живой пользователь меняет регион, смотрит разные склады. Статичный `dest` на протяжении часов — маркер бота.
Решение: ротация `dest` по списку регионов. Коды лежат в открытом виде на `static-basket-01.wb.ru/vol0/part0/regions.json`. 87 регионов, меняем каждые 20-30 запросов. `429` пропали полностью.
Кейс: пустые ответы вместо данных
Собрали 50 000 карточек, из них 12 000 вернули `200` с пустым массивом `products`. Ни ошибки, ни капчи — просто ничего.
Причина: Wildberries отдаёт пустоту, когда IP попал в «серую» зону. Не забанен, но и данные не отдаёт. Это дешевле для них, чем явный `403` — меньше шума в логах, сложнее детектить.
Решение: считать долю пустых ответов на IP. Если больше 5% — адрес в карантин на 30 минут. Плюс проверка: `products` пустой, но `total` в ответе ненулевой — точно бан. Если и `total` пустой — SKU реально не существует.
```python
def is_shadow_banned(resp_json):
if not resp_json.get("data", {}).get("products"):
if resp_json.get("data", {}).get("total", 0) > 0:
return True
return False
```
Прокси-слой: что важно
Не все IPv6-прокси одинаковы. Ключевые параметры: размер пула, скорость ротации, поддержка sticky-сессий, TLS-имперсонация.
| Параметр | Минимум | Комфорт |
|---|---|---|
| Размер пула | /112 (65k) | /64 (миллиарды) |
| Ротация | по запросу | по запросу + sticky 60s |
| Протокол | HTTP/HTTPS | SOCKS5 + HTTP |
| Имперсонация | нет | Chrome 120, Firefox 121 |
| Гео | RU | RU + несколько ASN |
Sticky-сессии нужны, когда парсите карточку в несколько шагов: сначала `/detail`, потом `/sellers`, потом отзывы. Если IP меняется между запросами, Wildberries может отдать неконсистентные данные или словить аномалию.
Мониторинг и алерты
Без мониторинга парсер тихо деградирует: доля `429` растёт, вы этого не видите, данные приходят неполные. Минимальный набор метрик:
- `rate_429` по IP — сколько запросов вернули `429`
- `rate_empty` — доля пустых ответов
- `latency_p95` — если растёт, IP под нагрузкой
- `unique_ips_per_minute` — сколько разных адресов использовано
Алерт на `rate_429 > 2%` за 5 минут. Алерт на `rate_empty > 5%`. Алерт на `latency_p95 > 2000ms`.
Пишите метрики в Prometheus, дашборд в Grafana. Без этого вы узнаете о бане только когда данные перестанут приходить вообще.
Итог
IPv6 даёт масштаб, но не решает проблему целиком. Wildberries смотрит на паттерны: заголовки, `dest`, TLS-fingerprint, соотношение запросов к каталогу и карточкам. Менять только IP — недостаточно. Нужен поведенческий профиль, ротация параметров, мониторинг и готовность менять ASN, когда текущий попадёт под бан.
Порог в 60-90 rps на IPv4 и возможность держать 30-40 rps на IPv6-адрес — рабочая отправная точка. Дальше подстраивайте под свои задачи и следите за метриками.