Каждый, кто льет трафик с Директа, рано или поздно сталкивается с одной и той же шизой: интерфейс Директа показывает одни конверсии, Метрика совершенно другие, и хрен поймешь, кому из них верить. Плюс всегда хочется видеть общую картину: расходы, отказы, реальные лиды и качество трафика по кампаниям в одной нормальной таблице, а не скакать по десятку вкладок.
В этой статье я покажу, как собрать связку простых Python-скриптов, которые стягивают данные из обоих API и сводят их в единый дашборд. Никаких громоздких BI-систем и баз данных только хардкор, requests и pandas.
Заодно подсвечу несколько неочевидных грабель Яндекса, о которые сам успел разбить лоб.
Что получаем на выходе
Список кампаний с расходами, кликами и реальными конверсиями из Метрики.
Жесткий анализ качества трафика: боты, отказы, время на сайте.
Разбивку по устройствам, дням недели и возрасту аудитории.
Аномалии - дни с подозрительными всплесками мусорного трафика.
Подготовка
Зависимости
pip install requests pandas
OAuth токен
Нам нужен всего один токен, он сработает для обоих API.
Шаг 1. Регаем приложуху в Яндекс OAuth
Залетаем на oauth.yandex.ru → «Зарегистрировать новое приложение».
Заполняем:
Название - любое, например
my-direct-analyticsПлатформы — выбираем «Веб-сервисы», в поле Callback URI кидаем
https://oauth.yandex.ru/verification_codeДоступы - раскрываем нужные группы и ставим галки:
Яндекс.Директ →
direct:apiЯндекс.Метрика →
metrika:read
Жмем «Создать приложение». Яндекс выплюнет ClientID копируем его, он нам сейчас понадобится.
Шаг 2. Запрашиваем доступ к API Директа
Метрика открыта сразу, а вот Директ выпендривается, для него нужен апрув.
Идем в рекламный кабинет direct.yandex.ru → Инструменты → API. Там стандартная бюрократия: цель использования, описание, примерные объёмы. Заполняем, отправляем. Модерация занимает от пары минут до нескольких дней (у меня как-то висело часа 4), по итогу прилетит письмо.
Пока доступ не одобрят, любые запросы к api.direct.yandex.com будут отбиваться 53-й ошибкой («Нет доступа к API»).
Шаг 3. Дергаем токен через браузер
Открываем в браузере URL (подставляем свой ClientID):
https://oauth.yandex.ru/authorize?response\_type=token&client\_id=ВАШ\_CLIENT\_ID
Яндекс попросит авторизоваться под аккаунтом рекламодателя и разрешить доступ. После этого вас перекинет на страницу вида:
https://oauth.yandex.ru/verification\_code#access\_token=y0\_\_XXXXX&token\_type=bearer&expires\_in=...
Значение access_token из URL - это и есть ваш токен. Живет 1 год.
Важно: токен жестко привязан к акку, под которым вы авторизовались. Для Директа это должен быть логин самого рекламодателя (или агентства с доступом).
config.py
from datetime import date, timedelta DIRECT_TOKEN = "y0__ВАШ_ТОКЕН" DIRECT_LOGIN = "ваш-логин" # логин рекламодателя METRIKA_TOKEN = DIRECT_TOKEN # тот же токен METRIKA_COUNTER_ID = 12345678 # ID счётчика METRIKA_GOAL_ID = 987654321 # ID целевой конверсии DATE_TO = date.today() DATE_FROM = DATE_TO - timedelta(days=30)
Важно: никогда не верьте конверсиям из Директа при оценке эффективности. Директ гребет все цели подряд, включая фродовые скликивания. Берем одну железобетонную цель из Метрики например, "отправка формы" и работаем только с ней.
Часть 1: Директ API - статистика по кампаниям
Директ работает с отчетами асинхронно: кидаешь запрос, получаешь 201 или 202 ("готовится"), сидишь ждешь, потом ловишь 200 - готово. Пишем базовый polling.
import time, io, requests, pandas as pd from config import DIRECT_TOKEN, DIRECT_LOGIN, DATE_FROM, DATE_TO REPORT_URL = "https://api.direct.yandex.com/json/v5/reports" HEADERS = { "Authorization": f"Bearer {DIRECT_TOKEN}", "Client-Login": DIRECT_LOGIN, "Accept-Language": "ru", "processingMode": "auto", "returnMoneyInMicros": "false", # получаем нормальные рубли, а не микрорубли } def get_campaign_stats(date_from, date_to): body = { "params": { "SelectionCriteria": {"DateFrom": date_from, "DateTo": date_to}, "FieldNames": [ "Date", "CampaignId", "CampaignName", "Impressions", "Clicks", "Cost", "AvgCpc", ], "ReportName": f"report_{date_from}_{date_to}", "ReportType": "CAMPAIGN_PERFORMANCE_REPORT", "DateRangeType": "CUSTOM_DATE", "Format": "TSV", "IncludeVAT": "YES", "IncludeDiscount": "NO", } } for _ in range(30): # максимум 30 попыток r = requests.post(REPORT_URL, json=body, headers=HEADERS, timeout=60) if r.status_code == 200: break elif r.status_code in (201, 202): wait = int(r.headers.get("retryIn", 5)) print(f"Отчёт готовится, ждём {wait} сек...") time.sleep(wait) else: r.raise_for_status() # Парсим TSV lines = r.text.splitlines() # Первая строка — название отчёта в кавычках, последняя — "Total rows: N" lines = [l for l in lines if l and not l.startswith('"') and not l.startswith("Total rows")] df = pd.read_csv(io.StringIO("\n".join(lines)), sep="\t", na_values=["--"]) df.rename(columns={ "Date": "date", "CampaignId": "campaign_id", "CampaignName": "campaign_name", "Impressions": "impressions", "Clicks": "clicks", "Cost": "cost", "AvgCpc": "avg_cpc", }, inplace=True) df["date"] = pd.to_datetime(df["date"]) return df
Грабля #1: первая строка выгрузки в TSV выглядит вот так:
"CAMPAIGN_PERFORMANCE_REPORT (2026-03-01 - 2026-03-31)"
Это не заголовок, это просто название отчёта в кавычках. Настоящие хедеры идут второй строкой. Если не снести первую строку до парсинга, pandas подавится и упадет.
Грабля #2: значение -- в TSV Яндекса означает «нет данных» (это не ноль). Обязательно скармливаем na_values=["--"], чтобы pandas адекватно это переварил.
Часть 2: Метрика API - реальные конверсии и качество трафика
Тут всё приятнее. Метрика отдает данные синхронно и без лишних заморочек.
import requests, pandas as pd from config import METRIKA_TOKEN, METRIKA_COUNTER_ID, METRIKA_GOAL_ID, DATE_FROM, DATE_TO BASE_URL = "https://api-metrika.yandex.net/stat/v1/data" HEADERS = {"Authorization": f"OAuth {METRIKA_TOKEN}"} def metrika_get(dimensions, metrics, filters=None, date_from=None, date_to=None): params = { "ids": METRIKA_COUNTER_ID, "date1": date_from or str(DATE_FROM), "date2": date_to or str(DATE_TO), "dimensions": dimensions, "metrics": metrics, "limit": 10000, } if filters: params["filters"] = filters r = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=30) r.raise_for_status() data = r.json() # Разбираем ответ: dimensions — список строк, metrics — список строк dim_names = data["query"]["dimensions"] met_names = data["query"]["metrics"] rows = [] for row in data["data"]: dim_vals = [d.get("name", d.get("id", "")) for d in row["dimensions"]] rows.append(dict(zip(dim_names + met_names, dim_vals + row["metrics"]))) return pd.DataFrame(rows)
Грабля #3: структура JSON. Поле query.dimensions - это плоский список строк (["ym:s:date", "ym:s:UTMCampaign"]), а вот data[].dimensions - это уже массив словарей с ключами name или id. Главное не перепутать при парсинге.
Трафик по кампаниям через UTM
Метрика цепляет кампании Директа через UTM-метки. Чтобы магия сработала, в настройках кампаний Директа должна стоять галка ADD_METRICA_TAG=YES (тогда он сам докидывает yclid к клику).
goal_metric = f"ym:s:goal{METRIKA_GOAL_ID}reaches" df = metrika_get( dimensions="ym:s:date,ym:s:UTMCampaign", metrics=f"ym:s:visits,ym:s:bounceRate,ym:s:avgVisitDurationSeconds,{goal_metric}", filters="ym:s:isRobot=='No'", # выпиливаем ботов )
Грабля #4: измерение ym:s:UTMCampaign отдает числовой ID кампании, а не её строковое название. Поэтому склеивать с базой Директа будем именно по campaign_id.
Часть 3: Сводим Директ и Метрику
df_direct = get_campaign_stats(str(DATE_FROM), str(DATE_TO)) df_metrika = metrika_get( dimensions="ym:s:date,ym:s:UTMCampaign", metrics=f"ym:s:visits,ym:s:bounceRate,ym:s:avgVisitDurationSeconds,{goal_metric}", filters="ym:s:isRobot=='No'", ) # Приводим типы для мержа df_direct["campaign_id"] = df_direct["campaign_id"].astype(int) df_metrika["utm_campaign_id"] = pd.to_numeric(df_metrika["ym:s:UTMCampaign"], errors="coerce") # Агрегируем Метрику по дате + кампании # Сначала дропаем строки без UTM — это органика, прямой заход и прочий нерекламный трафик. # Заодно избавляемся от NaN в utm_campaign_id, после чего можно безопасно кастить в int. df_m = ( df_metrika .dropna(subset=["utm_campaign_id"]) .groupby(["ym:s:date", "utm_campaign_id"]) .agg( sessions =("ym:s:visits", "sum"), bounce_rate =("ym:s:bounceRate", "mean"), goal_reaches =(goal_metric, "sum"), ) .reset_index() .rename(columns={"ym:s:date": "date"}) ) df_m["date"] = pd.to_datetime(df_m["date"]) df_m["utm_campaign_id"] = df_m["utm_campaign_id"].astype(int) # Мерж merged = df_direct.merge( df_m, left_on =["date", "campaign_id"], right_on =["date", "utm_campaign_id"], how="left" ) print(merged.groupby("campaign_name").agg( cost =("cost", "sum"), clicks =("clicks", "sum"), sessions =("sessions", "sum"), bounce_rate =("bounce_rate", "mean"), goal_reaches =("goal_reaches", "sum"), ).round(1))
Часть 4: Что искать в данных
Боты
Часть фрода Метрика фильтрует сама и вешает флаг isRobot. Но есть хитровыделанные поведенческие боты, которые проходят радары. Их палим по жесткому паттерну: отказ 100%, время 0 сек, глубина 1 страница.
# Явные боты из Метрики df_robots = metrika_get( dimensions="ym:s:date,ym:s:trafficSourceName", metrics="ym:s:visits", filters="ym:s:isRobot=='Yes'", ) # Поведенческие боты: время < 30 сек df_fast = metrika_get( dimensions="ym:s:date,ym:s:trafficSourceName", metrics="ym:s:visits", filters="ym:s:isRobot=='No' AND ym:s:avgVisitDurationSeconds<30", )
На моем проекте 28% всего трафика отваливалось меньше чем за 30 секунд. И 89% этого мусора летело из платной РСЯ.
Устройства
df_devices = metrika_get( dimensions="ym:s:deviceCategory", metrics=f"ym:s:visits,ym:s:bounceRate,ym:s:avgVisitDurationSeconds,{goal_metric}", filters="ym:s:isRobot=='No'", )
Классика жанра для B2B (например, в агро-секторе или промышленности): смартфоны дают 81% трафика, но конверсий с них - ноль. Зато 11% десктопного трафика закрывают кассу. Решение в лоб: идем в РСЯ и режем корректировки на мобилки (-50%) и планшеты (-100%).
Аудитория (пол и возраст)
df_age = metrika_get( dimensions="ym:s:gender,ym:s:ageInterval", metrics=f"ym:s:visits,ym:s:bounceRate,{goal_metric}", filters="ym:s:isRobot=='No' AND ym:s:trafficSourceName=='Ad traffic'", )
Вскрылось, что 44% платного трафика - это аудитория 55+, которая не конвертится от слова совсем. РСЯ бьет слишком широко по интересам и цепляет пенсионеров. Режем 55+ на -30%, бустим ядро 25-44 на +20%.
День недели
API Метрики не умеет отдавать ym:s:dayOfWeek напрямую (вывалится 400 ошибка). Делаем костыль: тянем стату по датам и считаем дни недели на стороне pandas.
df_daily = metrika_get( dimensions="ym:s:date", metrics=f"ym:s:visits,ym:s:bounceRate,{goal_metric}", filters="ym:s:isRobot=='No' AND ym:s:trafficSourceName=='Ad traffic'", ) df_daily["date"] = pd.to_datetime(df_daily["ym:s:date"]) df_daily["dow"] = df_daily["date"].dt.day_name() print(df_daily.groupby("dow")["ym:s:visits"].sum().sort_values(ascending=False))
Итог
Два API, один токен, ~200 строк кода и у вас на руках прозрачная архитектура:
Директ API | Метрика API | |
Расходы и клики | + | — |
Реальные конверсии | не доверять | + |
Боты | — | + |
Качество трафика | — | + |
Устройства / пол / возраст | — | + |
Главные грабли, которые собираешь по пути:
TSV-отчёт Директа: первая строка - это просто название в кавычках, а
--означает null.Метрика:
utm_campaignотдает числовой ID, а не название.ym:s:dayOfWeekнедопустимое измерение для API, парсите сами из даты.Деньги в Директе по дефолту отдаются в микрорублях (1 руб = 1 000 000). Не забудьте заголовок
returnMoneyInMicros: false.Конверсии Директа ≠ конверсии Метрики. Опирайтесь только на конкретные цели, настроенные в Метрике.
pd.to_numeric(..., errors="coerce")превращает нераспознанный мусор вNaN, из-за чего тип колонки улетает воfloat64. Мержитьintиfloat64пандас может, но это мина замедленного действия. Решение простое: перед группировкой делаемdropna(subset=["utm_campaign_id"])(заодно срежем нерекламный трафик без UTM), и спокойно кастуем вint.
Код открыт, будут вопросы - велкам в комментарии.
