Joomla 5 智能搜索结构剖析。创建插件(第 3 部分)

在第一篇文章中,我们了解了 Joomla 智能搜索组件的功能,讨论了使用 CRON 进行计划索引的参数和配置。在第二篇文章中,我们开始为 Joomla 智能搜索组件编写自定义插件。那么,让我们完成它吧!

index() 方法

此方法应调整从数据库获取的数据,以便将其提交用于索引。作为参数,`$item` 元素(文章、产品、标签等)作为 `\Joomla\Component\Finder\Administrator\Indexer\Result` 类的实例类传递给它。`$item` 的属性与我们从数据库中选择的属性相匹配。此方法的最终目标是调用 `$this->indexer->index($item)`。

您需要了解搜索结果是什么样的,以便了解要与什么进行比较。

Joomla 5 smart search result structure

这里我直接从代码中获取名称。在这种情况下,图像不会显示在搜索结果中,但它们也在那里 - `$this->result->imageUrl`。

  • imageUrl - Joomla 文章、产品、标签、联系人的图片。如果在智能搜索组件的设置中启用,则会显示。
  • title——资料的标题,产品名称,联系方式等。
  • description 和 body - 文本描述。我们记得 Joomla 中的许多实体都有简短而完整的描述或介绍和全文。在这里,它们被组合起来,然后修剪到设置中指定的字符限制。body 是全文或描述。
  • getTaxonomy() - 该方法接收并输出给定​​搜索结果的分类数据。
  • 因此,我们只有很少的数据可供用户查看 - 只有 4 种类型。而我们从数据库中获取更多数据。我们需要了解其中哪些数据可供搜索索引,哪些数据仅显示出来。

    以下是带有注释的 `index()` 方法的代码。

    setLanguage();
        // Check if JoomShopping is enabled in Joomla.
        if (ComponentHelper::isEnabled($this->extension) === false)
        {
            return;
        }
        // We take part of the paths to the pictures from the component parameters.
        $this->loadJshopConfig();
        // Setting the context for indexing
        $item->context = 'com_jshopping.product';
    
        // We collect all the parameters in a bunch: the search component, JoomShopping and the site.
        // They will be available to us in the output layout
        $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-data: meta-keywords, meta description, author,
        // index / no-index values for robots
        $item->metadata = new Registry($item->metadata);
    
        // Process the content with content plugins - the onContentPrepare event.
        // Content plugins always have a context check.
        // If it is equal to 'com_finder.indexer', then content plugins will usually not work.
        // ONLY TEXT should be given for indexing.
        // Neither pictures nor YouTube videos should get there, so
        // the indexed content will be cleared of HTML tags.
        // The raw short codes are just text and can get
        // into the search results.
    
        $item->summary = Helper::prepareContent($item->summary, $item->params, $item);
        $item->body    = Helper::prepareContent($item->body, $item->params, $item);
        // Include here JoomShopping classed.
        require_once JPATH_SITE . '/components/com_jshopping/bootstrap.php';
        \JSFactory::loadAdminLanguageFile($this->languageTag, true);
    
        //
        // A trick. We want to be able to search by price, product code, etc too.
        // Attaching all this data to the body.
        //
    
        // Manufacturer code
        $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;
        }
    
        // Old price
        $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;
        }
    
        // Buy 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;
        }
        // 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 - the unique key of the element in the table. Read more about this after the sample code
        $item->url = $this->getUrl($item->slug, 'com_jshopping', $item->catslug);
    
        // Link to element - to JoomShopping product
        $item->route = $item->url;
    
        // A menu item can be created for product. Menu item has own page header.
        // Joomla menu has higher priority then we'll take data from it.
        $title = $this->getItemMenuTitle($item->url);
    
        // Adjust the title if necessary.
        if (!empty($title) && $this->params->get('use_menu_title', true))
        {
            $item->title = $title;
        }
    
        // Add product image
        $product_image = $item->getElement('image');
        if (!empty($product_image))
        {
            $item->imageUrl = $item->params->get('image_product_live_path') . '/' . $product_image;
            $item->imageAlt = $item->title;
        }
    
        // The product has no author. But you can do this if you search by author/user
        // For example, you enabled search by author in the smart search component settings
        // and it should search for everything related to this author
        // $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');
    
        // Access group for the default search result.
        // We hardcode "1" - that is, for everyone. But here you can
        // take the access group from the product.
        // Or show different search results to different access groups.
        $item->access = 1;
    
        // Check if the category for the product is published.
        // Products should only be published if their category is published.
        $item->state = $this->translateState($item->state, $item->cat_state);
    
        // Get the list of taxonomies to display from the plugin parameters
        $taxonomies = $this->params->get('taxonomies', ['product', 'category', 'language']);
        // Name of the search type in the drop-down list of types: materials, contacts.
        // In our case - product. We take a language constant for this.
        $item->addTaxonomy('Type', Text::_('JSHOP_PRODUCT'));
        // Add product categories to the drop-down list
        // categories so that you can search only in a specific
        // categories. Here we already transfer the names of the categories.
        $item->addTaxonomy('Category', $item->category);
        // Search only in the desired language
        $item->addTaxonomy('Language', $this->getLangTag());
        // Search results can be limited by publication start and end dates
        $item->publish_start_date = Factory::getDate()->toSql();
        $item->start_date         = Factory::getDate()->toSql();
    
        // Add additional data for the indexed element.
        // Here in the helper the "onPrepareFinderContent" event is called
        // This is usually how comments, tags, labels
        // and other things that should be available for search are added.
        // Accordingly, individual plugins work with this event.
        // In our case, we do not need this yet.
        // Helper::getContentExtras($item);
    
        // Add custom fields (com_fields) Joomla, if the component
        // supports them.
        // In our case, we do not need this yet.
        // Helper::addCustomFields($item, 'com_jshopping.product');
    
        // Index the item.
        $this->indexer->index($item);
    }

    用于索引内容“标记权重”的指令类型。

    我不是索引微调方面的专家,所以我将尝试描述我在代码中看到的内容。在智能搜索组件的参数中,索引内容的每个部分都有权重设置:标题、正文、元数据、url、附加文本。

    Joomla 5 smart search params - indexing weight settings for title, body, metadata

    我们已经在系列的第一篇文章中看到了上面的这些设置,但是为了方便我们将重复截图。

    在我们的智能搜索插件中,我们可以通过添加指令来指定我们索引对象中的哪些数据属于哪种类型:

    addInstruction(Indexer::TEXT_CONTEXT, 'product_buy_price');

    我们在类“\Joomla\Component\Finder\Administrator\Indexer\Result”中默认查看指令的上下文类型及其名称。

    Joomla smart search indexing instructions types for weight markup

    后来我发现,“list_price”和“sale_price”指的是索引,而不是在线商店的术语。

    getUrl() 方法

    搜索的唯一键本质上是元素在其系统表单中的 url:**index.php?option=com_content&view=article&id=1**。在数据库的 **#__finder_links** 表中,它存储在 **url** 列中。但要在前端从搜索结果中构建所需元素的链接,则使用更复杂的选项,并在 url 中使用 id 和别名的组合:**index.php?option=com_content&view=article&id=1:article-alias&catid=2**,它存储在相邻的路由列中。但 Joomla 路由将在不指定别名的情况下确定最终的 url,在这种情况下,url 和路由的内容将相同。

    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 方式”的组件中,您可以使用一个控制器,如果未找到特定控制器,它将立即显示所需的“视图”。因此,在标准 Joomla 组件中,我们通常不会在 GET 参数中找到指示控制器的链接。它们都看起来像 **index.php?option=com_content&view=article&id=15**。这是 `Adapter` 类的 `getUrl()` 方法返回给我们的 URL。

    然而,JoomShopping 有自己的故事,URL 的构建方式也有所不同。我们将无法使用标准方法,我们正在重新定义它。

    loadJshopConfig();
        // Keeping in mind the difficulties with categories in JoomShopping
        // we separate the process of getting the category into a separate method.
        $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,
        ]);
        // When constructing a url in JoomShopping, it is advisable to find and specify
        // The correct itemId is the id of the menu item for JoomShopping.
        // Otherwise, we may have duplicate pages by url
        // We connected the JoomShopping API earlier, so the JSHelper should already be here.
        $defaultItemid = \JSHelper::getDefaultItemid($url->toString());
        $url->setVar('Itemid', $defaultItemid);
    
        return $url->toString();
    }

    多语言的非典型实现。索引化。

    让我们回到多语言问题,其中我们没有针对每种内容语言的单独索引实体。但有一个索引实体同时包含所有语言的值。

    解决方案是在 `index()` 方法中获取所有语言的列表,收集每种语言的 `Result` 对象,然后为每个对象提供索引。我们需要将数据分为两种语言相同的数据(通常是 `catid`、`access` 等)和不同的数据(`title`、`description`、`fulltext` 等)。也就是说,在我们插件的 `Index()` 方法中,`$this->indexer->index()` 方法将被调用多次。

    params);
        $item->params = clone ComponentHelper::getParams('com_swjprojects', true);
        $item->params->merge($registry);
        $item->context = 'com_swjprojects.project';
        $lang_codes    = LanguageHelper::getLanguages('lang_code');
    
        $translates = $this->getTranslateProjects($item->id, $item->catid);
    
        // Translate the state. projects should only be published if the category is published.
        $item->state = $this->translateState($item->state, $item->cat_state);
    
        // Get taxonomies to display
        $taxonomies = $this->params->get('taxonomies', ['type', 'category', 'language']);
    
        // Add the type taxonomy data.
        if (\in_array('type', $taxonomies))
        {
            $item->addTaxonomy('Type', 'Project');
        }
    
        $item->access = 1;
        foreach ($translates as $translate)
        {
            $item->language = $translate->language;
            $item->title    = $translate->title;
            // Trigger the onContentPrepare event.
            $item->summary = Helper::prepareContent($translate->introtext, $item->params, $item);
            $item->body    = Helper::prepareContent($translate->fulltext, $item->params, $item);
    
            $metadata       = new Registry($translate->metadata);
            $item->metakey  = $metadata->get('keywords', '');
            $item->metadesc = $metadata->get('description', $translate->introtext);
            // Add the metadata processing instructions.
            $item->addInstruction(Indexer::META_CONTEXT, 'metakey');
            $item->addInstruction(Indexer::META_CONTEXT, 'metadesc');
    
            $lang = '';
    
            if (Multilanguage::isEnabled())
            {
                foreach ($lang_codes as $lang_code)
                {
                    if ($translate->language == $lang_code->lang_code)
                    {
                        $lang = $lang_code->sef;
                    }
                }
            }
            // Create a URL as identifier to recognise items again.
            $item->url = $this->getUrl($item->id, $this->extension, $this->layout, $lang);
    
            // Build the necessary route and path information.
            $item->route = RouteHelper::getProjectRoute($item->id, $item->catid);
    
            // Get the menu title if it exists.
            $title = $this->getItemMenuTitle($item->route);
    
            // Adjust the title if necessary.
            if (!empty($title) && $this->params->get('use_menu_title', true))
            {
                $item->title = $title;
            }
    
            // Add the category taxonomy data.
            if (\in_array('category', $taxonomies))
            {
                $item->addTaxonomy('Category', $translate->category, 1, 1, $item->language);
            }
    
            // Add the language taxonomy data.
            if (\in_array('language', $taxonomies))
            {
                $item->addTaxonomy('Language', $item->language, 1, 1, $item->language);
            }
    
    
            $item->metadata = new Registry($item->metadata);
    
            $icon = ImagesHelper::getImage('projects', $item->id, 'icon', $item->language);
    
            // Add the image.
            if (!empty($icon))
            {
                $item->imageUrl = $icon;
                $item->imageAlt = $item->title;
            }
    
            // Add the meta author.
            // $item->metaauthor = $item->metadata->get('author');
    
            // Get content extras.
            // Helper::getContentExtras($item);
            // Helper::addCustomFields($item, 'com_swjprojects.project');
    
            // Index the item.
            $this->indexer->index($item);
        }
    }

    我们还需要统一每种语言的“route”字段的值,因此我们自己实现“getUrl()”方法,并将“$lang”参数添加到url中。

    getItems() 和 getContentCount() 方法

    一般来说,我们不需要任何其他东西来手动索引和重新索引内容,以及通过 CLI 进行安排。但是,如果我们遇到一些非常不寻常的非 Joomla 数据形式的野兽,一些第三方数据库,那么我们可以完全重新定义父 Adapter 类的逻辑以实现这些目的。

  • getContentCount() - 该方法应该返回一个整数 - 索引元素的数量。
  • getItems($offset, $limit, $query = null) - 在底层,调用 getListQuery() 并设置 $offset 和 $limit,将所有内容带到单个视图 - 对象。
  • 如果由于某种原因使用一个 `getListQuery()` 方法并从其他 3 个方法访问它不方便,则可以在重新定义的方法中自定义请求及其处理。

    动态重新索引内容

    为了解决这个问题,我们有几种方法,其中两种是定期手动和 CRON 索引,正如我上面所写。然而,这会导致索引更新不及时,网站用户可能无法及时在搜索结果中收到更新的数据。因此,还有另一种方法:**在保存更改后立即动态重新索引内容**。为此,创建了一个**内容组**插件,它会在正确的时刻触发智能搜索插件事件。

    Content plugin for joomla smart search

    **标准 Joomla 模型事件。**

    如果组件是按照 Joomla 的规范编写的并且继承了它的类,那么许多模型(“模型” - MVC)都会触发标准事件,其中我们感兴趣的有以下几个:

    onContentBeforeSave - 在保存任何 Joomla 实体之前触发该事件。

  • onContentAfterSave - 保存任何 Joomla 实体后触发该事件。
  • onContentAfterDelete - 删除任何 Joomla 实体后触发该事件。
  • onContentChangeState - 状态改变(未发布/已发布)后触发事件。
  • onCategoryChangeState - 类别状态改变后触发该事件(如果使用标准 Joomla 类别组件)。
  • 默认情况下,智能搜索插件会在列出的时间点重新索引内容。在每个事件中,事件调用的上下文以`。',例如,'com_content.article' 或 'com_menus.menu'。根据所需的上下文,您可以确定是否开始重新索引。我们已经在智能搜索插件中进行了此检查。以下是来自 Joomla 文章的 Finder 内容插件代码的示例:

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

    我们可以看到,这里调用了 `onFinderAfterSave` 事件,该事件是专门为智能搜索插件而设的。在我们的智能搜索插件的 `onFinderAfterSave()` 方法中,已经检查了所需的上下文并重新编制了索引。

    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”事件上调用该方法。在内部,它从“getListQuery()”方法接收 SQL 查询,向其添加所请求实体的“id”,然后执行查询。但是,在父类中,带有前缀“a”的“id”字段对于表是硬编码的 - “$query->where('a.id = ' . (int) $id)”。由于在我们的例子中,请求的字段的前缀和名称都不同,因此我们也重新定义了该方法。

    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 组件或第三方系统数据的人。

    我将非常感激地接受改进文章的建议和评论中的补充。

    Joomla 社区资源

  • https://joomla.org/
  • Joomla 社区杂志中的这篇文章
  • Mattermost 中的 Joomla 社区聊天(阅读更多)