簡易文本轉換器

高效將 指定文本 轉換為 自定文本

// ==UserScript==
// @name         簡易文本轉換器
// @version      0.0.1-Beta3
// @author       Canaan HS
// @description  高效將 指定文本 轉換為 自定文本

// @connect      *
// @match        *://yande.re/*
// @match        *://rule34.xxx/*
// @match        *://nhentai.to/*
// @match        *://nhentai.io/*
// @match        *://nhentai.net/*
// @match        *://nhentai.xxx/*
// @match        *://nhentaibr.com/*
// @match        *://nhentai.website/*
// @match        *://imhentai.xxx/*
// @match        *://konachan.com/*
// @match        *://danbooru.donmai.us/*

// @license      MPL-2.0
// @namespace    https://greasyfork.org/users/989635
// @icon         https://cdn-icons-png.flaticon.com/512/9616/9616859.png

// @noframes
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand

// @run-at       document-start
// ==/UserScript==

(async () => {
    const Config = {
        LoadDictionary: {
            /**
             * 載入數據庫類型 (要載入全部, 就輸入一個 "All_Words")
             *
             * 範例:
             * 單導入: "Short"
             * 無導入: [] or ""
             * 多導入: ["Short", "Long", "Tags"]
             * 自定導入: "自己的數據庫 Url" (建議網址是一個 Json, 導入的數據必須是 JavaScript 物件)
             *
             * 可導入字典
             *
             * ! 如果某些單字翻譯的很怪, 可以個別導入 但不導入 "Short"
             *
             * 全部: "All_Words"
             * 標籤: "Tags"
             * 語言: "Language"
             * 角色: "Character"
             * 作品: "Parody"
             * 繪師: "Artist"
             * 社團: "Group"
             * 短單詞: "Short"
             * 長單詞: "Long"
             * 美化用: "Beautify"
             *
             * 參數 =>
             */
            Data: "All_Words"
        },
        TranslationReversal: {
            /**
             * !! 專注於反轉 (也不是 100% 反轉成功, 只是成功率較高)
             *
             * 1. 轉換時性能開銷較高
             * 2. 轉換時可能會有重複疊加錯誤
             *
             * !! 不專注於反轉
             *
             * 1. 性能開銷較低處理的更快
             * 2. 反轉時常常會有許多無法反轉的狀況 (通常是短句)
             */
            HotKey: true, // 啟用快捷反轉 (alt + v)
            FocusOnRecovery: false // 是否專注於反轉
        }
    };

    /**
     * 自定轉換字典  { "要轉換的字串": "轉換成的字串" }, 要轉換字串中, 如果包含英文, 全部都要小寫
     *
     * 自定字典的優先級更高, 他會覆蓋掉導入的字典
     */
    const Customize = {
        "apple": "蘋果", // 範例
    };

    /* ====================== 不瞭解不要修改下方參數 ===================== */
    const [LoadDict, Translation] = [Config.LoadDictionary, Config.TranslationReversal];
    const Dev = GM_getValue("Dev", false);
    const Update = UpdateWordsDict();
    const Transl = TranslationFactory();
    const Time = new Date().getTime();
    const Timestamp = GM_getValue("UpdateTime", false);
    let Translated = true;
    let TranslatedRecord = new Set();
    let Dict = GM_getValue("LocalWords", null) ?? await Update.Reques();
    const Dictionary = {
        NormalDict: undefined,
        ReverseDict: undefined,
        RefreshNormal: function () {
            this.NormalDict = Dict;
        },
        RefreshReverse: function () {
            this.ReverseDict = Object.entries(this.NormalDict).reduce((acc, [key, value]) => {
                acc[value] = key;
                return acc;
            }, {});
        },
        RefreshDict: function () {
            TranslatedRecord = new Set();
            Dict = Translated ? (Translated = false, this.ReverseDict) : (Translated = true,
                this.NormalDict);
        },
        DisplayMemory: function () {
            const [NormalSize, ReverseSize] = [getObjectSize(this.NormalDict), getObjectSize(this.ReverseDict)];
            const ExactMB = (Dict === this.NormalDict ? NormalSize.MB : NormalSize.MB + getObjectSize(Dict).MB) + ReverseSize.MB;
            alert(`字典緩存大小
                \r一般字典大小: ${NormalSize.MB} MB
                \r反轉字典大小: ${ReverseSize.MB} MB
                \r全部緩存大小: ${ExactMB} MB
            `);
        },
        ReleaseMemory: function () {
            Dict = this.NormalDict = this.ReverseDict = {};
        },
        Init: function () {
            Object.assign(Dict, Customize);
            this.RefreshNormal();
            this.RefreshReverse();
        }
    };
    Dictionary.Init();
    WaitElem("body", body => {
        const RunFactory = () => Transl.Trigger(body);
        const observer = new MutationObserver(Debounce(mutations => {
            const hasRelevantChanges = mutations.some(mutation => mutation.type === "childList" || mutation.type === "characterData");
            hasRelevantChanges && RunFactory();
        }, 300));
        const StartOb = () => {
            RunFactory();
            observer.observe(body, {
                subtree: true,
                childList: true,
                characterData: true,
                attributeFilter: ["placeholder"]
            });
        };
        const DisOB = () => observer.disconnect();
        !Dev && StartOb();
        function ThePolesAreReversed(RecoverOB = true) {
            DisOB();
            Dictionary.RefreshDict();
            RecoverOB ? StartOb() : RunFactory();
        }
        Menu({
            "🆕 更新字典": {
                desc: "獲取伺服器字典, 更新本地數據庫, 並在控制台打印狀態",
                func: async () => {
                    Translated = true;
                    GM_setValue("Clear", false);
                    ThePolesAreReversed(false);
                    Dict = await Update.Reques();
                    Dictionary.Init();
                    ThePolesAreReversed();
                }
            },
            "🚮 清空字典": {
                desc: "清除本地緩存的字典",
                func: () => {
                    GM_setValue("LocalWords", {});
                    GM_setValue("Clear", true);
                    location.reload();
                }
            },
            "⚛️ 兩極反轉": {
                hotkey: "c",
                close: false,
                desc: "互相反轉變更後的文本",
                func: () => ThePolesAreReversed()
            }
        }, "Basic");
        if (Dev || Translation.HotKey) {
            document.addEventListener("keydown", event => {
                if (event.altKey && event.key.toLowerCase() === "v") {
                    event.preventDefault();
                    ThePolesAreReversed();
                }
            });
        }
        if (Dev) {
            Translated = false;
            Menu({
                "« 🚫 停用開發者模式 »": {
                    desc: "關閉開發者模式",
                    func: () => {
                        GM_setValue("Dev", false);
                        location.reload();
                    }
                },
                "🪧 展示匹配文本": {
                    desc: "在控制台打印匹配的文本, 建議先開啟控制台在運行",
                    func: () => Transl.Dev(body),
                    close: false
                },
                "🖨️ 輸出匹配文檔": {
                    desc: "以 Json 格式輸出, 頁面上被匹配到的所有文本",
                    func: () => Transl.Dev(body, false)
                },
                "📼 展示字典緩存": {
                    desc: "顯示當前載入的字典大小",
                    func: () => Dictionary.DisplayMemory()
                },
                "🧹 釋放字典緩存": {
                    desc: "將緩存於 JavaScript 記憶體內的字典數據釋放掉",
                    func: () => Dictionary.ReleaseMemory()
                }
            }, "Dev");
        } else {
            Menu({
                "« ✅ 啟用開發者模式 »": {
                    desc: "打開開發者模式",
                    func: () => {
                        GM_setValue("Dev", true);
                        location.reload();
                    }
                }
            }, "Dev");
        }
        if (!Timestamp || Time - new Date(Timestamp).getTime() > 36e5 * 24) {
            Update.Reques().then(data => {
                Dict = data;
                Dictionary.Init();
                ThePolesAreReversed(false);
                ThePolesAreReversed();
            });
        }
    });
    function TranslationFactory() {
        function getTextNodes(root) {
            const tree = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
                acceptNode: node => {
                    const tag = node.parentElement.tagName;
                    if (tag === "STYLE" || tag === "SCRIPT" || tag === "CODE" || tag === "PRE" || tag === "NOSCRIPT" || tag === "SVG") {
                        return NodeFilter.FILTER_REJECT;
                    }
                    const content = node.textContent.trim();
                    if (!content) return NodeFilter.FILTER_REJECT;
                    if (content.startsWith("src=") || content.startsWith("href=") || content.startsWith("data-") || content.startsWith("function ") || content.startsWith("const ") || content.startsWith("var ")) {
                        return NodeFilter.FILTER_REJECT;
                    }
                    const codeSymbolCount = (content.match(/[{}[\]()<>]/g) || []).length;
                    if (codeSymbolCount > content.length * .2) {
                        return NodeFilter.FILTER_REJECT;
                    }
                    if (/^\d+$/.test(content)) {
                        return NodeFilter.FILTER_REJECT;
                    }
                    if (/^\d+(\.\d+)?\s*[km]$/i.test(content)) {
                        return NodeFilter.FILTER_REJECT;
                    }
                    if (!/[\w\p{L}]/u.test(content)) {
                        return NodeFilter.FILTER_REJECT;
                    }
                    return NodeFilter.FILTER_ACCEPT;
                }
            });
            const nodes = [];
            while (tree.nextNode()) {
                nodes.push(tree.currentNode);
            }
            return nodes;
        }
        const TCore = {
            __ShortWordRegex: /[\d\p{L}]+/gu,
            __LongWordRegex: /[\d\p{L}]+(?:[^|()\[\]{}{[(\t\n])+[\d\p{L}]\.*/gu,
            __Clean: text => text.trim().toLowerCase(),
            Dev_MatchObj: function (text) {
                const Sresult = text?.match(this.__ShortWordRegex)?.map(Short => {
                    const Clean = this.__Clean(Short);
                    return [Clean, Dict[Clean] ?? ""];
                }) ?? [];
                const Lresult = text?.match(this.__LongWordRegex)?.map(Long => {
                    const Clean = this.__Clean(Long);
                    return [Clean, Dict[Clean] ?? ""];
                }) ?? [];
                return [Sresult, Lresult].flat().filter(([Key, Value]) => Key && !/^\d+$/.test(Key)).reduce((acc, [Key, Value]) => {
                    acc[Key] = Value;
                    return acc;
                }, {});
            },
            OnlyLong: function (text) {
                return text?.replace(this.__LongWordRegex, Long => Dict[this.__Clean(Long)] ?? Long);
            },
            OnlyShort: function (text) {
                return text?.replace(this.__ShortWordRegex, Short => Dict[this.__Clean(Short)] ?? Short);
            },
            LongShort: function (text) {
                return text?.replace(this.__LongWordRegex, Long => Dict[this.__Clean(Long)] ?? this.OnlyShort(Long));
            }
        };
        const RefreshUICore = {
            FocusTextRecovery: async textNode => {
                textNode.textContent = TCore.OnlyLong(textNode.textContent);
                textNode.textContent = TCore.OnlyShort(textNode.textContent);
            },
            FocusTextTranslate: async textNode => {
                textNode.textContent = TCore.LongShort(textNode.textContent);
            },
            FocusInputRecovery: async inputNode => {
                inputNode.value = TCore.OnlyLong(inputNode.value);
                inputNode.value = TCore.OnlyShort(inputNode.value);
                inputNode.setAttribute("placeholder", TCore.OnlyLong(inputNode.getAttribute("placeholder")));
                inputNode.setAttribute("placeholder", TCore.OnlyShort(inputNode.getAttribute("placeholder")));
            },
            FocusInputTranslate: async inputNode => {
                inputNode.value = TCore.LongShort(inputNode.value);
                inputNode.setAttribute("placeholder", TCore.LongShort(inputNode.getAttribute("placeholder")));
            }
        };
        const ProcessingDataCore = {
            __FocusTextCore: Translation.FocusOnRecovery ? RefreshUICore.FocusTextRecovery : RefreshUICore.FocusTextTranslate,
            __FocusInputCore: Translation.FocusOnRecovery ? RefreshUICore.FocusInputRecovery : RefreshUICore.FocusInputTranslate,
            Dev_Operation: function (root, print) {
                const results = {};
                [...getTextNodes(root).map(textNode => textNode.textContent), ...[...root.querySelectorAll("input[placeholder], input[value]")].map(inputNode => [inputNode.value, inputNode.getAttribute("placeholder")]).flat().filter(value => value && value != "")].map(text => Object.assign(results, TCore.Dev_MatchObj(text)));
                if (print) console.table(results); else {
                    const Json = new Blob([JSON.stringify(results, null, 4)], { type: "application/json" });
                    const Link = document.createElement("a");
                    Link.href = URL.createObjectURL(Json);
                    Link.download = "MatchWords.json";
                    Link.click();
                    URL.revokeObjectURL(Link.href);
                    Link.remove();
                }
            },
            OperationText: async function (root) {
                return Promise.all(getTextNodes(root).map(textNode => {
                    if (TranslatedRecord.has(textNode)) return Promise.resolve();
                    TranslatedRecord.add(textNode);
                    return this.__FocusTextCore(textNode);
                }));
            },
            OperationInput: async function (root) {
                return Promise.all([...root.querySelectorAll("input[placeholder]")].map(inputNode => {
                    if (TranslatedRecord.has(inputNode)) return Promise.resolve();
                    TranslatedRecord.add(inputNode);
                    return this.__FocusInputCore(inputNode);
                }));
            }
        };
        return {
            Dev: (root, print = true) => {
                ProcessingDataCore.Dev_Operation(root, print);
            },
            Trigger: async root => {
                await Promise.all([ProcessingDataCore.OperationText(root), ProcessingDataCore.OperationInput(root)]);
            }
        };
    }
    function UpdateWordsDict() {
        const ObjType = object => Object.prototype.toString.call(object).slice(8, -1);
        const Parse = {
            Url: str => {
                try {
                    new URL(str);
                    return true;
                } catch {
                    return false;
                }
            },
            ExtenName: link => {
                try {
                    return link.match(/\.([^.]+)$/)[1].toLowerCase() || "json";
                } catch {
                    return "json";
                }
            },
            Array: data => {
                data = data.filter(d => d.trim() !== "");
                return {
                    State: data.length > 0,
                    Type: "arr",
                    Data: data
                };
            },
            String: data => {
                return {
                    State: data != "",
                    Type: "str",
                    Data: data
                };
            },
            Undefined: () => {
                return {
                    State: false
                };
            }
        };
        const RequestDict = data => {
            const URL = Parse.Url(data) ? data : `https://raw.githubusercontent.com/Canaan-HS/Script-DataBase/main/Words/${data}.json`;
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    responseType: Parse.ExtenName(URL),
                    url: URL,
                    onload: response => {
                        if (response.status === 200) {
                            const data = response.response;
                            if (typeof data === "object" && Object.keys(data).length > 0) {
                                resolve(data);
                            } else {
                                console.error("請求為空數據");
                                resolve({});
                            }
                        } else {
                            console.error("連線異常, 地址類型可能是錯的");
                            resolve({});
                        }
                    },
                    onerror: error => {
                        console.error("連線異常");
                        resolve({});
                    }
                });
            });
        };
        return {
            Reques: async () => {
                const {
                    State,
                    Type,
                    Data
                } = Parse[ObjType(LoadDict?.Data)](LoadDict?.Data);
                const DefaultDict = Object.assign(GM_getValue("LocalWords", {}), Customize);
                if (!State || GM_getValue("Clear")) return DefaultDict;
                const CacheDict = {};
                if (Type == "str") Object.assign(CacheDict, await RequestDict(Data)); else if (Type == "arr") {
                    for (const data of Data) {
                        Object.assign(CacheDict, await RequestDict(data));
                    }
                }
                if (Object.keys(CacheDict).length > 0) {
                    Object.assign(CacheDict, Customize);
                    GM_setValue("UpdateTime", GetDate());
                    GM_setValue("LocalWords", CacheDict);
                    console.log("%c數據更新成功", `
                        padding: 5px;
                        color: #9BEC00;
                        font-weight: bold;
                        border-radius: 10px;
                        background-color: #597445;
                        border: 2px solid #597445;
                    `);
                    return CacheDict;
                } else {
                    console.log("%c數據更新失敗", `
                        padding: 5px;
                        color: #FF0000;
                        font-weight: bold;
                        border-radius: 10px;
                        background-color: #A91D3A;
                        border: 2px solid #A91D3A;
                    `);
                    return DefaultDict;
                }
            }
        };
    }
    function getObjectSize(object) {
        const visited = new WeakSet();
        const alignSize = size => Math.ceil(size / 8) * 8;
        const Type = obj => {
            if (obj === null) return "Null";
            if (obj === undefined) return "Undefined";
            return Object.prototype.toString.call(obj).slice(8, -1);
        };
        const calculateCollectionSize = (value, cache, iteratee) => {
            if (!value || cache.has(value)) return 0;
            cache.add(value);
            let bytes = 16;
            const size = value.size || value.length || 0;
            bytes += size * 8;
            for (const item of iteratee(value)) {
                if (item[0] !== undefined) {
                    bytes += Calculate[Type(item[0])]?.(item[0], cache) ?? 0;
                }
                if (item[1] !== undefined) {
                    bytes += Calculate[Type(item[1])]?.(item[1], cache) ?? 0;
                }
            }
            return alignSize(bytes);
        };
        const calculateStringSize = value => {
            let bytes = 12;
            for (let i = 0; i < value.length; i++) {
                const code = value.charCodeAt(i);
                if (code < 128) bytes += 1; else if (code < 2048) bytes += 2; else if (code < 65536) bytes += 3; else bytes += 4;
            }
            return alignSize(bytes);
        };
        const Calculate = {
            Undefined: () => 0,
            Null: () => 0,
            Boolean: () => 4,
            Number: () => 8,
            BigInt: value => alignSize(Math.ceil(value.toString(2).length / 8) + 8),
            String: calculateStringSize,
            Symbol: value => alignSize((value.description || "").length * 2 + 8),
            Date: () => 8,
            RegExp: value => alignSize(value.toString().length * 2 + 8),
            Function: value => alignSize(value.toString().length * 2 + 16),
            ArrayBuffer: value => alignSize(value.byteLength + 16),
            DataView: value => alignSize(value.byteLength + 24),
            Int8Array: value => alignSize(value.byteLength + 24),
            Uint8Array: value => alignSize(value.byteLength + 24),
            Uint8ClampedArray: value => alignSize(value.byteLength + 24),
            Int16Array: value => alignSize(value.byteLength + 24),
            Uint16Array: value => alignSize(value.byteLength + 24),
            Int32Array: value => alignSize(value.byteLength + 24),
            Uint32Array: value => alignSize(value.byteLength + 24),
            Float32Array: value => alignSize(value.byteLength + 24),
            Float64Array: value => alignSize(value.byteLength + 24),
            BigInt64Array: value => alignSize(value.byteLength + 24),
            BigUint64Array: value => alignSize(value.byteLength + 24),
            Array: (value, cache) => {
                return calculateCollectionSize(value, cache, function* (arr) {
                    for (let i = 0; i < arr.length; i++) {
                        yield [arr[i]];
                    }
                });
            },
            Set: (value, cache) => {
                return calculateCollectionSize(value, cache, function* (set) {
                    for (const item of set) {
                        yield [item];
                    }
                });
            },
            Map: (value, cache) => {
                return calculateCollectionSize(value, cache, function* (map) {
                    for (const [key, val] of map) {
                        yield [key, val];
                    }
                });
            },
            Object: (value, cache) => {
                if (!value || cache.has(value)) return 0;
                cache.add(value);
                let bytes = 16;
                const props = Object.getOwnPropertyNames(value);
                bytes += props.length * 8;
                for (const key of props) {
                    bytes += calculateStringSize(key);
                    const propValue = value[key];
                    bytes += Calculate[Type(propValue)]?.(propValue, cache) ?? 0;
                }
                const symbols = Object.getOwnPropertySymbols(value);
                bytes += symbols.length * 8;
                for (const sym of symbols) {
                    bytes += Calculate.Symbol(sym);
                    const symValue = value[sym];
                    bytes += Calculate[Type(symValue)]?.(symValue, cache) ?? 0;
                }
                return alignSize(bytes);
            },
            WeakMap: () => 32,
            WeakSet: () => 24,
            Error: value => {
                let bytes = 32;
                bytes += calculateStringSize(value.message || "");
                bytes += calculateStringSize(value.stack || "");
                return alignSize(bytes);
            },
            Promise: () => 64
        };
        const type = Type(object);
        const calculator = Calculate[type] || Calculate.Object;
        const bytes = calculator(object, visited);
        return {
            bytes: bytes,
            KB: Number((bytes / 1024).toFixed(2)),
            MB: Number((bytes / 1024 / 1024).toFixed(2)),
            GB: Number((bytes / 1024 / 1024 / 1024).toFixed(2))
        };
    }
    function Debounce(func, delay = 100) {
        let timer = null;
        return (...args) => {
            clearTimeout(timer);
            timer = setTimeout(function () {
                func(...args);
            }, delay);
        };
    }
    function GetDate(format = null) {
        const date = new Date();
        const defaultFormat = "{year}-{month}-{date} {hour}:{minute}:{second}";
        const formatMap = {
            year: date.getFullYear(),
            month: (date.getMonth() + 1).toString().padStart(2, "0"),
            date: date.getDate().toString().padStart(2, "0"),
            hour: date.getHours().toString().padStart(2, "0"),
            minute: date.getMinutes().toString().padStart(2, "0"),
            second: date.getSeconds().toString().padStart(2, "0")
        };
        const generate = temp => temp.replace(/{([^}]+)}/g, (_, key) => formatMap[key] || "Error");
        return generate(typeof format === "string" ? format : defaultFormat);
    }
    async function Menu(Item, ID = "Menu", Index = 1) {
        for (const [Name, options] of Object.entries(Item)) {
            GM_registerMenuCommand(Name, () => {
                options.func();
            }, {
                title: options.desc,
                id: `${ID}-${Index++}`,
                autoClose: options.close,
                accessKey: options.hotkey
            });
        }
    }
    async function WaitElem(selector, found) {
        const Core = async function () {
            let AnimationFrame;
            let timer, result;
            const query = () => {
                result = document.getElementsByTagName(selector)[0];
                if (result) {
                    cancelAnimationFrame(AnimationFrame);
                    clearTimeout(timer);
                    found && found(result);
                } else {
                    AnimationFrame = requestAnimationFrame(query);
                }
            };
            AnimationFrame = requestAnimationFrame(query);
            timer = setTimeout(() => {
                cancelAnimationFrame(AnimationFrame);
            }, 1e3 * 8);
        };
        if (document.visibilityState === "hidden") {
            document.addEventListener("visibilitychange", () => Core(), {
                once: true
            });
        } else Core();
    }
})();