Replaces censored words with their uncensored counterparts on wtr-lab.com chapter pages for a better reading experience, without breaking site functionality.
// ==UserScript==
// @name WTR Lab Uncensor
// @description Replaces censored words with their uncensored counterparts on wtr-lab.com chapter pages for a better reading experience, without breaking site functionality.
// @version 1.3.2
// @author MasuRii
// @supportURL https://github.com/MasuRii/wtr-lab-uncensor/issues
// @match https://wtr-lab.com/en/novel/*/*/*
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @homepageURL https://github.com/MasuRii/wtr-lab-uncensor
// @icon https://www.google.com/s2/favicons?sz=64&domain=wtr-lab.com
// @license MIT
// @namespace http://tampermonkey.net/
// @run-at document-idle
// ==/UserScript==
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ "./src/modules/ahoCorasick.ts"
/*!************************************!*\
!*** ./src/modules/ahoCorasick.ts ***!
\************************************/
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ AhoCorasick: () => (/* binding */ AhoCorasick)
/* harmony export */ });
/**
* Aho-Corasick string-matching automaton.
*
* Builds a deterministic finite automaton from a set of keyword patterns, then
* scans input text in a single O(n + m) pass (n = text length, m = total
* matches) regardless of how many patterns exist. This is asymptotically
* superior to a giant `|`-joined alternation regex, whose cost grows with the
* number of patterns due to backtracking.
*
* Used by the uncensor engine to find all censored-token occurrences in a text
* node's value in one linear scan. Patterns are matched case-insensitively by
* lowercasing both the patterns (at build time) and the input (at search time);
* the original cased text is preserved by the caller for case reconstruction.
*
* No runtime dependencies — pure TypeScript, suitable for a bundled userscript.
*/
class AhoCorasick {
/**
* Build the automaton from a set of patterns.
*
* Patterns are stored lowercased when `caseInsensitive` is true; callers
* must pass lowercased search text to `search` in that case. The original
* (pre-lowercase) pattern string is returned in `AcMatch.pattern`.
*/
constructor(patterns, caseInsensitive = true) {
this.nodes = [];
this.caseInsensitive = caseInsensitive;
this.nodes.push(this.makeNode());
for (const rawPattern of patterns) {
if (rawPattern.length === 0) {
continue;
}
const pattern = caseInsensitive ? rawPattern.toLowerCase() : rawPattern;
this.insert(pattern);
}
this.buildFailLinks();
}
makeNode() {
return { goto: new Map(), fail: 0, outputs: [] };
}
insert(pattern) {
let current = 0;
for (const char of pattern) {
let next = this.nodes[current].goto.get(char);
if (next === undefined) {
next = this.nodes.length;
this.nodes.push(this.makeNode());
this.nodes[current].goto.set(char, next);
}
current = next;
}
this.nodes[current].outputs.push(pattern);
}
buildFailLinks() {
const queue = [];
// Depth-1 nodes fail to root.
for (const child of this.nodes[0].goto.values()) {
this.nodes[child].fail = 0;
queue.push(child);
}
// BFS over remaining nodes.
while (queue.length > 0) {
const node = queue.shift();
for (const [char, child] of this.nodes[node].goto) {
queue.push(child);
let fail = this.nodes[node].fail;
while (fail !== 0 && !this.nodes[fail].goto.has(char)) {
fail = this.nodes[fail].fail;
}
const failTarget = this.nodes[fail].goto.get(char);
this.nodes[child].fail = failTarget !== undefined && failTarget !== child
? failTarget
: 0;
// Inherit outputs from the failure node (suffix matches).
this.nodes[child].outputs = this.nodes[child].outputs.concat(this.nodes[this.nodes[child].fail].outputs);
}
}
}
/** Follow the goto transition, using fail links when no direct child exists. */
goto(node, char) {
while (node !== 0 && !this.nodes[node].goto.has(char)) {
node = this.nodes[node].fail;
}
const next = this.nodes[node].goto.get(char);
return next !== undefined ? next : 0;
}
/**
* Scan `text` and return all pattern matches.
*
* When `caseInsensitive` was set, the caller must pass already-lowercased
* text. Matches are returned in left-to-right order of their end position.
*/
search(text) {
const matches = [];
let node = 0;
for (let i = 0; i < text.length; i++) {
const char = this.caseInsensitive ? text[i].toLowerCase() : text[i];
node = this.goto(node, char);
if (this.nodes[node].outputs.length > 0) {
for (const pattern of this.nodes[node].outputs) {
const start = i - pattern.length + 1;
matches.push({ start, end: i + 1, pattern });
}
}
}
return matches;
}
}
/***/ },
/***/ "./src/modules/censorData.ts"
/*!***********************************!*\
!*** ./src/modules/censorData.ts ***!
\***********************************/
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ CENSOR_DATA: () => (/* binding */ CENSOR_DATA)
/* harmony export */ });
const CENSOR_DATA = [
{ censored: "rottenbast*ards", uncensored: "rottenbastards" },
{ censored: "motherf*cking", uncensored: "motherfucking" },
{ censored: "motherf**kers", uncensored: "motherfuckers" },
{ censored: "son of a ****", uncensored: "son of a bitch" },
{ censored: "motherf***ing", uncensored: "motherfucking" },
{ censored: "motherf*cker", uncensored: "motherfucker" },
{ censored: "bullsh*tting", uncensored: "bullshitting" },
{ censored: "m*sturbating", uncensored: "masturbating" },
{ censored: "motherf***er", uncensored: "motherfucker" },
{ censored: "bullsh*ting", uncensored: "bullshitting" },
{ censored: "wh*rehouses", uncensored: "whorehouses" },
{ censored: "motherf***", uncensored: "motherfucker" },
{ censored: "dumb***es", uncensored: "dumbasses" },
{ censored: "motherf**", uncensored: "motherfucker" },
{ censored: "f * cking", uncensored: "fucking" },
{ censored: "b * stard", uncensored: "bastard" },
{ censored: "f*cked-up", uncensored: "fucked-up" },
{ censored: "f*ckchens", uncensored: "fuckchens" },
{ censored: "fu*ked-up", uncensored: "fucked-up" },
{ censored: "t*rturing", uncensored: "torturing" },
{ censored: "bulls**t", uncensored: "bullshit" },
{ censored: "b**tards", uncensored: "bastards" },
{ censored: "b*stards", uncensored: "bastards" },
{ censored: "s***hole", uncensored: "shithole" },
{ censored: "sh*tless", uncensored: "shitless" },
{ censored: "bullsh*t", uncensored: "bullshit" },
{ censored: "a**holes", uncensored: "assholes" },
{ censored: "motherf*", uncensored: "motherfucker" },
{ censored: "bull***t", uncensored: "bullshit" },
{ censored: "smarta**", uncensored: "smartass" },
{ censored: "bullsh**", uncensored: "bullshit" },
{ censored: "*ssholes", uncensored: "assholes" },
{ censored: "sh*tting", uncensored: "shitting" },
{ censored: "d * mned", uncensored: "damned" },
{ censored: "b*llshit", uncensored: "bullshit" },
{ censored: "b*tiches", uncensored: "bitches" },
{ censored: "as*holes", uncensored: "assholes" },
{ censored: "bast*rds", uncensored: "bastards" },
{ censored: "b*stard", uncensored: "bastard" },
{ censored: "f*cking", uncensored: "fucking" },
{ censored: "assh*le", uncensored: "asshole" },
{ censored: "dumb*ss", uncensored: "dumbass" },
{ censored: "f**king", uncensored: "fucking" },
{ censored: "a**hole", uncensored: "asshole" },
{ censored: "b*****d", uncensored: "bastard" },
{ censored: "b*tches", uncensored: "bitches" },
{ censored: "bl*wjob", uncensored: "blowjob" },
{ censored: "bull***", uncensored: "bullshit" },
{ censored: "bulls**", uncensored: "bullshit" },
{ censored: "bullsh*", uncensored: "bullshit" },
{ censored: "dumb***", uncensored: "dumbass" },
{ censored: "***hole", uncensored: "asshole" },
{ censored: "**tards", uncensored: "bastards" },
{ censored: "*stards", uncensored: "bastards" },
{ censored: "godd*mn", uncensored: "goddamn" },
{ censored: "f******", uncensored: "fucking" },
{ censored: "*sshole", uncensored: "asshole" },
{ censored: "sh*ting", uncensored: "shitting" },
{ censored: "b * tch", uncensored: "bitch" },
{ censored: "r*tards", uncensored: "retards" },
{ censored: "f***ing", uncensored: "fucking" },
{ censored: "bad-*ss", uncensored: "badass" },
{ censored: "b****es", uncensored: "bitches" },
{ censored: "****ing", uncensored: "fucking" },
{ censored: "fuc*ing", uncensored: "fucking" },
{ censored: "bast*rd", uncensored: "bastard" },
{ censored: "f*ckers", uncensored: "fuckers" },
{ censored: "p*rvert", uncensored: "pervert" },
{ censored: "s**cide", uncensored: "suicide" },
{ censored: "b*tchy", uncensored: "bitchy" },
{ censored: "f****r", uncensored: "fucker" },
{ censored: "f**ked", uncensored: "fucked" },
{ censored: "f*cker", uncensored: "fucker" },
{ censored: "p*ssed", uncensored: "pissed" },
{ censored: "sh***y", uncensored: "shitty" },
{ censored: "sh*tty", uncensored: "shitty" },
{ censored: "an*ses", uncensored: "anuses" },
{ censored: "as*ses", uncensored: "asses" },
{ censored: "f*cked", uncensored: "fucked" },
{ censored: "org*sm", uncensored: "orgasm" },
{ censored: "p*nile", uncensored: "penile" },
{ censored: "s*xual", uncensored: "sexual" },
{ censored: "w*ener", uncensored: "wiener" },
{ censored: "**hole", uncensored: "asshole" },
{ censored: "**kers", uncensored: "fuckers" },
{ censored: "**king", uncensored: "fucking" },
{ censored: "*cking", uncensored: "fucking" },
{ censored: "*stard", uncensored: "bastard" },
{ censored: "*tches", uncensored: "bitches" },
{ censored: "*tless", uncensored: "shitless" },
{ censored: "bada**", uncensored: "badass" },
{ censored: "f * ck", uncensored: "fuck" },
{ censored: "d * ck", uncensored: "dick" },
{ censored: "sh * t", uncensored: "shit" },
{ censored: "d*mmit", uncensored: "dammit" },
{ censored: "d*mnit", uncensored: "damnit" },
{ censored: "f*king", uncensored: "fucking" },
{ censored: "bad*ss", uncensored: "badass" },
{ censored: "fu*ked", uncensored: "fucked" },
{ censored: "rap*st", uncensored: "rapist" },
{ censored: "r*ping", uncensored: "raping" },
{ censored: "scr*w", uncensored: "screw" },
{ censored: "a**es", uncensored: "asses" },
{ censored: "idi*t", uncensored: "idiot" },
{ censored: "r*ped", uncensored: "raped" },
{ censored: "b***h", uncensored: "bitch" },
{ censored: "b*tch", uncensored: "bitch" },
{ censored: "a*ses", uncensored: "asses" },
{ censored: "as*es", uncensored: "asses" },
{ censored: "b**bs", uncensored: "boobs" },
{ censored: "bo*bs", uncensored: "boobs" },
{ censored: "c*cks", uncensored: "cocks" },
{ censored: "d*cks", uncensored: "dicks" },
{ censored: "p*bic", uncensored: "pubic" },
{ censored: "p*nes", uncensored: "penes" },
{ censored: "p*nis", uncensored: "penis" },
{ censored: "godd*", uncensored: "goddamn" },
{ censored: "assh*", uncensored: "asshole" },
{ censored: "****r", uncensored: "fucker" },
{ censored: "**ked", uncensored: "fucked" },
{ censored: "*cked", uncensored: "fucked" },
{ censored: "*cker", uncensored: "fucker" },
{ censored: "*ssed", uncensored: "assed" },
{ censored: "*tchy", uncensored: "bitchy" },
{ censored: "*sses", uncensored: "asses" },
{ censored: "a * s", uncensored: "ass" },
{ censored: "b****", uncensored: "bitch" },
{ censored: "b**ch", uncensored: "bitch" },
{ censored: "dr*gs", uncensored: "drugs" },
{ censored: "rap*d", uncensored: "raped" },
{ censored: "b*lly", uncensored: "bully" },
{ censored: "d*mn", uncensored: "damn" },
{ censored: "f**k", uncensored: "fuck" },
{ censored: "f*ck", uncensored: "fuck" },
{ censored: "p*ss", uncensored: "piss" },
{ censored: "s**t", uncensored: "shit" },
{ censored: "d**n", uncensored: "damn" },
{ censored: "sh*t", uncensored: "shit" },
{ censored: "an*s", uncensored: "anus" },
{ censored: "b*tt", uncensored: "butt" },
{ censored: "c**p", uncensored: "crap" },
{ censored: "c*ck", uncensored: "cock" },
{ censored: "d*ck", uncensored: "dick" },
{ censored: "h*rd", uncensored: "hard" },
{ censored: "p*rn", uncensored: "porn" },
{ censored: "sl*t", uncensored: "slut" },
{ censored: "t*ts", uncensored: "tits" },
{ censored: "scr*", uncensored: "screw" },
{ censored: "idi*", uncensored: "idiot" },
{ censored: "*tch", uncensored: "bitch" },
{ censored: "*cks", uncensored: "fucks" },
{ censored: "*nis", uncensored: "penis" },
{ censored: "*ped", uncensored: "raped" },
{ censored: "*tty", uncensored: "shitty" },
{ censored: "f***", uncensored: "fuck" },
{ censored: "s***", uncensored: "shit" },
{ censored: "f*uk", uncensored: "fuck" },
{ censored: "h*ll", uncensored: "hell" },
{ censored: "fu*k", uncensored: "fuck" },
{ censored: "fuc*", uncensored: "fuck" },
{ censored: "dr*g", uncensored: "drug" },
{ censored: "pr*n", uncensored: "porn" },
{ censored: "r*pe", uncensored: "rape" },
{ censored: "a*s", uncensored: "ass" },
{ censored: "s*x", uncensored: "sex" },
{ censored: "sh*", uncensored: "shit" },
{ censored: "**k", uncensored: "fuck" },
{ censored: "*ck", uncensored: "fuck" },
{ censored: "*mn", uncensored: "damn" },
{ censored: "*rn", uncensored: "porn" },
{ censored: "*ss", uncensored: "ass" },
{ censored: "a**", uncensored: "ass" },
{ censored: "f*k", uncensored: "fuck" },
{ censored: "fu*", uncensored: "fuck" },
];
/***/ },
/***/ "./src/modules/fallbackDecoder.ts"
/*!****************************************!*\
!*** ./src/modules/fallbackDecoder.ts ***!
\****************************************/
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ PROFANITY_WORDS: () => (/* binding */ PROFANITY_WORDS),
/* harmony export */ decodeSingleCandidate: () => (/* binding */ decodeSingleCandidate)
/* harmony export */ });
/**
* Conservative single-candidate fallback decoder.
*
* Supplements exact dictionary matching by attempting to reconstruct censored
* tokens that are NOT in `CENSOR_DATA`. It uses a constrained profanity-only
* word list and accepts a guess **only if exactly one candidate word matches**
* on (length + all preserved letters at their positions). If zero or multiple
* candidates match, the text is left unchanged — this guarantees effectively
* 0% false positives while adding coverage for novel censoring variants the
* site may introduce.
*
* This is a supplement to exact matching, never a replacement.
*/
/**
* Canonical profanity / censored-word list used for single-candidate
* reconstruction. Sourced from the original empirical dictionary plus
* cross-referenced against public profanity datasets:
* - RobertJGabriel/Google-profanity-words (705★, MIT)
* - rominf/profanity-filter (171★, MIT)
* - Surge AI profanity dataset (1600+ entries, MIT)
* - Wiktionary "English censored spellings" category
*
* Curated for the web-novel translation context: includes profanity, sexual
* terms, bodily-function terms, and mild insults commonly censored on
* translation sites. Extreme slurs, brand names, and non-English terms are
* excluded. Short ambiguous words (e.g. `sod`, `vag`, `jerk`, `strip`) that
* would produce false positives via single-candidate matching are omitted.
*/
const PROFANITY_WORDS = [
"anal",
"arse",
"arsehole",
"ass",
"asses",
"asshole",
"assholes",
"bastard",
"bastards",
"bitch",
"bitches",
"bitching",
"bitchy",
"bloody",
"blowjob",
"blowjobs",
"bollocks",
"boob",
"boobies",
"boobs",
"breasts",
"bugger",
"bullshit",
"bullshitting",
"butt",
"butthole",
"butts",
"cock",
"cocks",
"crap",
"crappy",
"cum",
"cumming",
"cunt",
"cunts",
"dammit",
"damn",
"damnit",
"dick",
"dicks",
"dildo",
"dildos",
"drug",
"drugs",
"dumbass",
"dumbasses",
"erection",
"fag",
"faggot",
"fuck",
"fucked",
"fucker",
"fuckers",
"fuckoff",
"fucks",
"fucking",
"goddammit",
"goddamn",
"grope",
"groper",
"handjob",
"hardon",
"hell",
"horny",
"idiot",
"jackass",
"jackasses",
"jackoff",
"jerkoff",
"jizz",
"knob",
"knobhead",
"masturbate",
"masturbating",
"masturbation",
"milf",
"mofo",
"motherfucker",
"motherfuckers",
"motherfucking",
"nuts",
"nutsack",
"orgasm",
"orgasms",
"pecker",
"penes",
"penile",
"penis",
"pervert",
"perverts",
"piss",
"pissed",
"pissing",
"porn",
"porno",
"prick",
"pricks",
"pube",
"pubes",
"pubic",
"pussy",
"rape",
"raped",
"raping",
"rapist",
"rectum",
"retard",
"retarded",
"retards",
"screw",
"screwed",
"screwing",
"scrotum",
"sex",
"sexual",
"shag",
"shagged",
"shit",
"shithole",
"shitless",
"shits",
"shitting",
"shitty",
"slut",
"sluts",
"slutty",
"smegma",
"spunk",
"suck",
"sucking",
"suicide",
"tit",
"tits",
"titties",
"titty",
"torturing",
"tosser",
"twat",
"vagina",
"vaginas",
"violated",
"wank",
"wanker",
"wanking",
"whore",
"whorehouses",
"whores",
"wiener",
"wienie",
"willy",
];
/**
* Characters treated as censoring-mask wildcards. Only common masking
* characters qualify — NOT punctuation like commas, periods, or quotes,
* which prevents false positives on tokens like `will,` or `"No,`.
*/
const MASK_CHARS = /[*#@$_^~]/;
/**
* Attempts to reconstruct a single censored token.
*
* `censored` may contain mask characters (`*`, `#`, `@`, `$`, `_`, `^`, `~`)
* in place of unknown letters. Returns the matching word if exactly one
* candidate fits, or `null` if zero or multiple candidates match
* (ambiguous → safe skip).
*
* Matching criteria:
* 1. Same length as the censored token.
* 2. The token must contain at least one mask character (otherwise it's
* not censored and should not be decoded).
* 3. Every non-mask character matches positionally (case-insensitive).
*
* @param censored The masked token (e.g. `f**k`, `*ck`, `s**t`).
* @returns The unique matching profanity word, or `null` if ambiguous/no match.
*/
function decodeSingleCandidate(censored) {
const lower = censored.toLowerCase();
// Reject tokens that don't contain any mask characters — they're not censored.
let hasMask = false;
for (const ch of lower) {
if (MASK_CHARS.test(ch)) {
hasMask = true;
break;
}
}
if (!hasMask) {
return null;
}
const candidates = [];
for (const word of PROFANITY_WORDS) {
if (word.length !== lower.length) {
continue;
}
let match = true;
for (let i = 0; i < lower.length; i++) {
const c = lower[i];
// Only recognized mask characters are treated as wildcards.
// Punctuation and other non-letter chars cause a mismatch.
if (MASK_CHARS.test(c)) {
continue;
}
if (word[i] !== c) {
match = false;
break;
}
}
if (match) {
candidates.push(word);
}
}
return candidates.length === 1 ? candidates[0] : null;
}
/***/ },
/***/ "./src/modules/logger.ts"
/*!*******************************!*\
!*** ./src/modules/logger.ts ***!
\*******************************/
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ isLoggingEnabled: () => (/* binding */ isLoggingEnabled),
/* harmony export */ log: () => (/* binding */ log),
/* harmony export */ logGroup: () => (/* binding */ logGroup),
/* harmony export */ logGroupEnd: () => (/* binding */ logGroupEnd),
/* harmony export */ logGroupExpanded: () => (/* binding */ logGroupExpanded),
/* harmony export */ logReplacement: () => (/* binding */ logReplacement),
/* harmony export */ logSummary: () => (/* binding */ logSummary),
/* harmony export */ logWarn: () => (/* binding */ logWarn),
/* harmony export */ toggleLogging: () => (/* binding */ toggleLogging)
/* harmony export */ });
/**
* Debug logging helper for the WTR Lab Uncensor userscript.
* The logging toggle is persisted via the userscript manager storage API
* and surfaced through a registered menu command.
*
* Logging is enriched with categorized methods so console output is easy to
* scan: replacements are grouped per chapter with a summary header, individual
* before → after diffs, and source attribution (exact match vs fallback).
*/
const LOGGING_STORAGE_KEY = "wtrLabUncensor_loggingEnabled";
let loggingEnabled = GM_getValue(LOGGING_STORAGE_KEY, false);
function isLoggingEnabled() {
return loggingEnabled;
}
function toggleLogging() {
loggingEnabled = !loggingEnabled;
GM_setValue(LOGGING_STORAGE_KEY, loggingEnabled);
alert(`WTR Lab Uncensor logging is now ${loggingEnabled ? "ENABLED" : "DISABLED"}.`);
}
/** Standard prefix for all log lines. */
const PREFIX = "WTR Lab Uncensor";
/**
* Logs a general informational message.
*/
function log(message, ...args) {
if (!loggingEnabled) {
return;
}
console.log(`[${PREFIX}] ${message}`, ...args);
}
/**
* Logs a group header for a batch of replacements (e.g. one chapter body).
* Opens a collapsible console group.
*/
function logGroup(label) {
if (!loggingEnabled) {
return;
}
console.groupCollapsed(`[${PREFIX}] ${label}`);
}
/**
* Logs a group header that is expanded by default (for summaries).
*/
function logGroupExpanded(label) {
if (!loggingEnabled) {
return;
}
console.group(`[${PREFIX}] ${label}`);
}
/**
* Closes the current console group.
*/
function logGroupEnd() {
if (!loggingEnabled) {
return;
}
console.groupEnd();
}
/**
* Logs a single replacement with before → after diff and source attribution.
*
* @param original The censored text as found in the DOM.
* @param replaced The uncensored text written back.
* @param source Whether this was an exact dictionary match or a fallback decode.
*/
function logReplacement(original, replaced, source) {
if (!loggingEnabled) {
return;
}
const tag = source === "exact" ? "exact" : "fallback";
console.log(` %c${original}%c → %c${replaced}%c [${tag}]`, "color:#e74c3c;font-weight:bold", "", "color:#2ecc71;font-weight:bold", "");
}
/**
* Logs a summary line with a total count.
*/
function logSummary(count, label) {
if (!loggingEnabled) {
return;
}
const color = count > 0 ? "color:#3498db;font-weight:bold" : "color:#95a5a6";
console.log(`%c${label}: ${count}%c`, color, "");
}
/**
* Logs a warning (always visible, not gated by loggingEnabled).
*/
function logWarn(message, ...args) {
console.warn(`[${PREFIX}] ${message}`, ...args);
}
/***/ },
/***/ "./src/modules/uncensor.ts"
/*!*********************************!*\
!*** ./src/modules/uncensor.ts ***!
\*********************************/
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ observeChapterMutations: () => (/* binding */ observeChapterMutations),
/* harmony export */ processAllVisibleChapters: () => (/* binding */ processAllVisibleChapters)
/* harmony export */ });
/* harmony import */ var _censorData__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./censorData */ "./src/modules/censorData.ts");
/* harmony import */ var _ahoCorasick__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./ahoCorasick */ "./src/modules/ahoCorasick.ts");
/* harmony import */ var _fallbackDecoder__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./fallbackDecoder */ "./src/modules/fallbackDecoder.ts");
/* harmony import */ var _logger__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./logger */ "./src/modules/logger.ts");
/**
* DOM traversal and replacement engine for the WTR Lab Uncensor userscript.
*
* Walks text nodes inside chapter bodies and replaces censored tokens with
* their uncensored counterparts while preserving element nodes, event
* listeners, surrounding punctuation, and original letter casing.
*
* Engine design:
* 1. **Aho-Corasick multi-pattern matching** — O(n + m) scan per text node
* regardless of pattern count, replacing the old giant alternation regex.
* 2. **Case-insensitive matching + case reconstruction** — all dictionary
* entries are lowercase; `reconstructCase` restores ALL-CAPS / Title-Case /
* lowercase from the matched censored token.
* 3. **Word-boundary + punctuation preservation** — matches are validated
* against word boundaries; surrounding punctuation/whitespace is never
* consumed by the replacement, so `"f***?"` becomes `"fuck?"`.
* 4. **Conservative single-candidate fallback** — tokens not in the
* dictionary are decoded only when exactly one profanity word matches
* (length + preserved letters), guaranteeing ~0% false positives.
*/
/** Primary target: the element that holds the rendered chapter text. */
const CHAPTER_BODY_SELECTOR = ".chapter-body";
/**
* Additional selectors whose injection signals new chapter content during SPA
* navigation. The site may add a `.chapter-tracker`, `.chapter-container`, or
* individual `.wtr-line` / `[data-line]` elements before the `.chapter-body`
* is fully present, so observing all of them avoids missing content loads.
*/
const CONTENT_SELECTORS = [
".chapter-body",
".chapter-tracker",
".chapter-container",
".wtr-line",
"[data-line]",
].join(", ");
const PROCESSED_MARKER = "data-uncensor-processed";
/**
* Characters that count as word boundaries. A censored token is only a valid
* match if it is preceded and followed by one of these (or string start/end).
* This prevents replacing substrings inside larger words.
*/
const BOUNDARY_CHARS = /[\s.,;:!?"'()\x5b\x5d{}<>\u201c\u201d\u2018\u2019\u2014\u2013\u2026]/;
// Build a fast lookup map: lowercase censored token → lowercase uncensored word.
const replacementMap = _censorData__WEBPACK_IMPORTED_MODULE_0__.CENSOR_DATA.reduce((acc, item) => {
acc[item.censored.toLowerCase()] = item.uncensored.toLowerCase();
return acc;
}, {});
// Build the Aho-Corasick automaton from all censored patterns (case-insensitive).
const patterns = _censorData__WEBPACK_IMPORTED_MODULE_0__.CENSOR_DATA.map((item) => item.censored.toLowerCase());
const automaton = new _ahoCorasick__WEBPACK_IMPORTED_MODULE_1__.AhoCorasick(patterns, true);
/**
* Determines whether the position immediately before `start` in `text` is a
* valid word boundary (or the start of the string).
*/
function isLeftBoundary(text, start) {
if (start === 0) {
return true;
}
return BOUNDARY_CHARS.test(text[start - 1]);
}
/**
* Determines whether the position immediately after `end` in `text` is a valid
* word boundary (or the end of the string).
*/
function isRightBoundary(text, end) {
if (end >= text.length) {
return true;
}
return BOUNDARY_CHARS.test(text[end]);
}
/**
* Reconstructs the case pattern of `original` onto `replacement`.
*
* - If `original` is all-uppercase → uppercase the replacement.
* - If `original` starts with an uppercase letter and the rest is lowercase →
* Title-Case the replacement.
* - Otherwise → keep the replacement as-is (lowercase).
*
* This preserves `F**K → FUCK`, `B*tch → Bitch`, `f**k → fuck`.
*/
function reconstructCase(original, replacement) {
// All-caps check.
let allUpper = true;
let hasLetter = false;
for (const ch of original) {
if (/[a-z]/.test(ch)) {
allUpper = false;
break;
}
if (/[A-Z]/.test(ch)) {
hasLetter = true;
}
}
if (allUpper && hasLetter) {
return replacement.toUpperCase();
}
// Title-case: first letter is uppercase, remaining letters (if any) are not all-caps.
const firstOriginal = original[0];
if (firstOriginal && /[A-Z]/.test(firstOriginal)) {
// Check if the rest is lowercase (ignoring wildcards).
let restLower = true;
for (let i = 1; i < original.length; i++) {
if (/[A-Z]/.test(original[i])) {
restLower = false;
break;
}
}
if (restLower) {
return replacement.charAt(0).toUpperCase() + replacement.slice(1);
}
}
return replacement;
}
/**
* Characters used as censoring masks on WTR-Lab (asterisks, hyphens, hashes,
* at-signs, dollar signs, and other common mask characters). Only these are
* treated as wildcard placeholders — NOT punctuation like commas, periods,
* quotes, etc.
*/
const MASK_CHARS = /[*#@$_^~]/;
/**
* Checks whether a character is a censoring-mask wildcard character.
* Only common masking characters (asterisk, hash, at, dollar, underscore,
* caret, tilde) qualify — punctuation like commas, periods, and quotes do NOT,
* which prevents false positives on tokens like "will," or '"No,'.
*/
function isWildcardChar(ch) {
return MASK_CHARS.test(ch);
}
/**
* Determines whether a matched substring is fully bracketed by word boundaries
* and is therefore a valid standalone token (not a substring of a larger word).
*/
function isValidMatch(text, start, end) {
return isLeftBoundary(text, start) && isRightBoundary(text, end);
}
/**
* Replaces all censored words within a text string using Aho-Corasick matching,
* case reconstruction, punctuation preservation, and the conservative
* single-candidate fallback.
*
* Returns both the new text and an array of replacement details for logging.
* If no replacements were made, returns the original text and an empty array.
*/
function replaceInText(text) {
const lower = text.toLowerCase();
const rawMatches = automaton.search(lower);
// Filter to boundary-valid matches.
const validMatches = [];
for (const match of rawMatches) {
if (isValidMatch(text, match.start, match.end)) {
validMatches.push(match);
}
}
// Also run the fallback decoder on potential censored tokens that the AC
// automaton did not match. We scan for token-like substrings (sequences
// containing at least one wildcard char and at least one letter) bounded by
// word boundaries, and attempt single-candidate decoding only on those that
// are NOT already covered by a valid AC match.
const fallbackRanges = findFallbackTokens(lower, validMatches);
const replacements = [];
for (const match of validMatches) {
const lowerPattern = match.pattern.toLowerCase();
const uncensored = replacementMap[lowerPattern];
if (uncensored === undefined) {
continue;
}
const original = text.slice(match.start, match.end);
const cased = reconstructCase(original, uncensored);
replacements.push({
start: match.start,
end: match.end,
text: cased,
detail: { original, replaced: cased, source: "exact" },
});
}
for (const fb of fallbackRanges) {
const original = text.slice(fb.start, fb.end);
const decoded = (0,_fallbackDecoder__WEBPACK_IMPORTED_MODULE_2__.decodeSingleCandidate)(original);
if (decoded !== null) {
const cased = reconstructCase(original, decoded);
replacements.push({
start: fb.start,
end: fb.end,
text: cased,
detail: { original, replaced: cased, source: "fallback" },
});
}
}
if (replacements.length === 0) {
return { text, details: [] };
}
// Sort by start position descending so we can splice right-to-left.
replacements.sort((a, b) => b.start - a.start);
let result = text;
const details = [];
for (const rep of replacements) {
result = result.slice(0, rep.start) + rep.text + result.slice(rep.end);
details.push(rep.detail);
}
// Reverse details so they appear in left-to-right reading order in logs.
details.reverse();
return { text: result, details };
}
/**
* Finds candidate token ranges in `lower` that look like censored words (contain
* at least one wildcard char and at least one letter) but are NOT already
* covered by any AC match. These are passed to the conservative fallback decoder.
*
* A token is a maximal run of characters between word boundaries that contains
* at least one letter and at least one non-letter non-digit character.
*/
function findFallbackTokens(lower, acMatches) {
// Build a set of covered index ranges from AC matches for fast overlap check.
const covered = acMatches.map((m) => [m.start, m.end]);
const isCovered = (start, end) => {
for (const [cs, ce] of covered) {
if (start < ce && end > cs) {
return true;
}
}
return false;
};
const tokens = [];
let i = 0;
while (i < lower.length) {
// Skip boundary characters.
if (BOUNDARY_CHARS.test(lower[i]) || lower[i] === " ") {
i++;
continue;
}
// Find the end of this token (next boundary or string end).
const start = i;
while (i < lower.length && !BOUNDARY_CHARS.test(lower[i]) && lower[i] !== " ") {
i++;
}
const end = i;
if (isCovered(start, end)) {
continue;
}
// Check if this token looks censored: has at least one letter and one wildcard.
const token = lower.slice(start, end);
let hasLetter = false;
let hasWildcard = false;
for (const ch of token) {
if (/[a-z]/.test(ch)) {
hasLetter = true;
}
else if (isWildcardChar(ch)) {
hasWildcard = true;
}
}
if (hasLetter && hasWildcard) {
tokens.push({ start, end });
}
}
return tokens;
}
/**
* Recursively traverses the DOM from a starting node, replacing text in text
* nodes. Element nodes and their event listeners are preserved.
*
* Collects all replacement details for logging.
*/
function traverseAndReplace(node, details) {
if (node.nodeType === Node.TEXT_NODE) {
const originalText = node.nodeValue ?? "";
const { text: newText, details: nodeDetails } = replaceInText(originalText);
if (newText !== originalText) {
node.nodeValue = newText;
details.push(...nodeDetails);
}
return;
}
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toLowerCase();
if (tagName === "script" || tagName === "style") {
return;
}
for (const child of node.childNodes) {
traverseAndReplace(child, details);
}
}
}
/**
* Replaces censored words within a target element using safe DOM traversal.
* Logs a structured summary of all replacements when logging is enabled.
*/
function applyUncensor(targetElement) {
if (!targetElement || targetElement.hasAttribute(PROCESSED_MARKER)) {
return;
}
const details = [];
traverseAndReplace(targetElement, details);
targetElement.setAttribute(PROCESSED_MARKER, "true");
// Structured logging: group header + individual diffs + summary.
const chapterId = targetElement.getAttribute("data-chapter-id") ?? "unknown";
const label = `Chapter ${chapterId}: ${details.length} replacement${details.length === 1 ? "" : "s"}`;
if (details.length > 0) {
(0,_logger__WEBPACK_IMPORTED_MODULE_3__.logGroup)(label);
const exactCount = details.filter((d) => d.source === "exact").length;
const fallbackCount = details.filter((d) => d.source === "fallback").length;
for (const d of details) {
(0,_logger__WEBPACK_IMPORTED_MODULE_3__.logReplacement)(d.original, d.replaced, d.source);
}
(0,_logger__WEBPACK_IMPORTED_MODULE_3__.logSummary)(exactCount, " Exact dictionary matches");
(0,_logger__WEBPACK_IMPORTED_MODULE_3__.logSummary)(fallbackCount, " Fallback decoder matches");
(0,_logger__WEBPACK_IMPORTED_MODULE_3__.logSummary)(details.length, " Total replacements");
(0,_logger__WEBPACK_IMPORTED_MODULE_3__.logGroupEnd)();
}
else {
(0,_logger__WEBPACK_IMPORTED_MODULE_3__.log)(label);
}
}
/**
* Finds and processes all chapter bodies currently in the DOM.
*/
function processAllVisibleChapters() {
const chapterBodies = document.querySelectorAll(CHAPTER_BODY_SELECTOR);
chapterBodies.forEach(applyUncensor);
}
/**
* Registers a MutationObserver that reprocesses chapter bodies whenever the
* site injects new content into the document.
*/
function observeChapterMutations() {
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node;
if (element.matches(CONTENT_SELECTORS) || element.querySelector(CONTENT_SELECTORS)) {
setTimeout(processAllVisibleChapters, 250);
return;
}
}
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
/***/ }
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ if (!(moduleId in __webpack_modules__)) {
/******/ delete __webpack_module_cache__[moduleId];
/******/ var e = new Error("Cannot find module '" + moduleId + "'");
/******/ e.code = 'MODULE_NOT_FOUND';
/******/ throw e;
/******/ }
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = (exports, definition) => {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = (exports) => {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ })();
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk.
(() => {
/*!**********************!*\
!*** ./src/index.ts ***!
\**********************/
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _modules_logger__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./modules/logger */ "./src/modules/logger.ts");
/* harmony import */ var _modules_uncensor__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./modules/uncensor */ "./src/modules/uncensor.ts");
/**
* WTR Lab Uncensor — entry point.
*
* Replaces censored words with their uncensored counterparts on wtr-lab.com
* chapter pages for a better reading experience, without breaking site
* functionality.
*/
// Register the userscript manager menu command for toggling debug logging.
GM_registerMenuCommand("Toggle Logging", _modules_logger__WEBPACK_IMPORTED_MODULE_0__.toggleLogging);
// Observe dynamically loaded chapter content.
(0,_modules_uncensor__WEBPACK_IMPORTED_MODULE_1__.observeChapterMutations)();
// Process any chapter bodies already present in the DOM.
setTimeout(_modules_uncensor__WEBPACK_IMPORTED_MODULE_1__.processAllVisibleChapters, 500);
})();
/******/ })()
;