The Jerusalem PostApple's predictive text restrictions include 'Dictator Trump' and 'Sleepy Joe,' researcher findsInquirerKeep Christmas parties simple as peak of Super El Niño looms – PalaceBollywood HungamaAjay Devgn’s Devgn CineX expands footprint with new multiplexes in Gurugram and RajkotDaily MaverickENVIRONMENTAL JUSTICE: African climate voices demand fair funding and influence before COP31ZDF heuteAktuelle Pressemitteilungen des ZDFScreen RantABC Paves The Way For Grey’s Anatomy’s Forgotten Best Chief’s Return To PowerCollider’Obsession’s Breakout Star Is Officially Breaking Records AgainSCMP ChinaA kill switch for epilepsy? China cuts seizures by 92.6% in first human chemogenetic trialDeadlineInternational Insider: Russell Brand Guilty; AFM Fires Up; Trudie Styler InterviewVanguardTroops nab notorious terrorist in KadunaESPNThe Bilas Index: The best 65 men's basketball teams in 2026-27Capital FMThree still missing as rescue teams call off Eastleigh collapsed building search
The Daily Newsstand · Free, Always
Friday, November 6, 2026

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

Translate

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.