API для организаций: выгрузка намерений

Организации загружают в «ХочуМогу» каталог товаров пакетно — по ссылке на файл или прямым вызовом API, без ручного ввода через мастер. Конвейер сам превращает позиции каталога в намерения «Могу продать». Оба способа уже работают и доступны в личном кабинете организации: блок «Импорт по ссылке» (поле URL, автосинхронизация, кнопка «Проверить сейчас») и «Подключение по API» (выпуск токена доступа).

1. Аутентификация

Прямой вызов авторизуется персональным токеном организации в заголовке Authorization: Bearer <org_token>. Токен выдаётся в личном кабинете организации (блок «Подключение по API» → «Выпустить ключ»), показывается один раз в момент выпуска и привязан к аккаунту — загруженные товары становятся намерениями от его имени. Для импорта «по ссылке» токен не нужен: фидом управляет сама организация под обычной сессией в личном кабинете.

2. Два способа загрузки

Оба принимают один и тот же формат файла (раздел 3 ниже).

  • Прямой вызов (push). Один запрос — один прогон синхронизации: POST /v1/org/intentions/import с телом-файлом.
  • По ссылке (pull). Организация один раз указывает URL файла в блоке «Импорт по ссылке» личного кабинета; сервис сам периодически его забирает и синхронизирует (подробности — раздел 6).
POST /v1/org/intentions/import?dry_run=1
Authorization: Bearer nmr_org_<32 hex-символа>
Content-Type: application/x-yaml

# тело — сам yaml-файл целиком, БЕЗ JSON-обёртки (см. формат ниже)

dry_run=1 — сухой прогон: тот же отчёт, что и боевой импорт, но без создания и изменения намерений (пригодится, чтобы проверить файл перед первой загрузкой). remove_missing=1 — закрыть намерения, чьи товары пропали из текущего файла (без параметра пропавшие товары остаются как есть).

3. Формат файла (YAML)

Файл — список товаров вашего каталога, а не готовых намерений: сервис сам решает, во что превратить каждый товар. Как поле обязателен только offers[].id — без него запись не свяжется с намерением, сервис вернёт по товару ошибку и не создаст черновик. category тоже нужна практически всегда: без известной сервису категории товар не пропадает с ошибкой всего запроса, но пропускается (раздел 5). Дальше — не поля, а требования к результату: импорт всегда создаёт намерения «Могу продать», а для этого типа по умолчанию обязательны цена и хотя бы один тег, который узнает словарь характеристик — оба разобраны в подсказках под примером.

shop: "Мой магазин"
city: "Хабаровск"           # территория по умолчанию для всех товаров

offers:
  - id: "sku-101"           # ваш идентификатор — для синхронизации, обязателен
    name: "Дрель-шуруповёрт Bosch GSR 12V-15, 2 АКБ, кейс"
    category: "Инструменты / Электроинструмент / Дрели-шуруповёрты"
    vendor: "Bosch"
    price: 8990
    params:
      "Тип аккумулятора": "Li-Ion"
      "Наличие реверса": "да"
      "Цвет": "синий"

  - id: "sku-102"
    name: "Платье летнее хлопковое, синее, 44 размер"
    category: "Женщинам / Женская одежда / Платья"
    vendor: "Zarina"
    price: 2490
    city: "Владивосток"     # переопределяет город магазина для этого товара
    params:
      "Размер": "44"
      "Цвет": "синий"
  • id — ваш идентификатор товара (он же external_id), обязателен: без него сервис не создаст запись, а вернёт по этому товару ошибку «у товара нет id» в errors отчёта (раздел 5) — в намерение он не превратится.
  • category — путь по вашему дереву категорий через /, из последнего уровня сервис определяет НАЗВАНИЕ будущего намерения по своему словарю (например, лист «Дрели-шуруповёрты» → название «дрель-шуруповёрт» — то, что покупатель наберёт в своём «хочу купить»). Пустая или незнакомая сервису категория — товар пропускается (попадает в skipped отчёта, раздел 5, с причиной «пустая категория» или «неизвестная категория: …»), а не превращается в намерение с произвольным названием; список известных категорий пополняем по обращениям.
  • name — обычное название товара из вашего каталога, необязательно. Если указано — идёт в описание намерения как есть (в заголовок НЕ становится — заголовок из категории выше); если не указано вовсе — описание остаётся пустым, это не ошибка.
  • vendor и params (произвольные пары «характеристика: значение») — становятся тегами намерения по внутреннему словарю, но только при ТОЧНОМ (без учёта регистра) совпадении имени характеристики со словарём: например, «Наличие реверса: да» → тег «с реверсом», а вот просто «Реверс: да» словарь не узнает по имени и молча отбросит. У каждого узнанного тега есть вес важности (зависит от категории товара, домена и самой характеристики), и в намерение он попадает, только если вес не ниже порога отсечения — например, «Цвет» у инструментов намеренно снижен (для подбора совпадений по дрели цвет не важен) и почти всегда отбрасывается, а у одежды, наоборот, входит в вес. В примере выше поэтому «Наличие реверса: да» у первого товара становится тегом «с реверсом», а «Цвет: синий» у него же — отбрасывается по весу; у второго товара (одежда) «Цвет: синий» тегом становится. Тегов на намерение остаётся немного (алгоритм подбора совпадений по тегам точнее работает с коротким набором самых важных, а не длинным списком характеристик). Отброшенное — не ошибка: агрегированную статистику (сколько раз и по какой причине) показывает поле metrics.drop_reasons отчёта (раздел 5); привязки «какой именно товар и характеристика» в отчёте нет — это сводка по всему фиду.
  • city — необязателен, задаёт территорию на уровне товара или всего магазина; действует каскад «товар → магазин → город профиля организации → вся страна» (первый заполненный уровень побеждает).
  • price — цена в рублях. Фактически ОБЯЗАТЕЛЬНА: импорт всегда создаёт намерения «Могу продать», а для этого типа по умолчанию нужна цена больше нуля (шаг «Стоимость» мастера обязателен, пока администратор сервиса явно не отключит его для этого типа в настройках конструктора мастера — по умолчанию включён). Импорт никогда не проставляет «цена договорная» автоматически — если price не указан или ≤ 0, сервис не создаст намерение и вернёт по товару ошибку «price: укажите стоимость или «договорная»» в errors отчёта (раздел 5).

Все загруженные товары становятся намерениями «Могу продать» — другие типы (аренда, услуги, вакансии) через этот канал сейчас недоступны. Количество — не отдельное поле оффера, а тоже характеристика в params: сервис ищет среди них ЛЮБУЮ с именем, содержащим «количество в упаковке» (регистр не важен), и её числовое значение становится количеством намерения — единица берётся из суффикса имени через запятую (например, «Количество в упаковке, шт» → 200 шт.; без суффикса единица останется не указана). Нет такой характеристики или её значение не число — количество принимается за 1 шт. Состояние товара при импорте всегда «новое» — это не настраивается.

4. Синхронизация и обновления

  • id связывает запись файла с намерением в сервисе: повторная загрузка того же id обновляет намерение на месте (без версий, как и везде в сервисе), а не создаёт дубль.
  • Товар, пропавший из очередного файла, сам по себе никак не меняется — намерение остаётся опубликованным. Чтобы такие товары закрывались автоматически, добавьте remove_missing=1 к запросу (push) или включите тумблер «Закрывать пропавшие из фида» (импорт по ссылке).
  • Загруженные намерения проходят те же проверки, что и созданные вручную: нормализация тегов, фильтр недопустимых слов, лимиты активных намерений — и публикуются сразу.

5. Ответ и коды

Отчёт о прогоне — один и тот же формат у прямого вызова и у «Проверить сейчас» (раздел 6), но они по-разному сообщают об отказе — это две разные вещи, их легко перепутать:

  • Прямой вызов (POST /v1/org/intentions/import) — обычный REST: успех — 200 с отчётом ниже, отказ — HTTP-код с текстом ошибки (коды — дальше в этом разделе).
  • «Проверить сейчас» (POST /v1/org/feed/sync, раздел 6) отвечает 200 практически всегда, даже если сама синхронизация не удалась — из специально обрабатываемых исходов ответа всего один не 200, он описан в разделе 6. Смотрите поле ok и feed.last_message в теле ответа, а не HTTP-код.

Успешный прогон (прямой вызов) отвечает 200:

{
  "shop": "Мой магазин",
  "dry_run": false,
  "counters": {
    "total": 2, "created": 1, "updated": 1,
    "unchanged": 0, "skipped": 0, "closed": 0, "errors": 0
  },
  "skipped": [],
  "errors": [],
  "metrics": {
    "total": 2, "converted": 2, "skipped": 0,
    "category_hit_rate": 1, "avg_tags": 2.5,
    "drop_reasons": { "вес 15 < порога 25": 1 }
  }
}

skipped[] — товары с пустой или незнакомой категорией (раздел 3), у каждой записи id товара и причина. errors[] — остальные отказы по товару: нет id, нет цены, ни одна характеристика не дала тега, лимит намерений, недопустимое слово — тоже с id и текстом причины. metrics — сводка по ВСЕМУ фиду, не по отдельному товару: total/converted/skipped — всего офферов / распознано по категории / пропущено, category_hit_rate — доля распознанных категорий (converted/total), avg_tags — среднее число тегов на распознанный товар, drop_reasons — сколько раз и по какой причине отброшена ХАРАКТЕРИСТИКА при формировании тегов (раздел 3); привязки «какой товар» здесь нет — это агрегат для калибровки фида в целом, не диагностика конкретной позиции.

Коды прямого вызова:

  • 401 — токен не передан, отозван или неверен.
  • 403 — аккаунт организации заблокирован либо тарифный план не оплачен (доступ по API приостановлен).
  • 409 — либо пара «Хочу купить» / «Могу продать» временно отключена администрацией сервиса, либо импорт вашего фида уже выполняется (вы отправили два файла разом, или в этот момент сработал импорт по ссылке из раздела 6). Точная причина — в тексте ошибки. В обоих случаях повторите позже: прогоны по одной организации идут строго по очереди, иначе они мешали бы друг другу — в частности, прогон со старым файлом снял бы с публикации товары, которые только что добавил прогон со свежим.
  • 413 — файл больше 1 МБ.
  • 422 — файл не разобрался как YAML целиком (например, битый синтаксис) — не путать с skipped/errors отдельного товара внутри валидного файла.

6. Импорт по ссылке (pull)

Альтернатива прямому вызову — без токена и без запросов с вашей стороны. В блоке «Импорт по ссылке» личного кабинета укажите URL файла (тот же формат, что в разделе 3) — сервис сам периодически его скачивает и синхронизирует намерения тем же конвейером.

  • Тумблер «Обновлять автоматически» включает периодическую выкачку; частоту (по умолчанию раз в 6 часов) настраивает поддержка сервиса.
  • Тумблер «Закрывать пропавшие из фида» — то же самое, что remove_missing у прямого вызова, но для pull-режима.
  • Кнопка «Проверить сейчас» запускает синхронизацию немедленно. Ответ практически всегда 200 вида { "ok": true|false, "feed": {...} } — сам HTTP-код не сигнализирует об отказе (в отличие от прямого вызова, раздел 5): любая ошибка синхронизации (не выкачался файл, битый YAML, отключена пара, не оплачен тариф и т. д.) — это ok: false и человекочитаемый текст в feed.last_message, а не отдельный HTTP-код. Исключений два: 422, если ссылка на фид ещё не сохранена (сначала заполните и сохраните поле URL), и 409, если импорт вашего фида в этот момент уже идёт (например, вы одновременно отправили файл напрямую) — тогда статус фида не меняется, просто дождитесь завершения. При удаче feed.last_counters содержит те же счётчики, что counters прямого вызова (раздел 5).
  • Ссылка должна быть публичной (обычный http/https, без входа) — сервис не проходит авторизацию на стороне вашего файлового хранилища.

7. Ограничения

  • Размер файла — не больше 1 МБ (около 3500 товаров с несколькими характеристиками каждый).
  • Территория — по справочнику ФИАС/DaData (регион, город, адрес до дома) или «вся страна».
  • Крупный файл обрабатывается не мгновенно: около 15 секунд на 500 товаров, до полутора-двух минут на файл, близкий к лимиту 1 МБ, — это ожидаемо, не сбой. Для очень большого каталога удобнее делить его на несколько файлов поменьше.
  • При слишком частых запросах сервис может ответить 429 — это защита от перегрузки; сделайте паузу и повторите позже. Для регулярной синхронизации предпочтительнее импорт по ссылке (раздел 6) с разумным интервалом, а не непрерывные повторы вручную.
  • Матчинг, статусы и остальные лимиты — целиком на стороне сервиса; файл задаёт только исходные данные товара.

Оба способа уже доступны без ожидания — не нужно ничего согласовывать заранее: выпустите ключ или укажите ссылку на файл в личном кабинете организации, разделы «Подключение по API» и «Импорт по ссылке». Вопросы по формату или подключению — через форму «Идея» или на почту поддержки.