Раньше раз в неделю я открывал три вкладки: Яндекс.Метрику, Яндекс.Вебмастер и папку с черновиками блога, и в каждой что-нибудь считал в уме, а к третьей вкладке обычно забывал, что искал в первой. Надоело, и я написал скрипт без зависимостей, который собирает всё это в одно текстовое сообщение и шлёт в Телеграм и на почту. Ниже расскажу, как он устроен, какие запросы к API делает и что в нём пришлось обходить. Код взят из рабочего скрипта, только домен и идентификаторы я заменил на заглавные константы и example.com.
Что должно получиться
Отчёт приходит обычным текстом, без разметки, и выглядит так (цифры выдуманы, реальный трафик сайта я здесь не показываю):
📊 Сайт example.com за неделю Людей: 120 · заходов: 140 · страниц открыто: 310 Ушли сразу: 35% · нажали кнопку в Телеграм: 6 Откуда пришли: поиск — 40 прямые заходы и мессенджеры — 55 соцсети — 30 С площадок: Habr 12, Telegram 8 Что читали: /blog/primer-statii.html — 25 / — 20 🔍 Поиск Яндекса: страниц в поиске: 30 · исключено: 0 ИКС: ещё не присвоен — сайт молодой показов в выдаче за неделю: 50 Что спрашивают: «пример запроса» — показов 7, кликов 1 📝 В запасе статей: 12 · хватит до 25.11
Из Метрики я узнаю, сколько людей пришло и откуда, из Вебмастера, видит ли сайт поиск. А последняя строка про очередь подсказывает, когда пора садиться писать: в Метрике и Вебмастере такого нет, и затевал я всё как раз ради неё.
Стек и доступы
Python 3.11 и только стандартная библиотека: urllib, smtplib, json. Тащить requests ради трёх десятков GET-запросов мне не захотелось. Доступы лежат в файле окружения рядом со скриптом, в репозиторий он не попадает, а имена переменных в файле-примере такие:
YANDEX_OAUTH_TOKEN # Метрика + Вебмастер, один токен на оба API TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID GMAIL_USER GMAIL_APP_PASSWORD # пароль приложения Google, не обычный пароль
Читаю файл десятью строками кода, без python-dotenv:
def load_env(): path = Path(__file__).parent / ENV_FILE if path.exists(): for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
setdefault я поставил намеренно: если переменная уже задана в окружении (например, в cron), файл её не перебьёт.
Один OAuth-токен Яндекса подходит и Метрике, и Вебмастеру, если при создании приложения выдать ему права на оба сервиса. Заголовок у них одинаковый:
def get(url, token): req = urllib.request.Request(url, headers={"Authorization": f"OAuth {token}"}) with urllib.request.urlopen(req, timeout=30) as r: return json.load(r)
Больше в Яндекс ничего не ходит. Эту же функцию потом заимствует второй скрипт, о нём ближе к концу.
Метрика: четыре запроса
Все запросы идут в Stat API (/stat/v1/data) за один и тот же период, от 7daysAgo до yesterday. Сегодняшний день я выкидываю, потому что неполный день занижает цифры и в понедельничном отчёте в хвосте недели получалась бы дыра. Параметром accuracy=full я прошу API не семплировать данные: на моём трафике это ничего не стоит, хотя на большом объёме запросы с full могут отвечать медленнее.
def metrika(token, metrics, dimensions=None, limit=10): params = { "ids": COUNTER, "metrics": metrics, "date1": "7daysAgo", "date2": "yesterday", "accuracy": "full", "limit": str(limit), } if dimensions: params["dimensions"] = dimensions url = "https://api-metrika.yandex.net/stat/v1/data?" + urllib.parse.urlencode(params) return get(url, token)
Для шапки отчёта нужны итоги без разбивки. Цель (клик по кнопке «написать в Телеграм») подставляется в имя метрики по номеру: ym:s:goal<ID>reaches.
t = metrika(token, f"ym:s:visits,ym:s:users,ym:s:pageviews,ym:s:bounceRate,ym:s:goal{GOAL}reaches")["totals"]
Значения в totals идут в том же порядке, что метрики в запросе, поэтому в коде я обращаюсь по индексам: t[0] визиты, t[1] люди, t[2] просмотры страниц, t[3] отказы, t[4] достижения цели. Именованных полей в ответе нет, так что стоит переставить метрики в запросе, и цифры молча уедут не туда.
Источники
Источники трафика Метрика отдаёт английскими названиями (Link traffic, Direct traffic), и в русском отчёте они смотрятся странно, так что в начале скрипта я держу словарь перевода:
SOURCES = { "Link traffic": "переходы по ссылкам с сайтов", "Direct traffic": "прямые заходы и мессенджеры", "Social network traffic": "соцсети", "Search engine traffic": "поиск", ... }
Незнакомое имя выводится как есть через SOURCES.get(name, name), и если Яндекс добавит новый тип источника, в отчёте просто появится строка на английском.
Мессенджеры я дописал к прямым заходам потому, что ссылки из Телеграма и многих приложений приходят без реферера и Метрика записывает их в «прямые». Глядя на один этот блок, можно подумать, что половина людей помнит мой адрес наизусть, хотя большая часть пришла по ссылке из чата.
Площадки
Чтобы видеть, откуда приходят люди, я добавил отдельный запрос по ym:s:lastsignReferalSource, то есть по домену, с которого пришёл человек, и сворачиваю домены в список площадок, где публикуюсь:
platforms = {"dzen.ru": "Дзен", "habr.com": "Habr", "tenchat.ru": "TenChat", "vk.com": "VK", "t.me": "Telegram", "l.threads.com": "Threads"} for row in ref.get("data", []): dom = row["dimensions"][0]["name"] or "" for key, label in platforms.items(): if dom.endswith(key): hits[label] = hits.get(label, 0) + int(row["metrics"][0])
Домены я сравниваю через endswith, потому что у площадок бывают поддомены (m., away.) и одна площадка легко приходит под двумя именами. Строк запрашиваю 30: в длинном хвосте мелких рефереров нужная площадка может оказаться далеко за первой десяткой. Если ни одна площадка ничего не дала, в отчёте так и написано, «пока ничего». Строку я оставил и в этом случае, мне полезно видеть и пустой результат.
Страницы
С этим блоком я провозился дольше всего. Просмотры страниц я беру по измерению ym:pv:URL, а там один и тот же материал встречается под разными адресами: с метками utm_*, с хвостами, которые дописывают площадки. Если печатать как есть, одна статья займёт в топе три строки, поэтому я сначала обрезаю адреса до пути без параметров и потом суммирую:
def short_url(u): u = u.replace("https://example.com", "").split("?")[0] return u or "/" seen = {} for row in pages["data"]: u = short_url(row["dimensions"][0]["name"]) seen[u] = seen.get(u, 0) + int(row["metrics"][0])
Запрашиваю я limit=12, а в отчёт попадает шесть строк: после склейки адресов остаётся меньше, и запас нужен, чтобы топ-6 всё равно набрался. Склеиваются адреса только внутри этих двенадцати, и если у статьи много вариантов адреса, часть её просмотров может не попасть в выборку. Так что порядок в топе приблизительный.
Вебмастер: поиск глазами Яндекса
Вебмастер v4 требует user_id в каждом пути, поэтому первый запрос только узнаёт его:
uid = get("https://api.webmaster.yandex.net/v4/user/", token)["user_id"] base = f"https://api.webmaster.yandex.net/v4/user/{uid}/hosts/{HOST_ID}"
На идентификаторе сайта (host_id) я споткнулся первым делом: у него формат https:example.com:443, протокол, домен и порт через двоеточия, без слэшей. Если подставить обычный https://example.com, API ответит ошибкой про несуществующий хост, и по тексту не сразу понятно, что дело в формате.
Из Вебмастера в отчёт попадает число страниц в поиске и исключённых, ИКС (индекс качества сайта), показы и популярные запросы:
summary = get(f"{base}/summary/", token) in_search = get(f"{base}/search-urls/in-search/samples/", token) lines.append( f" страниц в поиске: {in_search.get('count', summary.get('searchable_pages_count', '?'))}" f" · исключено: {summary.get('excluded_pages_count', 0)}" )
Число страниц в поиске я беру из списка таких страниц (count), а если поля нет, из сводки. Какая цифра свежее, документация не говорит, так что сводка здесь просто страховка на случай пустого ответа.
У нового сайта ИКС равен нулю, но «ИКС: 0» читается как оценка, поэтому скрипт пишет «ещё не присвоен — сайт молодой».
Показы за неделю я суммирую из истории по дням, а популярные запросы беру с сортировкой по показам и сразу с двумя индикаторами, показами и кликами:
hist = get(f"{base}/search-queries/all/history/?query_indicator=TOTAL_SHOWS", token) shows = sum(p["value"] for p in hist.get("indicators", {}).get("TOTAL_SHOWS", [])) pop = get( f"{base}/search-queries/popular/" "?order_by=TOTAL_SHOWS&query_indicator=TOTAL_SHOWS&query_indicator=TOTAL_CLICKS&limit=5", token, )
Параметр query_indicator=...&query_indicator=... повторяется дважды, так в этом API передают список. Если собрать параметры словарём для urlencode, второй ключ затрёт первый, поэтому эту строку я склеил вручную.
Показы Вебмастер считает по своей истории, и её окно с «неделей» Метрики не совпадает. В подписи у меня «за неделю», хотя на деле там то, что Вебмастер отдал по умолчанию. Мне этого хватает, но для честного сравнения периодов надо задавать date_from и date_to явно, и это у меня в списке дел.
Если Вебмастер молчит
Блок Вебмастера я завернул в try/except, больше нигде в скрипте ошибки не глотаются:
try: lines += webmaster_block(token) except Exception as e: lines += ["", f"🔍 Вебмастер не ответил: {e}"]
Если Вебмастер лёг, мне всё равно нужны цифры посещаемости, пусть и без поискового блока. Текст ошибки попадает прямо в письмо, и что сломалось, я вижу без лога.
Очередь статей: ради чего всё затевалось
Блог у меня выходит по расписанию: в шапке каждой markdown-статьи стоит поле date: ГГГГ-ММ-ДД, и пока дата не наступила, сборщик статью на сайт не выкладывает. Выходит, файлы с будущими датами и есть очередь, и скрипт их пересчитывает:
def queue_block(): today = dt.date.today() dates = [] for f in (Path(__file__).parent / "blog" / "posts").glob("*.md"): m = re.search(r"^date:\s*(\d{4}-\d{2}-\d{2})", f.read_text(encoding="utf-8"), re.M) if m: d = dt.date.fromisoformat(m.group(1)) if d > today: dates.append(d) if not dates: return ["", "📝 Запас статей кончился — новых публикаций не будет.", " Пора писать новые статьи"] last = max(dates) lines = ["", f"📝 В запасе статей: {len(dates)} · хватит до {last.strftime('%d.%m')}"] if (last - today).days <= 10: lines.append(" ⚠️ Меньше десяти дней — пора писать новые.") return lines
«Хватит до» я считаю по самой поздней дате в очереди. Можно было бы делить количество статей на темп, но даты я расставляю руками и идут они неровно, так что последняя дата надёжнее. Предупреждение включается, когда остаётся десять дней или меньше: за это время я успеваю сесть за новую статью заранее, без спешки.
Регулярка с флагом re.M ищет date: только в начале строки, и слово «date» в тексте статьи ей не мешает. Разбирать ради одного поля всю YAML-шапку я не стал.
Есть и ограничение: скрипт считает любую будущую дату в blog/posts, даже у черновика, который я выпускать не собираюсь. Положи туда заготовку с датой из будущего, и отчёт соврёт про запас, поэтому лишнего я туда пока просто не кладу.
Отправка: Телеграм и почта
В Телеграм отчёт уходит одним POST к Bot API, без библиотек:
def send_telegram(text): token, chat = os.environ.get("TELEGRAM_BOT_TOKEN"), os.environ.get("TELEGRAM_CHAT_ID") if not (token and chat): return "пропущен (нет токена бота или chat_id)" data = urllib.parse.urlencode({"chat_id": chat, "text": text}).encode() with urllib.request.urlopen(f"https://api.telegram.org/bot{token}/sendMessage", data=data, timeout=30) as r: return "отправлен" if r.status == 200 else f"код {r.status}"
parse_mode я не передаю намеренно. В отчёте есть поисковые запросы, адреса и символы вроде _ и ., и в режиме Markdown любой из них может сломать разметку и вызвать ошибку 400, а простой текст ломать нечему. Жирных заголовков из-за этого нет, их роль играют эмодзи в начале блоков.
Длину сообщения я не проверяю. У Telegram лимит 4096 символов на текст, мой отчёт заметно короче, но если блоков прибавится, резать придётся.
Почту отправляю через SMTP Gmail, порт 587 со STARTTLS:
msg = EmailMessage() msg["Subject"] = "example.com — отчёт за неделю" msg["From"] = user msg["To"] = user msg.set_content(text) with smtplib.SMTP("smtp.gmail.com", 587, timeout=30) as s: s.starttls() s.login(user, pwd) s.send_message(msg)
Письмо я шлю самому себе. Обычный пароль аккаунта Google для SMTP не подходит, нужен пароль приложения, а его дают только после включения двухфакторной аутентификации.
Телеграм я читаю сразу, но там сообщение потом теряется в ленте, а письмо остаётся в архиве, и по нему удобно сравнивать недели. Обе функции возвращают строку статуса, и без доступов скрипт пишет «пропущено» и работает дальше, так что его можно запускать и там, где настроен только один канал.
Когда я правлю текст блоков, запускаю режим --dry: отчёт печатается в консоль и никуда не уходит.
Тот же модуль в другом скрипте
Ещё у меня есть скрипт, который делает из статей блога копии для сторонних площадок. Выкладывать копию стоит только после того, как оригинал попал в поиск Яндекса, иначе копия может оказаться в индексе раньше. Для проверки индекса нужны те же доступы и те же запросы, так что я просто импортировал функции отчёта:
def in_yandex_index(slug): sys.path.insert(0, str(ROOT)) w = importlib.import_module("weekly-report") w.load_env() tok = os.environ.get("YANDEX_OAUTH_TOKEN") ... uid = w.get("https://api.webmaster.yandex.net/v4/user/", tok)["user_id"] base = f"https://api.webmaster.yandex.net/v4/user/{uid}/hosts/{w.HOST_ID}" url = f"{SITE}/blog/{slug}.html" offset = 0 while True: d = w.get(f"{base}/search-urls/in-search/samples?limit=100&offset={offset}", tok) if any(s.get("url") == url for s in d.get("samples", [])): return True offset += 100 if offset >= d.get("count", 0): return False
Файл называется weekly-report.py, с дефисом, и обычный import weekly-report Python не пропустит: дефис в имени модуля даёт синтаксическую ошибку. Выручает importlib.import_module("weekly-report"), ему годится любая строка. По-хорошему файл надо переименовать в weekly_report.py, но к имени уже привязаны другие места, и я это отложил.
Список страниц в поиске приходит порциями по 100, и нужную страницу приходится искать, листая offset. У меня в поиске пока несколько десятков страниц и всё влезает в первую порцию, но цикл я сразу написал с запасом на рост. Без токена функция печатает предупреждение и возвращает True, то есть работу не блокирует. В этом решении я не уверен, ведь так копия может выйти раньше времени, но в моём сценарии ущерб небольшой, и я его оставил.
Там же есть флаг --force, который отключает проверку целиком, когда я точно знаю, что делаю.
Токен на полгода
Токен Яндекса живёт шесть месяцев. Я получил его в конце августа 2026 года, значит, истечёт он в конце февраля 2027. По устройству скрипта понятно, что тогда будет: оба API начнут возвращать ошибку авторизации, Метрика уронит скрипт целиком (её запросы не обёрнуты в try), а блок Вебмастера напишет «Вебмастер не ответил» с текстом ошибки. Тихо это хотя бы не случится.
Напоминания о сроке в скрипте нет, дата записана только в заметках проекта. Меня это беспокоит: отчёт, который я читаю раз в неделю, в день X просто перестанет приходить, и я легко решу, что он где-то затерялся. Лечится просто: хранить в файле окружения дату выдачи, сравнивать её с сегодняшней и за 30 дней до конца добавлять в отчёт строку. Пока я этого не сделал, сделаю и допишу сюда.
Что бы я сделал иначе
Не полагался бы на индексы в
totals, а собрал бы словарь{имя_метрики: значение}из списка запрошенных метрик.Задавал бы период в Вебмастере явно, чтобы показы за «неделю» и правда были за ту же неделю, что и Метрика.
Обернул бы Метрику в такой же
try/except, как Вебмастер. Сейчас сбой одного запроса роняет отчёт целиком.Переименовал бы файл без дефиса, чтобы модуль импортировался по-человечески.
Добавил бы строку про срок токена в сам отчёт.
Написал бы тест хотя бы на
short_urlиqueue_block: обе функции чистые, и тестов у них пока нет совсем.
Итог
Весь отчёт уместился в один файл примерно на 200 строк, на стандартной библиотеке и трёх доступах. Чаще всего я смотрю в нём строку про запас статей, потому что такой цифры мне не покажет ни один сервис аналитики. Если будете делать похожее, начните с того, чего вам не хватает в готовых панелях, а цифры из Метрики добавьте потом, с ними возни меньше всего.

