Обычно поисковик узнаёт о новой странице сам: приходит по расписанию, читает 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 его дополняет, а не заменяет.