A trie-based sensitive-word filter for Node.js. It scans text against a keyword dictionary and masks matches, while also detecting common obfuscation tricks such as inserting spaces, symbols, digits, or letters between the characters of a keyword.
- Trie-based engine — keywords are compiled into a prefix tree; scanning starts from every character with per-match filler skipping
- Obfuscation detection — matches keywords even when filler characters are inserted between keyword characters
- Longest-match — when keywords overlap (e.g.
ABandABC), the longer match wins - Case-insensitive — keywords and input text are matched case-insensitively
- Flexible keyword loading — pass an array, a single file, or a directory of keyword files
- Singleton or instances —
WordFilter.instance()for a shared instance, ornew WordFilter()for independent dictionaries - Word-boundary mode for pure-English text — optional word-segmentation mode for English-only environments; restricts ASCII keywords to word-boundary matches (opt-in, off by default)
- Per-keyword lenient mode — tag a keyword as
{ word: 'drug', lenient: true }to let that word tolerate letter/digit fillers (d1rugmatchesdrug); every other keyword stays strict
npm install sensitive-words-dfa-filter --saveTypeScript users get full type hints out of the box — the package ships with type declarations.
const WordFilter = require('sensitive-words-dfa-filter');
const filter = WordFilter.instance();
// Load keywords from an array (or a file / directory, see below)
filter.init(['AB', 'ABC', '治国']);
// Basic matching
console.log(filter.replace('This is "AB|A B|AAB|A1B|A@B" filter word!'));
// This is "**|**|A**|A1B|**" filter word!
console.log(filter.replace('This is "ABC|A B C|A1B1C|A@B@C" filter word!'));
// This is "***|***|A1B1C|***" filter word!
// Custom replacement character
console.log(filter.replace('This is "治国|治 国|治A国|治1国|治@国" filter word!', '?'));
// This is "??|??|??|??|??" filter word!
// Matching modes
// Word-boundary mode: for pure-English environments — ASCII keywords match
// only at word boundaries (admin no longer matches administrator).
filter.init(['admin'], { wordBoundary: true });
console.log(filter.replace('administrator admin admin123'));
// administrator ***** admin123
// Both knobs compose in one call: wordBoundary is global, lenient is per
// keyword — d1rug matches drug (up to 3 letter/digit fillers), while
// drugabc does not match drug anymore because of the word boundary.
filter.init(['admin', { word: 'drug', lenient: true }], { wordBoundary: true });
console.log(filter.replace('d1rug admin drugabc'));
// **** ***** drugabcinit() accepts either an array of keywords or a path:
| Input | Example | Notes |
|---|---|---|
| Array | filter.init(['赌博', '诈骗']) |
Simple in-code keyword list |
| File | filter.init('/path/to/keywords.txt') |
Reads the file; keywords separated by 、 or newlines |
| Directory | filter.init('/path/to/keywords') |
Reads every file inside the directory, including subdirectories |
Example keyword file:
赌博、诈骗、禁言、敏感词
spam
cheat
Empty entries and surrounding whitespace are ignored.
Returns the shared singleton instance.
Creates an independent filter with its own dictionary — useful when different parts of an application need different keyword sets.
init(keywords: (string | { word: string; lenient?: boolean })[], options?: { wordBoundary?: boolean }): void
Synchronously builds the matching dictionary.
keywords— an array of keyword strings or entries ({ word, lenient }), or a path to a keyword file/directory.lenient(per entry) — whentrue, this keyword tolerates up to 3 letters/digits as fillers even in pure-ASCII matches (d1rugmatchesdrug,d1r2u3gtoo;d11r22u33gdoes not). The cap prevents the keyword's first letter from matching across unrelated words (e.g.admin drugdoes not matchdrugfrom thedofadmin). Off by default; note this also meansA1BmatchesABifABis tagged lenient.options.wordBoundary— a word-segmentation mode intended for pure-English environments. Whentrue, pure-ASCII keywords only match at word boundaries: the character immediately before and after the keyword must not be a letter, digit, or underscore (same definition as\b), soadminno longer matches insideadministrator. Off by default; CJK keywords are not affected. Composes with per-keywordleniententries in the same call.- Calling
init()again replaces the existing dictionary.
Scans searchValue and replaces every matched keyword with replaceValue repeated once per matched keyword character (default *).
- Returns
searchValueunchanged if the filter has not been initialized.
Returns the size of the preloaded "skippable-character" cache and the cached characters (useful for debugging and performance monitoring).
Fully resets the filter: clears the preloaded character cache, the keyword dictionary, and the initialization state. Call init() again before using the filter.
- Case-insensitive:
Textmatches keywordtext. - Longest match wins: with keywords
ABandABC, inputABCis masked as***. - Filler characters can be skipped between keyword characters:
| Filler type | Skipped by default? | Example |
|---|---|---|
Symbols (@, #, ., ~, ...) |
Always | A@B matches AB |
| Spaces / whitespace | Always | A B matches AB |
| CJK radicals | Always | 中灬国 matches 中国 |
Full-width spaces / punctuation ( , 、, …, ...) |
Always | 治 国 matches 治国 |
Full-width letters / digits (A-Z, a-z, 0-9) |
Always, as fillers (not normalized) | 治A国 matches 治国; AB does not match AB |
Digits (0-9) |
Only when the matched part already contains non-ASCII characters | 治1国 matches 治国; A1B does not match AB |
Letters (a-z, A-Z) |
Only when the matched part already contains non-ASCII characters | 治A国 matches 治国; AAB becomes A** |
The non-ASCII rule prevents false positives in pure English text: with keyword text, the input This is text only masks text itself — the leading words are left untouched.
- English keywords match as plain substrings by default —
adminmatches insideadministrator, andsexinsidesexy. For pure-English environments, pass{ wordBoundary: true }toinit()to switch to word-segmentation mode: ASCII keywords only match at word boundaries (e.g.init(['admin'], { wordBoundary: true })masksthe adminbut leavesadministratorandadmin123untouched). CJK keywords are not affected; ASCII keywords are boundary-checked regardless of the surrounding text language. - Per-keyword lenient mode — tag a keyword as
{ word: 'drug', lenient: true }to allow up to 3 letter/digit fillers for that keyword even when the match is pure ASCII (d1rugmatchesdrug). The cap stops cross-word matches (admin drugdoes not matchdrugvia thedofadmin). This is opt-in because it is a precision tradeoff: ifABis tagged lenient,A1BmatchesABtoo. - Single-character ASCII keywords are ignored (e.g.
a) to avoid over-matching; single-character CJK keywords (e.g.赌) are supported. - Overlapping matches are merged into a single masked region: with keywords
ABCandBCD, the inputA B C Dis masked as****.
Measured locally (Node.js 24, v2.1.0) with an 11-keyword dictionary and ~1MB inputs:
| Input | Time | Throughput |
|---|---|---|
| Chinese text | ~540 ms | ~1.9 MB/s |
| English text | ~190 ms | ~5.2 MB/s |
| Mixed Chinese/English | ~370 ms | ~2.7 MB/s |
Numbers vary by machine, dictionary size, and hit rate — run npm run bench on your own hardware. The engine is a trie with per-position scanning (not an Aho-Corasick automaton), which is plenty for interactive filtering but not for multi-GB/s pipelines.
A pathological case — a single 500-character keyword over 20KB of repetitive text — takes ~0.4s, since nearly every position walks the whole keyword path.
- Substring matching by default — English keywords match inside longer words (
adminmatchesadministrator). In pure-English environments, enable{ wordBoundary: true }(see Matching rules) to switch to word-segmentation mode. - No full-width normalization — full-width
ABdoes not match keywordAB; full-width letters/digits are only treated as skippable fillers between keyword characters. - Curated obfuscation coverage — the skippable-filler set is a fixed list (symbols, whitespace, CJK radicals, full-width characters). Exotic tricks such as emoji, zero-width characters, or homoglyphs are not covered.
- Memory — each
replace()call allocates a small amount of memory proportional to the input length (coverage/star arrays); fine for typical messages, worth noting for very large documents. - Pathological inputs — a very long keyword over highly repetitive text can approach O(n × keyword length).
npm install
npm test # compile and run all test suites
npm run build # compile src/index.ts to build/index.js
npm run bench # local performance benchmarkTest suites:
tests/WordsFilter.test.ts— 100 fixture-based integration cases (fails the run on any failure)tests/WordsFilter-New.test.ts— 60 additional fixture-based cases (fails the run on any failure)tests/unit.test.ts— 33 focused unit tests with assertions