Перейти к основному содержимому

COLLECTORS-SPEC — Yandex Market (8 коллекторов)

Детали механики вызова YM Partner API. Общие паттерны — в COLLECTORS-SPEC.md. Field-mapping'и — в data-model/intake-ym.md. Пути file:line — от marketplace-collector-v3/collectors/.

Общее: base = YANDEX_MARKET["base_url"] = https://api.partner.market.yandex.ru; авторизация — заголовок Api-Key (Bearer не используется). Два уровня API:

  • business-level (YANDEX_MARKET["business_id"]) — offer_mappings, prices, promos, analytics, storage; всегда mp_id=6;
  • campaign-level (YANDEX_MARKET["campaigns"] = {model: campaign_id}; fbs=22027612 → mp6, fby=61824486 → mp8) — stats_orders, stocks, returns; развод mp_id через _MODEL_TO_MP = {"fbs": MP_YM_FBS(6), "fby": MP_YM_FBY(8)} и инъекцию _v3_mp_id в каждый item (ym_stats_orders.py:26, :71-72, запись :153).

Постраничная пагинация везде одинакова: params={"limit": N} + page_token; следующий токен из result.paging.nextPageToken; стоп — токена нет; bounded _MAX_PAGES (epic-10/04). Пример:

next_token = paging.get("nextPageToken")   # ym_offer_mappings.py:64-67
if not next_token:
break
page_token = next_token

1. ym.offer_mappings — мэппинги офферов

  • YmOfferMappingsCollector, mp_id=6 (ym_offer_mappings.py:22-24, business-level). Dual-write вручную mappings/ym/offer_mappings.yaml (:17, :110).
  • POST /businesses/{business_id}/offer-mappings (:43), body {} (:49). Снапшот, окна нет.
  • Пагинация: limit=200 (:46), page_token (:63-67), _MAX_PAGES=500 (:19).
  • Таблица: gwptd_intake.ym_offer_mappings (:74, :94).

2. ym.stats_orders — заказы (обе кампании)

  • YmStatsOrdersCollector, mp_id default=6, фактический per-row из модели кампании (ym_stats_orders.py:29-31, :26, :71-72, :153). Dual-write вручную mappings/ym/stats_orders.yaml (:21, :170).
  • POST /v2/campaigns/{campaign_id}/stats/orders для каждой кампании из YANDEX_MARKET["campaigns"].items() (:50-51), body {"dateFrom": today-7, "dateTo": today} (:46-48).
  • Пагинация: limit=200 (:55) + page_token (:56-57, :75-79), _MAX_PAGES=500 (:23).
  • Ловушка: creationDate приходит в трёх форматах — ISO с T, DD-MM-YYYY HH:MM:SS, date-only — нормализация (:116-140).
  • Таблица: gwptd_intake.ym_stats_orders (:86, :146).

3. ym.stocks — остатки (обе кампании)

  • YmStocksCollector, mp_id per-campaign (ym_stocks.py:23-25, :20, :60-63, :115). Dual-write вручную mappings/ym/stocks.yaml (:15, :127).
  • POST /v2/campaigns/{campaign_id}/offers/stocks (:41), body {} (:48). Снапшот.
  • Пагинация: limit=200 (:46), page_token (:68-71), _MAX_PAGES=500 (:17).
  • Offer вложен в warehouse — итерируются склады, в item инъецируются _v3_warehouse_name/id (:56-64); stocks[] разбирается по типам в счётчики AVAILABLE/FIT/FREEZE/DEFECT/EXPIRED (:101-105).
  • Таблица: gwptd_intake.ym_stocks (:78, :108).

4. ym.prices — цены (business-level)

  • YmPricesCollector, mp_id=6 (ym_prices.py:30-32) — цены едины для FBS/FBY, хранятся под mp6 как канонический фид (:4-6). legacy_mapping_path = mappings/ym/prices.yaml (:34) → mp_prices_daily через BaseCollector (:105).
  • POST /v2/businesses/{business_id}/offer-prices (:47), body {} (:54). Снапшот.
  • Пагинация: limit=200 (:50), page_token (:68), _MAX_PAGES=100 (:16, меньше остальных).
  • Таблица: gwptd_intake.ym_prices (:77, :93). Пишет price.value, discountBase, currencyId (:100-103).

5. ym.returns — возвраты (обе кампании, F-15)

  • YmReturnsCollector, mp_id per-campaign (ym_returns.py:24-26, :21, :60-61, :103). Dual-write вручную mappings/ym/returns.yaml (:16, :116).
  • Единственный GET среди кампанийных: /v2/campaigns/{campaign_id}/returns (:44, :51); окно — query-параметры fromDate/toDate в формате %d-%m-%Y, today−30 … today (:41-42, :48).
  • Пагинация: limit=50 (не 200, :48); page_token, причём paging — на ВЕРХНЕМ уровне ответа, не в result (:63-64); _MAX_PAGES=500 (:18).
  • F-15 (DEFECT-LEDGER): дата возврата — submittedDate приоритетнее updateDate (updateDate дрейфует при смене статуса); accrual-семантика по BR-003 (:93-95). refundAmount.value читается только если dict (:111).
  • Таблица: gwptd_intake.ym_returns (:73, :97).

6. ym.promos — акции (business-level)

  • YmPromosCollector, mp_id=6 (ym_promos.py:35-37). legacy_mapping_path = mappings/ym/promos.yaml (:39) → mp_promotions через BaseCollector.
  • POST /v2/businesses/{business_id}/promos (:52), body {} (:53). Без пагинации — один запрос, весь список result.promos (:58-63). Снапшот видимых промо на момент сбора (:3-5).
  • Таблица: gwptd_intake.ym_promos (:72, :92). Из mechanicsInfo.type, assortmentInfo.activeOfferCount/potentialOfferCount (:103-107).

7. ym.analytics — воронка shows-sales (async xlsx)

  • YmAnalyticsCollector, mp_id=6 (ym_analytics.py:85-87). legacy_mapping_path = mappings/ym/analytics.yaml (:89) → mp_analytics_daily (:293).
  • self.use_cache = False (:95) — «async несовместим с HTTP-кэшем: закэшированный PROCESSING зациклил бы ожидание» (:93-94).
  • Async 3-step:
    1. generate: POST /v2/reports/shows-sales/generate, body {dateFrom: today-7, dateTo: today-1, grouping: "OFFERS", businessId} (:125-135) → result.reportId (:141). Окно по вчера — «YM не отдаёт сегодняшний день» (:10, :125-126);
    2. poll: GET /v2/reports/info/{report_id} до status=="DONE"result.file URL (:148, :157-159); FAILED → выход (:160-162). _POLL_ATTEMPTS=30 × _POLL_SLEEP=10 с (:31-32, sleep в начале попытки :151);
    3. download: self.session.get(file_url, timeout=120) — сырые байты НАПРЯМУЮ через session, мимо self.http() (тот JSON-only); Api-Key уже в headers (:168-171); статус скачивания вручную в run.record_http (:175-176).
  • Парсинг xlsx: openpyxl.load_workbook(read_only=True, data_only=True) (:195); обязательный ws.reset_dimensions() — YM-xlsx без корректного <dimension>, иначе заголовок схлопывается в одну ячейку (:196-200). Заголовки однорядные русские; маппинг заголовок→индекс (:210-225); без колонки «Ваш SKU» — abort (:216-221). Метрики: Показы/Клики/Добавления в корзину/Заказанные товары/суммы/Доставлено (:43-52, :240-251). Даты _normalize_day DD-MM-YYYY/YYYY-MM-DD (:73-82).
  • F-31 — архив xlsx: _XLSX_ARCHIVE_DIR = os.environ.get("V3_XLSX_ARCHIVE_DIR", "/opt/gwptd-analytics/xlsx-archive") (:34-38) — каталог ВНЕ git-репо (dirty worktree ломал автопулл); _archive_xlsx() fail-soft, имя {endpoint_code}_{YYYYmmdd_HHMMSS}.xlsx (:99-116); вызов ДО парсинга (:182). Мотив: в raw_payload только распарсенные строки — при смене русских заголовков без бинарника отчёт невосстановим (:34-37, :181). Temp-файл NamedTemporaryFile, удаляется в finally (:185-258).
  • Таблица: gwptd_intake.ym_analytics (:263, :279).

8. ym.storage — хранение/услуги (async xlsx, двухрядные заголовки)

  • YmStorageCollector, mp_id=6 (ym_storage.py:105-107). legacy_mapping_path = mappings/ym/storage.yaml (:109) → mp_storage_costs.
  • self.use_cache = False (:113-115, «см. wb_paid_storage»). Rate limit отчёта: 100 req/hour; объём ~49 строк/мес (:10-11) — темп задаёт poll-sleep 10 с (base.py:78-79).
  • Async 3-step:
    1. generate: POST /v2/reports/united-marketplace-services/generate?format=FILE, body {businessId, dateTimeFrom: "{today-30}T00:00:00+03:00", dateTimeTo: "{today-1}T23:59:59+03:00", placementPrograms: ["FBS","FBY"]} (:151-160) → result.reportId (:166);
    2. poll: GET /v2/reports/info/{report_id} до DONE/FAILED (:174, :182-187); _POLL_ATTEMPTS=30 × _POLL_SLEEP=10 (:29-30, :176);
    3. download: session.get(file_url, timeout=_DOWNLOAD_TIMEOUT=120) мимо http() (:31, :193-195).
  • Парсинг xlsx: сканируются ВСЕ листы (:228) — реальный отчёт = 13 листов по типам услуг, отдельного листа «Хранение невыкупов и возвратов» нет (:41-47). Двухрядные заголовки: строка-группа сверху + строка с «Стоимость услуги, ₽», ищется в первых 8 строках (:239-246); колонки — по русским алиасам (amount :48; дата :49-53; Услуга/SKU/номер заказа/номер возврата/количество/тип записи :54-59, :260-267). Обязательный ws.reset_dimensions() (:232).
  • Фильтр строк: keywords ("хранен","невыкуп","возврат") по имени листа или тексту услуги (:63, :272-296); пропуск «итого» (:287-288) и amount==0 (:283). _clean_id отрезает .0-хвост numeric-ID (:66-76); даты DD.MM.YYYY/YYYY-MM-DD (:79-88).
  • offer_id = sku or return_id or order_id (:303); warehouse_name=None, cost_type="storage", amount=abs(...) (:311-313). source_key = {sheet}:{date}:{offer_id}:{return_id|order_id} — уникален по строке, иначе legacy-upsert (mp_id,date,offer,wh,type) схлопнул бы разные начисления (:325-328).
  • F-31 — архив xlsx: тот же V3_XLSX_ARCHIVE_DIR + fail-soft _archive_xlsx (:33-37, :119-136), вызов до парсинга (:205).
  • Таблица: gwptd_intake.ym_storage (:345, :364).

Сводка по осям

  • fbs/fby разводятся только в campaign-level коллекторах (№2, 3, 5) через _v3_mp_id; business-level (№1, 4, 6, 7, 8) — всегда mp6 (у storage при этом в запросе обе программы FBS+FBY).
  • F-31 (архив xlsx) — только №7, №8. F-15 — только №5. use_cache=False — только async №7, №8.
  • Dual-write через BaseCollector (legacy_mapping_path) — №4, 6, 7, 8; вручную — №1, 2, 3, 5.