Блог на автопилоте: markdown в статику, выход статьи по дате и пинг в IndexNow только для новых страниц
У меня блог на 78 markdown-файлах, и руками я его не публикую уже больше недели. Статья выходит, когда наступает дата в её шапке: сборщик в тот день включает её в сайт, сервер замечает новую страницу, пишет о ней поисковику и обновляет sitemap с RSS. Ниже разбираю, как это собрано, и три места, где всё ломалось. Фреймворка тут нет, только один Python-файл примерно на 800 строк, bash-скрипт на 30 строк и cron.
Весь код упрощён, домен заменён на example.com, пути на /srv/blog и /srv/www.
Исходная схема и почему она не устроила
Сначала блог собирал и выкладывал мой Мак: по расписанию запускалась сборка, потом rsync в вебрут, потом пинг поисковику. Схема продержалась до первого дня, когда Мак вышел из сна позже, чем поднялась сеть. Статья выложилась, а запрос в IndexNow упал на DNS: имя хоста ещё не резолвилось. Поисковик про страницу не узнал, я заметил это только через день, когда в вебмастере её не оказалось в списке.
Из этого получилось два требования. Публикация не должна зависеть от того, включён ли у меня ноутбук. И пинг должен быть привязан к факту «страница появилась на сайте», а не к тому, что скрипт дошёл до последней строки.
Поэтому сборка переехала на сервер, где уже стоит nginx и отдаётся статика. Мак остался подсобным рабочим: рисует картинки и подвозит на сервер исходники (blog/posts/*.md, сам сборщик, картинки). Если Мак выключен, статьи всё равно выходят, потому что на сервере лежит всё, что нужно.
Мак (по желанию) Сервер (cron, каждый день 07:30 UTC)
───────────────── ─────────────────────────────────────
рисует картинки build-blog.py → blog/*.html, sitemap, rss
rsync исходников ──────────► diff с вебрутом → список новых страниц
cp в вебрут
indexnow.py <только новые>Формат статьи
Статья — это один .md с короткой шапкой:
---
title: Как выбрать CRM для небольшой команды
slug: crm-dlya-komandy
date: 2026-10-12
tag: Автоматизация
excerpt: Одно-два предложения для списка статей и og:description.
---
Текст в markdown...Шапку разбирает самодельный парсер на четыре строки. Это сознательно: YAML-библиотека ради пяти полей кажется лишней, а «ключ: значение» строка за строкой разбирается partition(":"):
def parse_post(path):
raw = path.read_text(encoding="utf-8")
meta, body = {}, raw
if raw.startswith("---"):
_, fm, body = raw.split("---", 2)
for ln in fm.strip().split("\n"):
if ":" in ln:
k, _, v = ln.partition(":")
meta[k.strip()] = v.strip()
meta.setdefault("slug", path.stem)
meta["body_html"] = md_to_html(body.strip())
return metaУ парсера есть цена: двоеточие в значении не ломает ничего (partition режет по первому), а вот три дефиса внутри самой шапки сломали бы split("---", 2). Таких шапок у меня пока не было.
Публикация по дате: один фильтр
Главная идея умещается в три строки main():
today = dt.date.today().isoformat()
all_posts = [parse_post(p) for p in sorted(POSTS_DIR.glob("*.md"))]
posts = [p for p in all_posts if p.get("date", "") <= today]Даты в ISO-формате сравниваются как строки, и "2026-10-12" <= "2026-10-12" даёт истину. Статья с датой в будущем в posts не попадает, значит, для неё не создаётся HTML, она не видна в списке, в sitemap и в RSS. Никакой очереди и планировщика нет: очередь это просто папка с файлами, а «выпустить завтрашнюю статью» означает ничего не делать и подождать завтра.
Это решение меня устраивает, потому что состояние нигде не хранится. Сборка идемпотентна: запустил её дважды за день, получил то же самое. Упал сервер на три дня, а потом поднялся, и следующий запуск выложит всё, что накопилось.
В конце сборщик печатает, сколько статей ждут своей даты и когда выйдет последняя:
waiting = [p for p in all_posts if p.get("date", "") > today]
if waiting:
last = max(p["date"] for p in waiting)
print(f" ждут своей даты: {len(waiting)} (последняя выйдет {last})")Эту строку подхватывает недельный отчёт и считает «запаса статей хватит до такой-то даты». Когда запас меньше десяти дней, отчёт предупреждает. Пока что такой сигнал оказался полезнее любого красивого календаря.
Две дыры в этом фильтре
Первая: p.get("date", "") для статьи без даты возвращает пустую строку, а "" <= "2026-10-12" тоже истина. Статья, где я забыл поле date, выходит немедленно, в день запуска сборки. Пока я ловил это глазами. Правильнее считать отсутствие даты ошибкой и падать на сборке.
Вторая: dt.date.today() берёт часовой пояс сервера. Мой cron работает в 07:30 UTC, то есть в 10:30 по Москве, и дата в обоих поясах совпадает. Если поставить запуск на 22:00 UTC, то в Москве уже будет следующее число, и статьи пойдут на сутки позже, чем я думаю. Для ежедневного блога это не катастрофа, но заметить такое без лога тяжело.
Серверный скрипт и поиск новых страниц
Скрипт на сервере делает три вещи: собирает, определяет новые страницы и пингует поисковик.
#!/bin/bash
set -e
cd "$(dirname "$0")"
WEB="/srv/www/example"
python3 build-blog.py
# Новые = собранные сейчас, которых ещё нет в вебруте.
NEW=""
for f in blog/*.html; do
[ "$f" = "blog/index.html" ] && continue
[ -e "$WEB/$f" ] || NEW="$NEW https://example.com/$f"
done
cp blog/*.html blog/blog.css "$WEB/blog/"
cp sitemap.xml rss.xml "$WEB/"
chmod -R a+rX "$WEB"
if [ -n "$NEW" ]; then
python3 indexnow.py $NEW
else
echo "Новых статей нет, поисковику писать не о чем."
fiОпределение «новая» построено на самом простом признаке: файла с таким именем ещё нет в вебруте. Состояние хранит сама файловая система, отдельного журнала опубликованного нет. Порядок строк важен: список новых считается до cp, иначе после копирования все страницы окажутся «уже существующими».
Раньше indexnow.py без аргументов отправлял весь sitemap. Для 78 страниц это не проблема, но слать поисковику каждый день весь список, где поменялась одна страница, шумно. Поэтому скрипт принимает адреса аргументами, а без них, как раньше, берёт всё из sitemap.
Cron вызывает скрипт через bash, а не напрямую:
30 7 * * * cd /srv/blog && bash server-publish.sh >> publish.log 2>&1Это отдельные грабли: rsync с Мака приносит файл без бита запуска, и прямой вызов молча не срабатывал. Через bash вопрос с правами исчез.
IndexNow
Протокол простой: POST с JSON на эндпоинт поисковика, в теле хост, ключ и список адресов. Ключ лежит ещё и текстовым файлом в корне сайта, и поисковик по нему убеждается, что запрос пришёл от владельца:
payload = json.dumps({
"host": HOST,
"key": KEY, # из переменной окружения INDEXNOW_KEY
"keyLocation": f"https://{HOST}/{KEY}.txt",
"urlList": urls,
}).encode()
req = urllib.request.Request(
ENDPOINT, data=payload,
headers={"Content-Type": "application/json; charset=utf-8"},
)Ответ 200 или 202 означает «принято». Это не гарантия индексации: пинг лишь сообщает, что страница существует. Насколько это быстрее ожидания по sitemap, я не замерял, поэтому цифр не будет.
Историю с DNS я закрыл повтором с нарастающей паузой. Это осталось от времён Мака, а на сервере почти не срабатывает, но вреда нет:
for attempt in range(1, 6):
try:
code = notify(urls)
break
except urllib.error.HTTPError as e: # 4xx/5xx: повторять бессмысленно
print(f"Ответ {e.code}: {e.read().decode()[:200]}")
sys.exit(1)
except (urllib.error.URLError, TimeoutError) as e:
if attempt == 5:
sys.exit(1)
time.sleep(60 * attempt)HTTPError я ловлю отдельно и выхожу сразу: если поисковик ответил 403 из-за ключа, то через минуту будет тот же 403. Повторяются только сетевые сбои.
Дыра в «новых»: пинг, который не повторится
Теперь о главной слабости схемы. В скрипте стоит set -e, а cp стоит перед indexnow.py. Если пинг упал, скрипт завершается с ошибкой, но страницы уже лежат в вебруте. На следующий день они уже «не новые», и поисковику о них никто не напишет.
Проверять надо было иначе: пинговать после успешной отправки и только тогда считать страницу обработанной. Я пока не переписал это, потому что на сервере сеть стабильна и сбой случился один раз, ещё на Маке. Но если вы повторяете схему, заведите файл pinged.txt и дописывайте в него адреса после ответа 200/202. «Новая» тогда означает «нет в этом файле», а не «нет в вебруте».
Ещё одна мелочь: cp без --delete. Если я уберу статью из исходников, она с сайта сама не исчезнет. Для блога, где ничего не удаляется, это терпимо, но удалять придётся руками.
Всё остальное делает та же сборка
Раз сборщик уже знает список опубликованных статей, остальные производные страницы строятся из него же.
Sitemap
def write_sitemap(posts):
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for loc, priority in STATIC_PAGES:
lines.append(f" <url><loc>{loc}</loc><priority>{priority}</priority></url>")
for p in posts:
loc = f"{SITE_URL}/blog/{p['slug']}.html"
lines.append(f" <url><loc>{loc}</loc><lastmod>{p.get('date','')}</lastmod>"
f"<priority>0.6</priority></url>")
lines.append("</urlset>")В lastmod у меня лежит дата публикации, а не дата последней правки. Поэтому, когда я переписываю старую статью, поисковик по lastmod этого не видит. Лечится пингом через IndexNow, а не правкой sitemap.
RSS
Лента нужна мне для агрегатора, который забирает статьи сам. У него свои требования: у каждого item должны быть заголовок, ссылка, guid, дата в формате RFC-822 и полный текст в content:encoded; обложка только JPEG, GIF или PNG шириной от 700 px. Обложки у меня в webp, и для ленты сборщик рядом делает jpg-копию, только если исходник новее копии:
dst = src.with_suffix(".jpg")
if not dst.exists() or dst.stat().st_mtime < src.stat().st_mtime:
Image.open(src).convert("RGB").save(dst, "JPEG", quality=88)Внутри content:encoded агрегатор понимает урезанный набор тегов, поэтому strong и em я заменяю на b и i, а code снимаю совсем. В теле ссылки записаны от корня сайта (/blog/...), в ленте их нужно переписать в полные адреса, иначе у читателя они поведут в никуда. Лента отдаёт 50 последних статей, и ей важен тот же фильтр по дате: завтрашняя статья в RSS не попадёт.
Автоперелинковка по ключевым фразам
Внутренние ссылки расставляет сборщик, у меня руками их не пишет никто. Словарь «статья → фразы» лежит в коде:
AUTOLINKS = {
"crm-dlya-komandy": ["CRM для команды", "выбрать CRM"],
"skolko-stoit-sajt": ["сколько стоит сайт", "стоимость сайта", "цена сайта"],
# ...
}
MAX_AUTOLINKS = 3Алгоритм: идём по абзацам статьи, для каждой целевой статьи ищем первое вхождение любой её фразы и оборачиваем в <a>. Ограничения такие:
Не больше трёх ссылок на статью, потому что больше выглядит как спам для читателя и для поиска.
Одна цель ссылается только один раз, а сам на себя материал не ссылается.
Целевая статья должна быть среди опубликованных (
known_slugs), иначе ссылка вела бы на 404 из будущего. Здесь фильтр по дате снова работает на нас бесплатно.Внутрь уже существующих
<a>и<code>не лезем. Для этого абзац режется регуляркой на теги и текст, и счётчик «внутри ссылки» ведётся по тегам:
parts = re.split(r"(<[^>]+>)", fragment)
pattern = re.compile(rf"\b{re.escape(phrase)}\b", re.IGNORECASE)Была и ошибка в логике. В абзаце про чужой продукт фраза вроде «бесплатный тариф» относилась к конкуренту, а автоссылка превращала её в ссылку на мою статью про бесплатный вариант. Выглядело нелепо. Теперь абзацы с названиями конкурентов (регулярка RIVALS) пропускаются целиком. Список названий приходится дополнять руками, но глазами такие случаи ловить было ещё дороже.
Есть и очевидное ограничение: \b в Python работает с кириллицей, но он не знает про падежи. Фраза «сколько стоит сайт» не найдёт «сколько стоит сайтик». Поэтому в списке у каждой статьи по три-четыре формы.
«Читайте также»
Три соседние статьи под каждой. Выбор строится не по случайности:
def related_posts(post, posts, k=3):
i = posts.index(post)
ring = [posts[(i + off) % len(posts)] for off in range(1, len(posts))]
picked = ring[:1] # первый слот: сосед по кольцу
taken = {p["slug"] for p in picked}
for pool in ([p for p in ring if p.get("tag") == post.get("tag")], ring):
for p in pool:
if len(picked) >= k: break
if p["slug"] not in taken:
picked.append(p); taken.add(p["slug"])
return pickedПервый слот занимает следующая по порядку статья в кольце. Такая арифметика даёт ровно одну входящую ссылку каждой статье из чужого блока, поэтому сирот без внутренних ссылок не бывает, даже у самой свежей. Остальные два слота добираются из статей с тем же тегом, а если их мало, то снова по кольцу. Статьи сортируются по дате, а в список входят только вышедшие, так что при выходе новой статьи блоки у соседей сдвигаются сами при следующей сборке.
Обложки: картинки без букв
Для превью в мессенджерах и соцсетях нужна обложка 1200×630. Генерирует её модель для картинок по сцене-метафоре, которую я пишу один раз на статью. Все обложки лежат в одной визуальной системе, поэтому общий стиль вынесен в константу и дописывается к каждой сцене:
STYLE = (
"Warm off-white paper background with subtle grain. "
"Polished chrome sculptures, acid-lime matte shapes, one deep matte black element. "
"Clean minimal editorial composition, soft studio shadows, "
"wide horizontal framing with generous empty space on the left. "
"Absolutely NO text, NO letters, NO words, NO numbers, NO logos, "
"NO user interface, NO screens, NO signage, NO human figures."
)Запрет на буквы стоит первым в списке, и я его не снимаю. Модели рисуют вывески и надписи на кириллице с ошибками: вместо слова получаются знакомые по форме, но несуществующие буквы. Одна такая обложка в ленте портит впечатление хуже, чем отсутствие картинки. Заголовок на превью всё равно подставляет сама соцсеть из мета-тегов страницы, так что на картинке он и не нужен. По той же причине в негативный список попали экраны и интерфейсы: на них модель обязательно пытается что-то написать.
Запрос на сцену описывает предмет, а не абстракцию: «матовый чёрный замок на салатовой панели, рядом три хромированных ключа, один вставлен». Абстрактные скульптуры без предмета у меня получались красивыми, но не связанными с темой статьи, и я их отбраковал.
Размер генерации чуть больше целевого (1216×640), а затем to_cover приводит его к 1200×630 обрезкой по центру, если пропорции не совпали, и сохраняет в webp:
def to_cover(src, dst):
im = Image.open(src).convert("RGB")
w, h = im.size
want = 1200 / 630
if abs(w / h - want) > 0.01:
if w / h > want:
new_w = int(h * want)
im = im.crop(((w - new_w) // 2, 0, (w - new_w) // 2 + new_w, h))
else:
new_h = int(w / want)
im = im.crop((0, (h - new_h) // 2, w, (h - new_h) // 2 + new_h))
im.resize((1200, 630), Image.LANCZOS).save(dst, "WEBP", quality=88)Подключает обложку функция из сборщика. Если файла нет, берётся общая, и статья всё равно выходит:
def cover_for(slug):
if (ROOT / "assets" / "og" / f"{slug}.webp").exists():
return f"{SITE_URL}/assets/og/{slug}.webp", 1200, 630
return OG_IMAGE, 1727, 910Эта мелочь важнее, чем кажется. Генерация зависит от внешнего сервиса с дневным лимитом, поэтому картинки рисуются «понемногу»: не больше четырёх обложек и шести иллюстраций за запуск, ближайшие по дате первыми. Если лимит кончился, статья выходит с общей обложкой, а недостающие картинки дорисовываются в следующие дни, потому что страницы пересобираются при каждом запуске. Сборщик молча пропускает отсутствующую картинку, и публикация не блокируется красотой.
Рисует всё это Мак, а на сервер картинки приезжают тем же rsync, что и исходники. Серверу ключи от сервиса генерации не нужны, и это меня устраивает.
Про темп публикации
Первое время статьи выходили чаще. Потом выяснилось, что поисковик начал снимать часть однотипных статей с формулировкой «недостаточно качественные», и я перешёл на три статьи в неделю: понедельник, среда, пятница. Это сделано без единой строчки кода, просто датами в шапках файлов, что и есть главное достоинство подхода: темп меняется переименованием одного поля. Cron при этом по-прежнему запускается каждый день, а в дни без новых дат сборка не находит новых страниц и пинг не отправляет.
Что бы я сделал иначе
Журнал
pinged.txtвместо проверки по вебруту. Тогда упавший пинг сам повторится на следующий день.Падение сборки на статье без
date. Забытое поле не должно значить «опубликовать сейчас».Явный часовой пояс в
today, например черезZoneInfo, чтобы не зависеть от настроек сервера.lastmodиз реальной даты правки файла в git, а не из даты публикации.Тест на
apply_autolinks: сейчас регулярки проверены прогоном на живых статьях, и каждое изменение словаря фраз я смотрю глазами по диффу HTML.
Итог
Весь автопилот держится на трёх вещах: дата в шапке как единственный источник правды о том, когда статья выходит, файловая система вебрута как журнал «что уже опубликовано» и cron на сервере, который не зависит от моего ноутбука. Всё остальное, от sitemap до перелинковки, считается из списка вышедших статей за одну сборку и не требует отдельной инфраструктуры. За время работы в исходниках лежит 78 статей, часть ещё ждёт своей даты, и в день публикации я не делаю ничего. Если делаете похожее, сразу заведите журнал пингов: единственная серьёзная дыра у меня именно там.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.