← Назад в базу знаний

Как обойти rate-limit Wildberries при парсинге отзывов через пул IPv6 прокси

Как обойти rate-limit Wildberries при парсинге отзывов через пул IPv6 прокси

Почему Wildberries душит парсеры отзывов

Wildberries отдаёт отзывы через публичный JSON API на `feedbacks1.wb.ru` и `feedbacks2.wb.ru`. Раньше можно было дёргать эндпоинт пачками и не париться. Сейчас всё иначе. На одном IP больше 30-40 запросов в минуту — и ты получаешь либо `429 Too Many Requests`, либо пустой `{"feedbacks":[]}`. Второй вариант хуже: код не падает, а тихо возвращает ноль данных, и ты узнаёшь об этом через сутки, когда в базе пусто.

Лимиты висят на нескольких уровнях. Первый — nginx перед бэкендом, он режет по IP. Второй — внутренний сервис, который считает запросы по связке IP + User-Agent + отсутствие/наличие cookies. Третий — WAF, который ловит паттерны: одинаковые заголовки, слишком ровные интервалы между запросами, отсутствие Referer.

Эндпоинт выглядит так:

```

https://feedbacks1.wb.ru/feedbacks/v1/{imtId}

```

где `imtId` — это числовой идентификатор товара (не `nmId`, не `root`, именно `imtId` из карточки). Ответ — JSON с полем `feedbacks`, где лежит массив отзывов, и `valuation` — счётчик.

Что именно считает лимитер

Разберём поведение по фактам. Запросы с одного IPv4 на 50 rpm дают стабильный 429 через 2-3 минуты. С одного IPv6 — та же картина, если адрес из /64-подсети, которую WB уже видит. Ключевой момент: WB считает не отдельный адрес, а префикс. Если у тебя /64 и ты гоняешь по нему 2^64 адресов, но все они в одном /64 — лимит срабатывает на весь блок.

Проверка простая. Берём два адреса из разных /64 и шлём по 40 rpm с каждого:

```bash

for ip in 2a01:4f8:c17:1234::1 2a01:4f8:c17:5678::1; do

for i in \$(seq 1 40); do

curl -s -o /dev/null -w "%{http_code} " \

--interface \$ip \

-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)" \

"https://feedbacks1.wb.ru/feedbacks/v1/1234567" &

done

wait

echo " <- \$ip done"

done

```

Если первый блок отдаёт 200, а второй начинает сыпать 429 — значит, режет по /64. Это типичная картина для CDN с geo-балансировкой.

Почему IPv6, а не IPv4

IPv4-пулы дорогие и грязные. Резидентные прокси на IPv4 стоят от \$3 за гигабайт, датацентровые быстро попадают в бан-листы. IPv6 дешевле в разы, потому что провайдеры выдают /64 и /48 бесплатно вместе с VPS. Один сервер на Hetzner за €4 даёт /64 — это 18 квинтиллионов адресов. Даже если использовать 0.0001% из них, получается приличный пул.

Проблема в том, что не все сайты принимают IPv6. Wildberries принимает — их инфраструктура на бэкенде давно dual-stack. Проверить можно так:

```bash

dig AAAA feedbacks1.wb.ru +short

curl -6 -s -o /dev/null -w "%{http_code}\n" https://feedbacks1.wb.ru/feedbacks/v1/1234567

```

Если `dig` возвращает AAAA-запись, а curl по IPv6 отдаёт 200 — всё в порядке. Если WB отвечает только по IPv4, IPv6-пул бесполезен.

Архитектура пула: ротация по /64

Простой пул из 1000 адресов в одном /64 не работает. Нужно раскидывать по разным /64. Минимум — 50-100 разных префиксов. Реальные провайдеры IPv6-прокси дают именно так: каждый клиент получает адрес из своего /64, а не из общего.

Схема ротации:

```python

import random

import asyncio

import aiohttp

PREFIXES = [

"2a01:4f8:c17:1234",

"2a01:4f8:c17:5678",

"2a01:4f8:c17:9abc",

... ещё 50-100 префиксов

]

def random_ipv6():

prefix = random.choice(PREFIXES)

suffix = ":".join(f"{random.randint(0, 0xffff):x}" for _ in range(4))

return f"{prefix}:{suffix}"

async def fetch_feedback(session, imt_id, sem):

async with sem:

ip = random_ipv6()

connector = aiohttp.TCPConnector(local_addr=(ip, 0))

async with aiohttp.ClientSession(connector=connector) as s:

url = f"https://feedbacks1.wb.ru/feedbacks/v1/{imt_id}"

async with s.get(url, headers={"User-Agent": "Mozilla/5.0"}) as r:

if r.status == 429:

await asyncio.sleep(random.uniform(2, 5))

return None

return await r.json()

async def main(imt_ids):

sem = asyncio.Semaphore(20)

tasks = [fetch_feedback(None, i, sem) for i in imt_ids]

return await asyncio.gather(*tasks)

```

Здесь важный момент: `local_addr` в `TCPConnector` заставляет соединение идти с конкретного IPv6. Без этого Linux выберет адрес сам, и весь смысл пула потеряется. Проверить, что адрес реально применился, можно через `curl --interface`.

Как WB ловит палево

Одной ротации IP мало. WB смотрит на несколько вещей одновременно.

Первый признак — TLS fingerprint. Если у тебя Python `requests` с дефолтным OpenSSL, JA3-хэш отличается от браузерного. WAF это видит и режет раньше, чем дойдёт до rate-limit. Лечится через `curl_cffi` или `httpx` с настройкой TLS-контекста под Chrome.

Второй признак — заголовки. Если нет `Accept-Language`, `Sec-Fetch-*`, `Referer` — палево. Сравни реальный запрос из браузера и свой:

```bash

curl -s -o /dev/null -v \

--interface 2a01:4f8:c17:1234::1 \

-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \

-H "Accept: */*" \

-H "Accept-Language: ru-RU,ru;q=0.9,en;q=0.8" \

-H "Referer: https://www.wildberries.ru/" \

-H "Sec-Fetch-Dest: empty" \

-H "Sec-Fetch-Mode: cors" \

-H "Sec-Fetch-Site: same-site" \

"https://feedbacks1.wb.ru/feedbacks/v1/1234567" 2>&1 | grep "< HTTP"

```

Третий признак — тайминги. Если ты шлёшь запросы ровно каждые 2 секунды, это бот. Нужен jitter: `random.uniform(0.8, 3.5)`.

Кейс: парсинг 50 000 товаров

Задача: собрать отзывы по 50 000 `imtId` за сутки. Один запрос на товар, плюс пагинация — в среднем 3 запроса на товар. Итого 150 000 запросов.

Первый заход: один IPv6, 20 параллельных воркеров, задержка 1 секунда. Результат — 429 после 200 запросов, дальше глухо на 10 минут.

Причина: WB считает не только IP, но и общую нагрузку с /64. Один адрес — это один /64, всё упирается в лимит префикса.

Решение: пул из 80 /64, 5 воркеров на префикс, jitter 0.5-2 секунды. Пропускная способность выросла до 400 запросов в минуту. 150 000 запросов закрылись за 6 часов 15 минут. Средний процент 429 — 3.2%, ретраи с задержкой 5 секунд вытянули остаток.

Технические детали: `imtId` брали из карточки через `card.wb.ru/cards/v1/detail`, оттуда же тянули `root` для склейки отзывов разных цветов. Пагинация в feedbacks API — через `take` и `skip`, максимум `take=30`. Больше не отдаёт, возвращает `{"error": true}`.

Кейс: тихая деградация вместо 429

Отдельная боль. WB иногда не отдаёт 429, а возвращает `{"feedbacks": [], "valuation": 0}`. Код думает, что у товара нет отзывов, и пишет в базу ноль.

Причина: WAF помечает IP как подозрительный и отдаёт заглушку. Это хуже явного 429, потому что не триггерит ретраи.

Решение: проверять `valuation`. Если товар точно имеет отзывы (например, рейтинг 4.5 при 200+ оценках из карточки), а API вернул пустой массив — это бан. Помечать IP как мёртвый на 15 минут, брать следующий.

```python

def is_suspicious(response, expected_rating_count):

data = response.json()

if data.get("valuation", 0) == 0 and expected_rating_count > 10:

return True

return False

```

На тестовой выборке из 2000 товаров такое поведение поймали на 47 адресах из 80. То есть 58% пула ушло в тихий бан за 3 часа работы. Без проверки `valuation` в базе оказалось бы 40% пустых записей.

Кейс: геолокация и CDN

Wildberries раздаёт через несколько CDN-провайдеров. `feedbacks1.wb.ru` и `feedbacks2.wb.ru` — это разные пулы. Если твои IPv6 из Германии, а запрос летит на российский edge, задержка 80-120 мс. Если из России — 15-25 мс.

Замеры на 1000 запросов с Hetzner Helsinki (IPv6) до `feedbacks1.wb.ru`:

| Регион прокси | Средний RTT | Доля 429 |

|---|---|---|

| Helsinki | 42 мс | 8.1% |

| Frankfurt | 51 мс | 9.4% |

| Москва | 18 мс | 4.2% |

| Санкт-Петербург | 21 мс | 4.8% |

Цифры с теста в феврале, 80 префиксов, 5 воркеров на префикс. Видно, что российские адреса и быстрее, и реже попадают под лимит — WB мягче относится к локальному трафику. Если есть выбор, брать прокси в РФ.

Настройка пула через lexic.ml

Если не хочется поднимать свои /64, можно взять готовый пул. Сервис lexic.ml отдаёт IPv6-прокси с ротацией по запросу, начиная с 2015 года. Схема подключения простая: получаешь список адресов или endpoint с автопереключением, дальше используешь как обычно через `--interface` или `local_addr`.

Для парсинга WB важно, чтобы прокси были из российских /64. Проверить это можно так:

```bash

curl -s --interface https://api.ipify.org

whois | grep -i country

```

Если страна не RU — лимиты будут выше, а часть запросов может вообще не дойти.

Что делать с 429 правильно

Простой `time.sleep(5)` не работает. Нужен экспоненциальный backoff с jitter и привязкой к конкретному IP. Если адрес попал в бан — не возвращать его в пул минимум 10 минут.

```python

import time

import random

class IPPool:

def __init__(self, ips):

self.ips = {ip: 0 for ip in ips}

self.banned = {}

def get(self):

now = time.time()

available = [ip for ip, until in self.banned.items() if until < now]

for ip in available:

del self.banned[ip]

if not available:

time.sleep(1)

return self.get()

return random.choice(available)

def ban(self, ip, seconds=600):

self.banned[ip] = time.time() + seconds

```

Ключевое: бан на 10 минут, а не на 30 секунд. WB держит блокировку дольше, чем кажется. На тестах адрес после 429 оживал в среднем через 8-12 минут.

Сколько адресов реально нужно

Считаем. Нужно 400 rpm стабильно. Один /64 держит ~30 rpm без 429. Значит, минимум 14 префиксов. С запасом на баны и деградацию — 40-60. Если брать /48, где внутри 65536 /64, можно раскидать нагрузку шире и снизить риск блокировки всего блока.

Практика: 50 /64 с 3 воркерами на каждый дают 300-350 rpm с долей 429 около 5%. 100 /64 — те же 400 rpm, но 429 падает до 2%. Дальше увеличение пула даёт мало — упираешься в общий лимит WB на регион.

Итог по граблям

Главные ошибки: один /64 на весь пул, отсутствие проверки `valuation`, ровные интервалы между запросами, дефолтный TLS-отпечаток Python. Каждая из них по отдельности убивает парсер за пару часов. Вместе — за 20 минут.

Рабочая связка: 50-100 российских /64, `curl_cffi` с Chrome-отпечатком, jitter 0.5-3 секунды, 3-5 воркеров на префикс, бан адреса на 10 минут при 429, проверка `valuation` на тихий бан. Такая схема держит 400 rpm сутками без деградации.

✔️Купить прокси