IndexNow для статического блога: что это, зачем и как я его прикручивал

Edit...
howto
Illustrated by Igan Pol

Привет, %username%! Выкатил новый пост, поправил страницу, обновил title — и сидишь, ждёшь, пока поисковики соизволят переобойти сайт. День, два, неделя. А хочется, чтобы апдейт подхватился сразу после git push, без танцев с Search Console и ручных submission-форм.

Дальше — как закрыть эту боль одним шагом в GitHub Actions через мой CLI indexnow и почему оно вообще работает.

Что такое IndexNow и зачем он нужен#

Протокол IndexNow — это простой способ сказать поисковику: «эти URL изменились, переобойди их побыстрее, пожалуйста». Ты делаешь один HTTP-запрос с ключом и списком URL, поисковик в ответ подтверждает приём (или отбивает с осмысленной ошибкой) — и дальше уже его проблема, когда именно он переобойдёт страницы.

Главное: submission автоматически шарится между всеми участниками протокола. По спеке достаточно дёрнуть один эндпоинт (например, api.indexnow.org или bing.com/indexnow), и тот же запрос провалидируется и расшарится со всеми: Bing, Yandex, Naver, Seznam, Yep и другие участники. Google к протоколу формально не присоединился — для него по-прежнему остаётся sitemap + Search Console.

Аутентификация — самая «забавная» часть протокола. Ты придумываешь ключ (произвольная hex-строка длиной 8–128 символов), хостишь его в виде текстового файла на собственном домене, например https://example.com/<key>.txt, и присылаешь этот ключ в каждом запросе. Поисковик идёт по URL, читает файл, сверяет содержимое с тем ключом, что ты прислал. Совпало — submission принят. Нет — 403. Просто и без OAuth.

И тут же — мой любимый перл из документации Яндекс.Вебмастера : «Ключ менять необязательно. Вы можете изменить ключ, если он стал известен третьим лицам.» То есть ключ, который ты по спеке обязан выложить публичным .txt на свой домен и который любой может вытащить одним curl, Яндекс предлагает ротировать, если он «стал известен третьим лицам». А он по дизайну протокола известен им всегда. Ну камон.

Зачем я написал свой CLI#

Дёргать API руками лениво, особенно когда у тебя на пуш приходится 10–50 изменившихся URL и хочется делать это из CI. Можно, конечно, нагуглить готовый пример с curl и jq, но:

  • ошибки разбирать в bash больно;
  • ретраи на 429/5xx писать руками лень;
  • sitemap парсить из XML в bash — отдельный жанр перформанса;
  • лимит протокола в 10 000 URL на запрос тоже надо учитывать.

Поэтому я написал на Go маленький indexnow — сначала как CLI для разовых submission’ов, а с релиза v0.5.0 это уже и готовый GitHub Action. По дороге всё то, что обычно копится в самописных скриптах, я аккуратно унёс внутрь бинаря.

Заодно это стало хорошим pet-project’ом: тесты с -race, golangci-lint, mkdocs для документации, релизы через goreleaser, а теперь и composite Action с верификацией бинарей. Маленький инструмент, который и время экономит, и руки в форме держит.

Минимальный шаг в GitHub Actions#

Самый короткий путь — взять готовый Action и засунуть один шаг в workflow:

- uses: jtprogru/indexnow@v0
  with:
    key: ${{ secrets.INDEXNOW_KEY }}
    sitemap: https://example.com/sitemap.xml

Всё. Этот шаг сам:

  • определит ОС и архитектуру раннера (linux/amd64, linux/arm64, darwin/arm64, …);
  • скачает релизный бинарь нужной сборки со страницы релизов;
  • сверит его sha256 с checksums.txt, который лежит рядом с бинарями в релизе — это защищает от «а что если кто-то подменил артефакт»;
  • закэширует бинарь в runner.tool_cache, чтобы следующие раны не качали его повторно;
  • запустит indexnow submit с переданными параметрами.

Ключ передаётся через env, а не флагом, — даже если в твоём шаге случайно отрабатывает set -x, ключ в логах не светится. В $GITHUB_STEP_SUMMARY после прогона падает короткий отчёт: сколько URL улетело, сколько батчей упало, куда отправлял. Не нужно лезть в полный лог раннера, чтобы понять, прошёл submit или нет.

Submit только тех URL, что изменились#

Слать весь sitemap при каждом пуше расточительно: поисковики переобойдут страницы и без тебя, IndexNow используешь именно для тех апдейтов, что важны прямо сейчас. Поэтому мой любимый рецепт — submit ровно того, что изменилось между предыдущим и текущим коммитом:

- uses: jtprogru/indexnow@v0
  with:
    key: ${{ secrets.INDEXNOW_KEY }}
    sitemap: https://example.com/sitemap.xml
    sitemap-since: ${{ github.event.before }}

sitemap-since фильтрует записи sitemap по <lastmod>: всё, что старее переданного времени, отбрасывается.

Один граничный случай, который я заложил по умолчанию: записи без <lastmod> всегда проходят. «Нет сигнала о времени» трактуется как «могло измениться», и для IndexNow это безопасный дефолт — повторный submit идемпотентен, лишний раз дёрнуть не страшно. Куда хуже промолчать про реально изменившуюся страницу.

А если sitemap’у не доверяешь — urls-from#

sitemap-since хорош, когда <lastmod> у тебя действительно проставляется. Если нет (или ты не хочешь полагаться на свежесть sitemap’а вообще) — есть второй рецепт: собрать список URL из git diff между двумя коммитами и скормить его в Action через urls-from.

- uses: actions/checkout@v6
  with:
    fetch-depth: 0
- uses: jtprogru/indexnow@v0
  with:
    key: ${{ secrets.INDEXNOW_KEY }}
    urls-from: |
      git diff --name-only "${{ github.event.before }}..HEAD" -- 'content/**/*.md' |
        sed 's#^content/\(.*\)/index\.md$#https://jtprog.ru/\1/#'

urls-from — это просто bash-сниппет, чей stdout (по одной строке на URL) превращается в источник URL. Сниппет крутится в $GITHUB_WORKSPACE, так что git видит весь checked-out репозиторий и git diff работает как обычно. Пустой вывод — это не ошибка, шаг тихо выйдет с submitted-count=0.

Вот тот самый маппинг: content/<slug>/index.mdhttps://jtprog.ru/<slug>/. Никакого парсинга sitemap, никаких лишних submission’ов на страницы, которые не трогал. fetch-depth: 0 обязательно — без полной истории git diff против github.event.before молча отдаст пустоту.

Тег @v0 и плавающие версии#

Одна из мелких, но важных деталей — модель версионирования.

  • @v0.7.0 — конкретный релиз. Поведение зафиксировано, обновления только если ты сам поправишь workflow.
  • @v0.7 — все патчи внутри 0.7.x, обновляется автоматически.
  • @v0 — все релизы внутри 0.x, едет с каждым новым минором.

Плавающие теги (v0, v0.5) переставляются отдельным workflow на каждый релиз v0.Y.Z. То есть подписался один раз на @v0 — и едешь на последнем 0.x без правок workflow. Когда выкачу v1.0.0, тег @v0 останется на последнем релизе ветки 0.x: ты сам решаешь, переезжать ли на мажор. Никаких сюрпризов в духе «обновился — поведение сломалось».

Inputs, outputs и downstream-шаги#

Inputs Action’а зеркалят флаги CLI: источники URL (urls / file / sitemap / urls-from), sitemap-since, endpoint, host, key-location, fail-on, retry-knobs (max-retries, base-backoff, max-backoff), dry-run, verbose, quiet, user-agent. Между «попробовал локально, всё работает» и «засунул в CI» нет шага «а как там оно в Action называется» — один и тот же набор имён.

Outputs тоже из коробки:

  • exit-code — финальный код выхода CLI (0 / 1 / 2);
  • submitted-count — сколько URL ушло (суммарно по батчам);
  • failed-count — сколько батчей завершились не-2xx или ошибкой;
  • report — одностраничная сводка; параллельно она же пишется в $GITHUB_STEP_SUMMARY.

Этими outputs удобно дёргать downstream-шаги: уронить алерт в Telegram, если failed-count > 0; или, наоборот, проигнорировать падение submit, если ты ему не доверяешь и не хочешь, чтобы из-за этого падал весь pipeline. Полный референс и готовые рецепты — в docs → GitHub Action .

Если Actions не вариант#

GitLab CI, Drone, Woodpecker, cron на VPS, локальный запуск перед публикацией поста — для всех этих кейсов есть тот же бинарь, ставится двумя способами:

brew tap jtprogru/tap && brew install indexnow
# или
go install github.com/jtprogru/indexnow/cmd/indexnow@latest

Готовые бинарники под Linux / macOS / FreeBSD в связках amd64/arm64 лежат на странице релизов . И дальше тот же submit, только из шелла:

export INDEXNOW_KEY=8f7e6d5c4b3a29180706050403020100
indexnow submit \
    --sitemap https://example.com/sitemap.xml \
    --sitemap-since 2026-06-01T00:00:00Z

Источник URL — на выбор:

  • позиционные аргументы: indexnow submit https://a https://b;
  • --file urls.txt (по одному URL на строку, # — комментарий);
  • --stdin — типичный pipe из другого CLI (sitemap-to-urls | indexnow submit --stdin);
  • --sitemap — URL или локальный путь к XML.

С sitemap’ом отдельная история: <sitemapindex> раскрывается рекурсивно (ходишь по всем дочерним), а .gz-варианты гунзипятся прозрачно — отдельный шаг с gunzip не нужен. На больших проектах с десятками сегментированных sitemap’ов это здорово упрощает жизнь.

Что CLI делает за тебя#

Если коротко — всё то, что обычно копится в скриптах вокруг такой задачи:

  • Ретраи 429/5xx и transport-ошибок с экспоненциальным backoff’ом, jitter’ом и уважением к заголовку Retry-After (понимает и секунды, и HTTP-date). Ретраи без backoff превращают деградацию в катастрофу — особенно если поисковик уже под нагрузкой.
  • Резка батчей по лимиту протокола (10 000 URL на запрос). Не нужно думать, что делать, когда у тебя 25 000 урлов: CLI сам режет на три батча, по каждому идёт отдельный submit и отдельный результат в отчёте.
  • Несколько эндпоинтов сразу: --endpoint bing,yandex,naver. Если ты по какой-то причине не доверяешь «один эндпоинт расшарит всем», передай список — CLI отправит в каждый и склеит результат.
  • Внятные exit code (0 — всё ок, 1 — submission failed по сети/HTTP/fail-on, 2 — usage error: кривые флаги, нет источника URL, нет ключа) и флаг --fail-on any|4xx|5xx|never. Например, на 429 ты можешь не хотеть падать (это не твоя проблема), а на 4xx по ключу — обязательно.
  • Управление ключом через indexnow key: key gen --write public/ генерит ключ и кладёт hosted-файл в нужную директорию, key verify --host example.com --key $KEY дёргает GET по hosted-файлу и сверяет содержимое.

key verify стоит гонять до первого submit — чтобы убедиться, что key-файл реально отдаётся не только тебе из браузера, но и поисковикам. Иначе все submit’ы будут возвращать 403, а ты будешь чесать репу и читать changelog в поисках сломанного релиза.

Тонкие места, на которых легко споткнуться#

  • fetch-depth: 0 в actions/checkout. Без полной истории git diff против github.event.before молча отдаст пустоту: shallow-клон не видит предыдущий коммит. Симптом — submitted-count=0 на каждом пуше, и ты долго гадаешь, почему.
  • Первый пуш в ветке. github.event.before там — нули (0000000...), git diff 0000000..HEAD сваливается с fatal: bad revision. Прикрой fallback’ом на HEAD~1.
  • CDN кеширует hosted key-файл. Cloudflare, Fastly и прочие радостно складывают .txt в кеш на пять-десять минут. Поменял ключ — поисковик в это окно ловит старую версию и отбивает 403 на твой свежий submit. После ротации — явный purge, без вариантов.
  • <lastmod> без timezone. sitemap-since ждёт RFC3339. Если Hugo (или чем ты там собираешь sitemap) пишет 2026-06-02T14:00:00 без Z и без смещения, фильтр поедет на таймзоне раннера: на GitHub-hosted это UTC, у себя на сервере — что настроил. Один и тот же sitemap начнёт давать разные срезы в зависимости от того, где крутится workflow.
  • Параллельные эндпоинты ≠ страховка от лимитов. Отправил в bing,yandex,naver — формально получишь три ответа, но Bing всё равно поделится submission’ом с остальными. Если поисковик отбил 429, лучше уменьшить частоту запусков, чем долбить параллельно во все доступные API.

Где взять и куда нести issue#

Если интегрируешь — поделись опытом в issues или прямо в комментариях. Особенно интересны истории про GitLab/Drone: под GitHub Action у меня самые подробные тесты, а на других CI оно проверяется реже.

PS: расскажи, как у тебя сейчас устроена индексация — Search Console + ручной запрос, sitemap-only, или что-то более хитрое? И насколько вообще IndexNow дал прирост по скорости индексации? Хочется собрать чуть более широкие данные, чем у меня одного.


Если у тебя есть вопросы, комментарии и/или замечания – заходи в чат , а так же подписывайся на канал .

О способах отблагодарить автора можно почитать на странице “Донаты ”.