Тридцать первого августа я снял отчёт по рекламному кабинету и увидел пять конверсий. Две кампании, которые всю неделю выглядели бесполезными, наконец можно было защитить перед заказчиком.
Пять конверсий оказались срабатываниями любых целей счётчика подряд, включая простое открытие виджета на сайте. Я добавил в запрос четыре целевые цели и получил таблицу без конверсий вообще. Решил, что обращений не было, и собрался писать об этом заказчику.
Это тоже было ошибкой. В запросе стоял параметр Goals, но в списке полей не было Conversions. API вернул корректный отчёт, просто он не содержал тех данных, по которым я пытался сделать вывод.
С третьего запроса выяснилось: обращений было три, все из одной кампании. Те две, которые я собирался защищать, дали ноль.
Запрос | Что вернулось | Что это значило |
|---|---|---|
| 5 | все цели счётчика, включая открытие виджета |
| колонок нет | о конверсиях вывод сделать нельзя |
| 3 | только целевые действия |
Ни один из трёх запросов не завершился ошибкой. Все три вернули 200 OK. Ошибочным был мой способ решать, достаточно ли полученных данных для вывода.
Дальше три случая, где формально успешный ответ означает разное: вы не получили нужных данных, не получили выполненную операцию, получили корректную настройку, которая не совпала с вашим намерением. Первые два воспроизведены живыми запросами второго сентября 2026 года на API v5, ответы ниже настоящие. Третий на боевом кабинете я воспроизводить не стал, он подтверждается сохранённым состоянием кампании. Кабинет клиентский: названия кампаний обезличены, идентификаторы целей заменены, суммы расхода убраны.
Случай первый: данных нет, потому что вы их не просили
Параметр Goals задаёт, по каким целям Метрики считать конверсии. Без него в конверсии попадают все цели счётчика подряд. У нас среди них есть «открытие помощника»: человек нажал кнопку чата и ушёл, ничего не написав. В отчёте это неотличимо от настоящего обращения, и цифра завышается правдоподобно. Не тысяча конверсий из воздуха, а пять вместо трёх.
Правило «всегда указывать Goals» я выучил ещё в июле и тридцать первого августа честно применил. И получил пустоту.
Потому что Goals определяет, по каким целям считать, а Conversions в FieldNames определяет, показывать ли результат. Нужны оба. Вот запрос целиком, вместе с заголовками:
params = { "SelectionCriteria": {"DateFrom": "2026-08-25", "DateTo": "2026-08-31"}, "FieldNames": ["CampaignName", "Clicks"], # тут не хватает "Conversions" "Goals": ["1000001", "1000002", "1000003", "1000004"], "AttributionModels": ["LSCCD"], "ReportName": "week", "ReportType": "CAMPAIGN_PERFORMANCE_REPORT", "DateRangeType": "CUSTOM_DATE", "Format": "TSV", "IncludeVAT": "YES", } headers = { "Authorization": f"Bearer {TOKEN}", "Client-Login": LOGIN, "Accept-Language": "ru", "processingMode": "auto", "returnMoneyInMicros": "false", "skipReportHeader": "true", # иначе первой строкой придёт название отчёта, а не колонки "skipReportSummary": "true", # иначе в конце добавится строка итогов } r = requests.post("https://api.direct.yandex.com/json/v5/reports", headers=headers, json={"params": params}) # отчёт считается не мгновенно: 201 значит «поставлен в очередь», # 202 значит «ещё формируется», тело в обоих случаях пустое. # Повторять тот же запрос через интервал из заголовка retryIn, пока не придёт 200.
Ответ:
CampaignName Clicks Кампания 1 19 Кампания 2 46 Кампания 3 73
На беглом взгляде это читается как «конверсий не было». На самом деле таблица вообще ничего не говорит о конверсиях: колонок с ними в ней нет.
Тот же период, те же кампании, в FieldNames добавлено поле Conversions:
CampaignName Clicks Conversions_1000001_LSCCD Conversions_1000002_LSCCD Conversions_1000003_LSCCD Conversions_1000004_LSCCD Кампания 1 19 -- -- -- -- Кампания 2 46 -- -- -- -- Кампания 3 73 -- 3 -- --
Три обращения. Они были там всё это время.
API выполнил запрос буквально и ничего не нарушил: я перечислил цели для расчёта, но не попросил вывести сам показатель. Претензия у меня не к Директу, а к себе, и она конкретная: я построил разбор, в котором отсутствие измерения неотличимо от нуля. Парсер искал колонки по именам, не находил, считал сумму пустого списка и получал честный ноль.
Различать надо три состояния:
что в ответе | что это значит |
|---|---|
колонок | конверсии не запрашивались, для вывода о них отчёт непригоден |
колонка есть, значение | числового значения в ячейке нет, за период конверсий не было |
колонка есть, значение | в моих проверках означало то же, что |
Первое нельзя сворачивать в ноль ни при каких условиях. Это разница между «заявок не было» и «я не спросил про заявки», и от неё зависит, что я скажу человеку, который платит за рекламу.
Поэтому проверка теперь стоит до всякого разбора данных и требует полный ожидаемый набор колонок, а не что-нибудь похожее:
expected = {f"Conversions_{goal}_{model}" for goal in GOALS for model in ATTRIBUTION_MODELS} missing = expected - set(head) if missing: raise RuntimeError(f"отчёт неполный, нет колонок: {sorted(missing)}")
Отдельно про --. Пустое значение приходит двумя дефисами, и float("--") роняет скрипт, причём падает он не при получении данных, а при подсчёте, когда вы уже решили, что всё хорошо. Соблазнительно написать функцию, которая сразу возвращает ноль, но тогда вы своими руками смешиваете «ноль» и «значения нет»:
def parse_number(value): value = (value or "").strip() if value in {"", "--", "-"}: return None # не ноль: значения просто нет return Decimal(value)
Семантика тут такая: -- это отсутствие числа в ячейке, и трактовать его как ноль допустимо, но только после того, как вы убедились, что нужные колонки в отчёте вообще есть. Сначала проверка контракта, потом свёртка пустых значений в ноль, не наоборот. Через pandas то же самое делается параметром na_values=["--"].
Случай второй: 200 OK не означает, что изменился каждый объект
Тридцатого августа мы гасили автотаргетинг в одной из кампаний. Скрипт отработал, отчитался, что всё выключено. Сутки я считал, что автотаргетинг стоит.
Он работал всё это время.
Автотаргетинг живёт в группе как псевдофраза с ключевым словом ---autotargeting и выглядит обычной фразой со своим Id. Логично попробовать остановить её методом Keywords.suspend, как любую другую фразу. Документация это подтверждает: метод «останавливает показы по ключевым фразам и автотаргетингам».
Вот что вернулось на самом деле (проверил заново на кампании, которая и так стоит на паузе):
фраз в группе: 12 обычных плюс 1 автотаргетинг id=20xxxxxxxxx keyword='---autotargeting' state=ON status=ACCEPTED вызываю Keywords.suspend... ОТВЕТ: {"result": {"SuspendResults": [{"Errors": [{"Code": 8305, "Message": "Невозможно выполнить действие", "Details": "Автотаргетинг не может быть остановлен"}]}]}} состояние ПОСЛЕ (перечитано): ON ACCEPTED
Сразу оговорюсь, потому что это важно: это не универсальный запрет метода. В документации по автотаргетингу сказано, что в кампаниях с показами на Поиске или на Поиске и в РСЯ автотаргетинг нельзя отключить полностью, попытка вызывает ошибку валидации. В группах, работающих только в РСЯ, приостановить его можно. Наша кампания поисковая, отсюда и 8305.
И вот тут главное, ради чего этот случай в статье. Метод честно сообщил об ошибке. Не сработал мой обработчик ответа.
Ошибка лежала внутри тела, в SuspendResults[0].Errors, а на верхнем уровне ответа было всё в порядке. Мой код смотрел на HTTP-статус и на наличие ключа result и видел успех. У методов, которые обрабатывают списки объектов, ошибки приходят поштучно: отправили сто фраз, у трёх отказ, транспортно всё прекрасно.
Отсюда правило, стоившее суток: проверять надо отдельно ошибку операции по каждому объекту и отдельно итоговое состояние. Это разные проверки, и вторая не заменяет первую. Причём в этом случае перечитывание само по себе не помогло бы: автотаргетинг числится State: ON даже в кампании, которая целиком стоит на паузе, так что по этому полю судить о показах нельзя.
Правильный способ нашёлся с третьего захода: автотаргетинг не выключается целиком, у него сужается набор категорий, и одна обязана остаться включённой. И здесь ждёт красивая асимметрия.
На чтение это три отдельных поля: AutotargetingCategories, AutotargetingBrandOptions и AutotargetingMode. Первые два приходят объектом с массивом внутри:
"AutotargetingCategories": {"Items": [{"Category": "EXACT", "Value": "YES"}, {"Category": "ALTERNATIVE", "Value": "NO"}, ...]}, "AutotargetingBrandOptions": {"Items": [{"Option": "WITHOUT_BRANDS", "Value": "YES"}, {"Option": "WITH_ADVERTISER_BRAND", "Value": "YES"}]}
На запись это один объект AutotargetingSettings с вложенными Categories и BrandOptions. Причём AutotargetingCategories, которую вы только что прочитали, в документации Keywords.update помечена устаревшей, и отправить её одновременно с новой структурой нельзя, будет ошибка валидации.
Забавная деталь: запросить AutotargetingSettings на чтение у меня не вышло, API отвечает, что такого значения в перечислении нет, и перечисляет допустимые. Так что прочитать новым именем и записать им же не получится, придётся собирать объект из трёх прочитанных полей.
Ещё одна мелочь оттуда же, стоившая мне захода: параметра AutotargetingCategoriesFieldNames не существует, хотя по аналогии с другими сущностями он напрашивается. Категории запрашиваются обычными полями в общем списке FieldNames.
Случай третий: запрос принят, а значения по умолчанию разошлись с замыслом
Этот случай привожу как наблюдение, а не как воспроизведённый эксперимент: ставить опыты на боевом кабинете ради статьи я не стал.
В июле я создавал кампанию через Campaigns.add и задал расписание показов не на все семь дней недели, а только на те, которые хотел изменить. Директ не ругнулся, вернул пустой список ошибок, кампания создалась.
Через день выяснилось, что расписание получилось такое: понедельник с семи до девяти вечера, остальные дни круглосуточно. Выходные я при этом выключал специально, после того как предыдущая кампания слила дневной бюджет в воскресенье при стопроцентном показателе отказов.
Это не молчаливый сбой, а документированное значение по умолчанию: не переданные дни заполняются коэффициентом сто.
Коварство в том, что по ответу на чтение нельзя определить, какие дни отсутствовали в исходном запросе: Директ возвращает уже нормализованное расписание из семи строк, в каждой номер дня и двадцать четыре часовых коэффициента. Я проверил все пять кампаний кабинета, включая созданные скриптом, у всех по семь строк:
Кампания 1 строк: 7 день 1: часов 24, все коэффициенты 100 день 2: часов 24, все коэффициенты 100 ... строк у всех кампаний: [7, 7, 7, 7, 7]
Само расписание при этом проверить можно и нужно, просто не по длине массива, а по содержимому: сравнить итоговые коэффициенты каждого дня с ожидаемыми.
want = {1: WORKDAY, 2: WORKDAY, 3: WORKDAY, 4: WORKDAY, 5: WORKDAY, 6: [0] * 24, 7: [0] * 24} # выходные выключены for item in schedule_items: day, *hours = [int(x) for x in item.split(",")] if hours != want[day]: raise RuntimeError(f"день {day}: в кабинете {hours}, ожидалось {want[day]}")
Из той же серии StartDate. В моём случае кампания имела State: ON и Status: ACCEPTED, а в интерфейсе объявления выглядели активными. Показы при этом ещё не начались: StartDate стоял на завтра. Я успел отчитаться «кампания запущена», и заказчик поймал меня вопросом «почему тогда запуск завтра?».
Механизм во всех случаях один: пустой список ошибок означает только то, что API не отклонил операцию. Он не означает, что итоговое состояние совпадает с вашим замыслом.
Что из этого выросло
Проверяется контракт ответа, а не его правдоподобие. Не «есть ли похожая колонка», а «есть ли ровно тот набор, который я запрашивал». Отсутствие данных обязано останавливать разбор, а не превращаться в ноль по дороге.
Ошибки читаются по каждому объекту. У методов, работающих со списками, транспортный успех ничего не говорит про отдельный элемент. Общая функция, которая проходит по всем Errors и Warnings в результате, пишется один раз и закрывает целый класс подобных случаев.
После записи состояние перечитывается. Всегда, включая «простые» правки. И перечитывать надо то поле, которое отвечает на ваш вопрос, а не любое похожее: State: ON у автотаргетинга не означает, что показы идут.
Скрипты правок по умолчанию ничего не меняют. Всё, что пишет в кабинет, запускается в dry-run и печатает, что собирается сделать. Для реальной заливки нужен явный --apply. Это спасло, когда расчёт ставок однажды выдал числа впятеро выше разумных: скрипт их напечатал, и я увидел их до того, как они уехали в кабинет.
Перед правкой снимается слепок. JSON с текущими значениями ровно тех полей, которые меняем, с датой в имени файла. Откат становится запуском скрипта с этим файлом на входе, а не попыткой вспомнить, как было.
Эти правила не делают работу безошибочной. Историю с -- я поймал во второй раз буквально в день написания текста: новый скрипт упал ровно там же, где падал старый. Они сокращают не количество ошибок, а время до их обнаружения: раньше это были сутки, теперь минуты.
Про расхождение конверсий Директа и Метрики между собой на Хабре уже писали в статье «Почему я перестал верить конверсиям в Яндекс Директе». У меня история про другое: как формально корректный ответ превращается в неверный вывод внутри собственного кода.
Разбор собран по рабочим кабинетам: я веду контекстную рекламу и делаю ИИ-помощников малому бизнесу в студии «Первый ИИ».
Если сталкивались с ответами API, которые были технически правильными, но легко приводили к ошибочному выводу для заказчика, расскажите в комментариях. Особенно интересны случаи, ради которых пришлось заводить отдельную проверку контракта или перечитывать состояние после записи.

