使用java或elasticsearch进行敏感词词条的过滤或替换为指定字符

1、因为项目需求,需要对热门搜索词进行敏感词的过滤;所以进行了以下开发的过程和大家分享一下;

第一阶段:使用 elasticsearch 存储查询语句,反向查询过滤敏感词,敏感词使用智能分词;

实现情况:elasticsearch 存储数据:【存在问题:分词结果会影响过滤效果,会将部分正常词汇过滤掉,最终没采编此方式】

第二阶段:修改mapping文件,使用前后* like方式匹配,过滤,可以解决第一阶段的 问题;但是elasticsearch的 like查询效率 短词入参还凑活;长词查询的话会太忙;不能满足效率方面发要求,也放弃了这种方式;

第三阶段:百度了一些代码 工程,使用字符的hash值,和tree进行过滤;优点:速度非常快,20万字的过滤,在30毫秒内完成;缺点:不支持单个字的过滤;

代码:

SensitiveWordSyncComponent 对存放 敏感词和 过滤方法进行了 简单封装;

SensitiveFilter核心工具类;put 敏感词 和 filter 过滤;

StringPointer 循环判断,以及计算 字符hash值;

SensitiveNode 获取节点;

下面我们对每一个类的每一个方法进行 详解:

SensitiveWordSyncComponent 代码详解:SensitiveWordSyncComponent 里面只有三个方法:

第一个方法:定时对表数据敏感词 进行更新到内存:

第二个和第三个 方法,调用了 SensitiveFilter 的filter方法:一个 可以将过滤的敏感词条返回,一个是 支持按照指定的字符进行替换;

 

SensitiveFilter 核心过滤类:下面是SensitiveFilter 类的代码【下面代码中 只有新增词库,过滤;没有删除敏感词;删除方法是后来加上的】 使用put 存放完成敏感词后,直接使用filter 进行过滤即可;定时任务因为方便测试 所以写的29秒执行一次;生产环境,半小时执行一次;

package com.unicom.kc.sensitive.util.sensi;

import org.springframework.stereotype.Component;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.NavigableSet;

/**
 * 敏感词过滤器,以过滤速度优化为主。<br/>
 * * 增加一个敏感词:{@link #put(String)} <br/>
 * * 过滤一个句子:{@link #filter(String, char)} <br/>
 * * 
 */
@Component
public class SensitiveFilter implements Serializable{
   
   private static final long serialVersionUID = 1L;

   /**
    * 默认的单例,使用自带的敏感词库
    */
// public static final SensitiveFilter DEFAULT = new SensitiveFilter(
//       new BufferedReader(new InputStreamReader(
//             ClassLoader.getSystemResourceAsStream("sensi_words.txt")
//             , StandardCharsets.UTF_8)));
    //public static SensitiveFilter DEFAULT = new SensitiveFilter();
   
   /**
    * 为2的n次方,考虑到敏感词大概在10k左右,
    * 这个数量应为词数的数倍,使得桶很稀疏
    * 提高不命中时hash指向null的概率,
    * 加快访问速度。
    */
   static final int DEFAULT_INITIAL_CAPACITY = 131072;
   
   /**
    * 类似HashMap的桶,比较稀疏。
    * 使用2个字符的hash定位。
    */
   private SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY];
   
   /**
    * 构建一个空的filter
    */
   public SensitiveFilter(){
      
   }
   
   /**
    * 加载一个文件中的词典,并构建filter<br/>
    * 文件中,每行一个敏感词条<br/>
    * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/>
    * <b>注意:</b>读取中的{@link IOException}不会抛出
    */
   public SensitiveFilter(BufferedReader reader){
      try{
         for(String line = reader.readLine(); line != null; line = reader.readLine()){
            put(line);
         }
         reader.close();
      }catch(IOException e){
         e.printStackTrace();
      }
   }

   public SensitiveFilter(List<String> dataList) {
      for (String dataItem : dataList) {
         put(dataItem);
      }
   }


   /**
    * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/>
    * 此方法(构建)并不是主要的性能优化点。
    */
   public boolean put(String word){
      // 长度小于2的不加入
      if(word == null || word.trim().length() < 2){
         return false;
      }
      // 两个字符的不考虑
      if(word.length() == 2 && word.matches("\\w\\w")){
         return false;
      }
      StringPointer sp = new StringPointer(word.trim());
      // 计算头两个字符的hash
      int hash = sp.nextTwoCharHash(0);
      // 计算头两个字符的mix表示(mix相同,两个字符相同)
      int mix = sp.nextTwoCharMix(0);
      // 转为在hash桶中的位置
      int index = hash & (nodes.length - 1);
      
      // 从桶里拿第一个节点
      SensitiveNode node = nodes[index];
      if(node == null){
         // 如果没有节点,则放进去一个
         node = new SensitiveNode(mix);
         // 并添加词
         node.words.add(sp);
         // 放入桶里
         nodes[index] = node;
      }else{
         // 如果已经有节点(1个或多个),找到正确的节点
         for(;node != null; node = node.next){
            // 匹配节点
            if(node.headTwoCharMix == mix){
               node.words.add(sp);
               return true;
            }
            // 如果匹配到最后仍然不成功,则追加一个节点
            if(node.next == null){
               new SensitiveNode(mix, node).words.add(sp);
               return true;
            }
         }
      }
      return true;
   }
   
   /**
    * 对句子进行敏感词过滤<br/>
    * 如果无敏感词返回输入的sentence对象,即可以用下面的方式判断是否有敏感词:<br/><code>
    * String result = filter.filter(sentence, '*');<br/>
    * if(result != sentence){<br/>
    * &nbsp;&nbsp;// 有敏感词<br/>
    * }
    * </code>
    * 
    * @param sentence 句子
    * @param replace 敏感词的替换字符
    * @return 过滤后的句子 
    */
   public String filter(String sentence, char replace){
      // 先转换为StringPointer
      StringPointer sp = new StringPointer(sentence);
      
      // 标示是否替换
      boolean replaced = false;
      
      // 匹配的起始位置
      int i = 0;
      while(i < sp.length - 1){
         /*
          * 移动到下一个匹配位置的步进:
          * 如果未匹配为1,如果匹配是匹配的词长度
          */
         int step = 1;
         // 计算此位置开始2个字符的hash
         int hash = sp.nextTwoCharHash(i);
         /*
          * 根据hash获取第一个节点,
          * 真正匹配的节点可能不是第一个,
          * 所以有后面的for循环。
          */
         SensitiveNode node = nodes[hash & (nodes.length - 1)];
         /*
          * 如果非敏感词,node基本为null。
          * 这一步大幅提升效率 
          */
         if(node != null){
            /*
             * 如果能拿到第一个节点,
             * 才计算mix(mix相同表示2个字符相同)。
             * mix的意义和HashMap先hash再equals的equals部分类似。
             */
            int mix = sp.nextTwoCharMix(i);
            /*
             * 循环所有的节点,如果非敏感词,
             * mix相同的概率非常低,提高效率
             */
            outer:
            for(; node != null; node = node.next){
               /*
                * 对于一个节点,先根据头2个字符判断是否属于这个节点。
                * 如果属于这个节点,看这个节点的词库是否命中。
                * 此代码块中访问次数已经很少,不是优化重点
                */
               if(node.headTwoCharMix == mix){
                  /*
                   * 查出比剩余sentence小的最大的词。
                   * 例如剩余sentence为"色情电影哪家强?",
                   * 这个节点含三个词从小到大为:"色情"、"色情电影"、"色情信息"。
                   * 则从“色情电影”开始向前匹配
                   */
                  NavigableSet<StringPointer> desSet = node.words.headSet(sp.substring(i), true);
                  if(desSet != null){
                     for(StringPointer word: desSet.descendingSet()){
                        /*
                         * 仍然需要再判断一次,例如"色情信息哪里有?",
                         * 如果节点只包含"色情电影"一个词,
                         * 仍然能够取到word为"色情电影",但是不该匹配。
                         */
                        if(sp.nextStartsWith(i, word)){
                           // 匹配成功,将匹配的部分,用replace制定的内容替代
                           sp.fill(i, i + word.length, replace);
                           // 跳过已经替代的部分
                           step = word.length;
                           // 标示有替换
                           replaced = true;
                           // 跳出循环(然后是while循环的下一个位置)
                           break outer;
                        }
                     }
                  }
                  
               }
            }
         }
         
         // 移动到下一个匹配位置
         i += step;
      }
      
      // 如果没有替换,直接返回入参(节约String的构造copy)
      if(replaced){
         return sp.toString();
      }else{
         return sentence;
      }
   }

   public String filter(String sentence){
      char replace = '*';
      StringBuilder sb = new StringBuilder();
      // 先转换为StringPointer
      StringPointer sp = new StringPointer(sentence);

      // 标示是否替换
      boolean replaced = false;

      // 匹配的起始位置
      int i = 0;
      while(i < sp.length - 1){
         /*
          * 移动到下一个匹配位置的步进:
          * 如果未匹配为1,如果匹配是匹配的词长度
          */
         int step = 1;
         // 计算此位置开始2个字符的hash
         int hash = sp.nextTwoCharHash(i);
         /*
          * 根据hash获取第一个节点,
          * 真正匹配的节点可能不是第一个,
          * 所以有后面的for循环。
          */
         SensitiveNode node = nodes[hash & (nodes.length - 1)];
         /*
          * 如果非敏感词,node基本为null。
          * 这一步大幅提升效率
          */
         if(node != null){
            /*
             * 如果能拿到第一个节点,
             * 才计算mix(mix相同表示2个字符相同)。
             * mix的意义和HashMap先hash再equals的equals部分类似。
             */
            int mix = sp.nextTwoCharMix(i);
            /*
             * 循环所有的节点,如果非敏感词,
             * mix相同的概率非常低,提高效率
             */
            outer:
            for(; node != null; node = node.next){
               /*
                * 对于一个节点,先根据头2个字符判断是否属于这个节点。
                * 如果属于这个节点,看这个节点的词库是否命中。
                * 此代码块中访问次数已经很少,不是优化重点
                */
               if(node.headTwoCharMix == mix){
                  /*
                   * 查出比剩余sentence小的最大的词。
                   * 例如剩余sentence为"色情电影哪家强?",
                   * 这个节点含三个词从小到大为:"色情"、"色情电影"、"色情信息"。
                   * 则从“色情电影”开始向前匹配
                   */
                  NavigableSet<StringPointer> desSet = node.words.headSet(sp.substring(i), true);
                  if(desSet != null){
                     for(StringPointer word: desSet.descendingSet()){
                        /*
                         * 仍然需要再判断一次,例如"色情信息哪里有?",
                         * 如果节点只包含"色情电影"一个词,
                         * 仍然能够取到word为"色情电影",但是不该匹配。
                         */
                        if(sp.nextStartsWith(i, word)){
                           // 匹配成功,将匹配的部分,用replace制定的内容替代
                           sp.fill(i, i + word.length, replace);
                           sb.append(word).append(",");
                           // 跳过已经替代的部分
                           step = word.length;
                           // 标示有替换
                           replaced = true;
                           // 跳出循环(然后是while循环的下一个位置)
                           break outer;
                        }
                     }
                  }

               }
            }
         }

         // 移动到下一个匹配位置
         i += step;
      }

      // 如果没有替换,直接返回入参(节约String的构造copy)
      if(replaced){
         return sb.substring(0, sb.length() - 1);
      }else{
         return null;
      }
   }

   public void resetNodes(){
      nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY];
   }

    @Override
    public String toString() {
        return "SensitiveFilter{" +
                "nodes=" + Arrays.toString(nodes) +
                '}';
    }
}

 

 

StringPointer 类方法: 提供filter核心类中一些hash值的计算方法等

package com.unicom.kc.sensitive.util.sensi;

import java.io.Serializable;
import java.util.HashMap;
import java.util.TreeMap;

/**
 * 没有注释的方法与{@link String}类似<br/>
 * <b>注意:</b>没有(数组越界等的)安全检查<br/>
 * 可以作为{@link HashMap}和{@link TreeMap}的key
 */
public class StringPointer implements Serializable, CharSequence, Comparable<StringPointer>{
   
   private static final long serialVersionUID = 1L;

   protected final char[] value;
   
   protected final int offset;
   
   protected final int length;
   
   private int hash = 0;
   
   public StringPointer(String str){
      value = str.toCharArray();
      offset = 0;
      length = value.length;
   }
   
   public StringPointer(char[] value, int offset, int length){
      this.value = value;
      this.offset = offset;
      this.length = length;
   }
   
   /**
    * 计算该位置后(包含)2个字符的hash值
    * 
    * @param i 从 0 到 length - 2
    * @return hash值
    */
   public int nextTwoCharHash(int i){
      return 31 * value[offset + i] + value[offset + i + 1];
   }
   
   /**
    * 计算该位置后(包含)2个字符和为1个int型的值<br/>
    * int值相同表示2个字符相同
    * 
    * @param i 从 0 到 length - 2
    * @return int值
    */
   public int nextTwoCharMix(int i){
      return (value[offset + i] << 16) | value[offset + i + 1];
   }
   
   /**
    * 该位置后(包含)的字符串,是否以某个词(word)开头
    * 
    * @param i 从 0 到 length - 2
    * @param word 词
    * @return 是否?
    */
   public boolean nextStartsWith(int i, StringPointer word){
      // 是否长度超出
      if(word.length > length - i){
         return false;
      }
      // 从尾开始判断
      for(int c =  word.length - 1; c >= 0; c --){
         if(value[offset + i + c] != word.value[word.offset + c]){
            return false;
         }
      }
      return true;
   }
   
   /**
    * 填充(替换)
    * 
    * @param begin 从此位置开始(含)
    * @param end 到此位置结束(不含)
    * @param fillWith 以此字符填充(替换)
    */
   public void fill(int begin, int end, char fillWith){
      for(int i = begin; i < end; i ++){
         value[offset + i] = fillWith;
      }
   }
   
   public int length(){
      return length;
   }
   
   public char charAt(int i){
      return value[offset + i];
   }
   
   public StringPointer substring(int begin){
      return new StringPointer(value, offset + begin, length - begin);
   }
   
   public StringPointer substring(int begin, int end){
      return new StringPointer(value, offset + begin, end - begin);
   }

   @Override
   public CharSequence subSequence(int start, int end) {
      return substring(start, end);
   }
   
   public String toString(){
      return new String(value, offset, length);
   }
   
   public int hashCode() {
      int h = hash;
      if (h == 0 && length > 0) {
         for (int i = 0; i < length; i++) {
            h = 31 * h + value[offset + i];
         }
         hash = h;
      }
      return h;
   }
   
   public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof StringPointer) {
           StringPointer that = (StringPointer)anObject;
            if (length == that.length) {
                char v1[] = this.value;
                char v2[] = that.value;
                for(int i = 0; i < this.length; i ++){
                   if(v1[this.offset + i] != v2[that.offset + i]){
                      return false;
                   }
                }
                return true;
            }
        }
        return false;
    }

   @Override
   public int compareTo(StringPointer that) {
      int len1 = this.length;
        int len2 = that.length;
        int lim = Math.min(len1, len2);
        char v1[] = this.value;
        char v2[] = that.value;

        int k = 0;
        while (k < lim) {
            char c1 = v1[this.offset + k];
            char c2 = v2[that.offset + k];
            if (c1 != c2) {
                return c1 - c2;
            }
            k++;
        }
        return len1 - len2;
   }

}
package com.unicom.kc.sensitive.util.sensi;

import java.io.Serializable;
import java.util.HashMap;
import java.util.TreeMap;

/**
 * 没有注释的方法与{@link String}类似<br/>
 * <b>注意:</b>没有(数组越界等的)安全检查<br/>
 * 可以作为{@link HashMap}和{@link TreeMap}的key
 */
public class StringPointer implements Serializable, CharSequence, Comparable<StringPointer>{
   
   private static final long serialVersionUID = 1L;

   protected final char[] value;
   
   protected final int offset;
   
   protected final int length;
   
   private int hash = 0;
   
   public StringPointer(String str){
      value = str.toCharArray();
      offset = 0;
      length = value.length;
   }
   
   public StringPointer(char[] value, int offset, int length){
      this.value = value;
      this.offset = offset;
      this.length = length;
   }
   
   /**
    * 计算该位置后(包含)2个字符的hash值
    * 
    * @param i 从 0 到 length - 2
    * @return hash值
    */
   public int nextTwoCharHash(int i){
      return 31 * value[offset + i] + value[offset + i + 1];
   }
   
   /**
    * 计算该位置后(包含)2个字符和为1个int型的值<br/>
    * int值相同表示2个字符相同
    * 
    * @param i 从 0 到 length - 2
    * @return int值
    */
   public int nextTwoCharMix(int i){
      return (value[offset + i] << 16) | value[offset + i + 1];
   }
   
   /**
    * 该位置后(包含)的字符串,是否以某个词(word)开头
    * 
    * @param i 从 0 到 length - 2
    * @param word 词
    * @return 是否?
    */
   public boolean nextStartsWith(int i, StringPointer word){
      // 是否长度超出
      if(word.length > length - i){
         return false;
      }
      // 从尾开始判断
      for(int c =  word.length - 1; c >= 0; c --){
         if(value[offset + i + c] != word.value[word.offset + c]){
            return false;
         }
      }
      return true;
   }
   
   /**
    * 填充(替换)
    * 
    * @param begin 从此位置开始(含)
    * @param end 到此位置结束(不含)
    * @param fillWith 以此字符填充(替换)
    */
   public void fill(int begin, int end, char fillWith){
      for(int i = begin; i < end; i ++){
         value[offset + i] = fillWith;
      }
   }
   
   public int length(){
      return length;
   }
   
   public char charAt(int i){
      return value[offset + i];
   }
   
   public StringPointer substring(int begin){
      return new StringPointer(value, offset + begin, length - begin);
   }
   
   public StringPointer substring(int begin, int end){
      return new StringPointer(value, offset + begin, end - begin);
   }

   @Override
   public CharSequence subSequence(int start, int end) {
      return substring(start, end);
   }
   
   public String toString(){
      return new String(value, offset, length);
   }
   
   public int hashCode() {
      int h = hash;
      if (h == 0 && length > 0) {
         for (int i = 0; i < length; i++) {
            h = 31 * h + value[offset + i];
         }
         hash = h;
      }
      return h;
   }
   
   public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof StringPointer) {
           StringPointer that = (StringPointer)anObject;
            if (length == that.length) {
                char v1[] = this.value;
                char v2[] = that.value;
                for(int i = 0; i < this.length; i ++){
                   if(v1[this.offset + i] != v2[that.offset + i]){
                      return false;
                   }
                }
                return true;
            }
        }
        return false;
    }

   @Override
   public int compareTo(StringPointer that) {
      int len1 = this.length;
        int len2 = that.length;
        int lim = Math.min(len1, len2);
        char v1[] = this.value;
        char v2[] = that.value;

        int k = 0;
        while (k < lim) {
            char c1 = v1[this.offset + k];
            char c2 = v2[that.offset + k];
            if (c1 != c2) {
                return c1 - c2;
            }
            k++;
        }
        return len1 - len2;
   }

}

SensitiveNode 类:

package com.unicom.kc.sensitive.util.sensi;

import java.io.Serializable;
import java.util.TreeSet;

/**
 * 敏感词节点,每个节点包含了以相同的2个字符开头的所有词
 */
public class SensitiveNode implements Serializable{
   
   private static final long serialVersionUID = 1L;

   /**
    * 头两个字符的mix,mix相同,两个字符相同
    */
   protected final int headTwoCharMix;
   
   /**
    * 所有以这两个字符开头的词表
    */
   protected final TreeSet<StringPointer> words = new TreeSet<StringPointer>();
   
   /**
    * 下一个节点
    */
   protected SensitiveNode next;
   
   public SensitiveNode(int headTwoCharMix){
      this.headTwoCharMix = headTwoCharMix;
   }
   
   public SensitiveNode(int headTwoCharMix, SensitiveNode parent){
      this.headTwoCharMix = headTwoCharMix;
      parent.next = this;
   }

   @Override
   public String toString() {
      return "SensitiveNode{" +
            "headTwoCharMix=" + headTwoCharMix +
            ", words=" + words +
            ", next=" + next +
            '}';
   }
}

以上代码逻辑即可完成敏感词 过滤 或 以指定字符替换:效果如下【其中component里 刷新敏感词数据逻辑需要自己编写;filter核心类中的del方法需要自己编写】

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值