WB Parser Service — API Specification

Версия: 1.0 Дата: 2026-03-20 Ветка: feature/PARSER-733-wb-enrich-task


Содержание

  1. Общие сведения
  2. Search — поиск товаров
  3. Enrich — полный сбор данных
  4. Recom — анализ рекомендательных полок
  5. Categories — дерево категорий
  6. Общие контракты
  7. Redis Streams — транспорт
  8. База данных — схема

1. Общие сведения

Архитектура задач

Все задачи (search, enrich, recom, check) проходят единый жизненный цикл:

HTTP POST /tasks/{type}
    → TaskProducerService.create_*_task()   — создание WBParserTask
    → TaskProducerService.publish_task()    — INSERT в tasks + XADD в parser:tasks
    → ParserWorker._process_task()          — consumer из Redis Stream
    → ParserWorker._parse_*_task()          — выполнение
    → XADD parser:results                   — публикация WBParserResult
    → UPDATE tasks SET status = completed   — финализация

Жизненный цикл задачи

СтатусОписание
pendingЗадача создана, ожидает worker
runningWorker взял задачу
completedУспешно завершена
failedОшибка при выполнении

Enum-справочники

class TaskType(str, Enum):
    SEARCH = "search"
    CHECK  = "check"
    ENRICH = "enrich"
    RECOM  = "recom"

class ParseMethod(str, Enum):
    TOKEN = "token"    # JSON API c WB-токеном (рекомендуемый)
    HTML  = "html"     # Playwright DOM-парсинг через прокси

class SourceType(str, Enum):
    KEYWORD  = "keyword"
    CATEGORY = "category"
    NM_IDS   = "nm_ids"

class ErrorType(str, Enum):
    NETWORK    = "network"
    CAPTCHA    = "captcha"
    RATE_LIMIT = "rate_limit"
    PARSING    = "parsing"
    VALIDATION = "validation"
    UNKNOWN    = "unknown"

2. Search — поиск товаров

2.1. Назначение

Поиск товаров по ключевому слову или категории с отслеживанием позиций, сохранением товаров и быстрой выдачей целевых артикулов (QuickData).

2.2. Два режима работы

ПараметрKeyword-режимCategory-режим
Поле запросаkeywordcategory_id
SourceTypekeywordcategory
WB APISearch API (search.wb.ru)Catalog API (catalog.wb.ru/{shard}/v4/catalog)
URL (html)/catalog/0/search.aspx?search={kw}/catalog/{category_id}?page={n}
Token-методJSON API с x_wbaas_tokenCatalog API (без токена)
Поле позицииkeyword в search_positionscategory:{id} в search_positions
Default pages33

2.3. HTTP-контракт

POST /tasks/search

Request Body — CreateSearchTaskRequest:

ПолеТипОбязательноеDefaultОграниченияОписание
keywordstr | nullнет*nullКлючевое слово
category_idstr | nullнет*nullID категории WB
pagesintнет31..30Количество страниц
parse_methodParseMethodнетtokentoken | htmlМетод парсинга
target_nm_idint | nullнетnullОдин целевой артикул
target_nm_idslist[int] | nullнетnull1..50 элементовСписок целевых артикулов
top_nintнет501..2000Топ-N товаров в QuickData

* Обязательно указать одно из keyword или category_id. Указание обоих → 400.

Пример (keyword):

{
  "keyword": "термокружка",
  "pages": 3,
  "parse_method": "token",
  "target_nm_id": 124203940,
  "top_n": 50
}

Пример (category):

{
  "category_id": "9468",
  "pages": 5,
  "parse_method": "token",
  "target_nm_ids": [124203940, 94608652],
  "top_n": 100
}

Response — TaskResponse:

{
  "task_id": "550e8400-e29b-41d4-a716-446655440000",
  "stream_entry_id": "1711000000000-0",
  "db_task_id": 123,
  "status": "queued"
}

2.4. Redis Stream — входящая задача (parser:tasks)

WBParserTask (search + keyword):

{
  "task_id": "550e8400-e29b-41d4-a716-446655440000",
  "db_task_id": 123,
  "task_type": "search",
  "parse_method": "token",
  "request": {
    "source_type": "keyword",
    "source_value": "термокружка",
    "nm_ids": null,
    "pages": 3,
    "target": {
      "target_nm_id": 124203940,
      "target_nm_ids": null,
      "top_n": 50
    }
  },
  "created_at": "2026-03-20T10:00:00Z"
}

WBParserTask (search + category):

{
  "task_id": "660f9500-f30c-52e5-b827-557766551111",
  "db_task_id": 124,
  "task_type": "search",
  "parse_method": "token",
  "request": {
    "source_type": "category",
    "source_value": "9468",
    "nm_ids": null,
    "pages": 5,
    "target": {
      "target_nm_id": null,
      "target_nm_ids": [124203940, 94608652],
      "top_n": 100
    }
  },
  "created_at": "2026-03-20T10:00:00Z"
}

2.5. Redis Stream — результат (parser:results)

WBParserResult (search):

{
  "task_id": "550e8400-e29b-41d4-a716-446655440000",
  "task_type": "search",
  "parse_method": "token",
  "duration_seconds": 12.5,
  "request": { "..." : "..." },
  "summary": {
    "items_found": 250,
    "items_saved": 250,
    "items_failed": 0,
    "pages_parsed": 3
  },
  "data": {
    "target_results": [
      {
        "nm_id": 124203940,
        "found_in_listing": true,
        "exists": null,
        "position": 5,
        "page": 1,
        "position_on_page": 5,
        "is_promoted": false,
        "price": 643.0,
        "rating": 4.8,
        "feedbacks": 1200,
        "total_stock": 540,
        "error": null
      }
    ],
    "top_products": [
      {
        "nm_id": 111222333,
        "position": 1,
        "page": 1,
        "position_on_page": 1,
        "is_promoted": false,
        "price": 590.0,
        "rating": 4.9,
        "feedbacks": 3200,
        "total_stock": 1200
      }
    ]
  },
  "details_in_db": true,
  "db_pointers": { "task_db_id": 123 },
  "error": null
}

2.6. Category-режим: внутренний API WB

Catalog API: GET https://catalog.wb.ru/{shard}/v4/catalog

ПараметрЗначение
appType"1"
curr"rub"
dest"-1257786"
spp"30"
cat"{category_id}"
page"1"..

Shard-маппинг:

Формат элемента в выдаче (keyword и category одинаковый):

{
    "nm_id": int,
    "position": int,            # (page-1)*100 + i
    "page": int,
    "position_on_page": int,    # 1..100
    "keyword": str,             # или "category_id": str
    "is_promoted": bool,
    "panel_promo_id": int | None,
}

2.7. Сохранение данных


3. Enrich — полный сбор данных

3.1. Назначение

Полное обогащение данных товаров: search + Cards API v4 + basket CDN (card.json, price-history.json) + Feedbacks API + 3D-фото проверка + SPP.

3.2. Три контракта

КонтрактОписаниеМодель
HTTPСоздание задачи через REST APICreateEnrichTaskRequestTaskResponse
Redis TaskВходящее сообщение для workerWBParserTask (task_type=enrich)
Redis ResultРезультат выполненияWBParserResult (data=null, summary)

3.3. Контракт 1: HTTP API

POST /tasks/enrich

Request Body — CreateEnrichTaskRequest:

ПолеТипОбязательноеDefaultОграниченияОписание
keywordstr | nullнет*nullКлючевое слово
category_idstr | nullнет*nullID категории WB
pagesintнет51..30Количество страниц
parse_methodParseMethodнетtokentoken | htmlМетод парсинга

* Обязательно указать одно из keyword или category_id. Указание обоих → 400.

Отличия от Search: нет target_nm_id, target_nm_ids, top_n. Default pages = 5 (вместо 3).

Пример (keyword):

{
  "keyword": "термокружка",
  "pages": 5,
  "parse_method": "token"
}

Пример (category):

{
  "category_id": "9468",
  "pages": 3,
  "parse_method": "token"
}

Response — TaskResponse:

{
  "task_id": "a1b2c3d4-e5f6-47g8-h9i0-j1k2l3m4n5o6",
  "stream_entry_id": "1711000000000-0",
  "db_task_id": 456,
  "status": "queued"
}

Коды ответа:

КодУсловие
200Задача создана
400Не указан keyword/category_id или оба сразу
500Внутренняя ошибка (Redis/DB)

3.4. Контракт 2: Redis Task Stream (parser:tasks)

WBParserTask (enrich + keyword):

{
  "task_id": "a1b2c3d4-e5f6-47g8-h9i0-j1k2l3m4n5o6",
  "db_task_id": 456,
  "task_type": "enrich",
  "parse_method": "token",
  "request": {
    "source_type": "keyword",
    "source_value": "термокружка",
    "nm_ids": null,
    "pages": 5,
    "target": null
  },
  "created_at": "2026-03-20T12:00:00Z"
}

WBParserTask (enrich + category):

{
  "task_id": "b2c3d4e5-f6a7-48h9-i0j1-k2l3m4n5o6p7",
  "db_task_id": 457,
  "task_type": "enrich",
  "parse_method": "token",
  "request": {
    "source_type": "category",
    "source_value": "9468",
    "nm_ids": null,
    "pages": 3,
    "target": null
  },
  "created_at": "2026-03-20T12:05:00Z"
}

Отличия от Search-задачи:


3.5. Контракт 3: Redis Result Stream (parser:results)

WBParserResult (enrich, success):

{
  "task_id": "a1b2c3d4-e5f6-47g8-h9i0-j1k2l3m4n5o6",
  "task_type": "enrich",
  "parse_method": "token",
  "duration_seconds": 205.2,
  "request": {
    "source_type": "keyword",
    "source_value": "термокружка",
    "nm_ids": null,
    "pages": 5,
    "target": null
  },
  "summary": {
    "items_found": 245,
    "items_saved": 245,
    "items_failed": 0,
    "pages_parsed": 5
  },
  "data": null,
  "details_in_db": true,
  "db_pointers": { "task_db_id": 456 },
  "error": null
}

WBParserResult (enrich, error):

{
  "task_id": "a1b2c3d4-e5f6-47g8-h9i0-j1k2l3m4n5o6",
  "task_type": "enrich",
  "parse_method": "token",
  "duration_seconds": 8.5,
  "request": { "...": "..." },
  "summary": {
    "items_found": 0,
    "items_saved": 0,
    "items_failed": 0,
    "pages_parsed": 0
  },
  "data": null,
  "details_in_db": true,
  "db_pointers": { "task_db_id": 456 },
  "error": {
    "type": "network",
    "message": "Connection timeout after 30s"
  }
}

Отличия от Search-результата:


3.6. Pipeline обогащения

Phase 1: Search (parse_with_positions)
    ├── Keyword → JSON API (search.wb.ru) → nm_ids + positions
    └── Category → Catalog API (catalog.wb.ru) → nm_ids + positions
        ↓
Phase 2: Cards API v4 (card.wb.ru/cards/v4/detail)
    → batch по 100 nm_ids → базовые данные + imt_id
        ↓
Phase 3: Параллельный сбор (asyncio.gather)
    ├── card.json       (basket CDN, concurrency=10)  → composition, description, vendor_code, ...
    ├── price-history   (basket CDN, concurrency=10)  → [{dt, price}, ...]
    ├── feedbacks       (feedbacks1.wb.ru, concurrency=10) → rating_distribution, feedbacks_with_*
    ├── 3D photo        (basket CDN HEAD, concurrency=20) → has_3d_photo
    └── SPP             (1 запрос)                      → spp value
        ↓
Phase 4: Merge + DB persist
    ├── UPDATE products (card.json + feedbacks + 3D)
    ├── Backfill price_history для новых товаров
    └── COMMIT

3.7. Внутренний результат enrich_products()

{
    "items_found": int,          # Товаров найдено в search
    "items_enriched": int,       # Товаров обновлено в БД
    "items_backfilled": int,     # Товаров с подгруженной историей цен
    "feedbacks_collected": int,  # Уникальных root_id с собранными отзывами
    "has_3d_photo": int,         # Товаров с 3D-фото
    "spp": float | None,        # Значение SPP
}

3.8. Поля, добавляемые Enrich (миграция 003)

Поле БДТипИсточникОписание
compositionTEXTcard.jsonСостав товара
feedbacks_with_photoINTEGERFeedbacks APIКол-во отзывов с фото
feedbacks_with_textINTEGERFeedbacks APIКол-во отзывов с текстом
feedbacks_with_videoINTEGERFeedbacks APIКол-во отзывов с видео
rating_distributionJSONBFeedbacks API{1: N, 2: N, ..., 5: N}
matching_sizeJSONBFeedbacks API{bigger: %, ok: %, smaller: %}
nm_rating_distributionJSONBFeedbacks APIРейтинг по nm_id в склейке
first_feedback_dateTIMESTAMPFeedbacks APIДата старейшего отзыва
avg_recent_ratingDECIMAL(3,2)Feedbacks APIСредний рейтинг последних 50 отзывов
negative_feedbacks_pctDECIMAL(5,1)Feedbacks API% отзывов с оценкой 1-3

3.9. Сравнение Search vs Enrich

АспектSearchEnrich
EndpointPOST /tasks/searchPOST /tasks/enrich
Default pages35
Target configДа (target_nm_id, top_n)Нет
Cards API v4 batchДа (для деталей)Да (для imt_id)
card.json CDNНетДа
Price historyНет (только текущая)Да (backfill из CDN)
Feedbacks APIНетДа
3D photo checkНетДа
SPPНетДа
QuickData в resultДа (data.target_results)Нет (data = null)
Время выполнения~10-30 сек~150-250 сек

3.10. Получение обогащённых данных из БД

Enrich не возвращает данные товаров в Redis — только summary-статистику. Все данные сохраняются in-place в PostgreSQL и доступны через связку search_positionsproducts по keyword или category_id.

Принцип

Enrich при выполнении:

  1. Phase 1 записывает позиции в search_positions с текущим recorded_at
  2. Phase 2-4 обновляет products in-place (enrich-поля, цены, отзывы)

Для получения результатов последнего enrich — берём позиции с максимальным recorded_at по нужному keyword/category и JOIN'им с products.

SQL: последний сбор по keyword

SELECT
    p.nm_id,
    p.name,
    p.brand,
    p.seller_name,
    p.price_current,
    p.price_basic,
    p.discount_percent,
    p.rating,
    p.feedbacks,
    p.total_stock,
    -- enrich-поля
    p.composition,
    p.feedbacks_with_photo,
    p.feedbacks_with_text,
    p.feedbacks_with_video,
    p.rating_distribution,
    p.matching_size,
    p.first_feedback_date,
    p.avg_recent_rating,
    p.negative_feedbacks_pct,
    p.has_3d_photo,
    p.has_video,
    p.photo_count,
    p.description,
    p.characteristics,
    p.colors,
    p.sizes,
    p.country,
    p.gender,
    -- позиция из последнего сбора
    sp.position,
    sp.page_number,
    sp.position_on_page,
    sp.is_promoted,
    sp.recorded_at
FROM search_positions sp
JOIN products p ON p.id = sp.product_id
WHERE sp.keyword = :keyword                          -- например 'термокружка'
  AND sp.recorded_at = (
      SELECT MAX(s2.recorded_at)
      FROM search_positions s2
      WHERE s2.keyword = sp.keyword
  )
ORDER BY sp.position;

SQL: последний сбор по категории

-- Для категорий keyword хранится как 'category:{id}'
SELECT p.*, sp.position, sp.page_number, sp.is_promoted, sp.recorded_at
FROM search_positions sp
JOIN products p ON p.id = sp.product_id
WHERE sp.keyword = 'category:9468'
  AND sp.recorded_at = (
      SELECT MAX(s2.recorded_at)
      FROM search_positions s2
      WHERE s2.keyword = sp.keyword
  )
ORDER BY sp.position;

SQL: история цен для конкретного товара из сбора

SELECT ph.price_current, ph.price_basic, ph.discount_percent, ph.spp, ph.recorded_at
FROM price_history ph
JOIN products p ON p.id = ph.product_id
WHERE p.nm_id = :nm_id
ORDER BY ph.recorded_at DESC;

SQL: все сборы по keyword (список прогонов)

SELECT
    keyword,
    recorded_at,
    COUNT(*) AS products_count
FROM search_positions
WHERE keyword = :keyword
GROUP BY keyword, recorded_at
ORDER BY recorded_at DESC;

SQL: данные конкретного прогона (не последнего)

SELECT p.*, sp.position, sp.page_number, sp.is_promoted
FROM search_positions sp
JOIN products p ON p.id = sp.product_id
WHERE sp.keyword = :keyword
  AND sp.recorded_at = :target_recorded_at    -- timestamp конкретного прогона
ORDER BY sp.position;

Важные замечания


4. Recom — анализ рекомендательных полок

4.1. Назначение

Анализ рекомендательных полок WB: «Показывает ли WB мой товар на страницах конкурентов?»

4.2. Два режима (автовыбор)

РежимУсловиеОписание
monitorcompetitor_nm_ids заданПрямая проверка: ищем target в рекомендациях каждого конкурента
cross_referencekeyword заданКонкуренты из поисковой выдачи + свои рекомендации + пересечение

4.3. HTTP-контракт

POST /tasks/recom

Request Body — CreateRecomTaskRequest:

ПолеТипОбязательноеDefaultОграниченияОписание
target_nm_idintдаМой артикул — ищем его в чужих рекомендациях
keywordstr | nullнет*nullКлючевое слово (→ cross_reference)
competitor_nm_idslist[int] | nullнет*null1..50 элементовАртикулы конкурентов (→ monitor)
top_nintнет101..100Лимит рекомендаций для сохранения

* Обязательно указать хотя бы одно из keyword или competitor_nm_ids. Можно указать оба — тогда keyword определяет режим (cross_reference), а competitor_nm_ids игнорируются (конкуренты берутся из выдачи).

Пример (monitor):

{
  "target_nm_id": 124203940,
  "competitor_nm_ids": [94608652, 376929502],
  "top_n": 10
}

Пример (cross_reference):

{
  "target_nm_id": 124203940,
  "keyword": "термокружка",
  "top_n": 10
}

Response — TaskResponse:

{
  "task_id": "d4e5f6a7-b8c9-50j1-k2l3-m4n5o6p7q8r9",
  "stream_entry_id": "1711000010000-0",
  "db_task_id": 789,
  "status": "queued"
}

4.4. Redis Task Stream (parser:tasks)

WBParserTask (recom, monitor):

{
  "task_id": "d4e5f6a7-b8c9-50j1-k2l3-m4n5o6p7q8r9",
  "db_task_id": 789,
  "task_type": "recom",
  "parse_method": "token",
  "request": {
    "source_type": "nm_ids",
    "source_value": null,
    "nm_ids": [124203940, 94608652, 376929502],
    "pages": 1,
    "target": {
      "target_nm_id": 124203940,
      "target_nm_ids": [94608652, 376929502],
      "top_n": 10
    }
  },
  "created_at": "2026-03-20T14:00:00Z"
}

nm_ids[0] = target, остальные — конкуренты.

WBParserTask (recom, cross_reference):

{
  "task_id": "e5f6a7b8-c9d0-51k2-l3m4-n5o6p7q8r9s0",
  "db_task_id": 790,
  "task_type": "recom",
  "parse_method": "token",
  "request": {
    "source_type": "keyword",
    "source_value": "термокружка",
    "nm_ids": [124203940],
    "pages": 1,
    "target": {
      "target_nm_id": 124203940,
      "target_nm_ids": null,
      "top_n": 10
    }
  },
  "created_at": "2026-03-20T14:00:00Z"
}

4.5. Redis Result Stream (parser:results)

WBParserResult (recom):

{
  "task_id": "d4e5f6a7-b8c9-50j1-k2l3-m4n5o6p7q8r9",
  "task_type": "recom",
  "parse_method": "token",
  "duration_seconds": 45.3,
  "request": { "...": "..." },
  "summary": {
    "items_found": 2,
    "items_saved": 47,
    "items_failed": 1,
    "pages_parsed": 1
  },
  "data": null,
  "details_in_db": true,
  "db_pointers": { "task_db_id": 789 },
  "error": null
}

Маппинг summary для recom:

ПолеЗначение
items_foundКол-во конкурентов, у которых target НАЙДЕН в рекомендациях
items_savedКол-во записей, сохранённых в product_recommendations
items_failedКол-во конкурентов, у которых target НЕ найден
pages_parsedВсегда 1

4.6. Pipeline анализа рекомендаций

Monitor-режим

Для каждого competitor_nm_id:
    ├── GET recom.wb.ru/recom/ru/common/v5/search?query=похожие+{comp_id}
    ├── Парсинг списка рекомендованных товаров (_parse_product)
    ├── Поиск target_nm_id в рекомендациях
    │   ├── Найден → we_appear_in[comp_id] = {position: N}
    │   └── Не найден → we_dont_appear_in.append(comp_id)
    └── INSERT в product_recommendations (top_n записей)

Cross_reference-режим

Шаг 1: Search (parse_with_positions, keyword, 3 страницы)
    → Получаем search_positions: {nm_id: position}
    → competitor_nm_ids = top_n из выдачи (без target)

Шаг 2: Свои рекомендации
    ├── GET recom.wb.ru?query=похожие+{target_nm_id}
    ├── Пересечение с search_positions → from_search_top
    ├── overlap_percent = len(пересечение) / len(рекомендации) * 100
    └── INSERT в product_recommendations (is_in_search_top, search_position)

Шаг 3: Обратная проверка (как в monitor)
    └── Для каждого конкурента → ищем target в его рекомендациях

4.7. WB Recommendations API

Endpoint: GET https://recom.wb.ru/recom/ru/common/v5/search

ПараметрЗначение
appType"1"
curr"rub"
dest"-446116"
page"1"
query"похожие {nm_id}"
resultset"catalog"
spp"30"
suppressSpellcheck"false"
uclusters"0"

Response: data.products[] — массив товаров в формате Cards API v4.

Каждый товар парсится через _parse_product() и получает дополнительное поле:

{
    "recom_position": int,   # 1-based позиция в рекомендациях
    # + все стандартные поля Cards API v4 (nm_id, name, price, rating, ...)
}

4.8. Внутренний результат analyze_recommendations()

{
    "mode": "cross_reference" | "monitor",
    "target_nm_id": 124203940,
    "we_appear_in": {
        94608652: {"position": 3, "search_position": 5},
        376929502: {"position": 1}
    },
    "we_dont_appear_in": [999999],
    "saved": 47,
    # Только cross_reference:
    "our_recommendations": {
        "total": 12,
        "from_search_top": [
            {"nm_id": 123, "position_in_recom": 2, "search_position": 8}
        ],
        "overlap_percent": 25.0
    }
}

4.9. Таблица product_recommendations (миграция 004)

CREATE TABLE product_recommendations (
    id                BIGSERIAL PRIMARY KEY,
    mode              VARCHAR(20)  NOT NULL,        -- 'monitor', 'cross_reference'
    keyword           VARCHAR(255),                 -- keyword (только cross_reference)
    source_nm_id      BIGINT       NOT NULL,        -- чьи рекомендации смотрим
    recommended_nm_id BIGINT       NOT NULL,        -- кого рекомендуют
    position          INTEGER,                      -- позиция в рекомендациях (1-based)
    is_in_search_top  BOOLEAN      DEFAULT FALSE,   -- входит в top-N по keyword
    search_position   INTEGER,                      -- позиция в поиске
    checked_at        TIMESTAMP    DEFAULT NOW() AT TIME ZONE 'Europe/Moscow',
    created_at        TIMESTAMP    DEFAULT NOW() AT TIME ZONE 'Europe/Moscow'
);

Записи создаются двумя способами:

  1. Свои рекомендации (cross_reference): source_nm_id = target, is_in_search_top и search_position заполняются
  2. Рекомендации конкурентов (оба режима): source_nm_id = competitor, is_in_search_top и search_position = NULL

5. Categories — дерево категорий

5.1. Назначение

Получение дерева категорий WB для выбора category_id при создании search/enrich задач.

5.2. HTTP-контракт

GET /categories/

ПараметрТипDefaultОписание
flatboolfalsetrue = плоский список, false = дерево

Response (flat=false) — Иерархическое дерево:

{
  "tree": [
    {
      "id": 1000,
      "name": "Женщинам",
      "url": "zhenshchinam",
      "shard": "blackhole",
      "childs": [
        {
          "id": 8126,
          "name": "Платья",
          "url": "zhenshchinam/odezhda/platya",
          "shard": "wc2",
          "childs": []
        }
      ]
    }
  ]
}

Категории с shard = "blackhole" — навигационные контейнеры. Только категории с конкретным shard (не blackhole) можно использовать в search/enrich.


Response (flat=true) — Плоский список (без blackhole):

{
  "count": 1234,
  "categories": [
    {
      "id": 8126,
      "name": "Платья",
      "parent_id": 1000,
      "parent_name": "Женщинам",
      "shard": "wc2",
      "url": "zhenshchinam/odezhda/platya"
    },
    {
      "id": 9468,
      "name": "Термосы и термокружки",
      "parent_id": 5001,
      "parent_name": "Дом",
      "shard": "c5",
      "url": "dom/posuda/termosy-i-termokruzhki"
    }
  ]
}

5.3. Источник данных

URL: https://static-basket-01.wbbasket.ru/vol0/data/main-menu-ru-ru-v3.json
Кэширование: в памяти WBApiClient (один раз при первом вызове)

6. Общие контракты

6.1. TaskResponse (ответ на создание любой задачи)

ПолеТипОписание
task_idstrUUID задачи
stream_entry_idstrID записи в Redis Stream
db_task_idintID в таблице tasks (PostgreSQL)
statusstrВсегда "queued" при создании

6.2. TaskStatusResponse

GET /tasks/{task_id}/status

ПолеТипОписание
db_idintID в таблице tasks
task_typestrsearch | check | enrich | recom
statusstrpending | running | completed | failed
items_foundint | nullТоваров найдено
items_processedint | nullТоваров обработано
error_messagestr | nullТекст ошибки (max 500 символов)
started_atstr | nullISO8601
completed_atstr | nullISO8601
created_atstr | nullISO8601

6.3. WBParserTask (общая структура)

class WBParserTask(BaseModel):
    task_id: UUID                    # auto: uuid4()
    db_task_id: int | None           # заполняется при publish_task
    task_type: TaskType              # search | check | enrich | recom
    parse_method: ParseMethod        # token | html
    request: RequestStructure        # вложенная структура
    created_at: datetime             # auto: utcnow()

class RequestStructure(BaseModel):
    source_type: SourceType          # keyword | category | nm_ids
    source_value: str | None         # значение keyword или category_id
    nm_ids: list[int] | None         # список артикулов (check, recom)
    pages: int = 3                   # 1..30
    target: TargetConfiguration | None

class TargetConfiguration(BaseModel):
    target_nm_id: int | None         # один целевой артикул
    target_nm_ids: list[int] | None  # 1..50 целевых артикулов
    top_n: int | None = 50           # 1..2000

6.4. WBParserResult (общая структура)

class WBParserResult(BaseModel):
    task_id: UUID
    task_type: TaskType
    parse_method: ParseMethod
    duration_seconds: float          # >= 0
    request: RequestStructure        # эхо запроса
    summary: SummaryData
    data: QuickData | None           # только для search
    details_in_db: bool = True
    db_pointers: DBPointers | None
    error: ErrorStructure | None

class SummaryData(BaseModel):
    items_found: int                 # >= 0
    items_saved: int | None          # >= 0
    items_failed: int = 0            # >= 0
    pages_parsed: int | None         # >= 0

class QuickData(BaseModel):          # только search
    target_results: list[TargetResult] | None  # max 50
    top_products: list[TopProduct] | None      # max 2000

class ErrorStructure(BaseModel):
    type: ErrorType
    message: str

class DBPointers(BaseModel):
    task_db_id: int | None

6.5. Дополнительные эндпоинты

МетодПутьОписание
GET/tasks/{task_id}/statusСтатус задачи по UUID
GET/tasks/pendingСписок зависших задач
GET/tasks/streams/healthЗдоровье Redis Streams
GET/healthK8s liveness probe
GET/readinessK8s readiness probe
GET/worker/statsМетрики worker'а

7. Redis Streams — транспорт

7.1. Потоки

StreamНаправлениеConsumer GroupОписание
parser:tasks→ Workerparser-workersВходящие задачи
parser:results→ Backendbidder-backendРезультаты парсинга

7.2. Параметры чтения

ПараметрЗначениеОписание
batch_size10Задач за один XREADGROUP
block_timeout5000 msБлокировка при отсутствии задач
max_entries10000Авто-очистка (XTRIM)
idle_timeout60 сПерехват зависших задач

7.3. Формат сообщения

Redis Stream entry = { "data": "<JSON-сериализация WBParserTask или WBParserResult>" }


8. База данных — схема

8.1. Ключевые таблицы

parser.tasks                  — очередь задач
parser.products               — товары WB (+ enrich-поля из 003)
parser.price_history          — история цен
parser.stock_history          — история остатков
parser.rating_history         — история рейтингов
parser.search_positions       — позиции в поиске/категории
parser.product_recommendations — рекомендательные полки (004)
parser.categories             — категории WB

8.2. Таблица tasks — metadata JSONB

{
  "task_uuid": "UUID строка",
  "task_type_original": "search | enrich | recom | check",
  "parse_method": "token | html",
  "request": {
    "source_type": "keyword | category | nm_ids",
    "source_value": "значение или null",
    "nm_ids": [int] | null,
    "pages": int,
    "target": { ... } | null
  }
}

Поиск задачи по UUID: WHERE metadata->>'task_uuid' = :uuid