На этом блоге много лет стоял All in One SEO. Бесплатная версия честно делала своё — заголовки, описания, карту сайта. Былf ещё пара плагинов SEO’шной тематики, и вот один из них везде, где только можно, вставлял рекламу платной версии себя-любимого. Не удивительно что однажды, прямо сегодня, мне вдруг захотелось провести оптимизацию этого зоопарка, а заодно проверить, сколько реально стоит написать объединённый плагин самому, если работать в паре с ИИ-агентом. С нуля! Без чужого кода.
Так вышел SEOJak: один файл на PHP, 675 строк, без единого баннера. Он заменил сразу: All in One SEO, Simple 301 Redirects и BetterLinks. На всё ушёл один день: от инвентаризации до переключения живого сайта. Ниже — как это было устроено, ключевой код и грабли, на которые мы наступили.
Сначала опись: что плагин делает на самом деле
Первое, что я попросил у агента (работал с Claude Code), — не писать код, а выяснить, чем All in One SEO занят на моём сайте. Список функций в описании плагина ничего не говорит: важно, что из этого включено и чем заполнено.
Агент сделал три вещи:
- снял «слепки»
<head>с 14 страниц разных типов: главная, запись, рубрика, автор, поиск, вторая страница ленты, 404, а заодно ответы/sitemap.xmlиrobots.txt; - выгрузил настройки плагина из базы (это JSON в опции
aioseo_options); - посмотрел таблицу
wp_aioseo_posts, где хранятся данные каждой записи.
Таблица оказалась показательной: 65 колонок на запись — ключевые фразы, оценки, настройки соцсетей, схемы. Заполнено из этого у меня было три вещи: 49 своих заголовков, 51 описание и 4 записи с запретом индексации. Ни одного своего canonical, ни одной ключевой фразы.
Попутно нашлись две странности самого All in One SEO. Вторая страница ленты отдавала noindex, nofollow, то есть запрещала поисковику идти по ссылкам на старые записи. А в Twitter-карточке главной висело старое название блога.
Из описи получилось техническое задание: всё, что реально видно в <head>, плюс блок в редакторе, плюс перенос данных. Остальное — мимо.
Что умеет SEOJak
- Блок «SEO» в редакторе записи: свой заголовок, описание, галочка «не индексировать», счётчики символов и предпросмотр сниппета.
<title>, meta description, robots, canonical, ссылки на соседние страницы ленты.- Open Graph и Twitter-карточки. Картинка — превью записи, если оно есть.
- Разметка Schema.org одним блоком JSON-LD: сайт, автор, страница, статья, хлебные крошки.
- Карта сайта — встроенная в WordPress, только без лишнего.
- IndexNow: после публикации плагин сам сообщает Яндексу новый адрес.
- Редиректы 301 списком в настройках.
- Перенос данных из All in One SEO и Simple 301 Redirects при активации.
Без чужого кода
Важный момент: плагин написан не по исходникам All in One SEO, а по поведению — по тому, что видно в <head> и что лежит в базе, — а не пересказом чужих исходников. Так проще и с юридической стороны, и с технической: ничего лишнего не тянется.
Всё строится на штатных механизмах WordPress — там, где он уже умеет, плагин только подправляет.
Заголовок — фильтр pre_get_document_title. Тема просто объявляет add_theme_support('title-tag'), остальное делает ядро:
add_filter( 'pre_get_document_title', function ( $title ) {
if ( ! seojak_front_enabled() ) {
return $title;
}
$t = seojak_context()['title'];
return '' !== $t ? $t : $title;
}, 20 );
seojak_context() — одна функция, которая один раз за запрос решает, что за страница открыта (главная, запись, рубрика, автор, поиск), и собирает для неё заголовок, описание, адрес, картинку и признак noindex. Из неё потом берут данные и title, и мета-теги, и Schema.org — поэтому они не расходятся между собой.
Robots — через фильтр wp_robots, который появился в WordPress 5.7. Не нужно печатать свой тег и бороться с дублем от ядра:
add_filter( 'wp_robots', function ( array $robots ) {
if ( seojak_front_enabled() && seojak_context()['noindex'] ) {
$robots['noindex'] = true;
$robots['follow'] = true;
}
return $robots;
} );
follow здесь не случайно: страница может не попадать в выдачу, но поисковик должен идти по её ссылкам дальше. Именно это сломал All in One SEO на второй странице ленты.
Карта сайта. С версии 5.5 у WordPress есть своя, /wp-sitemap.xml. Её недостаток — по умолчанию туда попадает всё: метки, авторы, вложения. Четыре фильтра — и в карте только записи, страницы и рубрики, без записей с noindex:
add_filter( 'wp_sitemaps_post_types', function ( $types ) {
return array_intersect_key( $types, array_flip( array( 'post', 'page' ) ) );
} );
add_filter( 'wp_sitemaps_taxonomies', function ( $taxonomies ) {
return seojak_opt( 'noindex_category' ) ? array() : array_intersect_key( $taxonomies, array( 'category' => 1 ) );
} );
add_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {
return 'users' === $name ? false : $provider;
}, 10, 2 );
add_filter( 'wp_sitemaps_posts_query_args', function ( $args ) {
$args['meta_query'] = array(
'relation' => 'OR',
array( 'key' => '_seojak_noindex', 'compare' => 'NOT EXISTS' ),
array( 'key' => '_seojak_noindex', 'value' => '1', 'compare' => '!=' ),
);
return $args;
} );
Старый адрес /sitemap.xml, который знают поисковики, отдаёт 301 на новую карту.
Данные записи лежат в обычных метаполях: _seojak_title, _seojak_description, _seojak_noindex. Никаких своих таблиц. Подчёркивание в начале скрывает их из блока «Произвольные поля», а register_post_meta с show_in_rest открывает их для REST API — так я заполняю SEO-описание прямо при заливке черновика скриптом.
IndexNow: Яндекс узнаёт о посте сразу
IndexNow — протокол, по которому сайт сам говорит поисковику: «вот этот адрес изменился». Яндекс его поддерживает. Нужен ключ — случайная строка, которая лежит в файле на сайте и тем доказывает, что пингует владелец. Файл плагин отдаёт сам, без записи на диск, а пинг уходит после публикации или обновления записи:
$response = wp_remote_post( 'https://yandex.com/indexnow', array(
'timeout' => 5,
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
'body' => wp_json_encode( array(
'host' => wp_parse_url( home_url(), PHP_URL_HOST ),
'key' => $key,
'keyLocation' => home_url( "/$key.txt" ),
'urlList' => array( $url ),
) ),
) );
Два ограничителя. Пинг не чаще раза в 10 минут на запись: кнопку «Обновить» жмут по нескольку раз подряд. И только на боевом сайте (wp_get_environment_type() === 'production'), чтобы локальная копия не дёргала Яндекс адресами вида 127.0.0.1.
Переключение без пустого окна
Самое нервное в замене SEO-плагина — момент переключения: секунда, когда старый уже выключен, а новый ещё не настроен, и поисковик приходит на страницу без описания. Поэтому SEOJak, пока All in One SEO активен, на сайте молчит:
function seojak_front_enabled(): bool {
return ! defined( 'AIOSEO_VERSION' ) && ! is_admin() && ! is_feed();
}
Порядок получился такой:
- Плагин проверен на локальной копии WordPress (SQLite вместо MySQL, WP-CLI, PHP 8.5), блок в редакторе — в безголовом Chrome.
- Залит на сайт и включён рядом со старым. При активации он забрал данные из
wp_aioseo_postsи настройки главной. Проверка: 49 заголовков, 51 описание, 4 noindex — сошлось один в один. - All in One SEO выключен, кэш сброшен,
<head>снят заново и сверен со слепками. - В
robots.txtстрокаSitemap:переведена на новую карту.
Откат на любом шаге — включить старый плагин: его таблицы в базе остались.
Перенос данных — обычный SQL по чужой таблице, с одной тонкостью: запрет индексации в All in One SEO действует, только если у записи снята галочка «как по умолчанию».
$rows = $wpdb->get_results( "SELECT post_id, title, description, robots_noindex, robots_default
FROM `$table`
WHERE (title <> '' AND title IS NOT NULL)
OR (description <> '' AND description IS NOT NULL)
OR (robots_noindex = 1 AND robots_default = 0)" );
Уже заполненные поля SEOJak перенос не трогает — его можно запускать повторно (wp seojak import-aioseo).
Грабли №1: поле есть, а скрипт его не видит
Блок в редакторе проверял скриптом в безголовом Chrome: вписать заголовок, нажать «Обновить», убедиться, что сохранилось. Счётчики символов не заработали, а сохранённое значение читалось как undefined.
Причина: поле заголовка называлось id="seojak-title". А WordPress сам выдаёт заголовку каждого блока в редакторе id вида <id блока>-title. Блок называется seojak — значит, <h2 id="seojak-title"> на странице уже был, выше моего поля. getElementById возвращал его, а у <h2> нет .value.
Исправление — переименовать поля в seojak-f-title и seojak-f-desc. Урок шире: не называйте элементы внутри своего метабокса <id метабокса>-title — и вообще с этого префикса. WordPress занимает ещё -hide, -handlediv и -handle-order-….
Грабли №2: карта сайта отдаёт 404
После выключения All in One SEO адрес /sitemap.xml честно перенаправлял на /wp-sitemap.xml, а там — 404. Локально всё работало.
Причина: старый плагин, пока был включён, вычистил из правил перезаписи адресов (rewrite rules) правило встроенной карты — чтобы не мешала его собственной. После выключения правило само не вернулось: WordPress хранит их в базе и пересобирает только по запросу.
Лечится одной командой — она пересобирает правила в базе и не трогает .htaccess:
wp rewrite flush
Без WP-CLI то же самое делает кнопка «Сохранить» в «Настройки → Постоянные ссылки». Если после смены SEO-плагина карта сайта пропала — начните с этого.
Грабли №3: «следующая страница» у ленты из одной страницы
Ссылки rel="next" и rel="prev" подсказывают поисковику соседние страницы ленты. Первая версия ставила next даже там, где страница одна. Условие было «текущая страница меньше числа страниц», а на первой странице get_query_var('paged') возвращает не 1, а 0. Ноль меньше единицы — ссылка на несуществующую вторую страницу готова.
max( 1, get_query_var( 'paged' ) ) < $GLOBALS['wp_query']->max_num_pages
Нашлось на тестовом сайте, где записей меньше, чем помещается на страницу. На живом блоге записей много, и баг прожил бы долго.
Как работать с ИИ-агентом на живом сайте
Собственно, ради этого раздела пост и в рубрике «ИИ-агенты». Агент работал по SSH и WP-CLI на хостинге и через REST API, а не «советовал в чате». Чтобы это не превратилось в русскую рулетку, у нас были правила:
- Сначала опись, потом план, потом код. План я утверждал словами («да, как написано»), и только после этого агент писал плагин.
- Сначала локальная копия. Любая правка проверяется на локальном WordPress, а не на боевом.
- Каждая правка — коммит и сборка. Файлы на сервер идут из сборки по коммиту, а копия заменяемого файла с сервера сохраняется рядом. Контрольная сумма после заливки сверяется.
- База — только по просьбе и с бэкапом. Перед удалением 949 ревизий агент сначала выгрузил полный дамп базы.
- Решения, которые нельзя откатить, принимаю я. Переключение плагина, открытие рубрик для индексации и удаление старых партнёрских ссылок агент сделал только после моего «да».
Где агент ошибался: id в редакторе (грабли №1) и rel="next" (грабли №3) — его баги, но их же поймали его собственные проверки до выкладки. Где он был полезнее меня: опись. Я бы не стал вручную разбирать 65 колонок чужой таблицы и сравнивать <head> на 14 страницах — а именно это показало, что от огромного плагина мне нужен десяток функций.
И честно о цене. Агент снимает с меня руки, но не голову: план, проверки и каждое «да» остаются на мне. SEOJak писался в выходные, которые я собирался провести без работы, — «пусть вкалывают роботы». Роботы вкалывали, а параллельно заканчивались токены, и к вечеру воскресенья закончился и я сам. ИИ ускоряет работу, но не отменяет усталость от неё.
Как поставить у себя
Нужны WordPress 6.4+ и PHP 7.4+.
- Скачайте
seojak.phpи папкуassetsиз репозитория вwp-content/plugins/seojak/. - Включите плагин. Если стоит All in One SEO — данные перенесутся сами, а SEOJak будет молчать, пока вы не выключите старый плагин.
- Загляните в «Настройки → SEOJak»: заголовок и описание главной, автор для Schema.org, картинка для соцсетей, какие архивы закрыть от индексации, редиректы.
- Выключите All in One SEO и проверьте
/wp-sitemap.xml. Если 404 —wp rewrite flushили «Сохранить» в настройках постоянных ссылок. - В
robots.txtпоменяйте адрес карты на/wp-sitemap.xml.
Плагин писался под мой блог и проверен на нём. На сайтах с WooCommerce, мультиязычностью или своими типами записей он скорее всего заработает, но карту и разметку для них придётся дописывать.
Итог
Было: три плагина, реклама в админке, 65 колонок на запись, из которых я пользовался тремя. Стало: один файл на 675 строк, который делает ровно то, что видно в <head> и нужно поисковику, и который я понимаю целиком.
Урок шире SEO: прежде чем менять большой плагин на свой (или на другой большой), снимите опись — что из него реально включено и заполнено. Обычно это малая часть, и замена оказывается проще, чем кажется.
Если ставите SEOJak и что-то не заводится — пишите в комментариях, разберёмся.
Как эти выходные выглядели изнутри — в дневнике: «Наверное, в душе я немножко не котик».