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