AI Asist
v2
Д
Д

Плейбук: реклама Ozon — поисковые запросы и разрез по типам РК

← Заметки  ·  Раздел: Ozon  ·  Файл: data/notes/ozon/ads-playbook.md

Плейбук: реклама Ozon — поисковые запросы и разрез по типам РК

Срабатывает, когда пользователь спрашивает про рекламу Ozon: «куда уходит рекламный бюджет», «по каким запросам показываемся / сливаем», «разбей рекламу по типам кампаний», «ДРР по артикулам Ozon», «почему артикул X жрёт рекламу», «поисковые запросы по товару».

Стек: mp-system (корень), .venv/bin/python, БД mp.db, shop 2 = Мой Ozon, shop 4 = Siluetta Ozon. Performance API — app/ozon/performance.py (PerformanceClient). Расчёт прибыли/ДРР — data/notes/ozon/profit-methodology.md.


0. Перед стартом: данные свежие?

Реклама и кампании часто устаревшие — сперва доингест (Performance API асинхронный, тянется минутами):

.venv/bin/python scripts/ingest_ozon.py --shop-id 2 campaigns
.venv/bin/python scripts/ingest_ozon.py --shop-id 2 ad-stats --from YYYY-MM-DD --to YYYY-MM-DD

Кладётся в ozon_ad_metrics_daily (день × SKU × кампания: views/clicks/orders/money/placement) и ozon_campaigns (тип/плейсмент кампаний).

Креды Performance. Резолв: shops.perf_cred_path → файл Ozon_Cred.txt (Client ID / Client Secret), резолвер app/web/shop_status.py::_resolve_ozon_perf. После Win→Linux миграции в perf_cred_path остаётся мёртвый Windows-путь → «Performance creds не заполнены». Фикс: UPDATE shops SET perf_cred_path='/home/mi/Bot_TG/mp-system/Ozon_Cred.txt' WHERE id=?.


1. Таксономия: типы кампаний и плейсменты

Тип кампании (ozon_campaigns.adv_object_type / payment_type): | adv_object_type | payment_type | Что | |---|---|---| | SKU | CPC | Оплата за клик (товарная) | | SEARCH_PROMO | CPO | Оплата за заказ (% с заказа, ДРР-cap) | | BANNER | CPM | Баннер | | VIDEO_BANNER | — | Видеобаннер | | REF_VK / REF_BLOGGER | — | Внешний трафик (VK / блогеры) |

Плейсмент (placement, для CPC): | placement | По-русски | Фразы? | |---|---|---| | PLACEMENT_TOP_PROMOTION | Поиск (топ выдачи) | да (отчёт по фразам только тут) | | PLACEMENT_SEARCH_AND_CATEGORY | Поиск и рекомендации | частично (только поиск-часть; рекомендации склеены) | | PLACEMENT_PDP | Карточка товара | нет (только ручные кампании) | | PLACEMENT_OVERTOP | Поиск и главная (спецразмещение) | — | | PLACEMENT_TAKEOVER | Первые 4 плитки (спецразмещение) | — | | None (в ad_metrics) | — | это CPO-операции (за заказ) |

Нет плейсмента «только рекомендации». Рекомендации всегда в связке с поиском (SEARCH_AND_CATEGORY). Без фраз умеют: PDP (карточка, только ручные) и CPO.


2. Разрез расхода по типам РК (главный запрос)

SELECT placement, payment_type, campaign_id, campaign_title,
       ROUND(SUM(money)) AS spend, SUM(clicks) AS clicks, SUM(orders) AS orders
FROM ozon_ad_metrics_daily
WHERE shop_id = :shop AND date BETWEEN :since AND :to
GROUP BY campaign_id ORDER BY SUM(money) DESC;

Чтение: placement=NULL → CPO (за заказ); TOP_PROMOTION → поиск-CPC; SEARCH_AND_CATEGORY → поиск+рекомендации-CPC. Свод по типу: GROUP BY placement (или payment_type).

Связка с прибылью: реклама в юнитке (scripts/_oneshot_ozon_unit_shop2.py как образец) агрегирует SUM(money) per color_key(offer_id) из ozon_ad_metrics_daily. Сверка с finance: SUM(amount) WHERE operation_type_name IN ('Оплата за клик','Продвижение с оплатой за заказ') должна совпасть с Performance в пределах 0.1%.


3. Отчёт по поисковым запросам

Только для CPC-кампаний с placement = PLACEMENT_TOP_PROMOTION. Метод на стадии тестирования.

POST /api/client/statistics/phrases/json, тело:

{ "campaigns": ["<id>", ...], "dateFrom": "YYYY-MM-DD", "dateTo": "YYYY-MM-DD", "groupBy": "NO_GROUP_BY" }

Асинхронно → UUIDclient.wait_statistics(uuid)client.statistics_report_bytes(uuid) (JSON-вариант возвращает dict). Дата не раньше 2025-02-01.

Структура: { "<campaign_id>": { title, report: { rows: [...] } } }, строка = {sku, title, search_phrase, views, clicks, ctr}.

Только показы/клики/CTR — НЕТ денег и заказов по фразе. Расход на фразу можно лишь оценить: клики × средний CPC (CPC = расход_кампании / Σ кликов). Конверсию «фраза→заказ» API не отдаёт. CTR низкий = плохая релевантность показа.

Минус-слова продавец на Ozon сам НЕ ставит. Вывод по фразам — для решения вкл/выкл кампании или ставки, не для ручной минусовки.

Готовый образец: scripts/_oneshot_ozon_phrases_shop2.py (берёт TOP_PROMOTION-кампании с расходом в окне, дёргает отчёт, агрегирует фразы по кликам, оценивает расход через ср.CPC). Кандидаты кампаний:

SELECT DISTINCT campaign_id FROM ozon_ad_metrics_daily
WHERE shop_id=:shop AND date BETWEEN :since AND :to AND placement='PLACEMENT_TOP_PROMOTION' AND money>0;

4. Прочие отчёты Performance (что ещё доступно)

Нужно Endpoint Метод Отдаёт
Дневная стата по кампаниям /api/client/statistics/daily GET расход/клики по дням
Расход по кампаниям /api/client/statistics/expense GET быстрый свод трат
CPO заказы /api/client/statistic/orders/generate/json POST {from,to} заказы CPO (исп. в ingest_cpo_orders)
CPO товары (выбранные) /api/client/statistic/products/generate/json POST {from,to} per-SKU: orders, ДРР, расход CPO + CPC-доля
CPO товары (все, сплит поиск/реком) /api/client/statistics/all_sku_promo/products/generate/json GET timeBounds.from/to продажи/заказы из поиска vs из рекомендаций
Конкурентные ставки /api/client/campaign/{id}/products/bids/competitive ставки конкурентов

Сплит «поиск vs рекомендации» (в деньгах/заказах) есть ТОЛЬКО в режиме «все товары» (all_sku_promo). Если CPO настроен как «выбранные товары» — этот отчёт даёт 404 «кампания не найдена», сплит недоступен (нужно переключить продвижение в кабинете на «все товары»).

⚠ CPO «выбранные товары» расход = CPO (за заказ, ДРР-cap) + CPC (за клик) в одном инструменте (поля MoneySpent/DRR и MoneySpentFromCPC/ordersFromCPC).


5. Механика Performance API

  • Авторизация: OAuth2 client_credentials → Bearer (TTL ~30 мин), уже в PerformanceClient.
  • Тяжёлые отчёты асинхронные: submit → UUIDwait_statistics(uuid, max_wait_sec=600)statistics_report_bytes(uuid). /json-вариант пути → dict сразу; обычный → CSV/ZIP.
  • Метод иногда GET, иногда POST — если 405, поменять метод; если параметры через query (timeBounds.from) — это GET. Есть rate-limit (раздел «Лимиты» в доке).
  • Зеркало доки: Dev-API-Ozon/html/latest/performance/index.md.

NOT_STARTED — норма, не баг

wait_statistics поллит статус UUID. Типичный сценарий: UUID создан → NOT_STARTED 2–5 мин → IN_PROGRESS ~30c → OK. 240 секунд — мало, использовать max_wait_sec=600. Реализовано в pipeline.py: step("ad-stats", ..., max_wait_sec=600).

Лимит 1 одновременная выгрузка на аккаунт → 429 «максимум 1» при попытке создать второй UUID. Надо дождаться завершения первого, потом повторить. PerformanceClient.request() ретраит 429 3 раза с паузой 30с — если очередь не освобождается за 90с, кидает исключение.

Колонки CSV переименованы Ozon (~2025)

POST /api/client/statistics возвращает ZIP/CSV с заголовками, которые изменились. Актуальные имена (июнь 2026, verified):

Старое Актуальное
Заказы Продано товаров
В корзину Добавления в корзину
Продажи, ₽ Продажи в продвижении, ₽
Сумма заказов, ₽ Заказано на сумму, ₽
Заказы модели Продано товаров модели
Продажи с заказов модели, ₽ Продажи в продвижении с заказов модели, ₽

Парсер app/ozon/ingest._parse_ad_stats_csv сначала ищет известные точные варианты через _idx("старое", "новое"), затем использует смысловой fallback по заголовкам:

  • количество заказов: содержит заказ / продано / заказано, но не содержит модель и денежные маркеры;
  • сумма заказов: приоритетно Заказано на сумму, ₽; затем старые поля продаж/сумм. Fallback содержит / руб / выруч / сумм / продажи, но не содержит модель и расход;
  • количество/сумма модели: те же правила, но обязательно содержит модель.

Это нужно, потому что Ozon меняет CSV-заголовки без смены endpoint. Если i_orders is None — в лог уйдёт WARNING "колонка 'Заказы'/'Продано товаров' не найдена". Диагностика: поднять уровень ozon_ingest до DEBUG и смотреть строку CSV campaign=X headers: [...].

Симптом: все записи ozon_ad_metrics_daily.orders = 0 при ненулевом money. Значит колонка renamed и парсер её не нашёл — добавить новое имя в _idx(...).

ingest_campaigns и ARCHIVED-кампании

GET /api/client/campaign возвращает только живые кампании. Архивированные из ответа исчезают, но в ozon_campaigns остаются со старым статусом (RUNNING/STOPPED/INACTIVE). В результате ingest_ad_stats посылает statistics_request с невалидными campaign_id → Ozon принимает, UUID выдаёт, но отчёт НИКОГДА не стартует (NOT_STARTED бесконечно).

Фикс (реализован в ingest_campaigns): после upsert свежих данных кампании, не попавшие в ответ API, помечаются CAMPAIGN_STATE_ARCHIVED, и ingest_ad_stats их пропускает.


6. CPA с поправкой на выкуп

Сырой рекламный CPA может выглядеть хорошим, но быть убыточным после отмен и невыкупов. Для товарной линейки считать три разные метрики:

рекламные заказы = SUM(orders + orders_model)  # direct + halo Ozon
raw CPA = рекламный расход / рекламные заказы
blended CPO = рекламный расход / все оформленные заказы товара
CAC ожидаемого выкупа = расход / Σ(рекламные_заказы_sku × зрелый_выкуп_sku)

blended CPO нельзя называть стоимостью рекламного привлечения: в знаменателе есть органика. Для решения о ставке использовать raw CPA, CAC ожидаемого выкупа и вклад до рекламы.

Зрелый выкуп считать по ozon_postings на когорте, закончившейся минимум за 15 дней до даты анализа, отдельно по каждому размеру. Подробная методика и прогноз активного хвоста — data/notes/ozon/orders-ad-cpo-playbook.md.

Экономический предел сырого CPA:

break-even raw CPA = вклад_до_рекламы_на_выкуп × зрелый_выкуп
целевой raw CPA < break-even raw CPA  # оставить запас на налог и общие расходы

Перед масштабированием также проверять остаток и покрытие в днях. Кампанию нельзя оценивать только по CPA, если рекламируемый размер почти закончился.

Проверочный кейс ELS04, Siluetta Ozon, 2026-07-09…15: raw CPA black/brown/grafit был 503/795/697 ₽, но зрелый выкуп 44.9/24.5/19.7% превращал его примерно в 1 121/3 250/3 540 ₽ за ожидаемый выкуп. Поэтому похожий raw CPA у разных цветов не означает похожую экономику.


7. Чек-лист разбора рекламы Ozon

  1. ☐ Кампании и ad-stats доингещены за окно? Креды Performance резолвятся (perf_cred_path Linux)?
  2. ☐ В ozon_ad_metrics_daily есть ненулевой money за окно? Если orders=0 при money>0 — смотреть §5 «Колонки CSV переименованы» (парсер не нашёл Продано товаров).
  3. ☐ Свод расхода по placement/campaign_id снят — видно, где деньги (CPO vs поиск vs поиск+реком)?
  4. ☐ Реклама сверена с finance (0.1%)?
  5. ☐ Если копаем поиск — фразы тянем только по TOP_PROMOTION-кампаниям; помним: CTR ≠ конверсия?
  6. ☐ Нужен сплит поиск/рекомендации — проверили, что CPO в режиме «все товары» (иначе недоступно)?
  7. ☐ Вывод пользователю: где утечка (обычно CPC на общих фразах), что управляемо (CPO ДРР-cap), рекомендация вкл/выкл/ставка (минус-слов на Ozon нет).
  8. ☐ Raw CPA пересчитан в CAC ожидаемого выкупа по зрелому выкупу размеров?
  9. ☐ Проверены остатки и дни покрытия рекламируемых SKU?

8. Реальный кейс (HZ030-Brown, Мой Ozon, июнь 2026)

Артикул убыточен по юните; реклама 25 263 ₽ = 60% бюджета магазина. Разложение: CPO (ДРР 10%, управляемо) ≈ 8.5к + CPC внутри CPO ≈ 8.6к + CPC «Поиск» 6.4к + CPC «поиск+полки» 1.7к. ~16.7к из 25.3к = CPC на общих фразах («комбинезон женский летний», «Каталог» — CTR 1.7–2.7%) с неуправляемым ДРР — вот это утечка, а не CPO-10%. Решение: по убыточным SKU оставлять CPO (ДРР-cap), а поисковый CPC резать. См. [[ads-playbook]] §3 (фразы), §2 (разрез по типам).

Фильтры