feat: polish profanity validation

This commit is contained in:
tdgao
2026-08-26 16:20:35 -06:00
parent 89b392dc3d
commit ae4519cc26
13 changed files with 435 additions and 576 deletions
@@ -1,8 +1,17 @@
import {
collapseDuplicatesTransformer,
parseRawPattern,
RegExpMatcher,
resolveConfusablesTransformer,
resolveLeetSpeakTransformer,
skipNonAlphabeticTransformer,
toAsciiLowerCaseTransformer,
} from 'obscenity'
export type ProfanityKind = 'profanity' | 'slur'
export interface ProfanityPattern {
kind: ProfanityKind
exceptions: readonly string[]
}
export interface ProfanityConfig {
@@ -12,8 +21,9 @@ export interface ProfanityConfig {
export interface ProfanityMatch {
kind: ProfanityKind
term: string
sanitizedStart: number
sanitizedEnd: number
rawText: string
start: number
end: number
}
export interface ProfanityResult {
@@ -25,321 +35,148 @@ export interface ProfanityResult {
}
export interface ProfanityValidator {
sanitize(text: string): string
findFirst(text: string): ProfanityMatch | undefined
findAll(text: string): ProfanityMatch[]
validate(text: string): ProfanityResult
}
interface NegativeMatch {
prefix: string
suffix: string
}
interface TrieNode {
children: Map<string, TrieNode>
negatives: NegativeMatch[]
terminal?: {
kind: ProfanityKind
term: string
}
}
const CHARACTER_REPLACEMENTS: Readonly<Record<string, string>> = {
'4': 'a',
'@': 'a',
'3': 'e',
'1': 'i',
'0': 'o',
'5': 's',
'7': 't',
'8': 'b',
'9': 'g',
'+': 't',
$: 's',
'(': 'c',
'{': 'c',
'[': 'c',
'!': 'i',
'|': 'i',
'£': 'e',
'€': 'e',
'¥': 'y',
'¢': 'c',
'<': 'c',
}
const MULTI_CHARACTER_REPLACEMENTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
'(': { ')': 'o' },
'[': { ']': 'o' },
'{': { '}': 'o' },
'<': { '>': 'o' },
}
const TERM_EXCEPTIONS: Readonly<Record<string, readonly string[]>> = {
anuslick: [],
arsehol: [],
arselick: [],
asslick: [],
arsch: [],
asshol: [],
auschwitz: [],
beaner: [],
bestiality: [],
baise: [],
bakachon: [],
bakatyon: [],
bastard: ['bastardized'],
bitch: [],
btch: [],
biatch: [],
bussy: [],
blowjob: [],
blowme: [],
bukakke: [],
buttplug: [],
buttchug: [],
butagorosi: [],
cagada: [],
caralho: [],
cameljockey: [],
castrate: [],
cazzo: [],
ceemen: [],
chankoro: [],
chink: [],
chingchong: [],
choad: [],
chode: [],
chlamydia: [],
clit: ['clitheroe'],
clitoris: [],
cock: [
'cockade',
'cockatiel',
'cockatiels',
'cockatoo',
'cockatoos',
'cockatrice',
'cockayne',
'cockburn',
'cockcroft',
'cocked',
'cocker',
'cockerel',
'cockers',
'cockeyed',
'cockiness',
'cocking',
'cocklebur',
'cockney',
'cockpit',
'cockpits',
'cockroach',
'cockroaches',
'cockscomb',
'cockspur',
'cocktail',
'gamecock',
'hancock',
'haycock',
'hitchcock',
'leacock',
'peacock',
'shuttlecock',
'stopcock',
'woodcock',
],
coon: ['cocoon', 'laocoon', 'raccoon', 'racoon', 'tycoon'],
cocain: [],
coitus: [],
cottonpic: [],
cottonpik: [],
cum: [
'acumen',
'acuminate',
'altocumulus',
'cumber',
'cumbing',
'cumbria',
'cumbrian',
'cumbrous',
'cummerbund',
'cumming',
'cumulat',
'cumuli',
'cumulonimbus',
'cumulus',
'encumber',
'encumbrance',
'scumbag',
'locum',
'modicum',
'magnacumlaude',
'macumba',
'practicum',
'recumbent',
'slocum',
'stratocumulus',
'succumb',
'talcum',
'taraxacum',
'tecumseh',
'tucuman',
'capsicum',
'cecum',
'circum',
'colchicum',
'document',
'ecumeni',
'illyricum',
'incumben',
],
cunt: ['scunthorpe'],
cvnt: [],
cunny: [],
cunnie: [],
csam: [],
cyka: [],
darkie: [],
dick: [
'chappaquiddick',
'dickens',
'dickensian',
'dickerson',
'dickey',
'dickies',
'dickinson',
'dickson',
'dickvandyke',
'dicky',
],
dildo: [],
douchebag: [],
dyke: ['vandyke'],
downie: [],
dumbass: [],
ejaculate: [],
fag: ['antofagasta', 'serfage', 'wharfage', 'fagin', 'leafage'],
feck: [],
fellate: [],
fellatio: [],
felch: [],
fuck: [],
fvck: [],
fxck: [],
fack: [],
fzck: [],
fck: [],
fudgepacker: [],
flange: ['flanged', 'flanges'],
gestapo: [],
gook: [],
horny: ['thorny'],
hooker: [],
hitler: [],
incest: [],
jap: ['japan'],
jizz: [],
jigabo: [],
junglebunny: [],
kkk: [],
kike: [],
klux: [],
kluklux: [],
klukluxklan: [],
koon: [],
lickmy: [],
masturbat: [],
molest: [],
muff: [
'muffed',
'muffin',
'muffins',
'muffle',
'muffled',
'muffler',
'mufflers',
'muffles',
'muffling',
'muffs',
'ragamuffin',
'earmuff',
'earmuffs',
],
nazi: ['ashkenazi', 'ashkenazic', 'ashkenazim', 'monazite'],
nigg: [],
niqa: [],
nigga: [],
niqqa: [],
niggu: [],
niqqu: [],
niggr: [],
nigger: [],
niglet: [],
nignog: [],
paki: ['pakistan'],
penis: ['openis', 'penistone'],
porn: [],
prostitut: [],
pube: [],
pussie: [],
pussy: ['pussycat', 'pussyfoot'],
raghead: [],
rape: [
'grape',
'forape',
'trapeze',
'trapezium',
'trapezius',
'trapezoid',
'therapeutic',
'drape',
'parapet',
'rapeseed',
'scrape',
'serape',
],
rapist: ['therapist'],
retard: ['retardant', 'retarder', 'retarding'],
rimjob: [],
shit: [
'cushitic',
'shitake',
'pushit',
'peshitta',
'libshitz',
'shitzu',
'wishit',
'yamashita',
'finishit',
'shitbox',
'shitmg',
'publishit',
'englishit',
],
slut: [],
spunk: ['spunky'],
suckmy: [],
sodom: [],
semen: ['sement'],
teensex: [],
tittie: [],
titty: [],
trannie: [],
tranny: [],
vagina: [],
wank: ['swank', 'wankel'],
wetback: [],
whore: ['whores', 'whorev', 'whoreturned'],
whitepower: [],
fondle: [],
minestorm: [],
kissmy: [],
blowmy: [],
jelqing: [],
dafuq: [],
}
const TERMS = [
'anuslick',
'arsehol',
'arselick',
'asslick',
'arsch',
'asshol',
'auschwitz',
'beaner',
'bestiality',
'baise',
'bakachon',
'bakatyon',
'bastard',
'bitch',
'btch',
'biatch',
'bussy',
'blowjob',
'blowme',
'bukakke',
'buttplug',
'buttchug',
'butagorosi',
'cagada',
'caralho',
'cameljockey',
'castrate',
'cazzo',
'ceemen',
'chankoro',
'chink',
'chingchong',
'choad',
'chode',
'chlamydia',
'clit',
'clitoris',
'cock',
'coon',
'cocain',
'coitus',
'cottonpic',
'cottonpik',
'cum',
'cunt',
'cvnt',
'cunny',
'cunnie',
'csam',
'cyka',
'darkie',
'dick',
'dildo',
'douchebag',
'dyke',
'downie',
'dumbass',
'ejaculate',
'fag',
'feck',
'fellate',
'fellatio',
'felch',
'fuck',
'fvck',
'fxck',
'fack',
'fzck',
'fck',
'fudgepacker',
'flange',
'gestapo',
'gook',
'horny',
'hooker',
'hitler',
'incest',
'jap',
'jizz',
'jigabo',
'junglebunny',
'kkk',
'kike',
'klux',
'kluklux',
'klukluxklan',
'koon',
'lickmy',
'masturbat',
'molest',
'muff',
'nazi',
'nigger',
'nigga',
'niqa',
'niqqa',
'niggu',
'niqqu',
'niggr',
'niglet',
'nignog',
'paki',
'penis',
'porn',
'prostitut',
'pube',
'pussie',
'pussy',
'raghead',
'rape',
'rapist',
'retard',
'rimjob',
'shit',
'slut',
'spunk',
'suckmy',
'sodom',
'semen',
'teensex',
'tittie',
'titty',
'trannie',
'tranny',
'vagina',
'wank',
'wetback',
'whore',
'whitepower',
'fondle',
'minestorm',
'kissmy',
'blowmy',
'jelqing',
'dafuq',
] as const
const SLUR_TERMS = new Set([
'beaner',
@@ -380,9 +217,9 @@ const SLUR_TERMS = new Set([
export const DEFAULT_PROFANITY_PATTERNS: Readonly<Record<string, ProfanityPattern>> =
Object.fromEntries(
Object.entries(TERM_EXCEPTIONS).map(([term, exceptions]) => {
TERMS.map((term) => {
const kind: ProfanityKind = SLUR_TERMS.has(term) ? 'slur' : 'profanity'
return [term, { kind, exceptions }] as const
return [term, { kind }] as const
}),
)
@@ -390,159 +227,69 @@ export const DEFAULT_PROFANITY_CONFIG: ProfanityConfig = {
patterns: DEFAULT_PROFANITY_PATTERNS,
}
function newTrieNode(): TrieNode {
return {
children: new Map(),
negatives: [],
}
}
function getDuplicateThresholds(terms: readonly string[]): Map<string, number> {
const thresholds = new Map<string, number>()
export function sanitizeProfanityText(text: string): string {
const transformed = text
.normalize('NFD')
.replaceAll(/\p{Mn}/gu, '')
.normalize('NFC')
const characters = Array.from(transformed)
let sanitized = ''
for (const term of terms) {
let runLength = 0
let previousCharacter = ''
for (let index = 0; index < characters.length; index++) {
const character = characters[index]
const multiReplacement = MULTI_CHARACTER_REPLACEMENTS[character]
const nextCharacter = characters[index + 1]
if (multiReplacement && nextCharacter && multiReplacement[nextCharacter]) {
sanitized += multiReplacement[nextCharacter]
index++
continue
}
const replacement = CHARACTER_REPLACEMENTS[character]
if (replacement) {
sanitized += replacement
} else if (character >= 'A' && character <= 'Z') {
sanitized += character.toLowerCase()
} else if ((character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')) {
sanitized += character
}
}
return sanitized
}
function createTrie(patterns: Readonly<Record<string, ProfanityPattern>>): TrieNode {
const root = newTrieNode()
for (const [rawTerm, pattern] of Object.entries(patterns)) {
const term = rawTerm.toLowerCase()
if (!term || sanitizeProfanityText(term) !== term) {
throw new Error(`Profanity term must already be sanitized: ${rawTerm}`)
}
let current = root
for (const character of term) {
let child = current.children.get(character)
if (!child) {
child = newTrieNode()
current.children.set(character, child)
}
current = child
}
if (current.terminal) {
throw new Error(`Duplicate sanitized profanity term: ${term}`)
}
current.terminal = { kind: pattern.kind, term }
for (const rawException of pattern.exceptions) {
const exception = rawException.toLowerCase()
if (sanitizeProfanityText(exception) !== exception) {
throw new Error(`Profanity exception must already be sanitized: ${rawException}`)
}
const termIndex = exception.indexOf(term)
if (termIndex < 0) {
throw new Error(`Profanity exception must contain ${term}: ${rawException}`)
}
current.negatives.push({
prefix: exception.slice(0, termIndex),
suffix: exception.slice(termIndex + term.length),
})
runLength = character === previousCharacter ? runLength + 1 : 1
previousCharacter = character
thresholds.set(character, Math.max(thresholds.get(character) ?? 1, runLength))
}
}
return root
}
function negativeMatches(negative: NegativeMatch, text: string, start: number, end: number) {
const prefixIndex = start - negative.prefix.length
const suffixIndex = end + negative.suffix.length
if (prefixIndex < 0 || suffixIndex > text.length) return false
return (
text.slice(prefixIndex, start) === negative.prefix &&
text.slice(end, suffixIndex) === negative.suffix
)
}
function findAt(root: TrieNode, text: string, start: number): ProfanityMatch | undefined {
let current = root
for (let index = start; index < text.length; index++) {
const child = current.children.get(text[index])
if (!child) return undefined
current = child
if (!current.terminal) continue
const end = index + 1
const matchesNegative = current.negatives.some((negative) =>
negativeMatches(negative, text, start, end),
)
if (matchesNegative) continue
return {
kind: current.terminal.kind,
term: text.slice(start, end),
sanitizedStart: start,
sanitizedEnd: end,
}
}
return undefined
return thresholds
}
export function createProfanityValidator(
config: ProfanityConfig = DEFAULT_PROFANITY_CONFIG,
): ProfanityValidator {
const root = createTrie(config.patterns)
function findFirst(text: string): ProfanityMatch | undefined {
const sanitized = sanitizeProfanityText(text)
for (let index = 0; index < sanitized.length; index++) {
const match = findAt(root, sanitized, index)
if (match) return match
const entries = Object.entries(config.patterns).map(([rawTerm, pattern]) => {
const term = rawTerm.toLowerCase()
if (!term || !/^[a-z]+$/.test(term)) {
throw new Error(`Profanity term must contain only ASCII letters: ${rawTerm}`)
}
return undefined
}
return { kind: pattern.kind, term }
})
const matcher = new RegExpMatcher({
blacklistedTerms: entries.map(({ term }, id) => ({ id, pattern: parseRawPattern(term) })),
blacklistMatcherTransformers: [
resolveConfusablesTransformer(),
resolveLeetSpeakTransformer(),
toAsciiLowerCaseTransformer(),
skipNonAlphabeticTransformer(),
collapseDuplicatesTransformer({
customThresholds: getDuplicateThresholds(entries.map(({ term }) => term)),
}),
],
})
function findAll(text: string): ProfanityMatch[] {
const sanitized = sanitizeProfanityText(text)
const matches: ProfanityMatch[] = []
for (let index = 0; index < sanitized.length; ) {
const match = findAt(root, sanitized, index)
if (match) {
matches.push(match)
index = match.sanitizedEnd
} else {
index++
}
for (const match of matcher.getAllMatches(text, true)) {
const profanityPattern = entries[match.termId]
if (!profanityPattern || match.startIndex < (matches.at(-1)?.end ?? 0)) continue
matches.push({
...profanityPattern,
rawText: text.slice(match.startIndex, match.endIndex + 1),
start: match.startIndex,
end: match.endIndex + 1,
})
}
return matches
}
function findFirst(text: string): ProfanityMatch | undefined {
return findAll(text)[0]
}
function validate(text: string): ProfanityResult {
const matches = findAll(text)
const profanityCount = matches.filter((match) => match.kind === 'profanity').length
@@ -558,7 +305,6 @@ export function createProfanityValidator(
}
return {
sanitize: sanitizeProfanityText,
findFirst,
findAll,
validate,
@@ -1,66 +1,49 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createProfanityValidator, sanitizeProfanityText, validateProfanity } from './index.ts'
import { createProfanityValidator, validateProfanity } from './index.ts'
test('sanitizes text with the configured single-character replacements', () => {
assert.equal(sanitizeProfanityText('4@3105789+$([{!|£€¥¢<'), 'aaeiostbgtsccciieeycc')
})
const blockedForms = [
['normal form', 'fuck'],
['capitalization', 'FUCK'],
['Unicode variants', 'fuck'],
['zero-width characters', 'f\u200Buck'],
['leetspeak', '$h!t'],
['period separators', 'f.u.c.k'],
['space separators', 'f u c k'],
['repeated letters', 'fuuuuuck'],
] as const
test('sanitizes paired characters before their single-character replacements', () => {
assert.equal(sanitizeProfanityText('()[]{}<>'), 'oooo')
})
for (const [form, input] of blockedForms) {
test(`blocks ${form}`, () => {
const result = validateProfanity(input)
test('strips accents, separators, emoji, and ASCII casing', () => {
assert.equal(sanitizeProfanityText('F Ü.C—K🙂'), 'fuck')
})
assert.equal(result.valid, false)
assert.equal(result.firstMatch?.rawText, input)
})
}
test('matches profanity across separators and common substitutions', () => {
assert.equal(validateProfanity('f.u c-k').firstMatch?.term, 'fuck')
assert.equal(validateProfanity('$h!t').firstMatch?.term, 'shit')
assert.equal(validateProfanity('p()rn').firstMatch?.term, 'porn')
})
test('honors exact negative prefix and suffix matches', () => {
test('does not use term exceptions', () => {
const validator = createProfanityValidator({
patterns: {
bad: { kind: 'profanity', exceptions: ['notbadword'] },
bad: { kind: 'profanity' },
},
})
assert.equal(validator.findFirst('not bad word'), undefined)
assert.equal(validator.findFirst('very bad word')?.term, 'bad')
assert.equal(validator.findFirst('not bad phrase')?.term, 'bad')
assert.equal(validator.findFirst('not bad word')?.term, 'bad')
})
test('matches the first profanity while ignoring a later negative match', () => {
test('uses the first match when one configured term prefixes another', () => {
const validator = createProfanityValidator({
patterns: {
shit: { kind: 'profanity', exceptions: ['horseshit', 'bullshit'] },
fuck: { kind: 'profanity', exceptions: [] },
},
})
assert.equal(validator.findFirst('this horseshit'), undefined)
assert.equal(validator.findFirst('fuck this bullshit')?.term, 'fuck')
})
test('uses the first terminal when one configured term prefixes another', () => {
const validator = createProfanityValidator({
patterns: {
bad: { kind: 'profanity', exceptions: [] },
badword: { kind: 'profanity', exceptions: [] },
bad: { kind: 'profanity' },
badword: { kind: 'profanity' },
},
})
assert.equal(validator.findFirst('badword')?.term, 'bad')
})
test('does not match configured false-positive substrings', () => {
assert.equal(validateProfanity('Scunthorpe, Clitheroe, and peacock').valid, true)
assert.equal(validateProfanity('cock and cunt').profanityCount, 2)
})
test('rejects any uncensored configured profanity', () => {
assert.equal(validateProfanity('A clean project').valid, true)
assert.equal(validateProfanity('This is shit').valid, false)
@@ -72,31 +55,31 @@ test('allows redacted profanity when the removed letters cannot reconstruct a te
assert.equal(validateProfanity('f.u.c.k').valid, false)
})
test('rejects slurs', () => {
test('classifies slurs separately from other profanity', () => {
const validator = createProfanityValidator({
patterns: {
forbidden: { kind: 'slur', exceptions: [] },
forbidden: { kind: 'slur' },
},
})
const result = validator.validate('FORBIDDEN')
assert.equal(validator.validate('forbidden').valid, false)
assert.equal(validator.validate('forbidden').slurCount, 1)
assert.equal(result.valid, false)
assert.equal(result.profanityCount, 0)
assert.equal(result.slurCount, 1)
})
test('counts matches from left to right without overlaps', () => {
test('returns non-overlapping matches and original input offsets', () => {
const validator = createProfanityValidator({
patterns: {
bad: { kind: 'profanity', exceptions: [] },
bad: { kind: 'profanity' },
},
})
assert.deepEqual(
validator
.findAll('bad-bad')
.map(({ sanitizedStart, sanitizedEnd }) => [sanitizedStart, sanitizedEnd]),
validator.findAll('b.a.d bad').map(({ start, end }) => [start, end]),
[
[0, 3],
[3, 6],
[0, 5],
[6, 9],
],
)
})
@@ -106,18 +89,9 @@ test('rejects invalid configuration', () => {
() =>
createProfanityValidator({
patterns: {
'not sanitized': { kind: 'profanity', exceptions: [] },
'not sanitized': { kind: 'profanity' },
},
}),
/term must already be sanitized/,
)
assert.throws(
() =>
createProfanityValidator({
patterns: {
bad: { kind: 'profanity', exceptions: ['innocent'] },
},
}),
/exception must contain bad/,
/term must contain only ASCII letters/,
)
})