Daily MaverickESCAPE: Walking the Leopard Trail — Four days of fynbos, fatigue and fierce resilienceBollywood HungamaVikram Malhotra on Abundantia Entertainment evolving their AI division aiOn into a standalone studio, “It is being shaped as a fully-integrated, total capability movie studio”PunchPolice arrest four over sexual offences in BauchiThe Jerusalem PostBennett's comments about sitting with Netanyahu in next gov't were 'unsuccesful irony,' Lapid saysInquirerGatchalian: Defense to start presentation on Nov. 9ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioUOLAtaques houthis colocam infraestruturas sauditas 'à prova'; aeroporto de Riade segue fechadoColliderNetflix's Beloved Spy Thriller Officially Confirms Its Long-Awaited Season 2 DateThe Hollywood Reporter‘Rebellion’: Why the Bradford 12 Doc Is So Timely and Its Directors Want People to Know “Your Problems Are Created by Elon Musk, Not Migrants”Fotogramas"Una rareza gótica que acaba en delirio": La película sobre el primer caso de vampirismo en España documentado por la IglesiaBBC NewsSinn Féin ministers facing no-confidence vote after Drumcree disputeVanguardTribargain Unveils a New E-Commerce Platform for the Nigerian Market
The Daily Newsstand · Free, Always
Monday, October 12, 2026

Недельный отчёт по сайту на 200 строках Python: Метрика, Вебмастер и очередь статей в одном сообщении

Translate

Раньше раз в неделю я открывал три вкладки: Яндекс.Метрику, Яндекс.Вебмастер и папку с черновиками блога, и в каждой что-нибудь считал в уме, а к третьей вкладке обычно забывал, что искал в первой. Надоело, и я написал скрипт без зависимостей, который собирает всё это в одно текстовое сообщение и шлёт в Телеграм и на почту. Ниже расскажу, как он устроен, какие запросы к 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 дней до конца добавлять в отчёт строку. Пока я этого не сделал, сделаю и допишу сюда.

Что бы я сделал иначе

  1. Не полагался бы на индексы в totals, а собрал бы словарь {имя_метрики: значение} из списка запрошенных метрик.

  2. Задавал бы период в Вебмастере явно, чтобы показы за «неделю» и правда были за ту же неделю, что и Метрика.

  3. Обернул бы Метрику в такой же try/except, как Вебмастер. Сейчас сбой одного запроса роняет отчёт целиком.

  4. Переименовал бы файл без дефиса, чтобы модуль импортировался по-человечески.

  5. Добавил бы строку про срок токена в сам отчёт.

  6. Написал бы тест хотя бы на short_url и queue_block: обе функции чистые, и тестов у них пока нет совсем.

Итог

Весь отчёт уместился в один файл примерно на 200 строк, на стандартной библиотеке и трёх доступах. Чаще всего я смотрю в нём строку про запас статей, потому что такой цифры мне не покажет ни один сервис аналитики. Если будете делать похожее, начните с того, чего вам не хватает в готовых панелях, а цифры из Метрики добавьте потом, с ними возни меньше всего.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.