java使用DFA算法实现敏感词过滤,替换(txt版)

  • Post author:
  • Post category:java




最近在开发过程中遇到了需要添加敏感词的地方,我这个方法是扫描项目目录下的resources下的txt完成敏感词过滤。

该方法只能过滤两个字或者两个字以上的敏感词。

首先,你可以去网上找一些关于敏感词的内容,放到一个txt中,名字随意,然后放入resources下,这个txt暂且可以先记作黑名单,下面的util中会用到一个test.txt,下面的test.txt是白名单。

黑名单中盛放的就是你所要用的敏感词,白名单就是对黑名单中的词的释放,如果你黑名单中有这个敏感词,你在白名单中也添加了这个词,这个词会被释放,不会当做敏感词进行过滤。

其次是pom.xml文件

<groupId>com.odianyun.util</groupId>
	<artifactId>sensitive-words</artifactId>
	<version>1.0.2</version>
	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-compiler-plugin</artifactId>
				<configuration>
					<source>7</source>
					<target>7</target>
				</configuration>
			</plugin>
		</plugins>
	</build>
	<packaging>jar</packaging>
	
    <properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	</properties>

	<dependencies>
		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<version>3.8.1</version>
			<scope>test</scope>
		</dependency>
	</dependencies>

然后自己可以创建一个utils,在其中存放四个工具类

第一个工具类SensitiveNode.java:

/**
 * 敏感词节点,每个节点包含了以相同的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;
	}

}

第二个工具类StringPointer.java:


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;
	}

}

第三个工具类SensitiveFilter.java:

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.NavigableSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 敏感词过滤器,以过滤速度优化为主。<br/>
 * * 增加一个敏感词:{@link #put(String)} <br/>
 * * 过滤一个句子:{@link #filter(String, char)} <br/>
 * * 获取默认的单例:{@link #DEFAULT}
 */
public class SensitiveFilter implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 默认的单例,使用自带的敏感词库
     */
    public static final SensitiveFilter DEFAULT = new SensitiveFilter(
            new BufferedReader(new InputStreamReader(
                    ClassLoader.getSystemResourceAsStream("mgc.txt")
                    , StandardCharsets.UTF_8)));

    /**
     * 为2的n次方,考虑到敏感词大概在10k左右,
     * 这个数量应为词数的数倍,使得桶很稀疏
     * 提高不命中时hash指向null的概率,
     * 加快访问速度。
     */
    static final int DEFAULT_INITIAL_CAPACITY = 131072;

    /**
     * 类似HashMap的桶,比较稀疏。
     * 使用2个字符的hash定位。
     */
    protected SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY];

    /**
     * 构建一个空的filter
     */
    public SensitiveFilter() {

    }

    /**
     * 加载一个文件中的词典,并构建filter<br/>
     * 文件中,每行一个敏感词条<br/>
     * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/>
     * <b>注意:</b>读取中的{@link IOException}不会抛出
     * @param reader
     */
    public SensitiveFilter(BufferedReader reader) {
//        File f2 = new File(this.getClass().getResource("").getPath());
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(
                ClassLoader.getSystemResourceAsStream("test.txt")));
        List<String> strings = loadKeywords(bufferedReader);
        try {
            for (String line = reader.readLine(); line != null; line = reader.readLine()) {
                if (strings.indexOf(line)==-1) {
                    put(line);
                }
            }
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static List<String> loadKeywords(BufferedReader br){
        List<String> keyArray=new ArrayList<String>();
        try{
            String s = null;
            while((s = br.readLine())!=null){//使用readLine方法,一次读一行
                keyArray.add(s);
            }
            br.close();
        }catch(Exception e){
            e.printStackTrace();
        }
        return keyArray;
    }
    
    /**
     *  去除空格 回车 换行 水平制表符
     * @param str
     * @return
     */
    public static String replaceAllBlank(String str) {
        String s = "";
        if (str != null) {
            Pattern p = Pattern.compile("\\s*|\t|\r|\n");
            /*   \n 回车(\u000a)   \t 水平制表符(\u0009)   \s 空格(\u0008)   \r 换行(\u000d)   */
            Matcher m = p.matcher(str);
            s = m.replaceAll("");
        }
        return s;
    }
    
    /**
     * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/>
     * 此方法(构建)并不是主要的性能优化点。
     *
     * @param word
     */
    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 - 2) {
            /*
             * 移动到下一个匹配位置的步进:
             * 如果未匹配为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;
        }
    }

    /**
     * 对句子进行敏感词过滤<br/>
     * 如果无敏感词返回输入的sentence对象,即可以用下面的方式判断是否有敏感词:<br/><code>
     * String result = filter.filter(sentence, '*');<br/>
     * if(result != sentence){<br/>
     * &nbsp;&nbsp;// 有敏感词<br/>
     * }
     * </code>
     *
     * @param sentence 句子
     * @return 敏感词
     */
    public List<String> CheckSensitiveWord(String sentence) {
        List<String> sensitiveList = new ArrayList<>();
        // 先转换为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)) {
                                    sensitiveList.add(word.toString());
                                    // 匹配成功,将匹配的部分,用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;
        }*/
        return sensitiveList;
    }
}

第四个工具类SensitiveUtils:

import com.sun.deploy.util.StringUtils;
import java.util.List;

/**
 * Created with IntelliJ IDEA.
 * @Description:
 */
public class SensitiveUtils {
        public static boolean CheckSensitiveWord(String sentence){
            //构建敏感词DFA模型
            SensitiveFilter filter = SensitiveFilter.DEFAULT;
            List<String> sentenceList = filter.CheckSensitiveWord(sentence);
            if(sentenceList.size()>0){
                //转成拼接字符串
                String sentenceStr = StringUtils.join(sentenceList, ",");
                System.out.println(sentenceStr);
                return false;
            }
            return true;
        }
}

然后在测试类或者使用的时候直接调用方法即可

boolean pd = SensitiveUtils.CheckSensitiveWord("哈哈哈");

如果是敏感词,这个boolean将会返回false,根据false或者true进行判断即可

下面这个是敏感词替换的方法:

    // 使用默认单例(加载默认词典)
	SensitiveFilter filter = SensitiveFilter.DEFAULT;
	// 向过滤器增加一个词
	filter.put("婚礼上唱春天在哪里");
	// 待过滤的句子
	String sentence = "然后,市长在婚礼上唱春天在哪里。";
	// 进行过滤
	String filted = filter.filter(sentence, '*');
	// 如果未过滤,则返回输入的String引用
	if(sentence != filted){
		// 句子中有敏感词
		System.out.println(filted);
	}

这个方法最后就是有敏感词就会输出替换成*号的结果。



版权声明:本文为whatevery原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。