Обычно поисковик узнаёт о новой странице сам: приходит по расписанию, читает sitemap, сравнивает. На маленьком сайте это занимает от нескольких дней до пары недель. IndexNow переворачивает схему: вы сами отправляете список изменившихся адресов и не ждёте обхода.

Протокол простой до неприличия - один POST с JSON. Сложность не в нём, а в двух местах, где легко выстрелить себе в ногу. Про них ниже.

Ключ и файл подтверждения

Нужен ключ - произвольная строка из 8-128 шестнадцатеричных символов:

php -r 'echo bin2hex(random_bytes(16));'

Дальше самое важное, из-за чего чаще всего ничего не работает: тот же ключ должен лежать в файле по адресу https://вашдомен/<ключ>.txt. Так поисковик проверяет, что доменом распоряжаетесь вы, а не случайный человек, решивший поспамить чужими адресами. Файл содержит ровно ключ, без переносов и кавычек.

Ключ при этом не секрет: он публикуется по протоколу и виден любому. Поэтому файл спокойно лежит в репозитории, а в .env дублируется значение:

'indexnow' => [
    'key' => env('INDEXNOW_KEY'),
],

Пустой ключ удобно использовать как выключатель: нет значения - отправка не происходит.

Команда отправки

Эндпоинтов три: Bing, Yandex и api.indexnow.org. Формально достаточно любого - участники раздают заявки друг другу. На практике я шлю во все три: стоит это ноль, а поведение предсказуемее.

$response = Http::timeout(10)->asJson()->post($endpoint, [
    'host'    => parse_url(config('app.url'), PHP_URL_HOST),
    'key'     => $key,
    'urlList' => array_values($urls),
]);

Ответ 200 значит «принято», 202 - «принято, ключ ещё проверяется». Оба считаем успехом. Всё остальное пишем в лог, но не роняем: не отправленный пинг - не повод ломать сохранение статьи.

У команды два режима: конкретные адреса аргументами и --all, который собирает все публичные страницы - лендинг, список блога и опубликованные статьи, в двух языках. Второй режим нужен для еженедельной подстраховки.

Ловушка первая: счётчик просмотров

Логично повесить отправку на обсервер модели:

public function saved(BlogPost $post): void
{
    // так делать не надо
    Artisan::call('indexnow:ping', ['urls' => [url('/blog/'.$post->slug)]]);
}

И это работает ровно до момента, когда на статью заходит первый читатель. Дело в том, что счётчик просмотров у меня инкрементится на самой модели:

$this->increment('views');

А increment() в Eloquent - это обычный апдейт, который поднимает события updated и saved. То есть каждый заход на статью означал бы три HTTP-запроса в поисковики. Сто просмотров в день - триста запросов ни о чём, и очень быстрый способ, чтобы вас перестали слушать.

Лечится сверкой того, что реально изменилось:

$changed = array_diff(array_keys($post->getChanges()), ['views', 'updated_at']);

// изменился только счётчик - сообщать нечего
if (! $post->wasRecentlyCreated && ! $changed) {
    return;
}

// черновик, который черновиком и остался
if (! $post->status && ! in_array('status', $changed, true)) {
    return;
}

Вторая проверка тоже с нюансом. Снятие публикации я намеренно отправляю: поисковику полезно узнать, что страница теперь отдаёт 404, - а вот правки в черновике не должны никуда уходить.

Ловушка вторая: тесты

Обсервер подключился, тесты позеленели - и прогон стал занимать 18 секунд вместо одной. Причина та же самая: каждый тест, создающий статью, честно стучался в три реальных поисковика и ждал ответа.

Лечится одной строкой в phpunit.xml:

<env name="INDEXNOW_KEY" value=""/>

Пустой ключ выключает отправку, о чём мы позаботились в самом начале. А там, где отправку нужно проверить, ключ подставляется в тесте и запросы перехватываются через Http::fake().

Чтобы админка не ждала поисковики

Три HTTP-запроса с таймаутом в 10 секунд внутри сохранения формы - это до половины минуты ожидания в худшем случае. Очередь заводить ради этого не хочется, воркер на маленьком сайте лишняя сущность.

Подходит defer(): коллбэк выполняется после того, как ответ ушёл в браузер.

defer(function () use ($urls) {
    Artisan::call('indexnow:ping', ['urls' => $urls]);
});

В тестах отложенные вызовы нужно выполнять сразу, иначе проверять будет нечего - для этого есть $this->withoutDefer().

Расписание как подстраховка

Разовая отправка может не дойти: сеть моргнула, сервис ответил ошибкой. Поэтому раз в неделю прогоняется полный список:

Schedule::command('indexnow:ping --all')
    ->weeklyOn(1, '05:00')
    ->withoutOverlapping();

withoutOverlapping() здесь не про долгие задачи. У меня в кроне оказались две записи schedule:run для одного приложения - одна от панели управления, вторая добавлена мимо неё. Обе срабатывают в одну секунду, и без блокировки любая задача выполнялась бы дважды.

Чего ждать

IndexNow не гарантирует индексацию и не влияет на позиции. Он сокращает путь: поисковик узнаёт об изменении сразу, а не когда дойдёт очередь. Для блога, который обновляется раз в неделю, это разница между «через час» и «через неделю».

Google протокол не поддерживает, так что sitemap никуда не девается. IndexNow его дополняет, а не заменяет.