Расширение проиндексированных заголовков индивидуальными поисковыми метками
Разбираем стандартный (общепринятый, популярный) подход расширения заголовка дополнительными словами, находим альтернативный и более продвинутый способ. Часть 2.
Вникаем в проблему, с которой нас сталкивает использование стороннего сервиса по полноте-кстовому поиску, находим варианты решения и допиливаем наш способ.
Поиск — один из важнейших сервисов любого сайта. Именно потому к поиску и его возможностям обычно много внимания. И чем вариативнее и «смышлённее» поиск, тем он полезнее.
В Битриксе поиск «среди контента» делится на поиск по заголовкам и общий поиск, при чём именно поиск по заголовкам находится на передовой. С задачей «искать в лоб» поиск справляется без проблем (разве что могут возникнуть вопросы к производительности хостинга или объёму данных), а вот усложнение условий, например чтобы поиск по заголовкам искал также по артикулу товара, бренду или, ещё сложнее, — по лингвистическим «синонимам» бренда (Самсунг = Samsung) — уже требует привлечение программиста. «Малой кровью» можно выкрутиться добавлением в заголовок товара всего, по чему должен быть произведён поиск (...и артикул ...и размер ...и бренды), но порой просто невозможно добавить всё, что только может понадобиться для поиска. Снижается привлекательность названий, страница начинает походить на поделку спаммеров и чёрных сеошников:
...Смартфон Samsung Galaxy S10 SM-G973 DS 128GB Green (SM-G973FZGD), акция, купить, скидка, скидкой, Самсунг, зелёный, зеленый, AMOLED, DYNAMIC AMOLED, амолед, Gorilla Glass, горилла горила гласс...
...
Данная статья предназначена программистам, в ней обсуждается способ обучения Битрикса искать по любой релевантной лингвистике, без искажения основного заголовка. В качестве целевой задачи-примера используем как раз поиск по синонимам бренда.
¶Задача
Научить «поиск по заголовкам» искать по дополнительным лингвистическим меткам.
¶Как поиск ищет
Чтобы понять, как мы могли бы «рассказать» поиску о том, какие слова использовать в качестве дополнительных меток (я не хочу создавать путаницу и использовать термин «теги», потому что такой термин уже существует в поисковой индексации Битрикс), зайдём с обратной стороны — поймём как поиск подбирает варианты в ответ на поисковый запрос.
Создадим элемент с заголовком, представленным выше. Переиндексируем поиск и заглянем в таблицу b_search_content_title:
Как видим, система разделила наш заголовок на составные части. И все они ведут на единую запись в таблице b_search_content:
Каждый раз, когда мы решаем воспользоваться поиском по заголовку (компонент bitrix:search.title), всё сводится к запросу вида:
То есть поисковый запрос раскладывается на слова и ищутся вхождения в таблице b_search_content_title. По результатам определяются строки из b_search_content, которые и возвращаются для отображения на странице.
Имеем: целевая запись одна, меток к ней — сколько угодно. То есть, в контексте задачи, если мы в таблице b_search_content_title обеспечим наличие меток, связанных с целевой записью, поиск сможет искать по ним.
¶Способ, который нам не подходит
Обычно, когда встаёт задача расширить возможности поиска (например, чтобы поиск по заголовкам искал и по бренду/артикулу/цвету/...), задачу решают в лоб — «на лету» дополняют заголовок, который готовится к индексации, нужными словами.
Для полноты картины, вкратце действия выглядят так:
-
подписываемся на событие
search/BeforeIndex
. В функцию-слушатель события в качестве аргумента приходит массив$arFields
, приготовленный к индексации; - по значению в ключе MODULE_ID определяем, какой модуль сейчас индексируется (в контексте обсуждаемой подзадачи, нас интересует модуль iblock);
- по значению в ключе ITEM_ID определяем код индексируемого элемента, читаем нужное свойство (бренд/цвет/артикул/...);
- дописываем в ключ TITLE хвост из дополнительных меток;
-
возвращаем
$arFields
в качестве результата поиска.
Заголовок уходит в механизм индексации, там формируется нужный набор слов, но и заголовок в поисковую выдачу уходит «с хвостом». Именно наличие этого хвоста нас не устраивает, потому мы пойдём другим путём.
Для полноты описания способа: если вы решите реализовывать данным способом, при выставлении заказчиком требования о том, чтобы в поисковой выдаче заголовок публиковался без хвоста, рассмотрите такое дополнение:
- когда мы склеиваем заголовок и хвост, размещаем между ними какой-то уникальный набор символов, например: «_/=\_». То есть на индексацию уходит: Заголовок _/=\_ хвост;
- перед публикацией результатов поиска на экран мы заголовки разрезаем по этому специальному набору символов и таким образом обеспечиваем и поиск по заголовку + хвосту, и публикацию заголовков в чистом виде.
¶Дополняем индексируемый заголовок метками
В процедуре индексации есть ещё одно событие: search/OnAfterIndexAdd
.
Оно срабатывает на каждом индексируемом элементе, но уже после того, как элемент проиндексирован. Нас ситуация устраивает, потому что согласно решению, к которому мы идём, нам нужно именно дополнить уже сформированные метки своими.
То есть если заголовок товара звучит так: Смартфон Samsung Galaxy S10 SM-G973 DS 128GB Green (SM-G973FZGD) ...нам и не нужно его менять. Нам достаточно иметь возможность дополнить набор слов, которые попали в b_search_content_title ещё и своими, связав с записью в b_search_content.
Событие OnAfterIndexAdd
через пробрасываемые подписчику переменные даёт
нам всё, что нужно:
всё тот же
массив
$arFields
и код ($ID
) записи из b_search_content.
Наша задача сводится к тому, чтобы прочитать
сформировать метки,
которыми мы дополним выдачу и доиндексировать их, с привязкой к целевой записи.
Наши действия:
-
подписываемся на событие
search/OnAfterIndexAdd
; -
в функции-слушателе проверяем ключи
$arFields
(нужный модуль? нужный инфоблок? ...другие условия); - составляем строку из дополнительных меток;
- до-индексируем составленный «хвост», с привязкой к целевой записи.
¶Подписываемся на событие search/OnAfterIndexAdd
Как правило, кастомное подписывание производят в файле
/bitrix/php_interface/init.php
В коде-примере ниже слушателем события является метод OnAfterIndexAdd класса \AlexeyGfi\SearchTitleExtender:
// Подписываемся на событие
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
'search', 'OnAfterIndexAdd',
['\AlexeyGfi\SearchTitleExtender', 'OnAfterIndexAdd']
);
Чтобы обеспечить автоподгрузку нашего класса, регистрируем его. В этом случае файл с кодом класса и его инициализация будут происходить только если к методу класса будет фактическое обращение:
// Регистрируем класс в автоподгрузчике Битрикса
\Bitrix\Main\Loader::registerAutoLoadClasses(
null,
[
'\AlexeyGfi\SearchTitleExtender' =>
'/bitrix/php_interface/include/lib/AlexeyGfi/SearchTitleExtender.php'
]
);
¶Слушаем событие и обрабатываем его
namespace AlexeyGfi;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;
class SearchTitleExtender
{
// Какие инфоблоки мы отслеживаем
protected static $targetIblocks = [19, 22];
/**
* @param $searchContentId
* @param $arFields
*/
public static function OnAfterIndexAdd($searchContentId, &$arFields)
{
/**
* Модуль: $arFields['MODULE_ID']
* Код элемента: $arFields['ITEM_ID']
* Код инфоблока: $arFields['PARAM2']
*/
if (
$arFields['MODULE_ID'] !== 'iblock' ||
!$arFields['ITEM_ID'] ||
!in_array($arFields['PARAM2'], self::$targetIblocks)
) {
return;
}
/**
* Метки, которые мы до-привяжем к тем,
* что были определены из заголовка
* и уже проиндексированы
*/
$additionalWords = []; // Реализуем под задачу
if (!empty($additionalWords)) {
\CSearch::IndexTitle(
$arFields["SITE_ID"],
$searchContentId,
implode(' ', $additionalWords)
);
}
}
}
Таким образом мы:
- не модифицируем оригинальный заголовок (в поисковую выдачу заголовок попадает в том виде, в каком он хранится в инфлоблоке);
- можем дополнять связанные с целевой записью любым количеством меток.
¶Столовая ложка дёгтя
Решение, описанное выше, не достаточно для тех битрикс-сайтов, в которых поиск работает через сторонний сервис (как правило — сфинкс/sphinxsearch), так называемый механизм полно-текстового поиска.
Если у нас подключен сфинкс, поиск происходит следующим образом:
-
поисковый запрос приходит в метод
Search
классаCAllSearchTitle
; - если подключен полно-текстовый поиск, запрос отдаётся ему;
- сфинкс, получив запрос, ищет в своей внутренней базе и в случае успеха, возвращает список ID (!) записей таблицы b_search_content;
- метод, получив набор кодов записей, читает их из таблицы b_search_content и возвращает ответ.
Соответственно, наша задача — найти способ во время индексации отдать сфинксу на индексацию заголовок с дополненными метками. И вот как раз тут нам не страшно отдать на индексацию заголовок с хвостом, потому что результат всё-равно возьмётся из b_search_content, а там у нас чистый заголовок.
Анализ кода в методе индексации
...показал, что сначала выполняется индексация сфинксом, а затем срабатывает событие search/OnAfterIndexAdd
,
на которое мы уже подписаны в init.php.
Таким образом, нам доступен финт, при котором мы можем дополнить заголовок своими метками и отдать его на повторную индексацию сфинксом. Да, двойная работа для сфинкса, но во-первых, это производится только при переиндексации, а во-вторых... сфинкс как раз и создан для индексации и поиска ¯\_(ツ)_/¯
Дополняем код в блоке с условием наличия дополнительных меток и получаем:
// Было
if (!empty($additionalWords)) {
\CSearch::IndexTitle(
$arFields["SITE_ID"],
$searchContentId,
implode(' ', $additionalWords)
);
}
// Стало
if (!empty($additionalWords)) {
\CSearch::IndexTitle(
$arFields["SITE_ID"],
$searchContentId,
implode(' ', $additionalWords)
);
$arFields["TITLE"] .= implode(' ', $additionalWords);
\CSearchFullText::getInstance()->replace($searchContentId, $arFields);
}
¶Чайная ложка дёгтя
Анализ нашего решения и тестирование поиска через сфинкс показывает, что индексация, которую мы подправили для полно-текстового поиска, работает только при полной переиндексации. Если же мы правим элемент инфоблока, тоже запускается переиндексация, но она идёт по другой логической ветке.
Алгоритм индексации в контексте этой ситуации построен следующим образом:
- приходит запрос на переиндексацию;
- код ищет, есть ли уже для текущей пары модуль + код элемента запись в таблице b_search_content;
- если записи нет, запускается ветка создания поискового индекса (в которую мы уже интегрировались);
-
если же запись уже есть, берётся её
$ID
и php-интерпретатор идёт по альтернативной ветке. И ложка дёгтя в этой ветке в том, что сначала наружу выбрасывается событиеsearch/OnBeforeIndexUpdate
, а уже после выполняется обновление поискового индекса как в базе данных так и в сфинксе:
...то есть мы по-прежнему можем добавить в базу данных Битрикс свои метки, но не можем перезаписать поисковый индекс в сфинксе.
Ухватываемся за условие «если же запись уже есть», вспоминаем про то, что на входе в метод
значится событие
search/BeforeIndex
и решаем реагировать на это событие, чтобы:
- определить, какая запись в b_search_content связана с индексируемым элементом;
- удалять все метки из b_search_content_title, залинкованные на эту запись;
- удалять из сфинкса данные, залинкованные на эту же запись;
- удалить запись из b_search_content.
Таким образом мы добиваемся, чтобы переиндексация всегда шла через ветку создания нового индекса для конкретной записи.
¶Подписываемся на событие search/BeforeIndex
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
'search', 'BeforeIndex',
['\AlexeyGfi\SearchTitleExtender', 'BeforeIndex']
);
¶Слушаем событие и обрабатываем его
/**
* @param array $arFields
*/
public static function BeforeIndex($arFields = [])
{
/**
* Модуль: $arFields['MODULE_ID']
* Код элемента: $arFields['ITEM_ID']
* Код инфоблока: $arFields['PARAM2']
*/
if (
$arFields['MODULE_ID'] !== 'iblock' ||
!$arFields['ITEM_ID']
) {
return;
}
global $DB;
$DB->StartTransaction();
/**
* Нам для чистки в сфинксе понадобится,
* потому сначала читаем а потом удаляем
*/
$result = $DB->Query(
sprintf(
'SELECT ID
FROM b_search_content
WHERE ITEM_ID="%s"',
$arFields['ITEM_ID']
)
);
$arr = $result->Fetch();
if (empty($arr)) {
return;
}
/**
* Чистим сразу в обоих таблицах
*/
$DB->Query(
sprintf(
'
DELETE b_search_content, b_search_content_title
FROM b_search_content
INNER JOIN b_search_content_title
ON b_search_content_title.SEARCH_CONTENT_ID = b_search_content.ID
WHERE
b_search_content.ITEM_ID = "%s";
',
$arFields['ITEM_ID']
)
);
$DB->Commit();
/**
* Чистим и в сфинксе.
* (!) Класс работы со сфинксом не имеет метода очистки,
* только замены (replace)
*
* ...потому мы на целевую запись скармливаем ему
* пустые поля
*/
$emptyKeys = ['URL', 'TITLE', 'BODY'];
array_walk($arFields, static function(&$item, $key) use ($emptyKeys) {
if (in_array($key, $emptyKeys)) {
$item = '';
}
});
\CSearchFullText::getInstance()->replace($arr['ID'], $arFields);
}
Обсуждение статьи 7
Вот так:
И во втором слчае тоже
Потому что в таблице, если это не курс значение может быть не integer, а строка,
например если это раздел его ITEM_ID будет равен S3 и mysql выбросит ошибку