Поиск по сайту с учётом контента PageBlocks в MODX 3

modx search Курс

PageBlocks Free хранит контент не в обычном поле content, а в отдельной таблице с JSON-данными. Это удобно для верстки и повторного использования элементов, но создаёт проблему: стандартный поиск по сайту не «видит» текст внутри этих блоков.

Ниже показано, как сделать простой, но рабочий поиск, который будет находить страницы по тексту из PageBlocks, сохранять его в TV-поле и использовать в Fenom-шаблоне страницы поиска.

Архитектура решения

Задача: научить MODX индексировать контент из таблицы modx_pb_block_values (PageBlocks) и использовать его в поиске.

Будем действовать так:

  • создаём отдельное TV-поле, куда будет складываться «плоский» текст из всех блоков PageBlocks ресурса;
  • пишем плагин на событие OnDocFormSave, который:
    • берёт все блоки PageBlocks для ресурса;
    • извлекает из JSON только текстовые поля;
    • очищает их от HTML и лишних данных;
    • записывает результат в TV pb_search_index;
  • один раз массово переиндексируем все существующие ресурсы через консольный код;
  • на странице поиска добавим условие, чтобы поиск шёл не только по pagetitleintrotextcontent, но и по pb_search_index через pdoTools/Fenom.

В итоге администраторам по-прежнему удобно работать с PageBlocks, а пользователи получат полноценный поиск по всему контенту.

Шаг 1. Подготовка TV-поля для индекса

Сначала создаётся TV, в которое будет складываться свёрнутый текст из PageBlocks.

  1. Зайдите: Элементы → TV-переменные → Создать TV.
  2. Параметры:
    • Имя: pb_search_index
    • Заголовок: «Индекс контента PageBlocks» (любой удобный)
    • Тип ввода: Textarea или Text (можно потом скрыть в форме ресурса).
  3. Привяжите TV к нужным шаблонам (тем, где используется PageBlocks).
  4. Выходной тип: Текст / По умолчанию.

Ресурсам пока ничего руками заполнять не нужно — поле будет заполняться автоматически плагином.

Шаг 2. Плагин для индексации PageBlocks (OnDocFormSave)

Теперь создаётся плагин, который при каждом сохранении ресурса соберёт текст из PageBlocks и запишет в TV.

Событие: только OnDocFormSave — этого достаточно.

Код плагина

Создайте новый плагин, назовите, например, pbSearchIndexer и привяжите к событию OnDocFormSave.

<?php
/**
 * PageBlocks Search Indexer
 * События: OnDocFormSave
 */
if ($modx->event->name !== 'OnDocFormSave') {
    return;
}
// Защита от повторных вызовов
static $processing = array();
$resourceId = $resource->get('id');
if (isset($processing[$resourceId])) {
    return; // Уже обрабатывается
}
$processing[$resourceId] = true;
// Получаем данные из таблицы PageBlocks
$c = $modx->newQuery('pbBlockValue');
$c->where(array(
    'model_id' => $resourceId,
    'values:!=' => '',
    'published' => 1,
    'deleted' => 0
));
$blocks = $modx->getCollection('pbBlockValue', $c);
if (!empty($blocks)) {
    $textContent = '';
    // Проходим по всем блокам и извлекаем текст
    foreach ($blocks as $block) {
        $jsonContent = $block->get('values');
        if (!empty($jsonContent)) {
            $blockData = json_decode($jsonContent, true);
            if (is_array($blockData) && json_last_error() === JSON_ERROR_NONE) {
                extractTextFromBlock($blockData, $textContent);
            }
        }
    }
    // Удаляем лишние пробелы
    $textContent = preg_replace('/\s+/', ' ', trim($textContent));
    // Устанавливаем значение TV БЕЗ save() - сохранится автоматически
    $resource->setTVValue('pb_search_index', $textContent);
}
// Снимаем блокировку
unset($processing[$resourceId]);
/**
 * Рекурсивная функция для извлечения текста из структуры блоков
 */
function extractTextFromBlock($data, &$text) {
    // Технические поля, которые не нужно индексировать
    $skipFields = array('url', 'href', 'link', 'width', 'height', 'size', 'file', 
                       'icon_class', 'color', 'classes', 'attr', 'type',
                       'published', 'disabled', 'target_blank', 'menuindex', 
                       'rank', 'id', 'resource', 'parent', 'limit', 'image');
    if (is_array($data)) {
        foreach ($data as $key => $value) {
            // Пропускаем технические поля
            if (in_array($key, $skipFields, true)) {
                continue;
            }
            if (is_string($value) && !empty(trim($value))) {
                $cleanText = strip_tags($value);
                $cleanText = html_entity_decode($cleanText, ENT_QUOTES, 'UTF-8');
                if (!empty(trim($cleanText)) && mb_strlen($cleanText) > 2) {
                    $text .= $cleanText . ' ';
                }
            } elseif (is_array($value)) {
                extractTextFromBlock($value, $text);
            }
        }
    } elseif (is_string($data) && !empty(trim($data))) {
        $cleanText = strip_tags($data);
        $cleanText = html_entity_decode($cleanText, ENT_QUOTES, 'UTF-8');
        if (!empty(trim($cleanText)) && mb_strlen($cleanText) > 2) {
            $text .= $cleanText . ' ';
        }
    }
}

Ключевые моменты:

  • нет вызова $resource->save() внутри события — так избегается бесконечное сохранение;
  • используется static $processing, чтобы не обрабатывать один и тот же ресурс дважды в рамках одного запроса;
  • из JSON фильтруются технические поля (url, href, размеры изображений и т.п.), а текст очищается от HTML.

После сохранения любого ресурса с PageBlocks TV pb_search_index должно заполняться автоматически.

Шаг 3. Массовая переиндексация существующих ресурсов (через Console)

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

  • найдёт все ресурсы, у которых есть блоки PageBlocks;
  • соберёт их текст;
  • заполнит TV pb_search_index напрямую через SQL.

Этот код удобно запускать через компонент Console в MODX Manager.

Код для Console

Откройте Console, вставьте:

<?php
/**
 * Переиндексация PageBlocks через Console
 */
set_time_limit(300);
// Получаем TV
$tv = $modx->getObject('modTemplateVar', ['name' => 'pb_search_index']);
if (!$tv) {
    return "❌ TV поле pb_search_index не найдено!";
}
$tvId = $tv->get('id');
$tvTable = $modx->getTableName('modTemplateVarResource');
// Получаем ресурсы с PageBlocks
$sql = "SELECT DISTINCT model_id 
        FROM {$modx->getTableName('pbBlockValue')} 
        WHERE deleted = 0 AND published = 1
        ORDER BY model_id";
$stmt = $modx->prepare($sql);
$stmt->execute();
$resourceIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo "Найдено ресурсов: " . count($resourceIds) . "\n\n";
$processed = 0;
$skipFields = ['url', 'href', 'link', 'width', 'height', 'size', 'file', 
               'icon_class', 'color', 'classes', 'attr', 'type',
               'published', 'disabled', 'target_blank', 'menuindex', 
               'rank', 'id', 'resource', 'parent', 'limit', 'image'];
foreach ($resourceIds as $resourceId) {
    // Получаем блоки
    $c = $modx->newQuery('pbBlockValue');
    $c->where([
        'model_id' => $resourceId,
        'values:!=' => '',
        'published' => 1,
        'deleted' => 0
    ]);
    $blocks = $modx->getCollection('pbBlockValue', $c);
    if (empty($blocks)) {
        echo "⚠ Ресурс #{$resourceId}: нет блоков\n";
        continue;
    }
    $textContent = '';
    // Извлекаем текст
    foreach ($blocks as $block) {
        $jsonContent = $block->get('values');
        if (empty($jsonContent)) continue;
        $blockData = json_decode($jsonContent, true);
        if (!is_array($blockData) || json_last_error() !== JSON_ERROR_NONE) continue;
        array_walk_recursive($blockData, function($value, $key) use (&$textContent, $skipFields) {
            if (in_array($key, $skipFields, true)) return;
            if (is_string($value) && mb_strlen(trim($value)) > 2) {
                $cleaned = strip_tags($value);
                $cleaned = html_entity_decode($cleaned, ENT_QUOTES, 'UTF-8');
                $cleaned = trim($cleaned);
                if (!empty($cleaned)) {
                    $textContent .= $cleaned . ' ';
                }
            }
        });
    }
    $textContent = preg_replace('/\s+/', ' ', trim($textContent));
    if (empty($textContent)) {
        echo "⚠ Ресурс #{$resourceId}: текст не извлечён\n";
        continue;
    }
    // Проверяем существование записи
    $checkSql = "SELECT id FROM {$tvTable} WHERE contentid = ? AND tmplvarid = ?";
    $checkStmt = $modx->prepare($checkSql);
    $checkStmt->execute([$resourceId, $tvId]);
    $exists = $checkStmt->fetchColumn();
    if ($exists) {
        // UPDATE
        $updateSql = "UPDATE {$tvTable} SET value = ? WHERE contentid = ? AND tmplvarid = ?";
        $updateStmt = $modx->prepare($updateSql);
        $result = $updateStmt->execute([$textContent, $resourceId, $tvId]);
    } else {
        // INSERT
        $insertSql = "INSERT INTO {$tvTable} (contentid, tmplvarid, value) VALUES (?, ?, ?)";
        $insertStmt = $modx->prepare($insertSql);
        $result = $insertStmt->execute([$resourceId, $tvId, $textContent]);
    }
    if ($result) {
        $processed++;
        echo "✓ Ресурс #{$resourceId}: " . mb_strlen($textContent) . " символов\n";
    } else {
        echo "❌ Ресурс #{$resourceId}: ошибка сохранения\n";
    }
}
// Очищаем кэш
$modx->cacheManager->refresh();
return "\n\n✅ Готово! Обработано: {$processed} из " . count($resourceIds);

Запустите, дождитесь окончания и выборочно проверьте несколько ресурсов: в TV pb_search_index должен появиться объединённый текст из всех блоков.

Шаг 4. Страница поиска на Fenom с учётом PageBlocks

Теперь, когда индекс PageBlocks лежит в TV, можно добавить его в where в pdoPage и использовать в шаблоне результатов. Логика похожа на урок с web-revenue: форма поиска → отдельная страница /search/ → Fenom-код с pdoPage для вывода результатов.

Форма поиска (в шапке)

Откройте базовый шаблон (base.tpl) найдите в нем форму поиска:

<form role="search" action="page-search-results.html" method="get">
    <div class="simple-search input-group">
        <input class="form-control text-1" id="headerSearch" name="q" type="search" value="" placeholder="Search...">
        <button class="btn" type="submit" aria-label="Search">
            <i class="fas fa-search header-nav-top-icon"></i>
        </button>
    </div>
</form>

И отредактируйте ее вот так:

<form role="search" action="/search/" method="get">
    <div class="simple-search input-group">
        <input class="form-control text-1" 
               id="headerSearch" 
               name="search" 
               type="search" 
               value="{$.get.search | escape}" 
               placeholder="Поиск по сайту...">
        <button class="btn" type="submit" aria-label="Search">
            <i class="fas fa-search header-nav-top-icon"></i>
        </button>
    </div>
</form>

Изменения:

  • action="/search/" вместо page-search-results.html
  • name="search" вместо name="q"
  • value="{$.get.search | escape}" для сохранения запроса

Форма поиска (в сайдбаре блога)

Откройте чанк elements/chunks/blog/sidebar.tpl и найдите там форму поиска

<form action="page-search-results.html" method="get">
	<div class="input-group mb-3 pb-1">
		<input class="form-control box-shadow-none text-1 border-0 bg-color-grey" placeholder="Search..." name="s" id="s" type="text">
		<button type="submit" class="btn bg-color-grey text-1 p-2"><i class="fas fa-search m-2"></i></button>
	</div>
</form>

И отредактируйте ее вот так:

<form action="/search/" method="get">
    <div class="input-group mb-3 pb-1">
        <input class="form-control box-shadow-none text-1 border-0 bg-color-grey" 
               placeholder="Поиск по блогу..." 
               name="search" 
               id="s" 
               type="text"
              value="{$.get.search | escape}">
        <button type="submit" class="btn bg-color-grey text-1 p-2">
            <i class="fas fa-search m-2"></i>
        </button>
    </div>
</form>

Изменения:

  • action="/search/"
  • name="search" вместо name="s"
  • value="{$.get.search | escape}"

Страница «Результаты поиска»

Создайте ресурс:

  • Заголовок: «Результаты поиска»
  • Псевдоним: search
  • Шаблон: любой Fenom-шаблон/статика
  • «Скрыть из меню», «Исключить из карты сайта» — по желанию.

В поле содержимого (Fenom) вставьте:

{set $query = $.get.search | escape}
{if $query?}
    <h1>Результаты поиска по запросу "{$query}"</h1>
    {set $results = '!pdoPage' | snippet : [
        'parents'       => 0,
        'includeContent'=> 1,
        'includeTVs'    => 'pb_search_index,img',
        'processTVs'    => 1,
        'tvPrefix'      => '',
        'limit'         => 10,
        'tpl'           => '@FILE chunks/search/searchResult.tpl',
        'where'         => [
            'pagetitle:LIKE'                 => '%' ~ $query ~ '%',
            'OR:introtext:LIKE'              => '%' ~ $query ~ '%',
            'OR:content:LIKE'                => '%' ~ $query ~ '%',
            'OR:pb_search_index:LIKE'     => '%' ~ $query ~ '%'
        ] | toJSON
    ]}
    {if $results?}
        {$results}
        {$page.nav}
    {else}
        <p>По запросу "{$query}" ничего не найдено.</p>
    {/if}
{else}
    <p>Введите запрос в форму поиска.</p>
{/if}

Здесь важные моменты:

  • includeTVs => 'pb_search_index,img' — чтобы TV было доступно в чанке результатов (например, для диагностики или подсветки);
  • в where добавлен OR:pb_search_index:LIKE, который как раз и даёт поиск по тексту PageBlocks;
  • tpl указывает на внешний файл-чанк Fenom для оформления карточки результата.

Пример чанка chunks/search/searchResult.tpl

<div class="row border-bottom mb-3 pb-3">
    <div class="col-12">
        <h3><a href="{$uri}">{$pagetitle}</a></h3>
        {if $introtext}
            <p>{$introtext}</p>
        {else}
            <p>{$content | strip_tags | truncate : 200 : '…'}</p>
        {/if}
    </div>
</div>

При желании можно сделать подсветку фрагмента из pb_search_index или добавить картинку из TV img, если она у вас уже используется в других чанках.

Шаг 5. Тестирование и отладка

Для проверки:

  1. Выберите уникальное слово из блока PageBlocks (которого нет в pagetitle/introtext/content).
  2. Убедитесь, что ресурс сохранён после установки плагина, либо переиндексирован через Console.
  3. В форме поиска введите это слово.
  4. Страница search должна показать нужный ресурс.

Если ресурс не находится:

  • проверьте значение TV pb_search_index у этого ресурса;
  • временно выведите {$pb_search_index} в чанке результата под заголовком, чтобы убедиться, что индекс действительно содержит нужный текст;
  • проверьте where в pdoPage — нет ли опечатки в имени TV.

Бонус: подсветка найденного текста (Highlighting)

Когда поиск находит страницу по содержимому PageBlocks, пользователь может не понять, где именно встретилось слово, если мы выведем просто introtext. Давайте сделаем «умный сниппет», который вырезает цитату вокруг найденного слова.

Создайте сниппет SearchHighlight:

<?php
/**
 * SearchHighlight
 * Вырезает кусок текста вокруг искомой фразы и подсвечивает её.
 *
 * @param string $text  Полный текст (content или индекс PageBlocks)
 * @param string $query Поисковый запрос
 * @param int $length   Длина возвращаемого фрагмента (символов)
 * @param string $tag   Тег для подсветки (по умолчанию strong)
 */

$text = strip_tags($text); // На всякий случай чистим
$query = trim($query);
$length = $modx->getOption('length', $scriptProperties, 200);
$tag = $modx->getOption('tag', $scriptProperties, 'strong');
$ellipsis = '...';

if (empty($query)) {
    return mb_substr($text, 0, $length) . $ellipsis;
}

// Ищем позицию первого вхождения (без учета регистра)
$pos = mb_stripos($text, $query);

if ($pos === false) {
    // Если слова нет в тексте (нашли по заголовку), отдаем начало
    return mb_substr($text, 0, $length) . $ellipsis;
}

// Вычисляем начало и конец обрезки
$start = max(0, $pos - ($length / 2));
$end = $start + $length;

// Обрезаем
$snippet = mb_substr($text, $start, $length);

// Если отрезали не с начала, добавим троеточие
if ($start > 0) {
    $snippet = $ellipsis . $snippet;
}
// Если текст продолжается дальше, добавим троеточие в конец
if ($end < mb_strlen($text)) {
    $snippet .= $ellipsis;
}

// Подсвечиваем искомое слово (сохраняя регистр оригинала)
// Используем preg_replace для регистронезависимой замены
$snippet = preg_replace(
    '/(' . preg_quote($query, '/') . ')/iu', 
    '<' . $tag . ' class="text-primary">$1</' . $tag . '>', 
    $snippet
);

return $snippet;

Обновляем чанк searchResult.tpl

Теперь используем этот сниппет в чанке вывода результатов. Логика такая:

  1. Если есть совпадение в pb_search_index, показываем фрагмент оттуда.
  2. Иначе показываем стандартный introtext или начало content.
<div class="row border-bottom mb-3 pb-3">
    <div class="col-12">
        <h3><a href="{$uri}">{$pagetitle}</a></h3>
        
        <p>
            {* Определяем, где искать текст для превью *}
            {var $sourceText = $_pls['tv.pb_search_index'] ?: $_pls['content']}
            
            {* Вызываем наш сниппет *}
            {'!SearchHighlight' | snippet : [
                'text' => $sourceText,
                'query' => $.get.search,
                'length' => 250
            ]}
        </p>
        
        <div class="text-muted text-1">
            <time datetime="{$publishedon | date : 'Y-m-d'}">
                {$publishedon | date : 'd.m.Y'}
            </time>
        </div>
    </div>
</div>

Итог

Таким образом, получился простой и расширяемый поиск по сайту:

  • контент из PageBlocks подтягивается из таблицы modx_pb_block_values, парсится из JSON и складывается в отдельное TV;
  • при сохранении ресурса индекс PageBlocks обновляется автоматически, без дополнительного кода в шаблонах;
  • поиск (через pdoPage и Fenom) ищет одновременно в pagetitleintrotextcontent и в индексе PageBlocks, что делает результаты гораздо полнее для лендингов и «конструкторных» страниц.

Дальше можно развивать решение: добавлять полнотекстовый поиск по этому TV, интегрировать с mSearch2 или выносить индексацию в очередь / cron для тяжёлых сайтов.

Оцените статью
MODX 3
Добавить комментарий

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.