Недельный отчёт по сайту на 200 строках Python: Метрика, Вебмастер и очередь статей в одном сообщении
Раньше раз в неделю я открывал три вкладки: Яндекс.Метрику, Яндекс.Вебмастер и папку с черновиками блога, и в каждой что-нибудь считал в уме, а к третьей вкладке обычно забывал, что искал в первой. Надоело, и я написал скрипт без зависимостей, который собирает всё это в одно текстовое сообщение и шлёт в Телеграм и на почту. Ниже расскажу, как он устроен, какие запросы к 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 строк, на стандартной библиотеке и трёх доступах. Чаще всего я смотрю в нём строку про запас статей, потому что такой цифры мне не покажет ни один сервис аналитики. Если будете делать похожее, начните с того, чего вам не хватает в готовых панелях, а цифры из Метрики добавьте потом, с ними возни меньше всего.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.