Для кого эта статья. Дата-инженеры и архитекторы, которым поставили задачу «подключить 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 использует асинхронный паттерн:
POST /nm-report/downloadsс параметрами фильтра → получаем taskId- Polling
GET /nm-report/downloads?filter[downloadIds]=...до статусаSUCCESS 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:
- Перед INSERT в Промежуточный сравниваем колонки DataFrame с
INFORMATION_SCHEMA.COLUMNS - Для отсутствующих колонок выводим тип из словаря (INT/FLOAT/BOOL/DATETIME), остальное — NVARCHAR
ALTER TABLE ADD COLUMN ...- В логе —
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 раз в минуту на статистические endpoint-ы. Не планируйте «выгружу всё за 5 минут».
- Сразу стройте account_manager + Fernet-шифрование токенов. Без этого ротация токенов раз в полгода превратится в инцидент.
- Для любого cursor-based endpoint-а — защитный stop-condition (last_date == current_from, empty result, max iterations).
- Отчёт о реализации — 42-дневное окно с полной перезагрузкой, не однократная выгрузка.
- Используйте Parquet как raw-слой — при ошибке БД не придётся заново ждать WB.
Все эти паттерны реализованы у нас в WB ETL для нескольких клиентских проектов. Если хотите посмотреть, как это устроено на живом хранилище, или нужна помощь с подключением WB к вашему DWH — страница про интеграции или 30-минутный звонок, разберём вашу ситуацию.