后端对关键字高亮显示的处理

607 阅读2分钟

最近做了一个站搜高亮显示的需求,需要对结果中的搜索内容做高亮显示。看了下我们系统现有架构,用【ES高亮功能】是可以满足这个需求处理的,不需要再引入其他组件。

1. ES 高亮功能

highlight 语句使用,具体使用可看 ES高亮

DSL语句支持指定高亮标签,如下所示,highlight 单独指定字段content和高亮标签
```
GET test/_search 
{
"query":{},
 "highlight":{
    "fields":{
        "contnet": {
          "pre_tags":[
            "<div>"
          ],
        "post_tags":[
          "</div>"
          ]
        }
    }
 }

} ``` 也可以全局设置高亮字段和标签

 "highlight": {
    "pre_tags": ["<div class='hightLight'>"],
    "post_tags": ["</div>"],
    "fields": {
      "content.search": {}
    }
  }

因为php 处理空 [] => {} 很麻烦,就没有使用全局的方式。用了第一种方式。

2. 处理关键字

正常highlight 可以返回高亮数据,如下:

"hits" : [
      {
        "_index" : "testtiku1",
        "_type" : "_doc",
        "_id" : "170825439135",
        "_score" : 42.778034,
        "_source" : {
          "id" : 170825439135,
          "state" : 0,
          "content" : "一元二次方程的根为(    )x=1x=-1x=2"
        },
        "highlight" : {
          "content.search" : [
            "<div class='hightLight'></div><div class='hightLight'></div><div class='hightLight'></div><div class='hightLight'></div><div class='hightLight'></div><div class='hightLight'></div>的根为(    )x=1x=-1x=2"
          ]
        }
      },

但是我们content字段置存储了文本信息,图片和latex并没有存储,所以这个返回的高亮数据并不能直接返回客户端使用。我利用highlight中的高亮做关键词,然后重新匹配content中的内容,重复做了一次字符串匹配工作。这样项目中省去引入分词组件,利用es的highlight做分词关键字,对content 内容做关键字过滤。
  因为content 内容会比较大,为了提高关键字过滤的效率,采用字典树的方式过滤关键字。简单说下字典树,假设关键字有【日本人,本日,日狗】,就是将关键字创建成一个如下图所示的树,然后去匹配需要过滤的 text ,时间复杂度为 O(nlogn):

tire.png

php代码如下:

<?php

/**
 * 指定词的替换,敏感词或高亮处理
 */
class WordFilter
{
    private $_tire;//敏感词字典

    public function __construct($arrWords)
    {
        foreach ($arrWords as $strWord) {
            $this->addWord(trim($strWord));
        }
    }

    /**
     * 分割文本
     * @param $str
     * @return array[]|false|string[]
     */
    protected function splitWord($str)
    {
        //将字符串分割成组成它的字符
        return preg_split("//u", $str, -1, PREG_SPLIT_NO_EMPTY);
    }

    /**
     * 添加敏感字至节点
     * @param $strWord
     */
    protected function addWord($strWord)
    {
        $arrWord = $this->splitWord($strWord);
        $curNode = &$this->_tire;
        foreach ($arrWord as $char) {
            // 判断是否为null
            if (!isset($curNode)) {
                $curNode[$char] = [];
            }
            $curNode = &$curNode[$char];
        }

        //标记到达当前节点完整路径
        $curNode['end'] = 1;
    }

    /**
     * 默认情况为敏感词遮挡
     * 对字符串进行过滤,比如 str=可耻的日本人,对其中的 '日本' 进行敏感词遮挡或 进行高亮显示,
     * 结果为 str= 可耻的**人或 可耻的*日本*人。
     *
     * @param string $strText 需要操作的字符串
     * @param bool $bolReplace 是否替换,false 时,表示包裹字符,默认替换
     * @param string $strPre 默认替换字符,包裹时为前置包裹字符 如 dd*A*ee
     * @param string $strSuf 后置替换字符
     * @return string
     */
    public function filter($strText, $bolReplace = true, $strPre = '*', $strSuf = '*')
    {
        $arrStrText = $this->splitWord($strText);
        $arrWrap = []; //包裹字符下标,统一处理
        $intLength = count($arrStrText);
        $i = 0;
        while ($i < $intLength) {
            $curChar = $arrStrText[$i];
            if (!isset($this->_tire[$curChar])) { //判断当前敏感字是否有存在对应节点
                $i++;
                continue;
            }
            $curNode = &$this->_tire[$curChar];
            $matchIndex = [$i]; //匹配后续字符串是否match剩余敏感词
            if (!isset($curNode['end'])) { // 当前节点是否是单独的关键字
                for ($j = $i + 1; $j < $intLength; $j++) {
                    if (!isset($curNode[$arrStrText[$j]])) { // 当前关键字不存在对应节点
                        break;
                    }
                    //如果匹配到的话,则把对应的字符所在位置存储起来,便于后续敏感词替换
                    $matchIndex[] = $j;
                    //继续引用
                    $curNode = &$curNode[$arrStrText[$j]];
                }
            }
            //判断是否已经到敏感词字典结尾,是的话,进行敏感词替换
            if (isset($curNode['end'])) {
                foreach ($matchIndex as $index) {
                    if ($bolReplace) {
                        $arrStrText[$index] = $strPre;
                    } else {
                        //统一处理
                        // $arrStrText[$index] = $strPre . $arrStrText[$index] . $strSuf;
                        $arrWrap[] = $index;
                    }
                }
                $i = max($matchIndex);
            }
            $i++;
        }
        $strRet = '';
        if ($bolReplace) {
            $strRet = implode('', $arrStrText);
        } else {
            $strRet = static::reduceTag($arrWrap, $arrStrText, $strPre, $strSuf);
        }
        return $strRet;
    }

    /**
     * 连续出现的命中字符,统一包裹,减少单个包裹的标签量
     * @param $arrWrap
     * @param $arrStrText
     * @param $strPre
     * @param $strSuf
     * @return string
     */
    public static function reduceTag($arrWrap, $arrStrText, $strPre, $strSuf)
    {
        $begin = $arrWrap[0] ?? 0;
        $end = $arrWrap[0] ?? 0;
        for ($i = 1; $i < count($arrWrap); $i++) {
            if ($arrWrap[$i] - $end == 1) {
                $end++;
            } else {
                $arrContinue = array_slice($arrStrText, $begin, $end - $begin + 1);
                $strTmp = $strPre . implode('', $arrContinue) . $strSuf;
                for ($j = $begin; $j <= $end; $j++) {
                    $arrStrText[$j] = '';
                }
                $arrStrText[$end] = $strTmp;
                $begin = $arrWrap[$i];
                $end = $arrWrap[$i];
            }
        }
        if (!empty($arrWrap) && ($end == $arrWrap[count($arrWrap) - 1])) {
            $arrContinue = array_slice($arrStrText, $begin, $end - $begin + 1);
            $strTmp = $strPre . implode('', $arrContinue) . $strSuf;
            for ($j = $begin; $j <= $end; $j++) {
                $arrStrText[$j] = '';
            }
            $arrStrText[$end] = $strTmp;
        }
        return implode('', $arrStrText);
    }

}

$keyword = ['日本人', '本日', '日狗', 'fuck', '日'];
$str = '为日本本日去你的狗日日狗feofohg 日本人fuck狗';
$obj = new WordFilter($keyword);
$r = $obj->filter($str);
var_dump($r);

输出

"为*本**去你的狗**狗feofohg *本人****狗"

其中日狗不会匹配到,因为关键字 ,日狗匹配时,按照最短路径匹配,如果要按照最长路径匹配,去掉 73 行 if (!isset($curNode['end'])) 这个判断即可。 结果为:

为日本**去你的狗***feofohg *******狗

3. 富文本高亮显示

最后用正则对富文本进行添加标签即可。

// 匹配所有标签性质字符串
preg_match_all('/</?[^>]+>/', $strText, $arrMatch);
// 去掉html标签
$arrText = preg_split('/</?[^>]+>/', $strText) ?: [];