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

В этой статье я покажу, как собрать связку простых 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

Расходы и клики

+

Реальные конверсии

не доверять

+

Боты

+

Качество трафика

+

Устройства / пол / возраст

+

Главные грабли, которые собираешь по пути:

  1. TSV-отчёт Директа: первая строка - это просто название в кавычках, а -- означает null.

  2. Метрика: utm_campaign отдает числовой ID, а не название.

  3. ym:s:dayOfWeek недопустимое измерение для API, парсите сами из даты.

  4. Деньги в Директе по дефолту отдаются в микрорублях (1 руб = 1 000 000). Не забудьте заголовок returnMoneyInMicros: false.

  5. Конверсии Директа ≠ конверсии Метрики. Опирайтесь только на конкретные цели, настроенные в Метрике.

  6. pd.to_numeric(..., errors="coerce") превращает нераспознанный мусор в NaN, из-за чего тип колонки улетает во float64. Мержить int и float64 пандас может, но это мина замедленного действия. Решение простое: перед группировкой делаем dropna(subset=["utm_campaign_id"]) (заодно срежем нерекламный трафик без UTM), и спокойно кастуем в int.

Код открыт, будут вопросы - велкам в комментарии.