Одна 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 откуда берётся: из плагина, из полей темы или тоже из вёрстки?
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.