Одна FAQPage на страницу: собираем разметку из видимых вопросов в WordPress

—

от автора

На одной странице нашего сайта (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 откуда берётся: из плагина, из полей темы или тоже из вёрстки?

ссылка на оригинал статьи https://habr.com/ru/articles/1104408/