Я performance-маркетолог, работал в трафик-агентстве и сам писал для него автоматизации: выгрузки из Яндекс Директа и Метрики, сводные отчёты для клиентов, алерты по перерасходу, связку рекламы с CRM. Начиналось всё с картины, знакомой любому агентству: в понедельник аккаунт-менеджеры полдня собирают отчёты руками, копируют цифры из Мастера отчётов в таблицу, а на вопрос «какой ДРР (доля рекламных расходов) был у клиента в прошлом месяце по кампаниям на поиске» отвечают через час.
В этой статье я разберу, как собрать дашборд эффективности рекламных кампаний для агентства с помощью Claude Code: забрать данные по API Яндекс Директа и Яндекс Метрики, сложить их в базу, вывести витрину в Google Sheets, а поверх всего этого поднять MCP-сервер, чтобы задавать вопросы по цифрам прямо из чата Claude Code, Codex или Telegram. Отдельно расскажу про ограничения API: часть данных по Единой перфоманс-кампании (ЕПК) получить в нужной детализации нельзя, и это надо закладывать в архитектуру с первого дня.
Зачем агентству свой дашборд, если есть Мастер отчётов
Мастер отчётов в Директе и отчёты Метрики хороши для одного клиента и одного специалиста. Когда клиентов десятки, начинаются проблемы:
Архитектура: из чего состоит дашборд
Я строю такие системы в четыре слоя. Главное правило: сырые данные хранятся в базе, а не в таблице. Google Sheets — это витрина для людей, а не хранилище.
Яндекс Директ API (Reports) ─┐
├─► сборщик на Python (cron, раз в сутки)
Яндекс Метрика API ──────────┘ │
▼
База: SQLite / PostgreSQL
(сырые таблицы + витрина daily)
│
┌────────────────────────┼─────────────────────────┐
▼ ▼ ▼
Google Sheets MCP-сервер DataLens / Looker Studio
(сводка для клиентов) (Claude Code, Codex, (графики, если нужны)
Telegram-бот)
- Сборщики. Отдельный модуль на каждый источник: Директ, Метрика, при необходимости CRM. Каждый пишет в свою сырую таблицу.
- База. Для небольшого агентства хватает SQLite-файла, при росте — PostgreSQL.
Здесь же собирается витрина
daily: клиент, дата, кампания, расход, клики, лиды, выручка. - Витрины. Google Sheets для сводок, которые можно открыть клиенту, и BI-инструмент для графиков.
- MCP-сервер. Набор read-only инструментов поверх той же базы, через которые ИИ-агент отвечает на вопросы.
Как я веду такой проект в Claude Code
Claude Code — это ИИ-агент от Anthropic, который работает в терминале или в VS Code: читает код проекта, пишет файлы, запускает скрипты и тесты. Для проекта с выгрузками это удобно: документация API длинная, форматы отчётов капризные, а агент может сам сделать запрос, посмотреть на ответ и поправить парсер.
Шаг 1. Опишите проект в CLAUDE.md
Первым делом я кладу в корень репозитория файл CLAUDE.md. Claude Code читает его в начале
каждой сессии. Туда идут термины, правила подсчёта метрик и ограничения — всё, что иначе пришлось бы
объяснять заново:
# Дашборд агентства: Директ + Метрика ## Метрики - ДРР = расход с НДС / выручка × 100%. Выручка — из Метрики (ecommerce) или CRM. - CPL = расход / число лидов. Лид = достижение цели из config/goals.yaml. - Атрибуция конверсий в Директе: LSC (последний значимый переход). ## Правила - Токены только из .env, никогда не выводить их в лог и в ответ. - Все API-вызовы только на чтение. Ставки и бюджеты не менять. - Расход берём из отчёта уровня кампании — это источник истины. Детализированные отчёты сверяем с ним, разницу пишем в строку «не распределено». - Последние 7 дней перезагружаем каждый запуск: Директ корректирует статистику задним числом. ## Команды - python -m collector.direct --days 7 - python -m collector.metrika --days 7 - pytest
Шаг 2. Начните с режима планирования
Я не прошу агента «сделай дашборд». Я включаю режим планирования (plan mode) и даю конкретную задачу с ограничениями. Например:
Напиши модуль collector/direct.py: выгрузка отчёта CAMPAIGN_PERFORMANCE_REPORT из API Директа v5 для каждого клиента агентства (список — через AgencyClients.get). Поля: Date, CampaignId, CampaignName, CampaignType, Impressions, Clicks, Cost, Conversions по целям из config/goals.yaml, модель атрибуции LSC, с НДС. Учитывай офлайн-режим отчётов (коды 201/202 и заголовок retryIn). Результат — upsert в таблицу direct_campaign_daily в SQLite. Сначала покажи план, потом напиши код и тесты на сохранённом TSV-ответе.
Дальше рабочий цикл такой: агент пишет код, запускает его на одном тестовом клиенте, я сверяю итог по расходу с интерфейсом Директа. Если цифры не сходятся, я прошу агента найти причину, а не «подогнать». Так всплывают почти все ограничения, о которых я пишу ниже.
OAuth-токены Директа и Метрики храните в .env и добавьте его
в .gitignore. Агенту достаточно знать имя переменной окружения, сам токен ему видеть
не нужно.
API Яндекс Директа: доступ, отчёты, лимиты
Доступ для агентства
- Приложение и токен. Зарегистрируйте OAuth-приложение на oauth.yandex.ru с правом доступа к API Директа и подайте заявку на доступ к API в интерфейсе Директа. Сначала обычно выдают тестовый доступ, для боевых данных нужен полный.
- Агентский токен. Токен представителя агентства даёт доступ ко всем клиентам.
Список клиентов отдаёт сервис
AgencyClients, а в каждом запросе к данным конкретного клиента передаётся заголовокClient-Login. - Прямые клиенты. Если кабинет не под агентством, нужен токен владельца кабинета или его представителя. На практике у агентства всегда есть пара таких клиентов, поэтому токены лучше хранить в таблице «клиент → токен», а не в одной переменной.
Сервис Reports
Статистику отдаёт сервис Reports. Он возвращает TSV, а тяжёлые отчёты формирует в офлайн-режиме:
сервер отвечает кодом 201 или 202 и заголовком retryIn, через сколько секунд повторить
запрос. Минимальный рабочий вариант выглядит так:
import os, time, requests API = "https://api.direct.yandex.com/json/v5/reports" def direct_campaign_report(client_login, date_from, date_to, goals): headers = { "Authorization": f"Bearer {os.environ['DIRECT_TOKEN']}", "Client-Login": client_login, "Accept-Language": "ru", "processingMode": "auto", "returnMoneyInMicros": "false", "skipReportHeader": "true", "skipReportSummary": "true", } body = {"params": { "SelectionCriteria": {"DateFrom": date_from, "DateTo": date_to}, "FieldNames": ["Date", "CampaignId", "CampaignName", "CampaignType", "Impressions", "Clicks", "Cost", "Conversions"], "Goals": goals, # ID целей Метрики "AttributionModels": ["LSC"], # имя отчёта должно быть уникальным для набора параметров "ReportName": f"campaigns_{client_login}_{date_from}_{date_to}", "ReportType": "CAMPAIGN_PERFORMANCE_REPORT", "DateRangeType": "CUSTOM_DATE", "Format": "TSV", "IncludeVAT": "YES", }} while True: r = requests.post(API, json=body, headers=headers, timeout=180) if r.status_code == 200: return r.text # TSV, колонки Conversions_<goal>_LSC if r.status_code in (201, 202): time.sleep(int(r.headers.get("retryIn", 10))) continue r.raise_for_status()
Лимиты, о которых стоит знать заранее
- Баллы API. Запросы к сервисам Директа расходуют баллы, суточный лимит считается
на рекламодателя. Остаток приходит в заголовке ответа
Units— логируйте его, иначе однажды выгрузка молча оборвётся на середине списка клиентов. - Очередь офлайн-отчётов. Число отчётов, которые одновременно формируются в офлайне, ограничено. Не запускайте выгрузку всех клиентов параллельно — ставьте их в очередь по одному или небольшими пачками.
- Статистика меняется задним числом. Директ списывает недействительные клики и уточняет конверсии, поэтому последние дни я перезагружаю каждый запуск (upsert по ключу «клиент + дата + кампания»).
- НДС. Решите один раз, считаете ли вы расход с НДС, и пропишите это
в
CLAUDE.md. ДРР с НДС и без НДС отличаются на размер налога, и клиент обязательно заметит расхождение с закрывающими документами.
Ограничения API: ЕПК и кампании, которые нельзя разложить по группировкам
Именно здесь дашборды начинают врать, поэтому на этом разделе я остановлюсь подробнее. Директ переводит рекламодателей на Единую перфоманс-кампанию (ЕПК), которая объединяет поиск, РСЯ, товарные и медийные форматы в одной кампании. Для рекламодателя это удобно, для аналитики хуже: API не всегда отдаёт данные ЕПК в той детализации, к которой все привыкли по классическим кампаниям «Текстово-графические объявления».
Что я вижу на проектах:
- Сумма по детализированному отчёту не сходится с итогом кампании. Отчёт уровня кампании показывает полный расход, а отчёты по группам, объявлениям и условиям показа для части трафика ЕПК (автотаргетинг, товарные форматы, объявления, собранные из фида) приходят агрегированными или не приходят совсем.
- Настройки кампании читаются не для всех типов. Для ЕПК в методе
Campaigns.getнужно отдельно запрашивать блок полейUnifiedCampaignFieldNames. Типы кампаний, которые API не поддерживает, возвращаются с типомUNKNOWN: расход по ним в отчётах есть, а стратегию и бюджет через API не прочитать. - Поисковые запросы — только часть. Отчёт по поисковым запросам и раньше показывал не все запросы, а в ЕПК доля «невидимого» трафика бывает заметно выше.
- Кампании из упрощённых интерфейсов. Кампании, созданные в Мастере кампаний или упрощённом режиме, ведут себя в API не так, как созданные в режиме эксперта. Перед запуском дашборда я выгружаю список кампаний клиента с типами и сверяю его с интерфейсом.
| Что нужно | Откуда в API | Где подвох |
|---|---|---|
| Расход, клики, конверсии по кампании за день | Reports, CAMPAIGN_PERFORMANCE_REPORT | Надёжно. Это источник истины для расхода |
| Разрез по группам и объявлениям | ADGROUP_ и AD_PERFORMANCE_REPORT | В ЕПК часть трафика не раскладывается, сумма меньше итога кампании |
| Ключевые фразы и автотаргетинг | CRITERIA_PERFORMANCE_REPORT | Автотаргетинг и товарные условия показа детализированы слабо |
| Поисковые запросы | SEARCH_QUERY_PERFORMANCE_REPORT | Отдаётся только часть запросов |
| Площадки РСЯ | CUSTOM_REPORT с полем Placement | Для части форматов ЕПК площадки не детализируются |
| Стратегия и бюджет кампании | Campaigns.get | Для ЕПК нужен UnifiedCampaignFieldNames, неподдерживаемые типы приходят как UNKNOWN |
| Конверсии по целям | Reports: Goals и AttributionModels | Модель атрибуции должна совпадать с той, что стоит в интерфейсе, иначе цифры разойдутся |
Как я с этим живу
- Два уровня выгрузки. Отчёт уровня кампании — всегда, это расход и конверсии, на которых строится ДРР. Детализированные отчёты — дополнительно, для разборов.
- Строка «не распределено». Разницу между итогом кампании и суммой детализации я явно пишу в витрину отдельной строкой. Так дашборд честно показывает, какую часть расхода нельзя разложить, и никто не делает выводов по неполным данным.
- Автосверка. После каждой выгрузки скрипт сравнивает сумму расхода по детализации с итогом и пишет предупреждение, если расхождение больше порога. Порог подбирается под клиента.
- Разрез через UTM-метки, а не через API. Если группировка нужна для отчёта
клиенту, я закладываю её в UTM-метки (
utm_campaign={campaign_id},utm_content={ad_id},utm_term={keyword}) и беру разрез из Метрики. Расход при этом всё равно берётся из Директа на уровне кампании.
Выгрузите расход по всем кампаниям клиента за прошлый месяц через API и сравните с Мастером отчётов в интерфейсе. Затем сравните сумму по группам с суммой по кампаниям. Если первая пара сходится, а вторая нет, вы нашли кампании, которые API не раскладывает по группам. Эту проверку удобно поручить Claude Code одним промптом.
API Яндекс Метрики: цели, выручка, атрибуция
Из Метрики я забираю визиты, достижения целей и выручку. Для агрегатов подходит API отчётов
(stat/v1/data), для сырых визитов с ClientID — Logs API. Сырые визиты нужны, когда
выручку надо связать с CRM и офлайн-конверсиями.
def metrika_by_campaign(counter_id, date1, date2, goal_id): r = requests.get("https://api-metrika.yandex.net/stat/v1/data", params={ "ids": counter_id, "date1": date1, "date2": date2, "metrics": f"ym:s:visits,ym:s:goal{goal_id}reaches,ym:s:ecommerceRevenue", "dimensions": "ym:s:date,ym:s:lastsignUTMSource,ym:s:lastsignUTMCampaign", "accuracy": "full", # без семплирования "limit": 100000, }, headers={"Authorization": f"OAuth {os.environ['METRIKA_TOKEN']}"}, timeout=120) r.raise_for_status() data = r.json() if data.get("sampled"): raise RuntimeError("Метрика вернула семплированные данные") return data["data"]
Подводные камни, на которые я натыкался:
- Разная атрибуция. В Директе конверсии считаются по выбранной модели
(в примере выше — последний значимый переход), в Метрике — по той, что указана
в названии группировки (
lastsign,last,first). Если модели разные, лиды в Директе и в Метрике не совпадут, и это нормально. Главное — в дашборде выбрать один источник для лидов и подписать его. - Семплирование. На больших счётчиках Метрика может отдать выборку. Проверяйте флаг
sampledв ответе и дробите период, если он есть. - Склейка Директа и Метрики. Надёжнее всего связывать данные через
utm_campaign={campaign_id}. Названия кампаний меняются, а ID — нет. - Мусорные лиды. Если на сайте нет проверки номера, в цели «Отправка формы» попадут боты и фейковые телефоны. CPL в дашборде будет красивым, а реальная стоимость целевого лида — нет. Поэтому для дашборда я ставлю цель на подтверждённый по SMS номер, а не на отправку формы.
Витрина в Google Sheets
Google-таблица остаётся самым удобным форматом для аккаунтов и клиентов: её можно открыть с телефона, дописать комментарий, построить сводную таблицу. Я выгружаю туда уже посчитанную витрину, а не сырые строки.
- Создайте сервисный аккаунт в Google Cloud, включите Google Sheets API и скачайте JSON-ключ.
- Откройте доступ к таблице на e-mail сервисного аккаунта с правами редактора.
- Запишите данные одним пакетным запросом: построчная запись быстро упирается в квоты API.
import gspread gc = gspread.service_account(filename="service_account.json") ws = gc.open_by_key(os.environ["SHEET_ID"]).worksheet("Сводка") header = ["Клиент", "Кампания", "Расход", "Клики", "Лиды", "CPL", "Выручка", "ДРР, %"] rows = build_summary(date_from, date_to) # SELECT из витрины daily ws.clear() ws.update([header] + rows, value_input_option="USER_ENTERED")
Несколько практических правил:
- Одна таблица на клиента, если вы открываете её клиенту. Одна общая — для руководителя агентства. Так проще управлять доступами.
- Формулы — только поверх данных. Лист, куда пишет скрипт, не редактируйте руками: при следующей выгрузке он перезапишется. Расчёты и комментарии держите на соседних листах.
- Следите за объёмом. У Google Sheets есть лимит ячеек на таблицу, а формулы начинают тормозить задолго до него. Дневная детализация по объявлениям за год для десятков клиентов в таблицу не поместится — для этого и нужна база.
- Графики. Если нужны полноценные дашборды с фильтрами, подключите базу к Yandex DataLens или Looker Studio. Таблица при этом остаётся для ручного анализа.
Подробнее про выгрузки в таблицы и базы я писал в статье про выгрузку данных маркетплейсов по API: подход тот же, меняются только источники.
MCP-сервер: вопросы к данным из Claude Code и Codex
MCP (Model Context Protocol) — открытый протокол, через который ИИ-агент вызывает внешние инструменты. Вы описываете функции вроде «статистика по кампаниям клиента за период», и любой клиент с поддержкой MCP (Claude Code, Codex, Claude Desktop, ваш бот) может их вызывать. Агент сам решает, какие инструменты вызвать и как свести результаты в ответ.
Одно решение я принимаю сразу: MCP-сервер ходит в вашу базу, а не в API Директа напрямую. Так ответы быстрые, баллы API не тратятся, а цифры в чате совпадают с цифрами в таблице.
import sqlite3 from mcp.server.fastmcp import FastMCP mcp = FastMCP("ads-dashboard") DB = "ads.db" def query(sql, params=()): con = sqlite3.connect(DB) con.row_factory = sqlite3.Row return [dict(r) for r in con.execute(sql, params)] @mcp.tool() def list_clients() -> list[str]: """Список клиентов агентства (логины Директа).""" return [r["client"] for r in query("SELECT DISTINCT client FROM daily ORDER BY client")] @mcp.tool() def campaign_stats(client: str, date_from: str, date_to: str) -> list[dict]: """Расход (с НДС), клики, лиды, выручка, CPL и ДРР по кампаниям клиента. Даты в формате YYYY-MM-DD. Строка 'не распределено' — расход, который API не раскладывает по кампаниям/группам.""" rows = query(""" SELECT campaign_name, SUM(cost) AS cost, SUM(clicks) AS clicks, SUM(leads) AS leads, SUM(revenue) AS revenue FROM daily WHERE client = ? AND date BETWEEN ? AND ? GROUP BY campaign_name ORDER BY cost DESC""", (client, date_from, date_to)) for r in rows: r["cpl"] = round(r["cost"] / r["leads"]) if r["leads"] else None r["drr_pct"] = round(100 * r["cost"] / r["revenue"], 1) if r["revenue"] else None return rows if __name__ == "__main__": mcp.run() # stdio: для Claude Code и Codex на своей машине
Описания инструментов (docstring) — это инструкция для модели. Пишите в них единицы измерения, формат дат, с НДС расход или без. От этого напрямую зависит, насколько точно агент будет отвечать.
Подключение к Claude Code и Codex
# Claude Code claude mcp add ads-dashboard -- python /path/to/server.py # Codex CLI codex mcp add ads-dashboard -- python /path/to/server.py
После этого в чате можно спрашивать обычным языком:
- «Какие кампании у клиента shop-msk за сентябрь дали ДРР выше 30%?»
- «Сравни CPL по всем клиентам за эту и прошлую неделю, покажи, где рост больше 20%».
- «Где за последние 7 дней был расход без единого лида?»
Если в агентстве пользуются Claude Desktop или веб-версией Claude, сервер можно запустить в режиме
HTTP (mcp.run(transport="streamable-http")) на своём сервере и подключить его как
удалённый коннектор. Такой же удалённый сервер нужен и для Telegram-бота.
Telegram-бот: вопросы по рекламе из мессенджера
Руководителю агентства или клиенту неудобно открывать терминал. Поэтому последний слой — Telegram-бот, который пересылает вопрос в Claude API вместе с адресом вашего MCP-сервера. Claude API сам подключается к серверу, вызывает инструменты и возвращает готовый ответ.
import os, anthropic from telegram.ext import Application, MessageHandler, filters claude = anthropic.AsyncAnthropic() # ключ из ANTHROPIC_API_KEY ALLOWED = {int(x) for x in os.environ["TG_ALLOWED_IDS"].split(",")} async def answer(update, context): if update.effective_user.id not in ALLOWED: return resp = await claude.beta.messages.create( model="claude-opus-5", max_tokens=16000, betas=["mcp-client-2025-11-20", "server-side-fallback-2026-07-01"], fallbacks="default", # резервная модель, если запрос отклонён mcp_servers=[{ "type": "url", "url": "https://mcp.example.ru/mcp", "name": "ads", "authorization_token": os.environ["MCP_TOKEN"], }], tools=[{"type": "mcp_toolset", "mcp_server_name": "ads"}], system="Ты аналитик трафик-агентства. Отвечай только по данным инструментов, " "указывай период и источник цифр. Расход с НДС.", messages=[{"role": "user", "content": update.message.text}], ) text = "".join(b.text for b in resp.content if b.type == "text") await update.message.reply_text(text or "Не получилось ответить, попробуйте переформулировать") app = Application.builder().token(os.environ["TG_TOKEN"]).build() app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, answer)) app.run_polling()
Что я проверяю перед запуском бота:
- Сервер должен быть доступен из интернета по HTTPS. К MCP-серверу
подключается инфраструктура Anthropic, а не ваш бот, поэтому
localhostне подойдёт. Я ставлю сервер за nginx, который пропускает только запросы с правильным токеном в заголовкеAuthorization. - Белый список пользователей. Бот отвечает только тем Telegram ID, которые перечислены в настройках. Иначе любой, кто найдёт бота, увидит расходы ваших клиентов.
- Клиентский бот — отдельный сервер. Если хотите дать бота клиенту, поднимите для него отдельный MCP-сервер с фильтром по его логину на уровне SQL, а не на уровне промпта.
Безопасность и данные клиентов
- Только чтение. Все инструменты MCP-сервера читают из базы. Не давайте ИИ-агенту инструменты, которые меняют ставки, бюджеты или статусы кампаний. Если очень хочется автоматизации управления — делайте её отдельным скриптом с подтверждением человеком.
- Без персональных данных. В витрину для MCP кладите агрегаты: расход, клики, число лидов, выручку. Телефоны и имена лидов из CRM туда попадать не должны: это и 152-ФЗ, и здравый смысл.
- Договор с клиентом. Если данные клиента уходят во внешний ИИ-сервис, проверьте, что это не противоречит договору и NDA.
- Логи. Сохраняйте, кто и что спрашивал у бота. Это помогает и в разборе ошибок, и в понимании, какие отчёты людям на самом деле нужны.
Короткий чек-лист
- Завести
CLAUDE.mdс определениями метрик (ДРР, CPL, НДС, атрибуция) и правилами работы с токенами. - Получить доступ к API Директа для агентства, собрать список клиентов через
AgencyClients. - Выгружать отчёт уровня кампании как источник истины для расхода, последние дни перезагружать.
- Проверить, какие кампании (прежде всего ЕПК) API не раскладывает по группам, и вывести «не распределено» отдельной строкой.
- Склеить Директ и Метрику через
utm_campaign={campaign_id}, выбрать один источник лидов. - Считать лидом только подтверждённый контакт, чтобы CPL и ДРР не искажали боты.
- Выгрузить витрину в Google Sheets пакетной записью.
- Поднять read-only MCP-сервер поверх базы, подключить его к Claude Code, Codex и Telegram-боту.
Я настраиваю выгрузки из Директа и Метрики, витрины в Google Sheets и MCP-серверы с Telegram-ботом под процессы конкретного агентства. Напишите в Telegram, обсудим ваш стек и число клиентов.