OpenSource - 基于 DFA 算法实现的高性能 java 敏感词过滤工具框架

文章目录


sensitive-word

sensitive-word 基于 DFA 算法实现的高性能敏感词工具。

The sensitive word tool for java.(敏感词/违禁词/违法词/脏词。基于 DFA 算法实现的高性能 java 敏感词过滤工具框架。请勿发布涉及政治、广告、营销、翻墙、违反国家法律法规等内容。高性能敏感词检测过滤组件,附带繁体简体互换,支持全角半角互换,汉字转拼音,模糊搜索等功能。)

在线体验

创作目的

实现一款好用敏感词工具。

基于 DFA 算法实现,目前敏感词库内容收录 6W+(源文件 18W+,经过一次删减)。

后期将进行持续优化和补充敏感词库,并进一步提升算法的性能。

希望可以细化敏感词的分类,感觉工作量比较大,暂时没有进行。

特性

全角半角互换、英文大小写互换、数字常见形式的互换、中文繁简体互换、英文常见形式的互换、忽略重复词等

变更日志

CHANGE_LOG.md

更多资料

敏感词控台

有时候敏感词有一个控台,配置起来会更加灵活方便。

java 如何实现开箱即用的敏感词控台服务?

敏感词标签文件

梳理了大量的敏感词标签文件,可以让我们的敏感词更加方便。

这两个资料阅读可在下方文章获取:

v0.11.0-敏感词新特性及对应标签文件

快速开始

准备

  • JDK1.7+

  • Maven 3.x+

Maven 引入

xml 复制代码
<dependency>
    <groupId>com.github.houbb</groupId>
    <artifactId>sensitive-word</artifactId>
    <version>0.12.0</version>
</dependency>

核心方法

SensitiveWordHelper 作为敏感词的工具类,核心方法如下:

方法 参数 返回值 说明
contains(String) 待验证的字符串 布尔值 验证字符串是否包含敏感词
replace(String, ISensitiveWordReplace) 使用指定的替换策略替换敏感词 字符串 返回脱敏后的字符串
replace(String, char) 使用指定的 char 替换敏感词 字符串 返回脱敏后的字符串
replace(String) 使用 * 替换敏感词 字符串 返回脱敏后的字符串
findAll(String) 待验证的字符串 字符串列表 返回字符串中所有敏感词
findFirst(String) 待验证的字符串 字符串 返回字符串中第一个敏感词
findAll(String, IWordResultHandler) IWordResultHandler 结果处理类 字符串列表 返回字符串中所有敏感词
findFirst(String, IWordResultHandler) IWordResultHandler 结果处理类 字符串 返回字符串中第一个敏感词
tags(String) 获取敏感词的标签 敏感词字符串 返回敏感词的标签列表

判断是否包含敏感词

java 复制代码
final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";

Assert.assertTrue(SensitiveWordHelper.contains(text));

返回第一个敏感词

java 复制代码
final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";

String word = SensitiveWordHelper.findFirst(text);
Assert.assertEquals("五星红旗", word);

SensitiveWordHelper.findFirst(text) 等价于:

java 复制代码
String word = SensitiveWordHelper.findFirst(text, WordResultHandlers.word());

WordResultHandlers.raw() 可以保留对应的下标信息:

java 复制代码
final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";

IWordResult word = SensitiveWordHelper.findFirst(text, WordResultHandlers.raw());
Assert.assertEquals("WordResult{startIndex=0, endIndex=4}", word.toString());

返回所有敏感词

java 复制代码
final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";

List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[五星红旗, 毛主席, 天安门]", wordList.toString());

返回所有敏感词用法上类似于 SensitiveWordHelper.findFirst(),同样也支持指定结果处理类。

SensitiveWordHelper.findAll(text) 等价于:

java 复制代码
List<String> wordList = SensitiveWordHelper.findAll(text, WordResultHandlers.word());

WordResultHandlers.raw() 可以保留对应的下标信息:

java 复制代码
final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";

List<IWordResult> wordList = SensitiveWordHelper.findAll(text, WordResultHandlers.raw());
Assert.assertEquals("[WordResult{startIndex=0, endIndex=4}, WordResult{startIndex=9, endIndex=12}, WordResult{startIndex=18, endIndex=21}]", wordList.toString());

默认的替换策略

java 复制代码
final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";
String result = SensitiveWordHelper.replace(text);
Assert.assertEquals("****迎风飘扬,***的画像屹立在***前。", result);

指定替换的内容

java 复制代码
final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";
String result = SensitiveWordHelper.replace(text, '0');
Assert.assertEquals("0000迎风飘扬,000的画像屹立在000前。", result);

自定义替换策略

V0.2.0 支持该特性。

场景说明:有时候我们希望不同的敏感词有不同的替换结果。比如【游戏】替换为【电子竞技】,【失业】替换为【灵活就业】。

诚然,提前使用字符串的正则替换也可以,不过性能一般。

使用例子:

java 复制代码
/**
 * 自定替换策略
 * @since 0.2.0
 */
@Test
public void defineReplaceTest() {
    final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";

    ISensitiveWordReplace replace = new MySensitiveWordReplace();
    String result = SensitiveWordHelper.replace(text, replace);

    Assert.assertEquals("国家旗帜迎风飘扬,教员的画像屹立在***前。", result);
}

其中 MySensitiveWordReplace 是我们自定义的替换策略,实现如下:

java 复制代码
public class MyWordReplace implements IWordReplace {

    @Override
    public void replace(StringBuilder stringBuilder, final char[] rawChars, IWordResult wordResult, IWordContext wordContext) {
        String sensitiveWord = InnerWordCharUtils.getString(rawChars, wordResult);
        // 自定义不同的敏感词替换策略,可以从数据库等地方读取
        if("五星红旗".equals(sensitiveWord)) {
            stringBuilder.append("国家旗帜");
        } else if("毛主席".equals(sensitiveWord)) {
            stringBuilder.append("教员");
        } else {
            // 其他默认使用 * 代替
            int wordLength = wordResult.endIndex() - wordResult.startIndex();
            for(int i = 0; i < wordLength; i++) {
                stringBuilder.append('*');
            }
        }
    }

}

我们针对其中的部分词做固定映射处理,其他的默认转换为 *

IWordResultHandler 结果处理类

IWordResultHandler 可以对敏感词的结果进行处理,允许用户自定义。

内置实现见 WordResultHandlers 工具类:

  • WordResultHandlers.word()

只保留敏感词单词本身。

  • WordResultHandlers.raw()

保留敏感词相关信息,包含敏感词的开始和结束下标。

  • WordResultHandlers.wordTags()

同时保留单词,和对应的词标签信息。

使用实例

所有测试案例参见 SensitiveWordHelperTest

1)基本例子

java 复制代码
final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";

List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[五星红旗, 毛主席, 天安门]", wordList.toString());
List<String> wordList2 = SensitiveWordHelper.findAll(text, WordResultHandlers.word());
Assert.assertEquals("[五星红旗, 毛主席, 天安门]", wordList2.toString());

List<IWordResult> wordList3 = SensitiveWordHelper.findAll(text, WordResultHandlers.raw());
Assert.assertEquals("[WordResult{startIndex=0, endIndex=4}, WordResult{startIndex=9, endIndex=12}, WordResult{startIndex=18, endIndex=21}]", wordList3.toString());
  1. wordTags 例子

我们在 dict_tag_test.txt 文件中指定对应词的标签信息。

java 复制代码
final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";

// 默认敏感词标签为空
List<WordTagsDto> wordList1 = SensitiveWordHelper.findAll(text, WordResultHandlers.wordTags());
Assert.assertEquals("[WordTagsDto{word='五星红旗', tags=[]}, WordTagsDto{word='毛主席', tags=[]}, WordTagsDto{word='天安门', tags=[]}]", wordList1.toString());

List<WordTagsDto> wordList2 = SensitiveWordBs.newInstance()
        .wordTag(WordTags.file("dict_tag_test.txt"))
        .init()
        .findAll(text, WordResultHandlers.wordTags());
Assert.assertEquals("[WordTagsDto{word='五星红旗', tags=[政治, 国家]}, WordTagsDto{word='毛主席', tags=[政治, 伟人, 国家]}, WordTagsDto{word='天安门', tags=[政治, 国家, 地址]}]", wordList2.toString());

更多特性

后续的诸多特性,主要是针对各种针对各种情况的处理,尽可能的提升敏感词命中率。

这是一场漫长的攻防之战。

样式处理

忽略大小写

java 复制代码
final String text = "fuCK the bad words.";

String word = SensitiveWordHelper.findFirst(text);
Assert.assertEquals("fuCK", word);

忽略半角圆角

java 复制代码
final String text = "fuck the bad words.";

String word = SensitiveWordHelper.findFirst(text);
Assert.assertEquals("fuck", word);

忽略数字的写法

这里实现了数字常见形式的转换。

java 复制代码
final String text = "这个是我的微信:9⓿二肆⁹₈③⑸⒋➃㈤㊄";

List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[9⓿二肆⁹₈③⑸⒋➃㈤㊄]", wordList.toString());

忽略繁简体

java 复制代码
final String text = "我爱我的祖国和五星紅旗。";

List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[五星紅旗]", wordList.toString());

忽略英文的书写格式

java 复制代码
final String text = "Ⓕⓤc⒦ the bad words";

List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[Ⓕⓤc⒦]", wordList.toString());

忽略重复词

java 复制代码
final String text = "ⒻⒻⒻfⓤuⓤ⒰cⓒ⒦ the bad words";

List<String> wordList = SensitiveWordBs.newInstance()
        .ignoreRepeat(true)
        .init()
        .findAll(text);
Assert.assertEquals("[ⒻⒻⒻfⓤuⓤ⒰cⓒ⒦]", wordList.toString());

更多检测策略

邮箱检测

java 复制代码
final String text = "楼主好人,邮箱 sensitiveword@xx.com";

List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[sensitiveword@xx.com]", wordList.toString());

连续数字检测

一般用于过滤手机号/QQ等广告信息。

V0.2.1 之后,支持通过 numCheckLen(长度) 自定义检测的长度。

java 复制代码
final String text = "你懂得:12345678";

// 默认检测 8 位
List<String> wordList = SensitiveWordBs.newInstance().init().findAll(text);
Assert.assertEquals("[12345678]", wordList.toString());

// 指定数字的长度,避免误杀
List<String> wordList2 = SensitiveWordBs.newInstance()
        .numCheckLen(9)
        .init()
        .findAll(text);
Assert.assertEquals("[]", wordList2.toString());

网址检测

用于过滤常见的网址信息。

java 复制代码
final String text = "点击链接 www.baidu.com查看答案";

List<String> wordList = SensitiveWordBs.newInstance().init().findAll(text);
Assert.assertEquals("[链接, www.baidu.com]", wordList.toString());

Assert.assertEquals("点击** *************查看答案", SensitiveWordBs
                .newInstance()
                .init()
                .replace(text));

引导类特性配置

说明

上面的特性默认都是开启的,有时业务需要灵活定义相关的配置特性。

所以 v0.0.14 开放了属性配置。

配置方法

为了让使用更加优雅,统一使用 fluent-api 的方式定义。

用户可以使用 SensitiveWordBs 进行如下定义:

java 复制代码
SensitiveWordBs wordBs = SensitiveWordBs.newInstance()
        .ignoreCase(true)
        .ignoreWidth(true)
        .ignoreNumStyle(true)
        .ignoreChineseStyle(true)
        .ignoreEnglishStyle(true)
        .ignoreRepeat(false)
        .enableNumCheck(true)
        .enableEmailCheck(true)
        .enableUrlCheck(true)
        .enableWordCheck(true)
        .numCheckLen(8)
        .wordTag(WordTags.none())
        .charIgnore(SensitiveWordCharIgnores.defaults())
        .init();

final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";
Assert.assertTrue(wordBs.contains(text));

配置说明

其中各项配置的说明如下:

序号 方法 说明 默认值
1 ignoreCase 忽略大小写 true
2 ignoreWidth 忽略半角圆角 true
3 ignoreNumStyle 忽略数字的写法 true
4 ignoreChineseStyle 忽略中文的书写格式 true
5 ignoreEnglishStyle 忽略英文的书写格式 true
6 ignoreRepeat 忽略重复词 false
7 enableNumCheck 是否启用数字检测。 true
8 enableEmailCheck 是有启用邮箱检测 true
9 enableUrlCheck 是否启用链接检测 true
10 enableWordCheck 是否启用敏感单词检测 true
11 numCheckLen 数字检测,自定义指定长度。 8
12 wordTag 词对应的标签 none
13 charIgnore 忽略的字符 none

忽略字符

说明

我们的敏感词一般都是比较连续的,比如【傻帽】

那就有大聪明发现,可以在中间加一些字符,比如【傻!@#$帽】跳过检测,但是骂人等攻击力不减。

那么,如何应对这些类似的场景呢?

我们可以指定特殊字符的跳过集合,忽略掉这些无意义的字符即可。

v0.11.0 开始支持

例子

其中 charIgnore 对应的字符策略,用户可以自行灵活定义。

java 复制代码
final String text = "傻@冒,狗+东西";

//默认因为有特殊字符分割,无法识别
List<String> wordList = SensitiveWordBs.newInstance().init().findAll(text);
Assert.assertEquals("[]", wordList.toString());

// 指定忽略的字符策略,可自行实现。
List<String> wordList2 = SensitiveWordBs.newInstance()
        .charIgnore(SensitiveWordCharIgnores.specialChars())
        .init()
        .findAll(text);

Assert.assertEquals("[傻@冒, 狗+东西]", wordList2.toString());

敏感词标签

说明

有时候我们希望对敏感词加一个分类标签:比如社情、暴/力等等。

这样后续可以按照标签等进行更多特性操作,比如只处理某一类的标签。

支持版本:v0.10.0

入门例子

接口

这里只是一个抽象的接口,用户可以自行定义实现。比如从数据库查询等。

java 复制代码
public interface IWordTag {

    /**
     * 查询标签列表
     * @param word 脏词
     * @return 结果
     */
    Set<String> getTag(String word);

}

配置文件

我们可以自定义 dict 标签文件,通过 WordTags.file() 创建一个 WordTag 实现。

  • dict_tag_test.txt

    五星红旗 政治,国家

格式如下:

敏感词 tag1,tag2

实现

具体的效果如下,在引导类设置一下即可。

默认的 wordTag 是空的。

java 复制代码
String filePath = "dict_tag_test.txt";
IWordTag wordTag = WordTags.file(filePath);

SensitiveWordBs sensitiveWordBs = SensitiveWordBs.newInstance()
        .wordTag(wordTag)
        .init();

Assert.assertEquals("[政治, 国家]", sensitiveWordBs.tags("五星红旗").toString());;

后续会考虑引入一个内置的标签文件策略。

动态加载(用户自定义)

情景说明

有时候我们希望将敏感词的加载设计成动态的,比如控台修改,然后可以实时生效。

v0.0.13 支持了这种特性。

接口说明

为了实现这个特性,并且兼容以前的功能,我们定义了两个接口。

IWordDeny

接口如下,可以自定义自己的实现。

返回的列表,表示这个词是一个敏感词。

java 复制代码
/**
 * 拒绝出现的数据-返回的内容被当做是敏感词
 * @author binbin.hou
 * @since 0.0.13
 */
public interface IWordDeny {

    /**
     * 获取结果
     * @return 结果
     * @since 0.0.13
     */
    List<String> deny();

}

比如:

java 复制代码
public class MyWordDeny implements IWordDeny {

    @Override
    public List<String> deny() {
        return Arrays.asList("我的自定义敏感词");
    }

}

IWordAllow

接口如下,可以自定义自己的实现。

返回的列表,表示这个词不是一个敏感词。

java 复制代码
/**
 * 允许的内容-返回的内容不被当做敏感词
 * @author binbin.hou
 * @since 0.0.13
 */
public interface IWordAllow {

    /**
     * 获取结果
     * @return 结果
     * @since 0.0.13
     */
    List<String> allow();

}

如:

java 复制代码
public class MyWordAllow implements IWordAllow {

    @Override
    public List<String> allow() {
        return Arrays.asList("五星红旗");
    }

}

配置使用

接口自定义之后,当然需要指定才能生效。

为了让使用更加优雅,我们设计了引导类 SensitiveWordBs

可以通过 wordDeny() 指定敏感词,wordAllow() 指定非敏感词,通过 init() 初始化敏感词字典。

系统的默认配置

java 复制代码
SensitiveWordBs wordBs = SensitiveWordBs.newInstance()
        .wordDeny(WordDenys.system())
        .wordAllow(WordAllows.system())
        .init();

final String text = "五星红旗迎风飘扬,毛主席的画像屹立在天安门前。";
Assert.assertTrue(wordBs.contains(text));

备注:init() 对于敏感词 DFA 的构建是比较耗时的,一般建议在应用初始化的时候只初始化一次。而不是重复初始化!

指定自己的实现

我们可以测试一下自定义的实现,如下:

java 复制代码
String text = "这是一个测试,我的自定义敏感词。";

SensitiveWordBs wordBs = SensitiveWordBs.newInstance()
        .wordDeny(new MyWordDeny())
        .wordAllow(new MyWordAllow())
        .init();

Assert.assertEquals("[我的自定义敏感词]", wordBs.findAll(text).toString());

这里只有 我的自定义敏感词 是敏感词,而 测试 不是敏感词。

当然,这里是全部使用我们自定义的实现,一般建议使用系统的默认配置+自定义配置。

可以使用下面的方式。

同时配置多个

  • 多个敏感词

WordDenys.chains() 方法,将多个实现合并为同一个 IWordDeny。

  • 多个白名单

WordAllows.chains() 方法,将多个实现合并为同一个 IWordAllow。

例子:

java 复制代码
String text = "这是一个测试。我的自定义敏感词。";

IWordDeny wordDeny = WordDenys.chains(WordDenys.system(), new MyWordDeny());
IWordAllow wordAllow = WordAllows.chains(WordAllows.system(), new MyWordAllow());

SensitiveWordBs wordBs = SensitiveWordBs.newInstance()
        .wordDeny(wordDeny)
        .wordAllow(wordAllow)
        .init();

Assert.assertEquals("[我的自定义敏感词]", wordBs.findAll(text).toString());

这里都是同时使用了系统默认配置,和自定义的配置。

注意:我们初始化了新的 wordBs,那么用新的 wordBs 去判断。而不是用以前的 SensitiveWordHelper 工具方法,工具方法配置是默认的!

spring 整合

背景

实际使用中,比如可以在页面配置修改,然后实时生效。

数据存储在数据库中,下面是一个伪代码的例子,可以参考 SpringSensitiveWordConfig.java

要求,版本 v0.0.15 及其以上。

自定义数据源

简化伪代码如下,数据的源头为数据库。

MyDdWordAllow 和 MyDdWordDeny 是基于数据库为源头的自定义实现类。

java 复制代码
@Configuration
public class SpringSensitiveWordConfig {

    @Autowired
    private MyDdWordAllow myDdWordAllow;

    @Autowired
    private MyDdWordDeny myDdWordDeny;

    /**
     * 初始化引导类
     * @return 初始化引导类
     * @since 1.0.0
     */
    @Bean
    public SensitiveWordBs sensitiveWordBs() {
        SensitiveWordBs sensitiveWordBs = SensitiveWordBs.newInstance()
                .wordAllow(WordAllows.chains(WordAllows.system(), myDdWordAllow))
                .wordDeny(myDdWordDeny)
                // 各种其他配置
                .init();

        return sensitiveWordBs;
    }

}

敏感词库的初始化较为耗时,建议程序启动时做一次 init 初始化。

动态变更

为了保证敏感词修改可以实时生效且保证接口的尽可能简化,此处没有新增 add/remove 的方法。

而是在调用 sensitiveWordBs.init() 的时候,根据 IWordDeny+IWordAllow 重新构建敏感词库。

因为初始化可能耗时较长(秒级别),所有优化为 init 未完成时不影响旧的词库功能,完成后以新的为准

java 复制代码
@Component
public class SensitiveWordService {

    @Autowired
    private SensitiveWordBs sensitiveWordBs;

    /**
     * 更新词库
     *
     * 每次数据库的信息发生变化之后,首先调用更新数据库敏感词库的方法。
     * 如果需要生效,则调用这个方法。
     *
     * 说明:重新初始化不影响旧的方法使用。初始化完成后,会以新的为准。
     */
    public void refresh() {
        // 每次数据库的信息发生变化之后,首先调用更新数据库敏感词库的方法,然后调用这个方法。
        sensitiveWordBs.init();
    }

}

如上,你可以在数据库词库发生变更时,需要词库生效,主动触发一次初始化 sensitiveWordBs.init();

其他使用保持不变,无需重启应用。

Benchmark

V0.6.0 以后,添加对应的 benchmark 测试。

BenchmarkTimesTest

环境

测试环境为普通的笔记本:

处理器	12th Gen Intel(R) Core(TM) i7-1260P   2.10 GHz
机带 RAM	16.0 GB (15.7 GB 可用)
系统类型	64 位操作系统, 基于 x64 的处理器

ps: 不同环境会有差异,但是比例基本稳定。

测试效果记录

测试数据:100+ 字符串,循环 10W 次。

序号 场景 耗时 备注
1 只做敏感词,无任何格式转换 1470ms,约 7.2W QPS 追求极致性能,可以这样配置
2 只做敏感词,支持全部格式转换 2744ms,约 3.7W QPS 满足大部分场景

后期 road-map

  • 移除单个汉字的敏感词,在中国,要把词组当做一次词,降低误判率。

  • 支持单个的敏感词变化?

remove、add、edit?

  • 敏感词标签接口支持

  • 敏感词处理时标签支持

  • wordData 的内存占用对比 + 优化

  • 用户指定自定义的词组,同时允许指定词组的组合获取,更加灵活

FormatCombine/CheckCombine/AllowDenyCombine 组合策略,允许用户自定义。

  • word check 策略的优化,统一遍历+转换

  • 添加 ThreadLocal 等性能优化

拓展阅读

敏感词工具实现思路

DFA 算法讲解

敏感词库优化流程

java 如何实现开箱即用的敏感词控台服务?

v0.11.0-敏感词新特性及对应标签文件

NLP 开源矩阵

pinyin 汉字转拼音

pinyin2hanzi 拼音转汉字

segment 高性能中文分词

opencc4j 中文繁简体转换

nlp-hanzi-similar 汉字相似度

word-checker 拼写检测

sensitive-word 敏感词

相关推荐
大数据编程之光21 分钟前
Flink Standalone集群模式安装部署全攻略
java·大数据·开发语言·面试·flink
爪哇学长35 分钟前
双指针算法详解:原理、应用场景及代码示例
java·数据结构·算法
Dola_Pan39 分钟前
C语言:数组转换指针的时机
c语言·开发语言·算法
ExiFengs39 分钟前
实际项目Java1.8流处理, Optional常见用法
java·开发语言·spring
paj12345678941 分钟前
JDK1.8新增特性
java·开发语言
繁依Fanyi1 小时前
简易安卓句分器实现
java·服务器·开发语言·算法·eclipse
慧都小妮子1 小时前
Spire.PDF for .NET【页面设置】演示:打开 PDF 时自动显示书签或缩略图
java·pdf·.net
m51271 小时前
LinuxC语言
java·服务器·前端
烦躁的大鼻嘎1 小时前
模拟算法实例讲解:从理论到实践的编程之旅
数据结构·c++·算法·leetcode
IU宝1 小时前
C/C++内存管理
java·c语言·c++