首页 > 技术文章 > JS 敏感词识别-模式匹配算法 DFA

everlose 2020-05-09 10:41 原文

DFA(Deterministic Finite Automaton,即确定有穷自动机。其原理为:有一个有限状态集合和一些从一个状态通向另一个状态的边,每条边上标记有一个符号,其中一个状态是初态,某些状态是终态。但不同于不确定的有限自动机,DFA中不会有从同一状态出发的两条边标志有相同的符号。

举例有限状态机字典如下

@startuml
(王) -> (八)
(八) -> (蛋)
(八) -> (羔)
(羔) -> (子)
@enduml
{
  '王': {
    '八': {
      '蛋': {
        empty: true
      }
    }
    '羔': {
      '子': {
        empty: true
      }
    }
  }
}

输入为王八端时,逐字对比有限状态机的字典

  1. 王字命中
  2. 八字命中
  3. 端没有命中,则返回八字继续开始匹配
  4. 八字没有命中有限状态机,over

输入为王八蛋时

  1. 王字命中
  2. 八字命中
  3. 蛋命中
  4. 下一个 empty 为 true 命中,匹配成功。

实现

找出敏感词代码

'use strict';

// 特殊符号过滤逻辑
const ignoreChars = " \t\r\n~!@#$%^&*()_+-=【】、{}|;':\",。、《》?αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ。,、;:?!…—·ˉ¨‘’“”々~‖∶"'`|〃〔〕〈〉《》「」『』.〖〗【】()[]{}ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛㈠㈡㈢㈣㈤㈥㈦㈧㈨㈩①②③④⑤⑥⑦⑧⑨⑩⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇≈≡≠=≤≥<>≮≯∷±+-×÷/∫∮∝∞∧∨∑∏∪∩∈∵∴⊥∥∠⌒⊙≌∽√§№☆★○●◎◇◆□℃‰€■△▲※→←↑↓〓¤°#&@\︿_ ̄―♂♀┌┍┎┐┑┒┓─┄┈├┝┞┟┠┡┢┣│┆┊┬┭┮┯┰┱┲┳┼┽┾┿╀╁╂╃└┕┖┗┘┙┚┛━┅┉┤┥┦┧┨┩┪┫┃┇┋┴┵┶┷┸┹┺┻╋╊╉╈╇╆╅╄";
const ignoreObj = {};
for (let i = 0, j = ignoreChars.length; i < j; i++) {
  ignoreObj[ignoreChars.charCodeAt(i)] = true;
}

// 构建有限状态机
// 生成的数据结构形制如下
/**
 * {
 *   '王': {
 *     '八': {
 *       '蛋': {
 *         empty: true
 *       }
 *       '羔': {
 *         '子': {
 *           empty: true
 *         }
 *       }
 *     }
 *   }
 * }
 */
function buildMap(wordList) {
  const result = {};

  for (let i = 0, len = wordList.length; i < len; ++i) {
    let map = result;
    const word = wordList[i];
    for (let j = 0; j < word.length; ++j) {
      const ch = word.charAt(j).toLowerCase();
      if (map[ch]) {
        map = map[ch];
        if (map.empty) {
          break;
        }
      } else {
        if (map.empty) {
          delete map.empty;
        }
        map[ch] = {
          empty: true,
        };
        map = map[ch];
      }
    }
  }
  return result;
}

// 获取敏感词列表
function getSensitiveWords() {
  /*
  let words = [];
  if (words.length === 0) {
    const data = fs.readFileSync(path.join(__dirname, './words'), 'utf8');
    words = data.split('\n');
  }
  return words.filter(item => !!item);
  */
  return [
    '王八蛋',
    '王八羔子'
  ]
}

// 获取敏感词库对象
const sensitiveWords = getSensitiveWords();
const map = buildMap(sensitiveWords);

// 具体检测代码。
function check(map, content) {
  const result = [];
  let stack = [];
  let point = map;
  for (let i = 0, len = content.length; i < len; ++i) {
    const code = content.charCodeAt(i);
    if (ignoreObj[code]) {
      continue;
    }
    const ch = content.charAt(i);
    const item = point[ch.toLowerCase()];
    if (!item) {
      i = i - stack.length;
      stack = [];
      point = map;
    } else if (item.empty) {
      stack.push(ch);
      result.push(stack.join(''));
      stack = [];
      point = map;
    } else {
      stack.push(ch);
      point = item;
    }
  }
  return result;
}

function checkSensitive(content) {
  const words = check(map, content);
  return words;
}

module.exports = checkSensitive;

测试

checkSensitive('老板黄鹤*王&八&(&蛋,吃喝嫖赌,欠下了3.5个亿,带着他的小姨子跑了')

// ['王八蛋']

缺陷

若用火星文、异体字、同音字来替代,这类算法没什么好的办法能识别

checkSensitive('王八羔仔')

// []

若敏感词短,则误伤很大,若前文设置的是 王八 作为敏感词。

checkSensitive('虎躯一震,散发一阵王八之气');

// ['王八']

自然还有大名鼎鼎的 江阴毛纺织厂 怕也会很容易被误伤。所以,有更智能的办法是先做分词,参照分词库, 比如说 Node 库的 nodejieba,Python 的 jieba

可以将 江阴毛纺织厂,拆解为 江阴毛纺织厂 的词后,在进行 DFA 模式匹配

但截止日前笔者还不敢直接在线上库当中直接使用分词库,市面上大多数的敏感词过滤也不会上分词库的,或许是担心性能问题,也存在宁杀错无放过的心态吧。如果有人有更好的办法,请不吝赐教啊。

推荐阅读