На одной странице нашего сайта (WordPress, финансовая тематика) разметка FAQPage содержала восемь вопросов, которых на самой странице не было. Среди них, например, «брокер поможет в 99% случаев». Посетитель этого не видел. Поисковики и нейросети, которые читают JSON-LD, видели прекрасно. Обещание, которое мы давно вычистили из текстов, спокойно жило в разметке.

Расскажу, откуда брались лишние схемы, как я оставил на странице ровно одну FAQPage и на что наступил по дороге. Код упрощён и обезличен, но рабочий.

Откуда на странице несколько FAQPage

Нашлось три источника.

Первый: старые поля темы. В теме есть отдельные поля для вопросов-ответов (faq_*), и из них она собирает FAQPage. На одной странице эти поля так и остались заполнены старыми вопросами, хотя текст давно переписали. Тема честно продолжала отдавать схему из полей.

Второй: ручные JSON-LD в текстах. В разное время схему вставляли прямо в контент записи, и таких вставок набралось 57. Вопросы на странице правят, а блок <script type="application/ld+json"> внутри текста никто не открывает. На одной странице ручная схема содержала 8 старых вопросов, при том что на самой странице было 9 совсем других.

Третий оказался самым забавным. На статьях блога тема строила FAQPage из всех <details> подряд, включая оглавление: оно у нас тоже свёрстано раскрывашкой. В итоге в разметке появлялся «вопрос» с текстом «Содержание статьи».

Был и обратный случай: на двух страницах вопросы есть, а схемы нет вообще.

Расхождение разметки с видимым контентом уже само по себе проблема: правила Google для структурированных данных требуют, чтобы размеченное было видно пользователю. Расширенные сниппеты FAQ Google с 2023 года показывает только авторитетным государственным и медицинским сайтам, так что на сниппеты мы не рассчитываем. Разметку держим ради согласованности и ради ИИ-ответов, которые её читают. А раз держим, она должна быть честной.

Источник: то, что видит человек

Вопросы на странице у нас свёрстаны одинаково:

<details class="faq-i" open>
  <summary>Вопрос так, как его задают в поиске?</summary>
  <div class="faq-a">
    <p>Прямой ответ первым предложением, дальше подробности.</p>
  </div>
</details>

Класс faq-i на элементе и обёртка faq-a у ответа обязательны. В реальном проекте у классов есть префикс, здесь я его убрал. Правило получилось такое:

  • если есть хотя бы два details.faq-i, FAQPage собирается из них;

  • все остальные FAQPage на странице удаляются, откуда бы ни пришли;

  • если блоков меньше двух, страницу не трогаем вообще.

Порог в два вопроса отсекает случайный одиночный блок, который на FAQ не тянет.

Почему буфер вывода

Первая мысль была повесить всё на wp_head или wp_footer. Не вышло. Чтобы собрать вопросы, нужен отрендеренный контент, а в wp_head его ещё нет. В wp_footer можно прогнать the_content ещё раз, но это второй рендер со всеми шорткодами. И главное, ручные схемы сидят внутри контента, а схема темы выводится где-то в шаблоне. Убрать и то и другое можно только из итогового HTML.

Поэтому сниппет открывает буфер вывода на template_redirect, в колбэке получает весь HTML страницы, правит его и вставляет свою разметку перед </body>.

template_redirect ──> ob_start(callback)
                          │
        тема рендерит страницу: шаблон + контент + схемы из полей
                          │
callback($html):
   1. собрать details.faq-i  ── меньше 2? ──> вернуть как есть
   2. вырезать все прочие FAQPage (отдельные блоки, узлы @graph, списки)
   3. вставить одну FAQPage перед </body>

Код

<?php
// Одна FAQPage на страницу. Источник: видимые <details class="faq-i">.
add_action('template_redirect', function () {
    if (is_admin() || wp_doing_ajax() || is_feed() || wp_is_json_request() || !is_singular()) {
        return;
    }
    ob_start('faq_one_schema');
}, 0);

function faq_one_schema(string $html): string {
    $items = faq_collect($html);
    if (count($items) < 2) {
        return $html;                       // своих вопросов нет, ничего не трогаем
    }
    $html = faq_strip_others($html);
    $json = wp_json_encode([
        '@context'   => 'https://schema.org',
        '@type'      => 'FAQPage',
        'mainEntity' => $items,
    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $tag = '<script type="application/ld+json">' . $json . '</script>' . "\n";
    $pos = strripos($html, '</body>');
    return $pos === false ? $html : substr_replace($html, $tag, $pos, 0);
}

function faq_collect(string $html): array {
    $re = '~<details\b[^>]*\bclass=["\'][^"\']*\bfaq-i\b[^"\']*["\'][^>]*>(.*?)</details>~si';
    if (!preg_match_all($re, $html, $m)) {
        return [];
    }
    $allowed = ['p' => [], 'br' => [], 'ul' => [], 'ol' => [], 'li' => [],
                'strong' => [], 'b' => [], 'em' => [], 'a' => ['href' => true]];
    $items = [];
    foreach ($m[1] as $inner) {
        if (!preg_match('~<summary\b[^>]*>(.*?)</summary>~si', $inner, $q)
            || !preg_match('~<div\b[^>]*\bclass=["\'][^"\']*\bfaq-a\b[^"\']*["\'][^>]*>(.*)</div>~si', $inner, $a)) {
            continue;
        }
        $question = trim(html_entity_decode(wp_strip_all_tags($q[1]), ENT_QUOTES, 'UTF-8'));
        $answer   = trim(wp_kses($a[1], $allowed));
        if ($question !== '' && $answer !== '') {
            $items[] = [
                '@type'          => 'Question',
                'name'           => $question,
                'acceptedAnswer' => ['@type' => 'Answer', 'text' => $answer],
            ];
        }
    }
    return $items;
}

function faq_is($node): bool {
    $t = is_array($node) ? ($node['@type'] ?? '') : '';
    return $t === 'FAQPage' || (is_array($t) && in_array('FAQPage', $t, true));
}

// Убираем все остальные FAQPage: отдельные блоки, узлы в @graph, элементы списка.
function faq_strip_others(string $html): string {
    $re = '~<script\b[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>~si';
    return preg_replace_callback($re, function ($m) {
        $data = json_decode(trim($m[1]), true);
        if (!is_array($data)) {
            return $m[0];                   // не разобрали, лучше не трогать
        }
        if (faq_is($data)) {
            return '';
        }
        $changed = false;
        if (array_is_list($data)) {
            $kept    = array_values(array_filter($data, fn($n) => !faq_is($n)));
            $changed = count($kept) !== count($data);
            $data    = $kept;
        } elseif (isset($data['@graph']) && is_array($data['@graph'])) {
            $kept    = array_values(array_filter($data['@graph'], fn($n) => !faq_is($n)));
            $changed = count($kept) !== count($data['@graph']);
            $data['@graph'] = $kept;
        }
        if (!$changed) {
            return $m[0];
        }
        return $data ? '<script type="application/ld+json">'
            . wp_json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>' : '';
    }, $html);
}

Несколько мест, которые требуют пояснений.

Регулярки вместо DOM. Разобрать страницу через DOMDocument можно, и для чужой непредсказуемой вёрстки это надёжнее. Но у него свои радости: libxml не знает тегов HTML5 и сыплет предупреждениями, без явной кодировки ломается кириллица, а saveHTML() переформатирует страницу целиком. Мне нужно было вырезать и вставить несколько фрагментов, не трогая остальной HTML. Вёрстку вопросов мы контролируем сами, поэтому здесь регулярки. Если у вас вопросы вставляют разные редакторы кто как привык, я бы выбрал DOM.

\bfaq-i\b. Граница слова срабатывает и на дефис, так что класс с префиксом вроде prefix-faq-i найдётся, а faq-item нет.

Ответ с HTML. Google допускает в тексте ответа ограниченный набор тегов: абзацы, списки, ссылки, жирный, курсив. wp_kses оставляет только их.

@graph. SEO-плагины часто отдают одну большую схему с массивом @graph, и удалять её целиком нельзя: там WebPage, Organization и всё остальное. Поэтому из @graph вырезается только узел FAQPage, а блок перекодируется обратно.

Если чужой блок не разобрался через json_decode, сниппет его не трогает. Лучше оставить мусор, чем сломать страницу.

Кэш

Страничные кэши обычно открывают свой буфер раньше, чем срабатывает template_redirect. Наш буфер оказывается вложенным: сначала отрабатывает наш колбэк, потом кэш сохраняет уже исправленный HTML. В кэш попадает правильная версия, и регулярки не гоняются на каждый просмотр. Одно но: после включения сниппета кэш надо сбросить, иначе ещё долго будут отдаваться старые страницы. Я про это сначала забыл и минут десять не понимал, почему ничего не изменилось.

Чего я делать не стал

Удалять 57 ручных схем из текстов. Они остались в базе, сниппет просто подменяет их на выводе. Выключил сниппет, и всё вернулось как было, руками ни одну запись не правили. Обратная сторона тоже понятна: в базе лежит мусор, и если сниппет когда-нибудь отключат, старые схемы вылезут снова. Тексты можно почистить потом, без спешки.

Чистить старые поля темы. По той же причине: вывод из них сниппет убирает, а сами поля пусть лежат.

Проверка

После включения я проверил семь страниц. На каждой ровно одна FAQPage, и вопросы в ней совпадают с теми, что на странице. Первую версию сниппета (она умела только достраивать схему там, где её не было) до этого гонял на двух страницах, с 8 и 10 вопросами, обе собрались правильно.

Для проверки пригодился маленький скрипт: он считает FAQPage на странице и сравнивает её с видимыми вопросами.

<?php
// php faqcheck.php https://example.ru/page/
$html = file_get_contents($argv[1]);
preg_match_all('~<script[^>]*application/ld\+json[^>]*>(.*?)</script>~si', $html, $m);
$schemas = [];
foreach ($m[1] as $raw) {
    $d = json_decode(trim($raw), true);
    if (!is_array($d)) {
        continue;
    }
    $nodes = array_is_list($d) ? $d : ($d['@graph'] ?? [$d]);
    foreach ($nodes as $n) {
        if (($n['@type'] ?? '') === 'FAQPage') {
            $schemas[] = array_map(fn($q) => trim($q['name']), $n['mainEntity'] ?? []);
        }
    }
}
preg_match_all('~<details[^>]*faq-i[^>]*>\s*<summary[^>]*>(.*?)</summary>~si', $html, $s);
$visible = array_map(fn($x) => trim(html_entity_decode(strip_tags($x), ENT_QUOTES, 'UTF-8')), $s[1]);

echo 'FAQPage на странице: ' . count($schemas) . PHP_EOL;
echo 'Видимых вопросов: ' . count($visible) . PHP_EOL;
if (count($schemas) === 1) {
    echo 'Нет на странице: ' . implode(' | ', array_diff($schemas[0], $visible)) . PHP_EOL;
    echo 'Нет в схеме: ' . implode(' | ', array_diff($visible, $schemas[0])) . PHP_EOL;
}

Хороший результат выглядит так: «FAQPage на странице: 1» и две пустые строки разницы. Если FAQPage две и больше, то либо сниппет не сработал, либо страница пришла из старого кэша.

Валидатор Schema.org и проверка расширенных результатов Google ловят синтаксические ошибки. Но что вопрос в схеме устарел, они не скажут. Это видно только при сравнении со страницей.

На что наступил

Оглавление в <details>. Самое неочевидное. Если собирать схему из всех раскрывающихся блоков, оглавление становится вопросом. Для статей у нас отдельный сниппет, который берёт только блоки вопросов. Лечится тем же принципом: собирать строго по своему классу.

Вложенные <details> в ответе. Регулярка (.*?)</details> остановится на первом закрывающем теге, и ответ обрежется. Если в ответах бывают вложенные раскрывашки, нужен DOM-разбор или прямой запрет на них в правилах вёрстки.

Логика первой версии. Она только достраивала FAQPage, если её не было. На страницах со старой схемой сниппет молча ничего не делал, и устаревшие вопросы оставались. Пришлось перевернуть логику: видимые вопросы главнее любых других источников.

Микроразметка через атрибуты. Код выше работает только с JSON-LD. Если тема размечает FAQ через itemscope и itemtype, регулярками по <script> это не поймать, там нужен отдельный разговор.

Итог

Теперь на странице одна FAQPage, и собирается она из того, что видит человек. Ручные схемы и поля темы больше не могут разойтись со страницей: на выводе их просто нет. Редактор поменял вопрос в тексте, и схема поменялась вместе с ним.

Повлияло ли это на позиции или на ИИ-ответы, пока не скажу, эффект ещё замеряю. Но «99%» в разметке больше не висит, и ради одного этого сниппет стоило написать.

А у вас разметка FAQ откуда берётся: из плагина, из полей темы или тоже из вёрстки?

Комментарии (0)