Исторически в ядре Joomla существовало 2 компонента поиска: просто "поиск" и "умный поиск" (smart search). Простой поиск был в составе ещё Joomla 1.x и с тех пор существенно не менялся. Для того, чтобы этот компонент (com_search) мог искать не только в компонентах ядра нужно было написать плагин, суть которого заключалась в основном в том, чтобы отдать компоненту нужный SQL запрос и сформировать из результатов запроса объект структуры, понятной для компонента. Сам поиск по сути был SQL-запросом в базу с LIKE '%word%'. Встречались также случаи, когда с помощью плагина к простому поиску использовали поисковый движок Sphinx в Joomla.

Оглавление.

Введение. Индексация контента умным поиском в Joomla 5

Начиная с Joomla 2.5 в состав ядра был включен компонент Умного поиска (smart search) - com_finder, главным отличием которого от простого поиска стала индексация контента. Сам поиск стал выдавать результаты на основе релевантности контента. Для пользователя сайта появились параметры поиска, позволяющие ограничить результаты с помощью фильтров по различным параметрам: дате начала и завершения публикации, языку, типу (материал, категория, товар и т.д.), конкретная категория, автор. Под капотом текст разбивается на токены, для токенов вычисляется вес и т.д. Настройки индексации доступны в параметрах компонента.

Скриншот настроек индексации умным поиском в Joomla 5.1
Скриншот настроек индексации умным поиском в Joomla 5.1

Также приведу пример из подсказки умного поиска Joomla для пользователей:

Примеры использования функции поиска:

Если ввести в поле поиска фразу Война и Мир, будут показаны элементы, содержащие и слово "Война", и слово "Мир".

Если ввести Война не Мир - элементы, содержащие слово "Война", но не содержащие слово "Мир".

Если ввести Война или Мир - элементы, содержащие или слово "Война", или слово "Мир".

Отмечу ещё раз, что это функционал ядра Joomla, а не стороннего расширения и не какой-то сторонний, как правило платный, сервис.

На практике я сталкивался с тем, что посетители сайтов обычно всеми этими дополнительными фильтрами и параметрами поиска почти не пользуются, а больше переспрашивают с уточнением запроса. На обычном сайте-статейнике параметры поиска вряд ли будут востребованы, но во внутренней закрытой справочной системе или системе работы над документацией (в Joomla есть версионность материалов и Workflow (статья 1, та же статья источник 2) эти параметры были бы более востребованы.

Обновление индекса (переиндексация)

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

Запускать индексацию можно вручную из админки

Или же (это предпочтительный вариант) с помощью Joomla CLI - командной строки сервера. Для этого перейдите в папку cli вашего сайта (как работать с Joomla CLI подробнее в статье Joomla 4: мощь CLI приложений)

Папка CLI в Joomla
Папка CLI в Joomla

И в этой папке выполните команду:

php joomla.php finder:index

И довольно быстро Joomla проиндексирует ваш контент.

Индексация умным поиском в Joomla 5 через CLI
Индексация умным поиском в Joomla 5 через CLI

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

0 2 * * * php /path/to/site/public_html/cli/joomla.php finder:index >/dev/null 2>&l

Индексация пользовательских полей в Joomla

Пользовательские поля Joomla используют для самых разных типов сайтов, нередко для каталогов услуг или товаров, где не требуется онлайн-оплата и расчет доставки на сайте. Для того, чтобы Joomla искала по значениям этих полей нужно для каждого поля указать параметр "Индексация" (вкладка "Параметры", в самом низу):

Также можно посмотреть статью Добавление полей Joomla в результаты Умного Поиска при помощи JFilters, где описывается как с помощью переопределения макета отображать значения полей в результатах поиска.

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

Пример таксономии в умном поиске Joomla
Пример таксономии в умном поиске Joomla

Список литературы

Перед началом технической части я упомяну некоторые статьи, затрагивающие непосредственно основную тему. А также статьи, в которых в целом освещается создание и/или обновление плагина по современной архитектуре Joomla 4 / Joomla 5. Далее я буду предполагать, что читатель ознакомился с ними и в целом имеет представление о том, как сделать работающий плагин для Joomla:


Техническая часть. Разработка плагина умного поиска Joomla 5

Компонент умного поиска работает с плагинами-провайдерами данных, основная задача которых осталась та же - выбрать данные и отдать их на индексацию компоненту. Но со временем в зону ответственности плагина попали и задачи по переиндексации. В статье мы будем предполагать, что индексацию контента мы запускаем вручную из админки. Работа из CLI отличается визуально, но суть его остается та же.

Для опытных разработчиков скажу, что плагин поиска расширяет класс \Joomla\Component\Finder\Administrator\Indexer\Adapter, файл класса находится в administrator/components/com_finder/src/Indexer/Adapter.php. Ну а дальше они уже сами разберутся ?. Также в качестве образца можно изучить плагины умного поиска ядра Joomla - для материалов, категорий, контактов, тегов и т.д. - в папке plugins/finder. Я начал работать над плагином умного поиска для JoomShopping, поэтому названия классов и некоторые нюансы будут затрагивать этот компонент.

Файловая структура плагина умного поиска

Файловая структура плагина не отличается от типовой:

Файл services/provider.php

Файл provider.php позволяет регистрировать плагин в DI-контейнере Joomla и даёт возможность обращаться к методам плагина извне с помощью MVCFactory.

<?php

/**
 * @package     Joomla.Plugin
 * @subpackage  Finder.Wtjoomshoppingfinder
 *
 * @copyright   (C) 2023 Open Source Matters, Inc. <https://www.joomla.org>
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

\defined('_JEXEC') or die;

use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Database\DatabaseInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\Finder\Wtjoomshoppingfinder\Extension\Wtjoomshoppingfinder;

return new class () implements ServiceProviderInterface {
    /**
     * Registers the service provider with a DI container.
     *
     * @param   Container  $container  The DI container.
     *
     * @return  void
     *
     * @since   4.3.0
     */
    public function register(Container $container)
    {
        $container->set(
            PluginInterface::class,
            function (Container $container) {
                $plugin     = new Wtjoomshoppingfinder(
                    $container->get(DispatcherInterface::class),
                    (array) PluginHelper::getPlugin('finder', 'wtjoomshoppingfinder')
                );
                $plugin->setApplication(Factory::getApplication());
                
                // Наш плагин использует DatabaseTrait, поэтому появился 
                // метод setDatabase(). 
                // Если его нет, то используем только setApplication().
                $plugin->setDatabase($container->get(DatabaseInterface::class));

                return $plugin;
            }
        );
    }
};

Файл класса плагина

Это файл, в котором содержится основной рабочий код вашего плагина. Он должен находится в папке src/Extension. В моём случае класс плагина \Joomla\Plugin\Finder\Wtjoomshoppingfinder\Extension\Wtjoomshoppingfinder находится в файле plugins/finder/wtjoomshoppingfinder/src/Extension/Wtjoomshoppingfinder.php. Namespace плагина - Joomla\Plugin\Finder\Wtjoomshoppingfinder\Extension.

Есть минимальный набор свойств и методов класса, необходимые для работы (к ним обращается в том числе родительский класс Adapter).

Минимально необходимые свойства класса:

  • $extension - имя вашего компонента, определяющее тип вашего контента. Например, com_content. В моём случае это com_jshopping.

  • $context - это уникальный идентификатор для плагина, устанавливает контекст работы индексации, при которой будет идти обращение к плагину. По сути, это имя класса плагина (элемент). В нашем случае - Wtjoomshoppingfinder.

  • $layout - имя макета вывода для элемента результатов поиска. Этот макет используется при отображении результатов поиска. Например, если для параметра $layout задано значение article, то в режиме просмотра по умолчанию будет выполняться поиск файла макета с именем default_article.php, когда потребуется отобразить результат поиска такого типа. Если такой файл не найден, то вместо него будет использоваться файл макета с именем default_result.php. Макеты вывода с HTML-вёрсткой находятся в components/com_finder/tmpl/search. Однако, размещать свои макеты мы должны как переопределения - в папке html шаблона - templates/YOUR_TEMPLATE/html/com_finder/search. В моём случае я назвал макет product, а файл называется default_product.php. Подробнее о шаблонах в Joomla и принципе переопределений в статье Создание шаблонов сайта в Joomla 4+.

  • $table - имя таблицы в базе данных, к которой мы обращаемся для получения данных, например, #__content. В моём случае основная таблица с товарами JoomShopping называется #__jshopping_products.

  • $state_field - имя поля в таблице базы данных, отвечающее за то опубликован ли индексируемый элемент или нет. По умолчанию это поле называется state. Однако, в случае JoomShopping это поле называется product_publish.

<?php
// Здесь пока указаны только те namespaces, которые использованы в примере. 

use Joomla\Component\Finder\Administrator\Indexer\Adapter;
use Joomla\Event\SubscriberInterface;
use Joomla\Database\DatabaseAwareTrait;

\defined('_JEXEC') or die;

final class Wtjoomshoppingfinder extends Adapter implements SubscriberInterface
{
	use DatabaseAwareTrait;

	/**
	 * Уникальный идентификатор плагина. Можно указать имя класса.
	 *
	 * @var    string
	 * @since  2.5
	 */
	protected $context = 'Wtjoomshoppingfinder';

	/**
	 * Для какого компонента индексируем контент
	 *
	 * @var    string
	 * @since  2.5
	 */
	protected $extension = 'com_jshopping';

	/**
	 *
	 * Имя суффикса для субмакета вывода результатов поиска. 
     * Если это "article", то имя файла будет "default_article.php"
	 *
	 * @var    string
	 * @since  2.5
	 */
	protected $layout = 'product';

	/**
	 * Тип индексируемого контента. Пользователь может 
     * искать только среди товаров, только среди тегов, только
     * среди статей и т.д.
	 *
	 * @var    string
	 * @since  2.5
	 */
	protected $type_title = 'Product';

	/**
	 * Поле в базе данных, хранящее флаг опубликован ли элемент или нет.
     * По умолчанию - "state"
     *
	 * @var string
	 * @since 1.0.0
	 */
	protected $state_field = 'product_publish';

	/**
	 * Имя таблицы базы данных.
	 *
	 * @var    string
	 * @since  2.5
	 */
	protected $table = '#__jshopping_products';

	/**
	 * Загружать ли языковые файлы плагина при инициализации класса.
	 *
	 * @var    boolean
	 * @since  3.1
	 */
	protected $autoloadLanguage = true;

	/**
	 * Тег языка для товаров JoomShopping. 
     * Не стандартное свойство класса, нужно только нам
     * и только для JoomShopping.
	 *
	 * @var    string
	 * @since  3.1
	 */
	protected string $languageTag = '';

}

Минимально необходимые методы класса:

  • setup() : bool - метод для предварительной настройки плагина, подключения библиотек и т.д. Метод вызывается при переиндексации (метод reindex()), на событии onBeforeIndex. Метод должен возвращать true, иначе индексация прервётся.

  • index() : void - метод для запуска собственно индексации. В нём собирается объект нужной структуры из сырых данных SQL запроса, который потом передаётся на индексацию классу \Joomla\Component\Finder\Administrator\Indexer\Indexer. Метод запускается для каждого индексируемого элемента. В качестве аргумента метода приходит $item - результат запроса в базу данных, оформленный в класс \Joomla\Component\Finder\Administrator\Indexer\Result.

  • getListQuery() : Joomla\Database\DatabaseQuery - метод для получения списка индексирумых элементов...

... и тут начинаются детали, так как метод getListQuery() на самом деле не является обязательным, несмотря на то, что об этом говорит как документация, так и большинство статей.

Здесь подойдет любая картинка на тему "сложная схема". Источник картинки.
Здесь подойдет любая картинка на тему "сложная схема". Источник картинки.

Погружение в детали. Структура данных индексируемого элемента.

Удивительно то, сколько раз порой мимо нас по кругу проходит какая-либо информация или идея, прежде чем мы её заметим и осознаем! Многие вещи, находясь перед глазами не один год всё равно не достигают осознания, а наше внимание фокусируется на них лишь спустя годы опыта.

В связи с Joomla почему-то не сразу приходит видение, что её компоненты предполагают некую общую, характерную для Joomla архитектуру (хотя это очевидный факт). В том числе и на уровне структуры таблиц баз данных. Посмотрим на некоторые поля таблицы #__content - материалов Joomla. Оговорюсь, что нам не так важны конкретные имена столбцов (всегда можно запросить SELECT `name` as `title`), сколько структура данных для одного индексируемого элемента:

  • id - автоинкремент

  • asset_id - id записи в таблице #__assets , где хранятся права доступа групп и пользователей для каждого элемента сайта: статьи, товара, меню, модуля, плагина и всего прочего. Joomla использует паттерн Access Control List (ACL).

  • title - название элемента.

  • language - язык элемента.

  • introtext - вступительный текст или краткое видимое описание элемента

  • fulltext - полный текст элемента, полное описание товара и т.д.

  • state - логический флаг, отвечающий за состояние публикации: опубликован элемент или нет.

  • catid - id категории элемента. В Joomla нет просто "страниц сайта", как в других движках. Есть сущности контента (статьи, контакты, товары и т.д.), которые должны принадлежать каким-то категориям.

  • created - дата создания элемента.

  • access - id группы прав доступа (неавторизованные пользователи сайта (гости), все, зарегистрированные и т.д.)

  • metakey - meta keywords для элемента. Да, с 2009-го года они не используются Google и Яндекс их почти не учитывает. Но в Joomla они исторически остаются, так как это поле используется в модуле похожих материалов для поиска собственно похожих материалов по заданным ключевым словам.

  • metadesc - meta description элемента.

  • publish_up и publish_down - дата начала публикации и снятия с публикации элемента. Это скорее уже опция, но встречается во многих компонентах.

Если мы сравним таблицы #__content (материалы Joomla), #__contact_details (компонент контактов), #__tags (теги Joomla), #__categories (компонент категорий Joomla), то мы везде встретим почти все перечисленные типы данных.

Если компонент, для которого создаётся плагин умного поиска, следовал "Joomla way" и наследует её архитектуру, то в классе плагина можно обойтись минимумом методов. Если же разработчики решили не искать лёгких путей и пойти своим собственным, то нелёгкими путями придётся идти и вам, переопределяя почти все методы класса Adapter.

Метод getListQuery()

Этот метод вызывается в 3-х случаях:

  1. Метод getContentCount() класса Adapter - получение количества индексируемых элементов (сколько всего статей, сколько всего товаров и т.д.).

    Можно видеть количество индексируемых элементов в режиме отладки.
    Можно видеть количество индексируемых элементов в режиме отладки.
  2. Метод getItem($id) класса Adapter - получение конкретного индексируемого элемента по его id. Метод getItem() в свою очередь вызывается в методе reindex($id) - при переиндексации.

  3. Метод getItems($offset, $limit, $query = null) класса Adapter - метод получения списка индексируемых элементов. Offset и limit устанавливаются исходя из настроек компонента - сколько индексируемых элементов должно войти в "пачку".

Посмотрим пример реализации в плагинах ядра Joomla:

<?php

// Это пример кода из плагина для материалов Joomla: Content.
use Joomla\Database\DatabaseQuery;

/**
     * Method to get the SQL query used to retrieve the list of content items.
     *
     * @param   mixed  $query  A DatabaseQuery object or null.
     *
     * @return  DatabaseQuery  A database object.
     *
     * @since   2.5
     */
    protected function getListQuery($query = null)
    {
        $db = $this->getDatabase();

        // Check if we can use the supplied SQL query.
        $query = $query instanceof DatabaseQuery ? $query : $db->getQuery(true)
            ->select('a.id, a.title, a.alias, a.introtext AS summary, a.fulltext AS body')
            ->select('a.images')
            ->select('a.state, a.catid, a.created AS start_date, a.created_by')
            ->select('a.created_by_alias, a.modified, a.modified_by, a.attribs AS params')
            ->select('a.metakey, a.metadesc, a.metadata, a.language, a.access, a.version, a.ordering')
            ->select('a.publish_up AS publish_start_date, a.publish_down AS publish_end_date')
            ->select('c.title AS category, c.published AS cat_state, c.access AS cat_access');

        // Handle the alias CASE WHEN portion of the query
        $case_when_item_alias = ' CASE WHEN ';
        $case_when_item_alias .= $query->charLength('a.alias', '!=', '0');
        $case_when_item_alias .= ' THEN ';
        $a_id = $query->castAsChar('a.id');
        $case_when_item_alias .= $query->concatenate([$a_id, 'a.alias'], ':');
        $case_when_item_alias .= ' ELSE ';
        $case_when_item_alias .= $a_id . ' END as slug';
        $query->select($case_when_item_alias);

        $case_when_category_alias = ' CASE WHEN ';
        $case_when_category_alias .= $query->charLength('c.alias', '!=', '0');
        $case_when_category_alias .= ' THEN ';
        $c_id = $query->castAsChar('c.id');
        $case_when_category_alias .= $query->concatenate([$c_id, 'c.alias'], ':');
        $case_when_category_alias .= ' ELSE ';
        $case_when_category_alias .= $c_id . ' END as catslug';
        $query->select($case_when_category_alias)

            ->select('u.name AS author')
            ->from('#__content AS a')
            ->join('LEFT', '#__categories AS c ON c.id = a.catid')
            ->join('LEFT', '#__users AS u ON u.id = a.created_by');

        return $query;
    }

Метод getListQuery() возвращает объект DatabaseQuery - объект конструктора запроса, где уже указано имя таблицы и полей для выборки. Работа с ним продолжается в вызывающих его методах.

В случае вызова getListQuery() из getContentCount() в объекте DatabaseQuery $query заданные значения для select заменяются на COUNT(*).

В случае вызова getListQuery() из getItem($id) к созданному запросу добавляется условие $query->where('a.id = ' . (int) $id) и происходит выборка только конкретного элемента. И уже тут мы видим, что в родительском классе Adapter заложено имя таблицы в запросе как a.*. Это значит, что в своей реализации метода getListQuery() нам также следует использовать эти префиксы.

В случае вызова getListQuery() из getItems() к запросу, который мы сконструировали добавляется $offset и $limit для того, чтобы двигаться по списку элементов для индексирования.

Итого: getListQuery() - должен содержать в себе "рыбу" для трёх разных запросов. И в реализации Joomla здесь нет ничего особо сложного. Но, при необходимости, можно реализовать 3 метода самостоятельно, не создавая getListQuery().

Non Joomla way: В случае с JoomShopping я столкнулся с тем, что у товара может быть несколько категорий и исторически у компонента id категорий (catid) для товара хранились в отдельной таблице. При этом для товара много лет не было возможности указать основную категорию. При получении категории товара шёл запрос в таблицу с категориями, где брался просто первый результат запроса, отсортированный по id категории по умолчанию - т.е. по возрастанию. Если при редактировании товара мы меняли категории, то основной категорией товара становилась та, у которой число id меньше. По ней строился URL товара и товар мог перескочить из одной категории в другую.

Но, почти 2 года назад это поведение JoomShopping исправили. Поскольку компонент с давней историей, большой аудиторией и не может просто так ломать обратную совместимость - исправление это сделали опциональным. Возможность указать основную категорию для товара нужно включить в настройках компонента. Тогда в таблице с товарами будет заполняться main_category_id.

Но по умолчанию этот функционал выключен. И нам в плагине умного поиска нужно получать параметры компонента JoomShopping, смотреть включена ли опция указания основной категории товара (а она может быть включена недавно и основная категория для каких-то товаров не указана - тоже нюанс...) и формировать SQL запрос на получение товара(ов) исходя из параметров компонента: либо простой запрос, где мы добавляем поле main_category_id, либо запрос с JOIN на получение id категории старым неправильным способом.

Сразу же в этом запросе выходит на первый план нюанс мультиязычности. По канону Joomla для каждого языка сайта создаётся отдельный элемент и между ними настраиваются связи - ассоциации. Так, для русского языка - одна статья. Эта же статья на английском языке создается отдельно. Потом мы их связываем между собой с помощью языковых ассоциаций и при переключении языка на фронтенде Joomla нас перенаправит с одного материала на другой.

В JoomShopping сделано не так: данные для всех языков хранятся в той же таблице с товарами (Ок). Добавление данных для других языков происходит добавлением столбцов с суффиксом этих языков (хмм...). То есть у нас в базе данных нет просто поля title или name. Но есть поля name_ru-RU, name_en-GB и т.д.

При этом нам надо сконструировать универсальный запрос так, чтобы можно было индексировать как из админки, так и из CLI. При этом выбрать язык индексации при запуске CLI по CRON - тоже задача. Признаюсь, на момент начала написания статьи полноценное решение этой задачи я пока отложил. Выбор языка осуществляется собственным методом getLangTag(), где либо берём основной язык из параметров JoomShopping, либо язык сайта по умолчанию. То есть пока что это решение только для одноязычного сайта. Поиск на разных языках пока работать не будет.

Посмотрим, что получилось:

<?php
use Joomla\Database\DatabaseQuery;

	/**
	 * Method to get the SQL query used to retrieve the list of content items.
	 *
	 * @param   mixed  $query  A DatabaseQuery object or null.
	 *
	 * @return  DatabaseQuery  A database object.
	 *
	 * @since   2.5
	 */
	protected function getListQuery($query = null): DatabaseQuery
	{

		$db  = $this->db;
		$tag = $this->getLangTag();
		// Check if we can use the supplied SQL query.
		$query = ($query instanceof DatabaseQuery) ? $query : $db->getQuery(true);
		$query->select(
			[
				'prod.product_ean',
				'prod.manufacturer_code',
				'prod.product_old_price',
				'prod.product_price',
				'prod.product_buy_price',
				'prod.min_price',
				'prod.product_weight',
			]);
        // Столбцы с ... AS
		$query->select(
			$db->quoteName(
				[
					'prod.product_id',
					'prod.name_' . $tag,
					'prod.alias_' . $tag,
					'prod.description_' . $tag,
					'prod.short_description_' . $tag,
					'prod.product_date_added',
					'prod.product_publish',
					'prod.image',
					'cat.name_' . $tag,
				],
				[ // ... AS ...
					'slug',
					'title',
					'alias',
					'body',
					'summary',
					'created',
					'state',
					'image',
					'category',
				]
			)
		);

		$query->from($db->quoteName('#__jshopping_products', 'prod'))
			->where($db->quoteName('prod.product_publish') . ' = ' . $db->quote(1))
			->where($db->quoteName('cat.category_publish') . ' = ' . $db->quote(1));


		/**
		 * Если есть и включена опция JoomShopping "Использовать основную категорию для продукта",
		 * то у товара есть поле main_category_id.
		 * Если нет - используем старый подход JoomShopping - берем 1-й попавшийся id категории из таблицы #__jshopping_products_to_categories
		 * для этого сделаем подзапрос, так как category_id должен быть только 1.
		 */
		if (property_exists($this, 'jshopConfig')
			&& !empty($this->jshopConfig)
			&& $this->jshopConfig->product_use_main_category_id == 1)
		{

			$query->select($db->quoteName('prod.main_category_id', 'catslug'));
			$query->join('LEFT', $db->quoteName('#__jshopping_categories', 'cat') . ' ON ' . $db->quoteName('cat.category_id') . ' = ' . $db->quoteName('prod.main_category_id'));
		}
		else
		{
			$query->select($db->quoteName('cat.category_id', 'catslug'));

			// Create a subquery for the sub-items list
			$subQuery = $db->getQuery(true)
				->select($db->quoteName('pr_cat.product_id'))
				->select('MIN(' . $db->quoteName('pr_cat.category_id') . ') AS ' . $db->quoteName('catslug'))
				->from($db->quoteName('#__jshopping_products_to_categories', 'pr_cat'))
				->group($db->quoteName('product_id'));

			$query->join('LEFT', '(' . $subQuery . ') AS ' . $db->quoteName('subquery') . ' ON ' . $db->quoteName('subquery.product_id') . ' = ' . $db->quoteName('prod.product_id'));
			$query->join('LEFT', $db->quoteName('#__jshopping_categories', 'cat'), $db->quoteName('cat.category_id') . ' = ' . $db->quoteName('subquery.catslug'));
		}

		return $query;
	}

Метод index()

Этот метод должен адаптировать данные, полученные из базы данных для того, чтобы отдать их на индексацию. В качестве аргумента ему передается элемент $item (статья, товар, тег и т.д.) в виде класса экземпляра класса \Joomla\Component\Finder\Administrator\Indexer\Result. Свойства $item совпадают с теми, что мы выбрали из базы данных. Конечной целью этого метода является вызов $this->indexer->index($item).

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

Тут названия беру прям из кода. В данном случае картинки в результатах поиска не показываются, но они тоже есть - $this->result->imageUrl.
Тут названия беру прям из кода. В данном случае картинки в результатах поиска не показываются, но они тоже есть - $this->result->imageUrl.
  • imageUrl - картинка материала Jooma, товара, тега, контакта. Отображается если включено в настройках компонента умного поиска.

  • title - заголовок материала, название товара, контакта и т.д.

  • description и body - текстовое описание. Мы помним, что у многих сущностей в Joomla есть краткое и полное описание или вступительный и полный текст. Здесь они объединяются, а затем обрезаются до указанного в настройках лимита символов. body - это полный текст или описание.

  • getTaxonomy() - метод получает и выводит данные таксономий для данного результата поиска.

Таким образом видимых пользователю данных у нас немного - всего 4 типа. А данных из базы мы получаем больше. Нам нужно понимать, что из них будет доступно для индексации поиском, а что только для отображения.

Привожу код метода index() с комментариями.

<?php
/**
 * Method to index an item. The item must be a Result object.
 *
 * @param   Result  $item  The item to index as a Result object.
 *
 * @return  void
 *     * @throws  \Exception on database error.
 * @since   2.5
 */
protected function index(Result $item)
{
    // Устанавливаем язык индексируемого элемента - язык сайта по умолчанию
	$item->setLanguage();
	// Проверяем, включён ли JoomShopping в Joomla. 
	if (ComponentHelper::isEnabled($this->extension) === false)
	{
		return;
	}
    // Часть путей к картинкам мы берём из параметров компонента. 
	$this->loadJshopConfig();
    // Устанавливаем контекст для индексации
	$item->context = 'com_jshopping.product';

	// Собираем все параметры в кучу: компонента поиска, JoomShopping и сайта.
    // Они нам будут доступны в макете вывода 
	$registry     = new Registry($item->params);
	$item->params = clone ComponentHelper::getParams('com_jshopping', true);
	$item->params->merge($registry);
	$item->params->merge((new Registry($this->jshopConfig)));
    // Мета-данные: meta-keywords, meta description, автор, 
    // значения index / no-index для robots
	$item->metadata = new Registry($item->metadata);

	// Обрабатываем содержимое плагинами контента - событие onContentPrepare.
    // У плагинов контента всегда идёт проверка на контекст. 
    // Если он равен 'com_finder.indexer', то плагины контента как правило работать
    // не будут. Для индексации должен отдаваться ТОЛЬКО ТЕКСТ. 
    // Ни картинки, ни видео с YouTube туда попадать не должны, поэтому
    // индексируемое содержимое очищается от тегов.
    // Необработанные шорт-коды при этом являются просто текстом и могут попасть
    // в результаты поиска.

	$item->summary = Helper::prepareContent($item->summary, $item->params, $item);
	$item->body    = Helper::prepareContent($item->body, $item->params, $item);
    // Подключаем классы JoomShopping. На новые рельсы компонент 
    // на момент написания статьи не переехал.
    require_once JPATH_SITE . '/components/com_jshopping/bootstrap.php';
	\JSFactory::loadAdminLanguageFile($this->languageTag, true);

  	//
	// Хитрость. Мы хотим, чтобы по цене, коду товара и прочее мы
    // тоже могли искать. Присоединяем все эти данные к body
	//

    // Код товара
	$manufacturer_code = $item->getElement('manufacturer_code');
	if(!empty($manufacturer_code))
	{
		$item->body .=  ' '.Text::_('JSHOP_MANUFACTURER_CODE').': '.$manufacturer_code;
	}
    
    // EAN
	$product_ean = $item->getElement('product_ean');
	if(!empty($product_ean))
	{
		$item->body .=  ' '.Text::_('JSHOP_EAN').': '.$product_ean;
	}
    
    // Старая цена
	$product_old_price = (float) $item->getElement('product_old_price');
	if(!empty($product_old_price))
	{
		$product_old_price = \JSHElper::formatPrice($item->getElement('product_old_price'));
		$item->body .=  ' '.Text::_('JSHOP_OLD_PRICE').': '.$product_old_price;
	}

    // Закупочная цена
	$product_buy_price = (float)$item->getElement('product_buy_price');
	if(!empty($product_buy_price))
	{
		$product_buy_price = \JSHElper::formatPrice($item->getElement('product_buy_price'));
		$item->body .=  ' '.Text::_('JSHOP_PRODUCT_BUY_PRICE').': '.$product_buy_price;
	}
    // Цена
	$product_price = (float) $item->getElement('product_price');
	if(!empty($product_price))
	{
		$product_price = \JSHElper::formatPrice($product_price);
		$item->body .=  ' '.Text::_('JSHOP_PRODUCT_PRICE').': '.$product_price;
	}


	// URL - уникальный ключ элемента в таблице. Подробнее об этом после примера кода
	$item->url = $this->getUrl($item->slug, 'com_jshopping',$item->catslug);

	// Ссылка на элемент - на товар JoomShopping
	$item->route = $item->url;

	// На товар может быть сделан пункт меню, у которого свой заголовок. 
    // Меню в Joomla самое главное. Берем данные оттуда.
	$title = $this->getItemMenuTitle($item->url);

	// Adjust the title if necessary.
	if (!empty($title) && $this->params->get('use_menu_title', true))
	{
		$item->title = $title;
	}

	// Добавляем картинку товара
	$product_image = $item->getElement('image');
	if (!empty($product_image))
	{
		$item->imageUrl = $item->params->get('image_product_live_path').'/'.$product_image;
		$item->imageAlt = $item->title;
	}

	// Автора у товара нет. Но так можно делать, если поиск по автору/пользователю
    // Например, вы включили в настройках компонента умного поиска
    // поиск по автору и он должен искать всё, что связано с этим автором
	// $item->metaauthor = $item->metadata->get('author');

	// Add the metadata processing instructions.
	$item->addInstruction(Indexer::META_CONTEXT, 'metakey');
	$item->addInstruction(Indexer::META_CONTEXT, 'metadesc');
//		$item->addInstruction(Indexer::META_COTNTEXT, 'metaauthor');
//		$item->addInstruction(Indexer::META_CONTEXT, 'author');
//		$item->addInstruction(Indexer::META_CONTEXT, 'created_by_alias');
	
    // Группа доступа для результата поиска по умолчанию. 
    // Мы хардкодим "1" - то есть для всех. Но здесь можно 
    // брать группу доступа из товара.
    // Или показывать разные результаты поиска разным группам доступа.
    $item->access 		= 1;
	
    // Проверяем, опубликована ли категория для товара. 
    // Товары должны быть опубликованы только если их категория опубликована.
  	$item->state = $this->translateState($item->state, $item->cat_state);

	// Получаем список таксономий для отображения из параметров плагина
	$taxonomies = $this->params->get('taxonomies', ['product', 'category', 'language']);
	// Название типа поиска в выпадающем списке типов: материалы, контакты. 
	// В нашем случае - товар. Берём языковую константу для этого.  
	$item->addTaxonomy('Type', Text::_('JSHOP_PRODUCT'));
	// Добавляем категории товаров в выпадающий список
	// категорий, чтобы можно было искать только в конкретной 
  	// категории. Здесь уже передаём названия категорий.
	$item->addTaxonomy('Category', $item->category);
	// Поиск только на нужном языке
	$item->addTaxonomy('Language', $this->getLangTag());
	// Результаты поиска можно ограничивать датами
  	// начала и окончания публикации
	$item->publish_start_date =  Factory::getDate()->toSql();
	$item->start_date= Factory::getDate()->toSql();

	// Добавляем дополнительные данные для индексируемого элемента.
    // Здесь в хелпере вызывается событие "onPrepareFinderContent"
    // Таким образом обычно добавляются комментарии, теги, лейблы
    // и прочее, что должно быть доступно для поиска.
    // Соответственно работают с этим событием отдельные плагины.
  	// В нашем случае нам это пока не нужно.
    // Helper::getContentExtras($item);

    // Добавляем пользовательские поля (com_fields) Joomla, если компонент
    // их поддерживает.
    // В нашем случае нам это пока не нужно.
    // Helper::addCustomFields($item, 'com_jshopping.product');

	// Index the item.
	$this->indexer->index($item);
}

Виды инструкций для "разметки веса" контента для индексации

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

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

В своём плагине умного поиска мы можем указать какие данные в нашем объекте для индексации к какому типу относятся с помощью добавления инструкций:

<?php
// В методе index()
$item->addInstruction(Indexer::TEXT_CONTEXT, 'product_buy_price');

Виды контекста для инструкций и их названия по умолчанию смотрим в классе \Joomla\Component\Finder\Administrator\Indexer\Result

Как позже выяснил list_price и sale_price относятся к индексации, а не к терминологии интернет-магазина.
Как позже выяснил list_price и sale_price относятся к индексации, а не к терминологии интернет-магазина.

Метод getUrl()

Уникальным ключом для поиска по сути является url элемента в его системном виде: index.php?option=com_content&view=article&id=1. В базе данных в таблице #__finder_links он хранится в столбце url. Но для построения ссылки во фронтенде на искомый элемент из результатов поиска используется более сложный вариант с сочетанием id и алиасов в url: index.php?option=com_content&view=article&id=1:article-alias&catid=2, который хранится в соседнем столбце route. Но роутинг Joomla определит конечный url и без указания алиаса, в таком случае содержание url и route будет одинаково.

<?php
// Фрагмент метода index() плагина умного поиска для материалов Joomla

// Create a URL as identifier to recognise items again.
$item->url = $this->getUrl($item->id, $this->extension, $this->layout);

// Build the necessary route and path information.
$item->route = RouteHelper::getArticleRoute($item->slug, $item->catid, $item->language);

Системный url для индексируемого элемента в разных компонентах выглядит по-разному. В компонентах, которые следуют "Joomla way" можно использовать один контроллер, который в случае, если специфичного контроллера не найдено, сразу будет показывать нужный View. Поэтому в штатных компонентах Joomla мы обычно не встретим ссылок с указанием контроллера в GET-параметрах. Все они будут вида index.php?option=com_content&view=article&id=15. Именно такой url нам возвращает метод getUrl() класса Adapter.

<?php
/**
 * Method to get the URL for the item. The URL is how we look up the link
 * in the Finder index.
 *
 * @param   integer  $id         The id of the item.
 * @param   string   $extension  The extension the category is in.
 * @param   string   $view       The view for the URL.
 *
 * @return  string  The URL of the item.
 *
 * @since   2.5
 */
protected function getUrl($id, $extension, $view)
{
    return 'index.php?option=' . $extension . '&view=' . $view . '&id=' . $id;
}

Однако, в JoomShopping своя история и url строятся несколько по-иному. Стандартный метод использовать не получится и мы его переопределяем.

<?php
use Joomla\CMS\Uri\Uri;

/**
 * @param string $product_id  Product id
 * @param string $extension  Always 'com_jshopping'
 * @param string $view  Not used for JoomShopping
 *
 * @return string
 *
 * @since 1.0.0
 */
public function getUrl($product_id, $extension, $view = 'not_used') : string
{
	/**
	 * There is the trick. For JoomShopping product url construction
	 * we need only in product id and category id
	 */
	$this->loadJshopConfig();
	// Памятуя о сложностях с категориями в JoomShopping
	// выделяем получение категории в отдельный метод.
	$category_id = $this->getProductCategoryId((int)$product_id);
	$url = new Uri();
	$url->setPath('index.php');
    $url->setQuery([
		'option'      => 'com_jshopping',
		'controller'  => 'product',
		'task'        => 'view',
		'category_id' => $category_id,
		'product_id'  => $product_id,
	]);
	// При построении url в JoomShopping желательно находить и указывать
	// Правильный itemId - id пункта меню для JoomShopping.
	// Иначе у нас могут быть дубли страниц по url
	// API JoomShopping мы подключали ранее, поэтому JSHelper уже должен быть тут.
	$defaultItemid = \JSHelper::getDefaultItemid($url->toString());
	$url->setVar('Itemid', $defaultItemid);

	return $url->toString();
}

Методы getItems() и getContentCount()

В целом для ручной индексации и переиндексации контента, а также по расписанию через CLI нам больше ничего не нужно. Однако, если у нас попался какой-то совсем необычный зверь в виде non-Joomla данных, какой-нибудь сторонней базе данных, то можно полностью переопределить логику родительского класса Adapter для этих целей.

  • getContentCount() - метод должен вернуть целое число - количество индексируемых элементов.

  • getItems($offset, $limit, $query = null) - под капотом вызывает getListQuery() и устанавливает $offset и $limit, приводит всё к единому виду - объектам.

Если использование одного метода getListQuery() и обращение к нему из 3-х других методов неудобно по каким-то причинам - можно кастомизировать запросы и их обработку в переопределенных методах.

Переиндексация контента на лету

Для решения этой задачи у нас есть несколько путей, о двух из которых - периодический запуск индексации вручную и по CRON уже писал выше. Однако, это чревато несвоевременным обновлением индекса и пользователи сайта могут не вовремя получать в результатах поиска обновлённые данные. Поэтому есть ещё один способ: переиндексация контента на лету, сразу после сохранения изменений. Для этого создаётся плагин группы content, который в нужные моменты триггерит события плагина умного поиска.

Стандартные события моделей Joomla
Стандартные события моделей Joomla

Если компонент написан по канонам Joomla и наследует её классы, то у многих моделей (Model - MVC) вызываются стандартные события, среди которых нас интересуют несколько:

  • onContentBeforeSave - событие вызывается до сохранения любой сущности Joomla.

  • onContentAfterSave - событие вызывается после сохранения любой сущности Joomla.

  • onContentAfterDelete - событие вызывается после удаления любой сущности Joomla.

  • onContentChangeState - событие вызывается после изменения состояния (снят с публикации / опубликован)

  • onCategoryChangeState - событие вызывается после изменения состояния категории (если используется стандартный компонент категорий Joomla).

Плагин умного поиска по умолчанию вызывает переиндексацию контента в перечисленные моменты времени. В каждом из этих событий передаётся контекст вызова события в виде <component>.<entity>, например, com_content.article или com_menus.menu. По нужному контексту можно определить запускать переиндексацию или нет. Проверку эту мы делаем уже в плагине умного поиска. Пример из кода контент плагина Finder для материалов Joomla:

<?php
use Joomla\CMS\Event\Finder as FinderEvent;

/**
 * Smart Search after save content method.
 * Content is passed by reference, but after the save, so no changes will be saved.
 * Method is called right after the content is saved.
 *
 * @param   string  $context  The context of the content passed to the plugin (added in 1.6)
 * @param   object  $article  A \Joomla\CMS\Table\Table\ object
 * @param   bool    $isNew    If the content has just been created
 *
 * @return  void
 *
 * @since   2.5
 */
public function onContentAfterSave($context, $article, $isNew): void
{
    $this->importFinderPlugins();

    // Trigger the onFinderAfterSave event.
    $this->getDispatcher()->dispatch('onFinderAfterSave', new FinderEvent\AfterSaveEvent('onFinderAfterSave', [
        'context' => $context,
        'subject' => $article,
        'isNew'   => $isNew,
    ]));
}

Как мы видим здесь вызывается событие onFinderAfterSave, специфичное уже именно для плагинов умного поиска. А в методе onFinderAfterSave() нашего плагина умного поиска уже происходит проверка на нужный контекст и переиндексация.

<?php
use Joomla\CMS\Event\Finder as FinderEvent;

/**
 * Smart Search after save content method.
 * Reindexes the link information for an article that has been saved.
 * It also makes adjustments if the access level of an item or the
 * category to which it belongs has changed.
 *
 * @param   FinderEvent\AfterSaveEvent   $event  The event instance.
 *
 * @return  void
 *
 * @since   2.5
 * @throws  \Exception on database error.
 */
public function onFinderAfterSave(FinderEvent\AfterSaveEvent $event): void
{
    $context = $event->getContext();
    $row     = $event->getItem();
    $isNew   = $event->getIsNew();

    // We only want to handle articles here.
    if ($context === 'com_content.article' || $context === 'com_content.form') {
        // Check if the access levels are different.
        if (!$isNew && $this->old_access != $row->access) {
            // Process the change.
            $this->itemAccessChange($row);
        }

        // Reindex the item.
        $this->reindex($row->id);
    }

    // Check for access changes in the category.
    if ($context === 'com_categories.category') {
        // Check if the access levels are different.
        if (!$isNew && $this->old_cataccess != $row->access) {
            $this->categoryAccessChange($row);
        }
    }
}

Подобным же образом выстраивается работа при изменении состояния и удалении статьи или товара.

Метод getItem()

Этот метод получает индексируемый элемент по его id. Он вызывается при переиндексации после сохранения материалов, товаров и т.д. - на событие onFinderAfterSave. Внутри он получает SQL запрос из метода getListQuery(), добавляет к нему id запрашиваемой сущности и выполняет запрос. Однако, в родительском классе "зашито" поле id с префиксом a для таблиц - $query->where('a.id = ' . (int) $id). Поскольку в нашем случае и префикс и имя поля для запроса другие - переопределяем метод тоже.

<?php
// Указал здесь только используемые в примере
// неймспейсы.
use Joomla\Utilities\ArrayHelper;
use Joomla\Component\Finder\Administrator\Indexer\Result;

/**
 * Method to get a content item to index.
 *
 * @param   integer  $id  The id of the content item.
 *
 * @return  Result  A Result object.
 *
 * @throws  \Exception on database error.
 * @since   2.5
 */
protected function getItem($id)
{
	// Get the list query and add the extra WHERE clause.
	$query = $this->getListQuery();
	$query->where('prod.product_id = ' . (int) $id);

	// Get the item to index.
	$this->db->setQuery($query);
	$item = $this->db->loadAssoc();

	// Convert the item to a result object.
	$item = ArrayHelper::toObject((array) $item, Result::class);

	// Set the item type.
	$item->type_id = $this->type_id;

	// Set the item layout.
	$item->layout = $this->layout;

	return $item;
}

Заключение

Эта статья не претендует на полное описание механики умного поиска в Joomla даже после 4-х месяцев работы над ней. И в данном случае это не традиционная фигура речи ))) Но, надеюсь, статья поможет тем, кто возьмётся за написание плагинов для индексации данных из компонентов Joomla или сторонних систем.

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

Полезные ресурсы

Ресурсы сообщества:

Telegram:

Мой личный Telegram-канал - WebTolkRu.

Комментарии (2)


  1. FanatPHP
    26.08.2024 09:55

    Спасибо, статья выглядит очень добротно. Ну и сама идея придумать сфинкс на коленке вызывает уважение.

    Но кстати, интересно было бы узнать, как именно должны подключаться настоящие поисковики. В факе написано как-то очень обтекаемо,

    Large sites may be better off with a dedicated standalone search engine, such as Solr, rather than the pure-PHP implementation used in Smart Search

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


    1. sergeytolkachyov Автор
      26.08.2024 09:55

      Спасибо за комплимент ) Что касается поиска, то соглашусь с вашей оценкой, но зато оно есть, работает и бесплатно.

      Эластик для Joomla есть не то отдельным компонентом, не то плагином для smart search, но оно платное. Сфинкс я прикручивал к к простому поиску, думаю, если постараться, то и к умному наверное можно. Работает он (сфинкс) шустрее гораздо, базу данных не забивает. Но нужно понимать, что тот же сфинкс на сайте (или эластик потом) - это уже определенный уровень проектов и специалистов и там уже другие задачи стоят (фасетные поиски всякие, манипуляции с результатами поиска на лету) . А умный поиск Joomla - это решение из коробки. Поэтому да, для растущих проектов можно и умным обойтись, а потом уже не так важно будет какой компонент. Надо будет - с нуля напишут.