SEO для самохостируемого блога на FastAPI: что реально работает

Опубликовано: 2026-06-05

Большинство руководств по SEO написано под WordPress или SaaS-платформы, где достаточно поставить плагин. Когда сайт работает на FastAPI с Jinja2-шаблонами, ничего из этого не подходит — HTML полностью под твоим контролем, а значит, мета-теги, структурированные данные и интеграции с поисковиками нужно прописывать вручную. Этот пост — полное описание SEO-стека weblog.antonnovikov.com: что реализовано, где в коде живёт и что ломается при ошибках.


Проблема

Блог работает на weblog.antonnovikov.com: FastAPI + Jinja2, посты — Markdown-файлы, рендеринг на стороне сервера. Два языка — английский и русский — с отдельными URL-деревьями. Задачи простые:

  • Каждый пост проиндексирован в Google и Яндексе в течение нескольких часов после публикации
  • Двуязычные альтернативы распознаются корректно (без штрафов за дублированный контент)
  • Расширенные сниппеты в результатах поиска (тип статьи, даты, хлебные крошки)
  • Работающие фиды для RSS-читалок

Ничто из этого не появляется само. Всё нужно реализовать руками.


Как это работает

SEO-стек живёт в трёх местах: базовый Jinja2-шаблон (templates/base.html), шаблон поста (templates/blog/index.html) и роутер (app/routers/blog.py). Каждый слой отвечает за своё.

Мета-теги

Каждая страница получает <meta name="description"> из поля description в index.json. Для страниц постов директива robots расширена:

html<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">

max-snippet:-1 разрешает Google использовать полное описание в сниппете, без обрезки. max-image-preview:large включает большой превью изображений в Google Discover. Это opt-in — без явного указания поисковики по умолчанию применяют консервативные ограничения.

Ограничение на длину описания — 160 символов. Это не требование Google, но длинное описание обрежется в выдаче — смысла добавлять лишнее нет.

Canonical URL

На каждой странице стоит явный <link rel="canonical">:

html<link rel="canonical" href="https://weblog.antonnovikov.com/2026-06-05-seo-fastapi-blog">

Canonical строится в шаблоне из переменной контекста og_url, которую формирует _handle_blog_post:

python"og_url": f"{url_prefix}/{slug}",

Где url_prefix — либо https://weblog.antonnovikov.com, либо https://weblog.antonnovikov.com/ru в зависимости от языка. Canonical — это канонический URL без параметров трекинга и альтернативных форм.

Open Graph и Twitter Card

Страницы постов получают полный набор OG-тегов для типа article:

html<meta property="og:type"              content="article">
<meta property="og:title"             content="...">
<meta property="og:description"       content="...">
<meta property="og:url"               content="...">
<meta property="og:site_name"         content="weblog.antonnovikov.com">
<meta property="og:locale"            content="ru_RU">
<meta property="og:locale:alternate"  content="en_US">
<meta property="og:image"             content="...">
<meta property="og:image:width"       content="1200">
<meta property="og:image:height"      content="630">
<meta property="og:image:alt"         content="...">
<meta property="article:published_time" content="2026-06-05T00:00:00Z">
<meta property="article:modified_time"  content="...">
<meta property="article:author"       content="https://antonnovikov.com/">
<meta property="article:section"      content="DevOps">

og:locale:alternate редко упоминается в документации, но важен для двуязычных сайтов — сообщает скраперам об альтернативной локали. article:modified_time берётся из mtime файла на диске, а не из хардкода — редактирование поста автоматически обновляет метку времени.

Twitter Card — summary_large_image с метаданными о времени чтения:

html<meta name="twitter:card"    content="summary_large_image">
<meta name="twitter:label2"  content="Reading time">
<meta name="twitter:data2"   content="5 min">

Пары label/data отображаются как дополнительные метаданные в превью ссылок в Twitter/X. Время чтения передаётся из контекста шаблона как og_read_time (по умолчанию 5 мин; можно задать явно в index.json через поле read_time).

JSON-LD структурированные данные

Самая многословная часть. Каждый пост генерирует два JSON-LD блока: BlogPosting и BreadcrumbList.

Схема BlogPosting:

json{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Заголовок поста",
  "description": "Описание поста",
  "url": "https://weblog.antonnovikov.com/ru/2026-06-05-seo-fastapi-blog",
  "mainEntityOfPage": "https://weblog.antonnovikov.com/ru/2026-06-05-seo-fastapi-blog",
  "datePublished": "2026-06-05T00:00:00Z",
  "dateModified": "...",
  "author": { "@type": "Person", "name": "Anton Novikov", "url": "https://antonnovikov.com/" },
  "publisher": { "@type": "Person", "name": "Anton Novikov", "url": "https://antonnovikov.com/" },
  "image": { "@type": "ImageObject", "url": "...", "width": 1200, "height": 630 },
  "keywords": "kubernetes, k0s, helm",
  "inLanguage": "ru",
  "isPartOf": { "@type": "Blog", "name": "Anton Novikov — вебlog", "url": "https://weblog.antonnovikov.com/ru/" },
  "wordCount": 1240,
  "timeRequired": "PT6M"
}

wordCount считается из сырого Markdown (простой .split()) при рендере и передаётся в контекст шаблона. timeRequired — ISO 8601 длительность: PT6M = 6 минут. Google использует эти поля для расширенных метаданных в Search Console.

Блок BreadcrumbList формирует хлебные крошки в результатах поиска:

json{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    { "@type": "ListItem", "position": 1, "name": "weblog", "item": "https://weblog.antonnovikov.com/ru/" },
    { "@type": "ListItem", "position": 2, "name": "Заголовок поста", "item": "https://weblog.antonnovikov.com/ru/2026-06-05-seo-fastapi-blog" }
  ]
}

Страницы индекса и листинги тегов/серий получают схемы WebSite, Blog и CollectionPage вместо BlogPosting.

hreflang для двуязычного контента

Каждый пост, существующий на обоих языках, получает hreflang-ссылки в <head>:

html<link rel="alternate" hreflang="en" href="https://weblog.antonnovikov.com/2026-06-05-seo-fastapi-blog">
<link rel="alternate" hreflang="ru" href="https://weblog.antonnovikov.com/ru/2026-06-05-seo-fastapi-blog">
<link rel="alternate" hreflang="x-default" href="https://weblog.antonnovikov.com/2026-06-05-seo-fastapi-blog">

Те же пары дублируются в sitemap — Google рекомендует указывать hreflang в обоих местах. Генератор sitemap (generate_sitemap_xml()) находит посты, присутствующие в обоих языках, через пересечение множеств слагов:

pythonen_slugs = {p.get("slug") for p in en_posts}
ru_slugs = {p.get("slug") for p in ru_posts}
both_slugs = en_slugs & ru_slugs

Для двуязычных постов в <url> добавляются <xhtml:link> элементы:

xml<url>
  <loc>https://weblog.antonnovikov.com/ru/2026-06-05-seo-fastapi-blog</loc>
  <lastmod>2026-06-05</lastmod>
  <changefreq>never</changefreq>
  <priority>0.8</priority>
  <xhtml:link rel="alternate" hreflang="en"        href="https://weblog.antonnovikov.com/2026-06-05-seo-fastapi-blog"/>
  <xhtml:link rel="alternate" hreflang="ru"        href="https://weblog.antonnovikov.com/ru/2026-06-05-seo-fastapi-blog"/>
  <xhtml:link rel="alternate" hreflang="x-default" href="https://weblog.antonnovikov.com/2026-06-05-seo-fastapi-blog"/>
</url>

changefreq=never для постов — опубликованный контент не меняется, только метаданные. lastmod берётся из поля date в index.json.

Навигация по серии: rel=prev / rel=next

Посты, входящие в серию, получают ссылки rel=prev и rel=next на соседние посты серии (упорядоченные по дате по возрастанию):

html<link rel="prev" href="https://weblog.antonnovikov.com/ru/2026-06-03-fastapi-s3-yandex-cloud">

Google отказался от этого для пагинации в 2019-м, но Яндекс и RSS-читалки, которые понимают последовательную навигацию, всё ещё используют.

Atom-фид

Два Atom 1.0 фида: /feed.xml (EN) и /ru/feed.xml (RU), каждый — последние 20 постов с полным HTML-контентом:

xml<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Anton Novikov  вебlog (ru)</title>
  <link href="https://weblog.antonnovikov.com/ru/"/>
  <entry>
    <id>https://weblog.antonnovikov.com/ru/2026-06-05-seo-fastapi-blog</id>
    <title>SEO для самохостируемого блога на FastAPI</title>
    <updated>2026-06-05T00:00:00Z</updated>
    <summary>...</summary>
    <content type="html">...отрендеренный HTML...</content>
    <category term="seo"/>
  </entry>
</feed>

Полный контент в фиде (а не только описание) удобнее для читалок и индексируется некоторыми агрегаторами. Фид кешируется в памяти на 1 час через TTLCache.

Ссылка на фид объявлена в <head> каждой страницы:

html<link rel="alternate" type="application/atom+xml"
      title="Anton Novikov — вебlog (RU)"
      href="https://weblog.antonnovikov.com/ru/feed.xml">

IndexNow

При публикации нового поста через admin API отправляется IndexNow-пинг в Bing и Яндекс:

pythonasync def _indexnow_ping(urls: list[str]) -> None:
    payload = {
        "host": "weblog.antonnovikov.com",
        "key": "0c887d6665c1de09334e138e0c31962c",
        "keyLocation": "https://weblog.antonnovikov.com/0c887d6665c1de09334e138e0c31962c.txt",
        "urlList": urls[:10000],
    }
    endpoints = [
        "https://api.indexnow.org/indexnow",
        "https://yandex.com/indexnow",
    ]
    async with httpx.AsyncClient(timeout=10.0) as client:
        for ep in endpoints:
            r = await client.post(ep, json=payload, ...)

Файл верификации ключа лежит по пути /0c887d6665c1de09334e138e0c31962c.txt и отдаётся статически. Без него поисковики отклонят пинг — они забирают файл для проверки владения. Пинг работает по принципу fire-and-forget: ошибки логируются как warnings, ответ API не блокируется.

Sitemap-эндпоинт

Sitemap генерируется динамически по адресу /weblog/sitemap.xml через generate_sitemap_xml(): читает из кешей индексов и строит полный XML. Включает:

  • Статические страницы (antonnovikov.com, cv.antonnovikov.com, weblog.antonnovikov.com)
  • Все EN- и RU-посты с lastmod, changefreq=never, priority=0.8
  • Страницы листингов тегов с changefreq=weekly, priority=0.5

Отдельный статический sitemap.xml в корне репозитория используется для главного домена antonnovikov.com — это простой файл, не генерируемый приложением.

robots.txt

textUser-agent: *
Allow: /

Disallow: /weblog/api/
Disallow: /weblog/stats
Disallow: /*.json$
Allow: /weblog/index.json
Allow: /weblog/index-ru.json

User-agent: GPTBot
Disallow: /

User-agent: ChatGPT-User
Disallow: /

AI-краулеры для обучения моделей (GPTBot, ChatGPT-User, CCBot, anthropic-ai) заблокированы полностью. API-эндпоинты закрыты — нет смысла их индексировать, и они бы показывались как странные URL. Файлы index.json явно разрешены обратно после паттерна *.json$ — это публичные индексы постов.


Сборка в FastAPI

Контекст шаблона для страницы поста целиком формируется в _handle_blog_post():

pythonctx: dict = {
    "og_title":       meta.get("title", slug),
    "og_description": meta.get("description", ""),
    "og_url":         f"{url_prefix}/{slug}",
    "og_date":        meta.get("date", ""),
    "og_modified":    og_modified,      # из mtime файла
    "og_tags":        meta.get("tags", []),
    "og_read_time":   meta.get("read_time", 5),
    "og_word_count":  word_count or 0,  # посчитан из сырого markdown
    "og_slug":        slug,
    "og_image":       meta.get("cover_image", ""),
    "og_body":        body_html,
    "og_series_prev": og_series_prev,
    "og_series_next": og_series_next,
}

Словарь meta берётся из _slug_index_en / _slug_index_ru — обычные Python-словари, перестраиваемые из index.json при обновлении кеша индекса (раз в 5 минут). Никакого запроса к базе, никакого stat-а файла на запрос — просто поиск по словарю.

og_modified собирается из mtime файла:

pythonmtime = md_path.stat().st_mtime
og_modified = email.utils.formatdate(mtime, usegmt=False)[:16].rstrip()

Результат — ISO-подобная строка вроде "Wed, 05 Jun 2026", которая попадает в dateModified в JSON-LD блоке.


Что может пойти не так

Отсутствует файл ключа IndexNow. Если задеплоить без 0c887d6665c1de09334e138e0c31962c.txt, каждый IndexNow-пинг будет возвращать 403 Forbidden. В логах будет IndexNow https://api.indexnow.org/indexnow → 403. Проверить:

bashcurl -I https://weblog.antonnovikov.com/0c887d6665c1de09334e138e0c31962c.txt

Должен вернуть 200 OK. Файл просто должен содержать строку с ключом.

hreflang только в <head>, но не в sitemap, или наоборот. Google рекомендует согласованность обоих мест. Если пост есть только на английском, hreflang-блоки пропускаются полностью (пересечение множеств both_slugs это обеспечивает). Если добавить RU-пост без соответствующего EN, он попадёт в sitemap как одиночный URL без hreflang — это правильное поведение, не баг.

Несоответствие canonical с трейлинг-слешем. Canonical для индексной страницы — https://weblog.antonnovikov.com/ (со слешем). Страницы постов — без слеша. Путаница приводит к soft 404 в Search Console. Шаблон всегда использует og_url точно в том виде, в каком он передан из роутера — если роутер вернёт неправильную форму, canonical окажется неправильным. Проверка:

bashcurl -sI https://weblog.antonnovikov.com/some-slug | grep canonical

Убедиться, что <link rel="canonical"> в HTML ответа совпадает с запрошенным URL.


Итого

  • Мета-описание, robots-директивы и canonical берутся из метаданных index.json и mtime файла
  • OG-теги типа article и Twitter Card генерируются для каждого поста; индекс получает типы website/blog
  • JSON-LD выдаёт BlogPosting + BreadcrumbList для постов, WebSite + Blog для индексных страниц
  • hreflang проставляется в <head> и sitemap; только посты, существующие на обоих языках, получают альтернативы
  • Atom-фиды несут полный HTML-контент, кешируются в памяти на 1 час
  • IndexNow срабатывает при публикации в Bing и Яндекс; требует файла ключа по известному URL
  • robots.txt блокирует AI-краулеры и API-эндпоинты, явно разрешает index-файлы JSON