node-profanity-filter
Version:
A Node.js profanity filter with support for exact matching, obfuscated word detection, and customizable replacement.
89 lines (88 loc) • 3.21 kB
JavaScript
import { AhoCorasick } from './aho-corasick.js';
export class Filter {
matcher;
wordBoundaries;
normalize;
replaceWith;
strict;
static charMap = {
'@': 'a',
'$': 's',
'1': 'i',
'0': 'o',
'3': 'e'
};
constructor({ words = [], wordBoundaries = false, normalize = true, replaceWith = '', strict = false } = {}) {
this.wordBoundaries = wordBoundaries;
this.normalize = normalize;
this.replaceWith = replaceWith;
this.strict = strict;
this.matcher = new AhoCorasick(words.map(w => w.toLowerCase()));
}
sanitize(input, strict = this.strict) {
const { normalized } = this._normalizeToMap(input, strict, this.normalize);
return normalized;
}
search(input, options = {}) {
const normalize = options.normalize ?? this.normalize;
const query = normalize ? this._normalizeToMap(input, options.strict ?? this.strict, normalize).normalized : input.toLowerCase();
const res = this.matcher.search(query);
const filtered = this.wordBoundaries
? res.filter(m => this.isBoundary(query, m.start - 1) &&
this.isBoundary(query, m.end + 1))
: res;
return this.resolveMatches(filtered);
}
isProfane(input, options = {}) {
return this.search(input, options).length > 0;
}
replace(input, options = {}) {
const strict = options.strict ?? this.strict;
const normalize = options.normalize ?? this.normalize;
const { map } = normalize
? this._normalizeToMap(input, strict, normalize)
: { map: Array.from({ length: input.length }, (_, i) => i) };
const matches = this.search(input, { normalize, strict });
const replaceWith = options.replaceWith ?? this.replaceWith;
const sorted = matches.sort((a, b) => a.start - b.start);
let result = '';
let cursor = 0;
for (const m of sorted) {
const start = map[m.start];
const end = map[m.end];
result += input.slice(cursor, start);
result += replaceWith;
cursor = end + 1;
}
result += input.slice(cursor);
return { text: result, matches };
}
_normalizeToMap(input, strict, normalize) {
const map = [];
let normalized = '';
for (let i = 0; i < input.length; i++) {
let ch = input[i].toLowerCase();
if (strict && !/[a-z0-9@$]/.test(ch))
continue;
if (normalize) {
ch = Filter.charMap[ch] ?? ch;
}
normalized += ch;
map.push(i);
}
return { normalized, map };
}
resolveMatches(matches) {
const sorted = [...matches].sort((a, b) => a.start - b.start || b.end - a.end);
const result = [];
let lastEnd = -1;
for (const m of sorted) {
if (m.start < lastEnd)
continue;
result.push(m);
lastEnd = m.end;
}
return result;
}
isBoundary = (query, i) => i < 0 || i >= query.length || !/[a-z0-9]/i.test(query[i]);
}