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