Building a Generic Context-Aware Scoring Engine Ts-profanity-filter, a TypeScript library for context-aware profanity filtering, now offers a streaming batch runner with a CLI, PII detection, and optional AI checks, supporting English and German out of the box with custom language registration. The library splits text into segments for UI rendering, never mutating the string, and includes a DSA Art. 17 statement generator. Version 1.0.0 is available on npm, with zero runtime dependencies and optional React, Vue, and Angular adapters. A strict TypeScript profanity filter that splits text into segments so your UI can render the redaction itself — the library never mutates or masks your string. English and German ship pre-registered; any other language is a registerLanguage call away. Leet spellings, lookalike letters, spaced-out words and repetition are matched; a cross-check keeps ordinary words like Klassik and classic out of the results.Zero runtime dependencies. Optional adapters for React, Vue and Angular, an optional AI check, a PII detector for e-mail addresses, phone numbers, IBANs and cards, a streaming batch runner with a CLI for corpora that do not fit in memory, and an optional generator for the DSA Art. 17 statement of reasons you owe whoever you moderated. Each is its own subpath, so nothing you do not import reaches your bundle. npm install ts-profanity-filter Analysing one comment is a function call. Analysing two million is a different problem — and the difference is not speed. The obvious version holds the whole corpus in memory, dies at row 900 000 with nothing written, and makes one paid model call per row. js import { runBatch, formatSummary } from 'ts-profanity-filter/batch'; import { ndjsonFrom } from 'ts-profanity-filter/batch/node'; const summary = await runBatch ndjsonFrom 'comments.ndjson' , { filter: { languages: 'en', 'de' }, pii: true, ai: { provider: 'gemini', when: 'matched', maxCalls: 500 }, onResult: result = { if result.flagged hold result.id ; }, } ; console.log formatSummary summary ; Iterable in, results streamed out: peak memory is one record , whatever the file size. Every stage is wrapped per record, so one hostile input costs a result and not the run. And the model is gated rather than called per row — when: 'matched' asks only about records a word list already hit, and maxCalls is a hard ceiling, because a batch is exactly where one call per row becomes a bill. There is a command now: npx ts-profanity-filter scan comments.ndjson --pii --out flagged.ndjson --pdf report.pdf It reads NDJSON, CSV, TSV or plain lines, guesses the text column from the CSV header and says which one it picked, writes the flagged records back as NDJSON, and exits 1 under --fail-on-findings for CI. Progress goes to stderr and the summary to stdout, so it composes. The full section → batch-processing · The CLI guide → /Kevinci/ts-profanity-filter/blob/main/docs/cli.md · Try it in the playground → https://kevinci.github.io/ts-profanity-filter/ sec-batch Also in this release: A chat log to test against. is 25 messages with a documented expected verdict for every row — twelve of them deliberately clean, which is the half that catches a detector getting eager. examples/batch/chat-log.csv The CSV reader no longer scans the wrong column quietly. It used to default to column 0, so pointing it at a file whose first column is an id reported 0 flagged — indistinguishable from a genuinely clean file. It now guesses from the header, announces the choice, and stops and asks when it cannot. PDF reports via renderSummaryPdf , throughas an fast-pdf optional peer dependency loaded by dynamic import in that one function. dependencies stays empty.lists every feature with one line of what it is and one of why it works that way, in English and German. FEATURES.md /Kevinci/ts-profanity-filter/blob/main/FEATURES.md A moderation filter that cannot see an IBAN is half a filter. The same comment box that collects insults collects phone numbers, bank details and card numbers, and ts-profanity-filter/pii reports those the way this library reports everything — as spans, so the redaction stays yours to render. js import { detectPii } from 'ts-profanity-filter/pii'; detectPii 'IBAN DE44 5001 0517 5407 3249 31, Tel. 030 12345678' ; // // { kind: 'iban', confidence: 0.99, evidence: 'structure', 'checksum', 'context' , … }, // { kind: 'phone', confidence: 0.99, evidence: 'structure', 'context' , … }, // The admission criterion is that a finding can be verified. An IBAN passes mod-97 and its country's length, a card passes Luhn and owns its issuer prefix, a German tax id passes ISO 7064 and the repetition rule the BZSt guarantees. Names, postal addresses and dates of birth are missing on purpose: nothing inside the string can confirm them, and a detector that guesses at those turns every capitalised word into a finding. It is one pass, not six regexes. The text is walked once for anchors, digit clusters are built once and interpreted by three recognizers, every candidate is scored rather than accepted, and overlaps are settled by weighted interval scheduling — because ::ffff:192.168.1.1 is an IPv6 address containing an IPv4 one, and resolving greedily from the left picks the earliest candidate rather than the best one. The full section → personal-data · Try it in the playground → https://kevinci.github.io/ts-profanity-filter/ sec-pii Also in 1.4.0, alongside this: The playground shows what it suppresses. The panel has a switch that drops minConfidence to 0.2, so the findings that scored too low to be reported become visible in grey instead of being invisible. The demo build now catches a clash the old guard could not. Its modules share one script scope on the page, and two of them declaring the same top-level const is a SyntaxError that blanks the whole page. The check compared the bundle against the page script but never against itself — it now does, which is how SEPARATOR and ALNUM were caught before shipping. In the EU, deleting the comment is only half the obligation. Article 17 of the Digital Services Act requires that whoever is moderated gets a statement of reasons : what was done, on which ground, on which facts, whether an automated system was involved, how long it lasts, and where to contest it — in their language, on a durable medium they can keep. A filter that returns flagged: true gives you none of that. So the new ts-profanity-filter/compliance subpath builds the notice out of the moderation result you already have: js import { moderateText } from 'ts-profanity-filter/ai'; import { generateJustification, formatJustificationAsText, } from 'ts-profanity-filter/compliance'; const result = await moderateText comment, { languages: 'de' , ai: { provider: 'gemini', enabled: true }, // the graded verdict } ; const notice = await generateJustification comment, result, { action: 'CONTENT REMOVED', policyBases: { name: 'Community Guidelines', section: '§4.2' } , appealUrl: 'https://example.com/appeal/8f21', ai: { provider: 'gemini', enabled: true }, // wording only — optional } ; formatJustificationAsText notice ; // the text you send the user exportJustification notice ; // the JSON you keep for your records The facts are never the model's to decide. Action, policy basis, categories, severity, confidence, the quoted excerpt and the timestamp are all fixed by the code before any model is asked. What a model contributes is the two things a template cannot write: a reason that names the measure and the behaviour in one breath, and an assessment that weighs the case — and says so plainly when the call is uncertain. Leave ai out and the built-in German and English templates carry the notice on their own. The full section → dsa-art-17-justifications · See one generated → https://kevinci.github.io/ts-profanity-filter/ sec-compliance Also in 1.3.0, alongside this: A third AI provider that needs no network. ollama runs the check on your own machine — no key, no third party, the same JSON Schema constraining the answer, so switching is a config change and not a second code path. See Nothing leaves the building nothing-leaves-the-building . Three false positives and a Cyrillic , found by pointing an adversarial benchmark of 81 attacks at this filter. к fixed Cockburn , Lightwater and Matsushita are names, which is the most expensive kind of false positive; к was never in the expandable set, so no pattern could reach it. English went from 82/83 to 94/100, German to 74/100, both with regression tests. Optionally, a model reads the whole sentence — for what no word list can see. A message can be a threat without containing a single listed word, and it can be full of them and still be a quotation. Word lists cannot tell the difference; this can. It reports hate, threats, harassment, racism, obscenity, sexual content involving minors and pressure toward self-harm — with a severity, a confidence, one sentence of reasoning in the language of the text, and the exact stretch it objected to, so you can highlight it. js import { moderateText } from 'ts-profanity-filter/ai'; const result = await moderateText comment, { languages: 'en', 'de' , ai: { provider: 'gemini', enabled: true }, // key from GEMINI API KEY } ; result.matchedList // a word list matched result.ai.flagged // the model flagged the sentence as a whole result.flagged // either of the two Google Gemini needs nothing installed — it is a plain fetch , and the free tier covers this. Anthropic Claude works through the optional SDK. Or bring any model at all with ai.complete . Off unless you ask for it. No ai option means no model is contacted and nothing leaves your machine — the word-list half never calls out at all. The full section → ai-check-optional · Try it with your own key → https://kevinci.github.io/ts-profanity-filter/ sec-ai Evasion is a Unicode problem , and the matching path treats it as one. Compatibility spellings fold with NFKC to the letters the patterns are written in, so Drecksau , 𝐃𝐫𝐞𝐜𝐤𝐬𝐚𝐮 and Ⓓⓡⓔⓒⓚⓢⓐⓤ stop walking past the list. Whole-word anchors use Unicode boundaries instead of \b — which is defined in terms of \w and stays ASCII even under the u flag, so every umlaut and every ß read as a word boundary and Straußschwanz came back flagged. Iteration is by code point rather than code unit, and the offset map carries one entry per output character, so a folded character that expands still points back at the one it came from — and a segment boundary can no longer land inside a surrogate pair. Seventeen cases assert the offsets , not the round trip. Rebuilding the string intact proves only that nothing was dropped; it says nothing about whether the flagged span still covers the right characters, which is exactly where a filter holding three representations of the input — original, folded haystack, segments — goes wrong. js import { filterFWordsToSegments } from 'ts-profanity-filter'; const output = filterFWordsToSegments 'This is bullsh1t.', { languages: 'en' } ; // // { text: 'This is bull', isProfane: false }, // { text: 'sh1t', isProfane: true }, // { text: '.', isProfane: false }, // Concatenating every segment.text always reproduces the original input exactly, so rendering is lossless: js