xiangpei
2025-05-27 f2cc79e9e83aafacc1af0e2c86e3c8df384fc895
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
package cn.lili.common.sensitive;
 
import lombok.extern.slf4j.Slf4j;
 
import java.io.Serializable;
import java.util.List;
import java.util.NavigableSet;
 
/**
 * 敏感词过滤器
 *
 * @author Bulbasaur
 * @version v1.0
 * @since v1.0
 * 2020-02-25 14:10:16
 */
@Slf4j
public class SensitiveWordsFilter implements Serializable {
 
    /**
     * 字符*
     */
    public final static char WILDCARD_STAR = '*';
 
    /**
     * 为2的n次方,考虑到敏感词大概在10k左右,
     * 这个数量应为词数的数倍,使得桶很稀疏
     * 提高不命中时hash指向null的概率,
     * 加快访问速度。
     */
    static final int DEFAULT_INITIAL_CAPACITY = 131072;
 
    /**
     * 类似HashMap的桶,比较稀疏。
     * 使用2个字符的hash定位。
     */
    protected static SensitiveWordsNode[] nodes = new SensitiveWordsNode[0];
 
    /**
     * 更新中的nodes,用于防止动态更新时,原有nodes被清空,导致无法正常写入过滤词
     */
    protected static SensitiveWordsNode[] nodesUpdate;
 
 
    /**
     * 过滤铭感次
     *
     * @param sentence 过滤赐予
     * @return
     */
    public static String filter(String sentence) {
        return filter(sentence, WILDCARD_STAR);
    }
 
    /**
     * 判断内容是否包含敏感词,如果不包含 filter方法会原封不动的返回
     *
     * @param content
     * @return true:包含  false:不包含
     */
    public static Boolean includeSentenceWord(String content) {
        String filter = filter(content, WILDCARD_STAR);
        return !content.equals(filter);
    }
 
    /**
     * 对句子进行敏感词过滤<br/>
     * 如果无敏感词返回输入的sentence对象,即可以用下面的方式判断是否有敏感词:<br/>
     *
     * @param sentence 句子
     * @param replace  敏感词的替换字符
     * @return 过滤后的句子
     */
    public static 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);
 
            //如果没有敏感词,则直接返回内容
            if (nodes.length == 0) {
                return sentence;
            }
            /*
             * 根据hash获取第一个节点,
             * 真正匹配的节点可能不是第一个,
             * 所以有后面的for循环。
             */
            SensitiveWordsNode 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) {
            String res = sp.toString();
            return res.substring(0, res.length() - 2);
        } else {
            return sentence;
        }
    }
 
 
    /**
     * 初始化敏感词
     */
    public static void init(List<String> words) {
        log.info("开始初始化敏感词");
        nodesUpdate = new SensitiveWordsNode[DEFAULT_INITIAL_CAPACITY];
        for (String word : words) {
            put(word);
        }
        nodes = nodesUpdate;
    }
 
    /**
     * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/>
     * 此方法(构建)并不是主要的性能优化点。
     *
     * @param word 敏感词
     * @return 操作结果
     */
    public static 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 & (nodesUpdate.length - 1);
 
        //从桶里拿第一个节点
        SensitiveWordsNode node = nodesUpdate[index];
        if (node == null) {
            //如果没有节点,则放进去一个
            node = new SensitiveWordsNode(mix);
            //并添加词
            node.words.add(sp);
            //放入桶里
            nodesUpdate[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 SensitiveWordsNode(mix, node).words.add(sp);
                    return true;
                }
            }
        }
        return true;
    }
 
    /**
     * 移除敏感词
     *
     * @param word
     * @return
     */
    public static void remove(String word) {
 
        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);
        SensitiveWordsNode node = nodes[index];
 
        for (; node != null; node = node.next) {
            //匹配节点
            if (node.headTwoCharMix == mix) {
                node.words.remove(sp);
            }
 
        }
    }
 
 
}