§ P2 — Интеграции · Маркетплейсы

Wildberries API: реальные грабли

Из production-опыта

Wildberries API выглядит документированным и стабильным, пока не начнёшь выгружать данные в DWH. Rate limit 1 запрос в минуту, курсоры, которые умеют зависать, async-отчёты с клиентским task_id, токены без refresh-flow. Собрали 10 граблей, на которые стоит рассчитывать заранее.

Для кого эта статья. Дата-инженеры и архитекторы, которым поставили задачу «подключить WB к нашему хранилищу». Здесь — конкретные проблемы и конкретные паттерны их решения из нашего production-ETL на Python.

Мы выгружаем из WB по нескольким аккаунтам, несколько десятков endpoint-ов: заказы, продажи, отчёт о реализации, карточки, остатки, реклама, цены, возвраты, финансы. У каждого свои особенности. Ниже — то, чего нет в документации, но всплывает на практике.

1. Rate limit «1 запрос в минуту». Это hard-cap

Для endpoint-ов /api/v1/supplier/orders, /api/v1/supplier/sales, /api/v5/supplier/reportDetailByPeriod в документации написано «рекомендуемая частота запросов». На практике это жёсткий лимит: любая попытка идти быстрее ловит HTTP 429 с блокировкой на 60 секунд.

Что это даёт на цифрах: выгрузка месячных продаж одного аккаунта (lookback 30 дней, ~300 000 строк, ≤80 000 за запрос) — минимум 4 запроса = 4 минуты «стены времени». Если у вас 5 аккаунтов, их параллелят между собой, но внутри каждого — только последовательно.

В коде это выглядит как time.sleep(60) после каждой страницы пагинации. Обхода нет — WB считает частоту по токену аккаунта.

2. Курсор по lastChangeDate умеет зависать

Стандартный паттерн инкрементальной выгрузки: запросить с dateFrom=last_loaded_timestamp, получить пачку, взять lastChangeDate последней строки, передать в следующий запрос.

Проблема: если в пачке все строки имеют одинаковый lastChangeDate (бывает на пустых минутах или при пакетном обновлении на стороне WB), следующий запрос вернёт те же самые строки. Курсор не продвигается, скрипт зацикливается.

Решение — защитное stop-condition:

if last_item_date == current_date_from:
    logger.warning(f"Cursor stuck at {last_item_date}, breaking")
    break

if len(items) == 0:
    break

Редкая, но повторяющаяся проблема. Без защиты ETL висит на одном minute-окне, таймаутится и помечается как failed. С защитой — гарантированно заканчивается.

3. rrd_id не всегда в последней строке

Отчёт о реализации (reportDetailByPeriod) пагинируется по rrd_id: следующий запрос передаёт rrdid=max(rrd_id) предыдущего чанка. Очевидное решение — взять data[-1]['rrd_id'].

Это работает в 95% случаев. В 5% случаев максимальный rrd_id находится не в последней записи, а в середине пачки (WB не сортирует строго по rrd_id). Если взять data[-1], следующий запрос получит дубли.

Правильно — max(item['rrd_id'] for item in data). Плюс защитное условие:

new_rrdid = max(item['rrd_id'] for item in data)
if new_rrdid <= rrdid:
    logger.warning(f"rrd_id didn't advance: {rrdid} → {new_rrdid}")
    break
rrdid = new_rrdid

4. Отчёт о реализации приходит с задержкой и перегенерируется задним числом

Частое заблуждение: «недельный отчёт создан в воскресенье, можно забрать в понедельник». Нет.

  • Отчёт появляется через 2-4 дня после окончания недели
  • Ещё в течение 7-14 дней содержимое отчёта перегенерируется задним числом: добавляются новые строки, меняются суммы штрафов, корректируются возвраты
  • После 2-3 недель отчёт «замерзает», но не гарантированно

Для ETL это означает: нельзя загрузить отчёт один раз и больше не трогать. Правильное окно — последние 42 дня, каждый день, с полной перезагрузкой. last_loaded_batch_id по каждому недельному отчёту — канон, остальные игнорируются.

На уровне DWH это реализовано через partition SWITCH по неделе отчёта: каждый запуск переписывает только затронутые партиции, не трогая старое.

5. Async CSV-отчёты: task_id генерирует клиент, не сервер

Для больших отчётов (поисковые запросы, поисковая воронка) WB использует асинхронный паттерн:

  1. POST /nm-report/downloads с параметрами фильтра → получаем taskId
  2. Polling GET /nm-report/downloads?filter[downloadIds]=... до статуса SUCCESS
  3. GET /nm-report/downloads/file/{taskId} → ZIP с CSV

Первая неожиданность: taskId генерирует клиент, а не сервер. В теле запроса нужно передать id: uuid4(). Значит, при сбое polling-а можно сохранить свой taskId в БД и позже вернуться к нему без повторного создания отчёта.

Вторая: статус RETRY — это не просьба к клиенту retry. WB сам перепроверяет результат, клиент просто ждёт. Как это выглядит в коде:

WAIT_STATUSES = {'WAITING', 'PROCESSING', 'RETRY'}
while True:
    status = poll(task_id)
    if status == 'SUCCESS':
        return download(task_id)
    if status == 'FAILED':
        requests.post(f'/nm-report/downloads/{task_id}/retry')
        # retry до 2 раз, потом bail out
    if status in WAIT_STATUSES:
        if status == 'RETRY':
            consecutive_retry += 1
            if consecutive_retry > 5:
                raise ReportStuck(task_id)
        time.sleep(21)
        continue

Квота на создание: 20 отчётов в сутки на аккаунт. Превышение — 403 до следующего дня.

6. Токены: JWT до 180 дней, refresh-flow нет

Формат аутентификации WB: долгоживущий JWT, срок действия до 180 дней. Генерируется вручную в личном кабинете продавца. Refresh-flow отсутствует: когда токен истекает, пользователь должен физически зайти в ЛК WB и нажать «сгенерировать новый».

Для ETL это означает:

  • Нельзя просто сохранить токен в .env и забыть — раз в 6 месяцев понадобится ротация
  • Нужен account_manager — CLI-скрипт для добавления/обновления токенов продавцом. Мы реализовали его как Python-скрипт с интерактивным промптом: ввод токена → валидация через /seller-info → сохранение
  • Токены нужно шифровать в БД. У нас — Fernet (AES-128) с единым ENCRYPTION_KEY из .env. Таблица WB_CONFIG.wb_api_tokens.token_encrypted хранит зашифрованные строки, расшифровка — в utils_db_loader.get_active_accounts() перед каждым запуском ETL
  • Нужен health-check — отдельный скрипт, который раз в день дёргает /seller-info по всем аккаунтам и алертит при 401

7. Джем (платная подписка) и разбор 403 по тексту

Некоторые endpoint-ы (seller-analytics, часть advert) работают только при активной подписке «Джем». Без подписки — 403 с текстом ошибки типа "not available for your tariff" или упоминанием «джем».

Разбор 403 — по тексту (нет структурированного error_code):

if resp.status_code == 403:
    text = resp.text.lower()
    if 'jam' in text or 'джем' in text:
        raise NoJamSubscription(account_id)
    if 'not available' in text:
        # временная недоступность, retry через 5 минут
        return 'temporary_unavailable'
    raise Unauthorized(account_id)

Это не баг, а дизайн: WB хочет продавать платную подписку. Но в ETL нужно явно отличать «нет подписки» (не пытаться дальше) от «временно недоступно» (retry).

8. Множественные субдомены с независимыми rate limits

WB API — не один хост, а семейство сервисов с отдельными доменами:

  • statistics-api.wildberries.ru — orders, sales, отчёт реализации
  • content-api.wildberries.ru — карточки, фото, характеристики
  • advert-api.wildberries.ru — рекламные кампании
  • seller-analytics-api.wildberries.ru — воронка, остатки, поисковые запросы (Джем)
  • supplies-api.wildberries.ru — поставки
  • discounts-prices-api.wildberries.ru — цены
  • marketplace-api.wildberries.ru — склады, кабинеты
  • common-api.wildberries.ru — seller-info, тарифы, новости

Rate limits у каждого свои. Падения у каждого свои — один сервис может быть доступен, другой в тот же момент отдавать 503. Перед запуском ETL мы делаем ping по 11 доменам и пропускаем endpoint-ы, где домен недоступен.

9. Schema evolution: новые поля появляются, коды должны это переживать

За последний год WB несколько раз добавлял новые поля в ответы API (cashback_amount, installment_cofinancing, новые столбцы в отчёт реализации). Рассылки «завтра добавим поле X» нет — узнаёшь по факту.

Паттерн защиты — auto-schema-migration:

  1. Перед INSERT в Промежуточный сравниваем колонки DataFrame с INFORMATION_SCHEMA.COLUMNS
  2. Для отсутствующих колонок выводим тип из словаря (INT/FLOAT/BOOL/DATETIME), остальное — NVARCHAR
  3. ALTER TABLE ADD COLUMN ...
  4. В логе — schema_version+1, алерт в Telegram инженеру

Плюс — fields_hash (MD5 от отсортированного списка table.col.type). При изменении hash-а автоматически инкрементируется schema_version — удобно отслеживать через мониторинг.

Что не автоматизируется: переименование полей, смена типов, удаление колонок. Случаи редкие, правятся вручную.

10. Dependency DAG внутри Python-ETL

Порядок выгрузки endpoint-ов не произволен. Карточки — раньше остатков (остатки ссылаются на nmId). Sales — раньше аналитики (воронка ссылается на продажи). Общий seller-info — самым первым: смена тарифа влияет на доступные endpoint-ы.

Оркестратор run_all.py строит DAG зависимостей:

ETL_JOBS = {
    'seller_info':   {'depends': []},
    'cards':         {'depends': ['seller_info']},
    'orders':        {'depends': []},
    'sales':         {'depends': []},
    'stocks':        {'depends': ['cards']},
    'prices':        {'depends': ['cards']},
    'warehouses':    {'depends': []},
    'adv_campaigns': {'depends': []},
    'adv_fullstats': {'depends': ['adv_campaigns']},
    ...
}

Запускаются параллельно те, у кого dependencies уже выполнились. Failed-таски отмечаются, их зависимые — skipped. retry_failed.json — список задач для следующего запуска.

Парсинг 429 из stdout: re.search(r'Rate limit \(429\) for account (\d+)', stdout) — аккаунт помечается как temporarily-blocked, остальные продолжают работать.

Что забрать в свой проект

Если вы только начинаете интеграцию с WB:

  1. Закладывайте на запросы минимум 1 раз в минуту на статистические endpoint-ы. Не планируйте «выгружу всё за 5 минут».
  2. Сразу стройте account_manager + Fernet-шифрование токенов. Без этого ротация токенов раз в полгода превратится в инцидент.
  3. Для любого cursor-based endpoint-а — защитный stop-condition (last_date == current_from, empty result, max iterations).
  4. Отчёт о реализации — 42-дневное окно с полной перезагрузкой, не однократная выгрузка.
  5. Используйте Parquet как raw-слой — при ошибке БД не придётся заново ждать WB.

Все эти паттерны реализованы у нас в WB ETL для нескольких клиентских проектов. Если хотите посмотреть, как это устроено на живом хранилище, или нужна помощь с подключением WB к вашему DWH — страница про интеграции или 30-минутный звонок, разберём вашу ситуацию.

§ Консультация · 30 минут

Похожая
задача?

30 минут — обсудим ваши источники, метрики, ограничения. Вернёмся с архитектурой и оценкой в течение недели.

Телефон+7 918 042 34 43