WTR Lab Uncensor

Replaces censored words with their uncensored counterparts on wtr-lab.com chapter pages for a better reading experience, without breaking site functionality.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==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);

})();

/******/ })()
;