敏感词过滤--DFA算法及代码案例
我们应该都遇见过敏感词过滤,比如当我们输入一些包含暴力或者色情的文本,系统会阻止信息提交。敏感词过滤就是检查用户输入的内容有没有敏感词,检查之后有两个策略。
- 直接阻止信息保存,接口返回错误信息
- 允许信息保存,但是会把敏感词替换为***
不管是哪种策略,首先都得找到是否包含敏感词,这个判断一般是在服务端完成的。
要判断用户输入有无敏感词,首先要知道哪些词语是敏感词,也就是得有个敏感词库。比如现在敏感词库里记录了两个敏感词:“abc”和“cde”,如何判断用户输入的内容里是否包含这两个敏感词呢?最容易想到的方法是遍历敏感词库,依次判断输入内容是否有“abc”和“cde”。这种方法是可靠的,但是真实的敏感词库里存放的敏感词是非常多的,这时候遍历敏感词库的性能比较低,而且大部分情况下用户输入的内容都是不包含敏感词的,这时需要完全遍历敏感词库,也就是说大部分情况下遇到的都是这个算法复杂度最高的情形。
有没有一种比较高效的方法来查找敏感词呢?DFA算法可以做到。
DFA全称为:Deterministic Finite Automaton,即确定有穷自动机。其特征为:有一个有限状态集合和一些从一个状态通向另一个状态的边,每条边上标记有一个符号,其中一个状态是初态,某些状态是终态。但不同于不确定的有限自动机,DFA中不会有从同一状态出发的两条边标志有相同的符号。
敏感词过滤很适合用DFA算法,用户每次输入都是状态的切换,如果出现敏感词,既是终态,就可以结束判断。
我们把数组形式的敏感词整理为一个树状结构,准确的说是一个森林。
这样查找敏感词就变成了一个查找路径的问题,如果用户输入的内容中包含一个从根节点到叶子节点的完整路径,就说明包含敏感词。
算法实现逻辑是循环用户输入的字符串,依次查找每个字符是否出现在树的节点上,比如用户输入“打倒日本人”,从第一个字开始判断,“打”不在树的根节点上,进入下一步,“倒”也不在根节点上,进入下一步,“日”出现在了根节点上,这时状态切换,下一步的查找范围变为“日”的子节点;“本”出现在子节点中,状态再次切换为“本”的子节点;“人”出现在子节点中,并且为叶子节点,所以包含敏感词。
上述过程只是基本的思路,具体实现还要考虑中途退出时的状态重置,完整代码放在下面。(html代码)
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> <style> #text{ width: 100%; } .output{ margin: 30px 0; font-size: 16px; line-height: 28px; } </style> </head> <body> <textarea id="text"></textarea> <div> <button id="button">提交</button> </div> <div class="output"></div> </body> </html> <script> const dirtyTextArr = ['日本人', '日本鬼子', '日本男人'] console.log('敏感词个数 -> ', dirtyTextArr.length) const dirtyRoot = new Map() const buildMap = function (text) { let dirtyMap = dirtyRoot let origin = true if (!text) { // 敏感词不能为空 return } for (let a = 0; a < text.length; a++) { if (origin && dirtyMap.get(text[a])) { // 当前字符已存在 if (dirtyMap.get(text[a]) === true) { // 已经存在更短的敏感词 return; } let oldDirtyMap = dirtyMap dirtyMap = dirtyMap.get(text[a]) // 当前字符是最后一个字, 将dirtyMap置为true if (a === text.length - 1) { oldDirtyMap.set(text[a], true) // oldDirtyMap. } } else { origin = false if (a === text.length - 1) { // 最后一个字 dirtyMap.set(text[a], true) } else { // console.log(text[a]) dirtyMap.set(text[a], new Map()) dirtyMap = dirtyMap.get(text[a]) } } } } for (let b = 0; b < dirtyTextArr.length; b++) { buildMap(dirtyTextArr[b]) } console.log('整理为map结构 -> ', dirtyRoot) const dirtyCheckDFA = function (word){ if (!word || !dirtyRoot) { return {dirty: false} } let dirty = false let dirtyMap = dirtyRoot let start = null let end = 0 let origin = true for (let i = 0; i < word.length; i++) { // console.log(word[i]) const aMap = dirtyMap.get(word[i]) if (aMap) { // 在敏感词中匹配到该字符,且该字符为最后一个字 if (aMap === true) { // 敏感词长度为1 if (start === null) { start = i } end = i dirty = true break } else { // 在敏感词中匹配到该字符,但不是结尾 if (origin) { start = i } // console.log(aMap) dirtyMap = aMap origin = false if (i === word.length - 1) { i = start origin = true dirtyMap = dirtyRoot } } } else { if (!origin) { // 在树结构中途退出,重新从进入字符的下一个字符检查 i = start } origin = true start = null dirtyMap = dirtyRoot } } if (dirty) { return { dirty: true, start, end} } return {dirty: false} } const dirtyCheckLoop = function (word) { if (!word) { return {dirty: false} } for (let a = 0; a < dirtyTextArr.length; a++) { const startIndex = word.indexOf(dirtyTextArr[a]) if (startIndex !== -1) { return {dirty: true, start: startIndex, end: startIndex + dirtyTextArr[a].length - 1} } } return {dirty: false} } function submit() { console.log('**********************') const text = document.querySelector('#text').value const textLength = text.length // console.log('待检测字符串长度',) console.time('dirtyCheckDFA') const checkResult = JSON.stringify(dirtyCheckDFA(text)) console.timeEnd('dirtyCheckDFA') console.time('dirtyCheckLoop') const checkResult2 = JSON.stringify(dirtyCheckLoop(text)) console.timeEnd('dirtyCheckLoop') const str = `输入字符串长度:${textLength}<br>dirtyCheckDFA运行结果:${checkResult}<br>dirtyCheckLoop运行结果:${checkResult2}` document.querySelector('.output').innerHTML = str } document.querySelector('#button').addEventListener('click', submit) </script>
上述例子中还比较了DFA算法和遍历词库算法的运行速度,由于敏感词库只有三个敏感词,所以DFA性能并不占优势,但是当敏感词库很大时,DFA优势会很明显,截图如下。
从catchadmin框架下找到以下代码:
<?php declare(strict_types=1); use think\facade\Cache; class Trie { protected $tree = []; protected $end = 'end'; protected $sensitiveWord = ''; protected $sensitiveWords = []; /** * add * * @time 2020年06月17日 * @param string $word * @return $this */ public function add(string $word) { $words = mb_str_split($word); $array = []; $len = count($words); $end = true; while ($len > 0) { if ($end) { $array[] = [ $words[$len - 1] => ['end' => true], ]; } else { $latest = array_pop($array); $array[] = [ $words[$len-1] => $latest, ]; } $end = false; $len--; } $this->tree = array_merge_recursive($this->tree, array_pop($array)); return $this; } /** * 获取 * * @time 2020年06月17日 * @throws \Psr\SimpleCache\InvalidArgumentException * @return array|bool */ public function getTries() { if (!empty($this->tree)) { return $this->tree; } return Cache::store('redis')->get("trie_tree"); } /** * 获取敏感词 * * @time 2020年06月17日 * @param array $trieTree * @param string $content * @param bool $all * @return array|string */ public function getSensitiveWords(array $trieTree, string $content, $all = true) { $words = mb_str_split($content); $len = count($words); for ($start = 0; $start < $len; $start++) { // 未搜索到 if (!isset($trieTree[$words[$start]])) { continue; } $node = $trieTree[$words[$start]]; $this->sensitiveWord = $words[$start]; // 从敏感词开始查找内容中是否又符合的 for ($i = $start+1; $i< $len; $i++) { $node = $node[$words[$i]] ?? null; $this->sensitiveWord .= $words[$i]; if (isset($node['end'])) { if ($all) { $this->sensitiveWords[] = $this->sensitiveWord; $this->sensitiveWord = ''; } else { break 2; } } if (!$node) { $this->sensitiveWord = ''; $start = $i-1; break; } } // 防止内容比敏感词短 导致验证过去 // 使用敏感词【傻子】校验【傻】这个词 // 会提取【傻】 // 再次判断是否是尾部 if (!isset($node['end'])) { $this->sensitiveWord = ''; } } return $all ? $this->sensitiveWords : $this->sensitiveWord; } /** * replace * * @time 2020年06月17日 * @param $tree * @param string $content * @return string|string[] */ public function replace($tree, string $content) { $sensitiveWords = $this->getSensitiveWords($tree, $content); $replace = []; foreach ($sensitiveWords as $word) { $replace[] = str_repeat('*', mb_strlen($word)); } return str_replace($sensitiveWords, $replace, $content); } /** * cache * * @time 2020年06月17日 */ public function cached() { return Cache::store('redis')->set("trie_tree", $this->tree); } }
用法:
$trie = new Trie(); $trie->add("敏感词"); // 获取 trie tree $trieTree = $trie->getTries(); //getSensitiveWords 方法可以获取到内容里面存在的敏感词汇 //$content为用户提交的内容 $trie->getSensitiveWords($trieTree, $content); //replace 方法可以替换到内容里面存在的敏感词。 $trie->replace(['敏感词111','敏感词2222'], $content);