簡易文本轉換器

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

// ==UserScript==
// @name         簡易文本轉換器
// @version      0.0.1-Beta2
// @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      MIT
// @namespace    https://greasyfork.org/users/989635
// @icon         https://cdn-icons-png.flaticon.com/512/9616/9616859.png

// @noframes
// @run-at       document-start
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// ==/UserScript==

/**
 * Data Reference Sources:
 * https://github.com/EhTagTranslation/Database
 * https://github.com/DominikDoom/a1111-sd-webui-tagcomplete
 * https://github.com/scooderic/exhentai-tags-chinese-translation
 * https://greasyfork.org/zh-TW/scripts/20312-e-hentai-tag-list-for-chinese
 */

(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: true // 是否專注於反轉
        }
    };

    /**
     * 自定轉換字典  { "要轉換的字串": "轉換成的字串" }, 要轉換字串中, 如果包含英文, 全部都要小寫
     *
     * 自定字典的優先級更高, 他會覆蓋掉導入的字典
     */
    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 ] = [ objectSize(this.NormalDict), objectSize(this.ReverseDict) ];
            alert(`字典緩存大小
                \r一般字典大小: ${NormalSize.KB} KB
                \r反轉字典大小: ${ReverseSize.KB} KB
                \r全部緩存大小: ${NormalSize.MB * 2 + ReverseSize.MB} 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 options = {
            subtree: true,
            childList: true,
            characterData: true
        };
        let mutation;
        const observer = new MutationObserver(Debounce((mutationsList, observer) => {
            for (mutation of mutationsList) {
                if (mutation.type === "childList" || mutation.type === "characterData") {
                    RunFactory();
                    break;
                }
            }
        }, 300));
        const StartOb = () => {
            RunFactory();
            observer.observe(body, options);
        };
        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") {
                        return NodeFilter.FILTER_REJECT;
                    }
                    const content = node.textContent.trim();
                    if (content == "") return NodeFilter.FILTER_REJECT;
                    if (!/[\w\p{L}]/u.test(content) || /^\d+$/.test(content) || /^\d+(\.\d+)?\s*[km]$/i.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 = document.createElement("a");
                    Json.href = `data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(results, null, 4))}`;
                    Json.download = "MatchWords.json";
                    Json.click();
                    setTimeout(() => {
                        Json.remove();
                    }, 500);
                }
            },
            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 objectSize(object) {
        const Type = obj => 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 = 0;
            for (const item of iteratee(value)) {
                bytes += Calculate[Type(item[0])]?.(item[0], cache) ?? 0;
                bytes += Calculate[Type(item[1])]?.(item[1], cache) ?? 0;
            }
            return bytes;
        };
        const Calculate = {
            Boolean: () => 4,
            Date: () => 8,
            Number: () => 8,
            String: value => value.length * 2,
            RegExp: value => value.toString().length * 2,
            Symbol: value => (value.description || "").length * 2,
            DataView: value => value.byteLength,
            TypedArray: value => value.byteLength,
            ArrayBuffer: value => value.byteLength,
            Array: (value, cache) => calculateCollectionSize(value, cache, function*(arr) {
                for (const item of arr) {
                    yield [ item ];
                }
            }),
            Set: (value, cache) => calculateCollectionSize(value, cache, function*(set) {
                for (const item of set) {
                    yield [ item ];
                }
            }),
            Object: (value, cache) => calculateCollectionSize(value, cache, function*(obj) {
                for (const key in obj) {
                    if (Object.prototype.hasOwnProperty.call(obj, key)) {
                        yield [ key, obj[key] ];
                    }
                }
            }),
            Map: (value, cache) => calculateCollectionSize(value, cache, function*(map) {
                for (const [ key, val ] of map) {
                    yield [ key, val ];
                }
            })
        };
        const bytes = Calculate[Type(object)]?.(object, new WeakSet()) ?? 0;
        return {
            Bytes: bytes,
            KB: (bytes / 1024).toFixed(2),
            MB: (bytes / 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 observer = new MutationObserver(Debounce(() => {
            const element = document.querySelector(selector);
            if (element) {
                observer.disconnect();
                found(element);
            }
        }));
        observer.observe(document, {
            subtree: true,
            childList: true,
            attributes: true,
            characterData: true
        });
    }
})();