NTR ToolBox

ToolBox for Novel Translate bot website

目前為 2025-02-22 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         NTR ToolBox
// @namespace    http://tampermonkey.net/
// @version      v0.3-20250223
// @author       TheNano(百合仙人)
// @description  ToolBox for Novel Translate bot website
// @match        https://books.fishhawk.top/*
// @match        https://books1.fishhawk.top/*
// @grant        GM_openInTab
// @icon         https://raw.githubusercontent.com/LittleSurvival/NTRTools/refs/heads/main/icon.jpg
// @license      All Rights Reserved
// 
// ==/UserScript==

(async function () {
    'use strict';

    const style = document.createElement('style');
    style.textContent = `
    #ntr-panel {
        position: fixed;
        left: 20px;
        top: 70px;
        z-index: 9999;
        background: #1E1E1E;
        color: #BBB;
        padding: 8px;
        border-radius: 8px;
        font-family: Arial, sans-serif;
        width: 320px;
        box-shadow: 2px 2px 12px rgba(0,0,0,0.5);
        border: 1px solid #333;
    }
    .ntr-titlebar {
        font-weight: bold;
        padding: 10px;
        cursor: move;
        background: #292929;
        border-radius: 6px;
        color: #CCC;
    }
    .ntr-panel-body {
        padding: 6px;
        background: #232323;
        border-radius: 4px;
    }
    .ntr-module-container {
        margin-bottom: 12px;
        border: 1px solid #444;
        border-radius: 4px;
    }
    .ntr-module-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        background: #2E2E2E;
        padding: 6px 8px;
        border-radius: 3px 3px 0 0;
        border-bottom: 1px solid #333;
        cursor: pointer;
        transition: background 0.3s;
    }
    .ntr-module-header:hover {
        background: #3a3a3a;
    }
    .ntr-settings-container {
        padding: 6px;
        background: #1C1C1C;
        display: none;
    }
    .ntr-input {
        width: 120px;
        padding: 4px;
        border: 1px solid #555;
        border-radius: 4px;
        background: #2A2A2A;
        color: #FFF;
    }
    .ntr-number-input {
        width: 60px;
        padding: 4px;
        border: 1px solid #555;
        border-radius: 4px;
        background: #2A2A2A;
        color: #FFF;
    }
    .ntr-bind-button {
        padding: 4px 8px;
        border: 1px solid #555;
        border-radius: 4px;
        background: #2A2A2A;
        color: #FFF;
        cursor: pointer;
    }
    .ntr-info {
        text-align: center;
        font-size: 10px;
        color: #888;
        margin-top: 8px;
    }
    .ntr-module-header.active {
        background: #63E2B7 !important;
        color: #fff !important;
    }
    `;
    document.head.appendChild(style);

    function newBooleanSetting(nameDefault, boolDefault) {
        return { name: nameDefault, type: 'boolean', value: Boolean(boolDefault) };
    }
    function newNumberSetting(nameDefault, numDefault) {
        return { name: nameDefault, type: 'number', value: Number(numDefault || 0) };
    }
    function newStringSetting(nameDefault, strDefault) {
        return { name: nameDefault, type: 'string', value: String(strDefault == null ? '' : strDefault) };
    }
    function newSelectSetting(nameDefault, arrOptions, valDefault) {
        return { name: nameDefault, type: 'select', value: valDefault, options: arrOptions };
    }
    function getModuleSetting(moduleObj, key) {
        if (!moduleObj.settings) return;
        const found = moduleObj.settings.find(x => x.name === key);
        return found ? found.value : undefined;
    }

    const CONFIG_VERSION = 11;
    const CONFIG_STORAGE_KEY = 'NTR_ToolBox_Config';
    const domainAllowed = ['books.fishhawk.top', 'books1.fishhawk.top'].includes(location.hostname);

    const moduleAddSakuraTranslator = {
        name: '添加Sakura翻譯器',
        type: 'onclick',
        whitelist: '/workspace/sakura',
        settings: [
            newNumberSetting('數量', 5),
            newNumberSetting('延遲', 5),
            newStringSetting('名稱', 'NTR translator '),
            newStringSetting('鏈接', 'https://sakura-share.one'),
            newStringSetting('bind', 'none'),
        ],
        run: async function (configObj) {
            const totalCount = getModuleSetting(configObj, '數量') || 1;
            const namePrefix = getModuleSetting(configObj, '名稱') || '';
            const linkValue = getModuleSetting(configObj, '鏈接') || '';
            const delayValue = getModuleSetting(configObj, '延遲') || 5;
            const delay = ms => new Promise(r => setTimeout(r, ms));
            let currentIndex = 1;
            async function closeTab() {
                const closeButton = document.querySelector(
                    'button[aria-label="close"].n-base-close,button.n-base-close[aria-label="close"],button.n-base-close.n-base-close--absolute.n-card-header__close'
                );
                if (closeButton) closeButton.click();
            }
            async function openAddTab() {
                const addBtn = [...document.querySelectorAll('button.n-button')]
                    .find(btn => {
                        const txt = (btn.querySelector('.n-button__content') || {}).textContent || '';
                        return txt.trim().indexOf('添加翻译器') !== -1;
                    });
                if (addBtn) addBtn.click();
            }
            async function fillForm() {
                const nameInput = document.querySelector('input.n-input__input-el[placeholder="给你的翻译器起个名字"]');
                const linkInput = document.querySelector('input.n-input__input-el[placeholder="翻译器的链接"]');
                const segInput = document.querySelectorAll('input.n-input__input-el[placeholder="请输入"]')[0];
                const preInput = document.querySelectorAll('input.n-input__input-el[placeholder="请输入"]')[1];
                const addBtn = [...document.querySelectorAll('button.n-button.n-button--primary-type')]
                    .find(btn => {
                        const txt = (btn.querySelector('.n-button__content') || {}).textContent || '';
                        return txt.trim().indexOf('添加') !== -1;
                    });
                if (nameInput && linkInput && segInput && preInput && addBtn) {
                    nameInput.value = namePrefix + currentIndex;
                    nameInput.dispatchEvent(new Event('input', { bubbles: true }));
                    linkInput.value = linkValue;
                    linkInput.dispatchEvent(new Event('input', { bubbles: true }));
                    segInput.dispatchEvent(new InputEvent('input', { data: '500' }));
                    preInput.dispatchEvent(new InputEvent('input', { data: '500' }));
                    addBtn.click();
                    currentIndex++;
                    if (currentIndex <= totalCount) {
                        await delay(delayValue);
                        await fillForm();
                    }
                }
            }
            await openAddTab();
            await delay(300);
            await fillForm();
            await delay(100);
            await closeTab();
        }
    };

    const moduleAddGPTTranslator = {
        name: '添加GPT翻譯器',
        type: 'onclick',
        whitelist: '/workspace/gpt',
        settings: [
            newNumberSetting('數量', 5),
            newNumberSetting('延遲', 5),
            newStringSetting('名稱', 'NTR translator '),
            newStringSetting('模型', 'deepseek-chat'),
            newStringSetting('鏈接', 'https://api.deepseek.com'),
            newStringSetting('Key', 'sk-wait-for-input'),
            newStringSetting('bind', 'none'),
        ],
        run: async function (configObj) {
            const countVal = getModuleSetting(configObj, '數量') || 1;
            const namePrefixVal = getModuleSetting(configObj, '名稱') || '';
            const modelVal = getModuleSetting(configObj, '模型') || '';
            const apiKeyVal = getModuleSetting(configObj, 'Key') || '';
            const apiUrlVal = getModuleSetting(configObj, '鏈接') || '';
            const delayValue = getModuleSetting(configObj, '延遲') || 5;
            const delay = ms => new Promise(r => setTimeout(r, ms));
            let currentIndex = 1;
            async function closeTab() {
                const cBtn = document.querySelector(
                    'button[aria-label="close"].n-base-close,button.n-base-close[aria-label="close"],button.n-base-close.n-base-close--absolute.n-card-header__close'
                );
                if (cBtn) cBtn.click();
            }
            async function openAddTab() {
                const addBtn = [...document.querySelectorAll('button.n-button')]
                    .find(btn => {
                        const txt = (btn.querySelector('.n-button__content') || {}).textContent || '';
                        return txt.trim().indexOf('添加翻译器') !== -1;
                    });
                if (addBtn) addBtn.click();
            }
            async function fillForm() {
                const nameInput = document.querySelector('input.n-input__input-el[placeholder="给你的翻译器起个名字"]');
                const modelInput = document.querySelector('input.n-input__input-el[placeholder="模型名称"]');
                const urlInput = document.querySelector('input.n-input__input-el[placeholder="兼容OpenAI的API链接,默认使用deepseek"]');
                const keyInput = document.querySelector('input.n-input__input-el[placeholder="请输入Api key"]');
                const confirmBtn = [...document.querySelectorAll('button.n-button.n-button--primary-type')]
                    .find(btn => {
                        const txt = (btn.querySelector('.n-button__content') || {}).textContent || '';
                        return txt.trim().indexOf('添加') !== -1;
                    });
                if (nameInput && modelInput && urlInput && keyInput && confirmBtn) {
                    nameInput.value = namePrefixVal + currentIndex;
                    nameInput.dispatchEvent(new Event('input', { bubbles: true }));
                    modelInput.value = modelVal;
                    modelInput.dispatchEvent(new Event('input', { bubbles: true }));
                    urlInput.value = apiUrlVal;
                    urlInput.dispatchEvent(new Event('input', { bubbles: true }));
                    keyInput.value = apiKeyVal;
                    keyInput.dispatchEvent(new Event('input', { bubbles: true }));
                    confirmBtn.click();
                    currentIndex++;
                    if (currentIndex <= countVal) {
                        await delay(delayValue);
                        await fillForm();
                    }
                }
            }
            await openAddTab();
            await delay(300);
            await fillForm();
            await delay(100);
            await closeTab();
        }
    };

    const moduleDeleteTranslator = {
        name: '刪除翻譯器',
        type: 'onclick',
        whitelist: '/workspace',
        settings: [
            newStringSetting('排除', '共享,本机,AutoDL'),
            newStringSetting('bind', 'none'),
        ],
        run: async function (configObj) {
            const excludeStr = getModuleSetting(configObj, '排除') || '';
            const excludeArr = excludeStr.split(',').filter(x => x);
            const listItems = document.querySelectorAll('.n-list-item');
            [...listItems].forEach(li => {
                const titleEl = li.querySelector('.n-thing-header__title');
                if (!titleEl) return;
                const titleText = titleEl.textContent.trim();
                const keep = excludeArr.some(x => titleText.indexOf(x) !== -1);
                if (!keep) {
                    const delBtn = li.querySelector('.n-button--error-type');
                    const parentEl = delBtn?.parentElement;
                    if (parentEl) {
                        const siblingBtns = parentEl.querySelectorAll('button');
                        if (siblingBtns.length === 5 && delBtn) delBtn.click();
                    }
                }
            });
        }
    };

    const moduleLaunchTranslator = {
        name: '啟動翻譯器',
        type: 'onclick',
        whitelist: '/workspace',
        settings: [
            newNumberSetting('延遲間隔', 50),
            newStringSetting('bind', 'none'),
        ],
        run: async function (configObj) {
            const intervalVal = getModuleSetting(configObj, '延遲間隔') || 50;
            const allBtns = document.querySelectorAll('button');
            const delay = ms => new Promise(r => setTimeout(r, ms));
            let idx = 0;
            async function nextClick() {
                while (idx < allBtns.length) {
                    const btn = allBtns[idx];
                    idx++;
                    if (btn.textContent.indexOf('启动') !== -1 || btn.textContent.indexOf('啟動') !== -1) {
                        btn.click();
                        await delay(intervalVal);
                    }
                }
            }
            await nextClick();
        }
    };

    const moduleQueueSakura = {
        name: '排隊Sakura',
        type: 'onclick',
        whitelist: '/wenku',
        settings: [
            newSelectSetting('模式', ['常規', '過期', '重翻'], '常規'),
            newNumberSetting('延遲間隔', 50),
            newNumberSetting('並行延遲', 1000),
            newNumberSetting('並行數量', 5),
            newStringSetting('bind', 'none'),
        ],
        run: async function(configObj) {
            const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
            const pollInterval  = getModuleSetting(configObj, '並行延遲') || 300;
            const concurrentLimit = getModuleSetting(configObj, '並行數量') || 5;
            const mode = getModuleSetting(configObj, '模式');
            const modeMap = { '常規': '常规', '過期': '过期', '重翻': '重翻' };
            const cnMode = modeMap[mode] || '常规';

            async function setMode(doc) {
                const tags = doc.querySelectorAll('.n-tag__content');
                for (const tag of tags) {
                    if (tag.textContent.trim() === cnMode) {
                        tag.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
                        break;
                    }
                }
            }
            async function clickSakuraButtons(doc) {
                const btns = Array.from(doc.querySelectorAll('button')).filter(b => b.textContent.includes('排队Sakura'));
                btns.forEach(btn => btn.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})));
            }
            if (location.pathname !== '/wenku') {
                setMode(document);
                await clickSakuraButtons(document);
                return;
            }
            const domain = window.location.origin;
            const allLinks = Array.from(document.querySelectorAll('a[href]'))
              .map(a => a.href)
              .filter(href => href.startsWith(domain) && /\/wenku\/[^/]+/.test(href));
            const uniqueLinks = [...new Set(allLinks)];
            async function waitForTabLoad(newTab) {
                const maxWait = 10000;
                const startTime = Date.now();
                while (true) {
                    await delay(pollInterval);
                    if (!newTab || newTab.closed) {
                        throw new Error('New tab was closed or blocked before loading.');
                    }
                    if (newTab.document && (newTab.document.readyState === 'complete' || newTab.document.querySelector('.n-tag__content'))) {
                        break;
                    }
                    if (Date.now() - startTime > maxWait) {
                        throw new Error('Timed out waiting for new tab to load.');
                    }
                }
            }
            async function processUrl(url) {
                const newTab = window.open(url, '_blank');
                if (!newTab) {
                    throw new Error(`Failed to open new tab for: ${url}`);
                }
                await waitForTabLoad(newTab);
                await setMode(newTab.document);
                await clickSakuraButtons(newTab.document);
                newTab.close();
            }
            let activeCount = 0;
            let index = 0;
            async function spawnNext() {
                if (index >= uniqueLinks.length) return;
                const url = uniqueLinks[index++];
                activeCount++;
                try {
                    await processUrl(url);
                } catch (err) {
                    console.error('Failed to process:', url, err);
                } finally {
                    activeCount--;
                }
            }
            while (index < uniqueLinks.length) {
                if (activeCount < concurrentLimit) {
                    spawnNext();
                } else {
                    await delay(50);
                }
            }
            while (activeCount > 0) {
                await delay(50);
            }
            console.log('All Sakura tasks complete.');
        }
    };
    
    const moduleQueueGPT = {
        name: '排隊GPT',
        type: 'onclick',
        whitelist: '/wenku',
        settings: [
            newSelectSetting('模式', ['常規', '過期', '重翻'], '常規'),
            newNumberSetting('延遲間隔', 5),
            newNumberSetting('並行延遲', 300),
            newNumberSetting('並行數量', 5),
            newStringSetting('bind', 'none'),
        ],
        run: async function(configObj) {
            const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
            const pollInterval  = getModuleSetting(configObj, '並行延遲') || 300;
            const concurrentLimit = getModuleSetting(configObj, '並行數量') || 5;
            const mode = getModuleSetting(configObj, '模式');
            const modeMap = { '常規': '常规', '過期': '过期', '重翻': '重翻' };
            const cnMode = modeMap[mode] || '常规';
            function setMode(doc) {
                const tags = doc.querySelectorAll('.n-tag__content');
                for (const tag of tags) {
                    if (tag.textContent.trim() === cnMode) {
                        tag.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
                        break;
                    }
                }
            }
            async function clickGPTButtons(doc) {
                const btns = Array.from(doc.querySelectorAll('button')).filter(b => b.textContent.includes('排队GPT'));
                btns.forEach(btn => btn.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})));
            }
            if (location.pathname !== '/wenku') {
                setMode(document);
                await clickGPTButtons(document);
                return;
            }
            const domain = window.location.origin;
            const allLinks = Array.from(document.querySelectorAll('a[href]'))
              .map(a => a.href)
              .filter(href => href.startsWith(domain) && /\/wenku\/[^/]+/.test(href));
            const uniqueLinks = [...new Set(allLinks)];
            async function waitForTabLoad(newTab) {
                const maxWait = 10000;
                const startTime = Date.now();
                while (true) {
                    await delay(pollInterval);
                    if (!newTab || newTab.closed) {
                        throw new Error('New tab was closed or blocked before loading.');
                    }
                    if (newTab.document && (newTab.document.readyState === 'complete' || newTab.document.querySelector('.n-tag__content'))) {
                        break;
                    }
                    if (Date.now() - startTime > maxWait) {
                        throw new Error('Timed out waiting for new tab to load.');
                    }
                }
            }
            async function processUrl(url) {
                const newTab = window.open(url, '_blank');
                if (!newTab) {
                    throw new Error(`Failed to open new tab for: ${url}`);
                }
                await waitForTabLoad(newTab);
                setMode(newTab.document);
                await clickGPTButtons(newTab.document);
                newTab.close();
            }
            let activeCount = 0;
            let index = 0;
            async function spawnNext() {
                if (index >= uniqueLinks.length) return;
                const url = uniqueLinks[index++];
                activeCount++;
                try {
                    await processUrl(url);
                } catch (err) {
                    console.error('Failed to process:', url, err);
                } finally {
                    activeCount--;
                }
            }
            while (index < uniqueLinks.length) {
                if (activeCount < concurrentLimit) {
                    spawnNext();
                } else {
                    await delay(50);
                }
            }
            while (activeCount > 0) {
                await delay(50);
            }
            console.log('All GPT tasks complete.');
        }
    };    

    const moduleAutoRetry = {
        name: '自動重試',
        type: 'keep',
        whitelist: '/workspace/*',
        settings: [
            newNumberSetting('最大重試次數', 3),
        ],
        _keepIntervalId: null,
        _keepActive: false,
        _attempts: 0,
        run: function (configObj) {
            if (!this._keepActive) {
                this._keepActive = true;
                const maxAttempts = getModuleSetting(configObj, '最大重試次數');
                document.addEventListener('click', (e) => {
                    if (e.target.tagName === 'BUTTON') {
                        this._attempts = 0;
                    }
                });

                this._keepIntervalId = setInterval(() => {
                    const listItems = document.querySelectorAll('.n-list-item');
                    const unfinishedItems = Array.from(listItems).filter(item => {
                        const descriptionText = item.querySelector('.n-thing-main__description');
                        return descriptionText && descriptionText.textContent.includes('未完成');
                    });

                    if (unfinishedItems.length > 0 && this._attempts < maxAttempts) {
                        const hasStopButton = Array.from(document.querySelectorAll('button')).some(btn => 
                            btn.textContent === '停止'
                        );

                        if (!hasStopButton) {
                            const retryButtons = Array.from(document.querySelectorAll('button')).filter(btn => 
                                btn.textContent.includes('重试未完成任务')
                            );
                            const clickCount = Math.min(unfinishedItems.length, listItems.length);
                            if (retryButtons[0]) {
                                for (let i = 0; i < clickCount; i++) {
                                    retryButtons[0].click();
                                }
                                this._attempts++;
                            }
                        }
                    }
                }, 1000);
            } else {
                this._keepActive = false;
                if (this._keepIntervalId) {
                    clearInterval(this._keepIntervalId);
                    this._keepIntervalId = null;
                }
            }
        }
    };

    const moduleCacheOptimization = {
        name: '緩存優化',
        type: 'keep',
        whitelist: '',
        settings: [
            newNumberSetting('同步間隔', 100)
        ],
        run: async function(configObj) {
            const interval = getModuleSetting(configObj, '同步間隔') || 1000;
            const origSession = window.sessionStorage;
            try {
                const proxyStorage = new Proxy(origSession, {
                    get(target, prop) {
                        if (typeof prop === 'string') {
                            let val = target[prop];
                            if (val === undefined) {
                                val = localStorage.getItem(prop);
                                if (val !== null) target.setItem(prop, val);
                            }
                            return val;
                        }
                        return target[prop];
                    },
                    set(target, prop, value) {
                        target[prop] = value;
                        localStorage.setItem(prop, value);
                        return true;
                    }
                });
                Object.defineProperty(window, 'sessionStorage', { value: proxyStorage, configurable: true });
            } catch(e) {}
            setInterval(() => {
                for (let i = 0; i < localStorage.length; i++) {
                    const key = localStorage.key(i);
                    const localVal = localStorage.getItem(key);
                    const sessVal = origSession[key];
                    if (sessVal !== localVal) {
                        origSession[key] = localVal;
                        try {
                            window.dispatchEvent(new StorageEvent('storage', {
                                key: key,
                                newValue: localVal,
                                storageArea: origSession
                            }));
                        } catch (e) {}
                    }
                }
            }, interval);
        }
    };    

    const defaultModules = [
        moduleAddSakuraTranslator,
        moduleAddGPTTranslator,
        moduleDeleteTranslator,
        moduleLaunchTranslator,
        moduleQueueSakura,
        moduleQueueGPT,
        moduleAutoRetry,
        moduleCacheOptimization,
    ];

    function loadConfiguration() {
        let tempStorage;
        try {
            tempStorage = JSON.parse(localStorage.getItem(CONFIG_STORAGE_KEY));
        } catch (e) { }
        if (!tempStorage || tempStorage.version !== CONFIG_VERSION) {
            return { version: CONFIG_VERSION, modules: JSON.parse(JSON.stringify(defaultModules)) };
        }

        const loadedModules = JSON.parse(JSON.stringify(defaultModules));
        tempStorage.modules.forEach(storedModule => {
            const defaultModule = loadedModules.find(mod => mod.name === storedModule.name);
            if (defaultModule) {
                for (const key in storedModule) {
                    if (defaultModule.hasOwnProperty(key) && typeof defaultModule[key] === typeof storedModule[key] && storedModule[key] !== undefined) {
                        defaultModule[key] = storedModule[key];
                    }
                }
            }
        });

        return { version: CONFIG_VERSION, modules: loadedModules };
    }
    function saveConfiguration(obj) {
        localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(obj));
    }

    let configuration = loadConfiguration();
    if (configuration.modules.length !== defaultModules.length) {
        configuration = { version: CONFIG_VERSION, modules: JSON.parse(JSON.stringify(defaultModules)) };
        saveConfiguration(configuration);
    } else {
        const defaultModuleNames = defaultModules.map(x => x.name).sort().join(',');
        const storedModuleNames = configuration.modules.map(x => x.name).sort().join(',');
        if (defaultModuleNames !== storedModuleNames) {
            configuration = { version: CONFIG_VERSION, modules: JSON.parse(JSON.stringify(defaultModules)) };
            saveConfiguration(configuration);
        }
    }
    configuration.modules.forEach(moduleObj => {
        const foundDefaultModule = defaultModules.find(x => x.name === moduleObj.name);
        if (foundDefaultModule && typeof foundDefaultModule.run === 'function') {
            for (const prop in foundDefaultModule) {
                if (!moduleObj.hasOwnProperty(prop)) {
                    moduleObj[prop] = foundDefaultModule[prop];
                }
            }
            moduleObj.run = foundDefaultModule.run;
        }
    });

    const keepIntervals = new Map();
    const keepActiveSet = new Set();

    function updateKeepStateStorage() {
        const state = {};
        keepActiveSet.forEach(moduleName => {
            state[moduleName] = true;
        });
        localStorage.setItem('NTR_KeepState', JSON.stringify(state));
    }

    function startKeepModule(modItem, modHeader) {
        if (keepIntervals.has(modItem.name)) return;
        modHeader.classList.add('active');
        keepActiveSet.add(modItem.name);
        const intervalId = setInterval(() => {
            if (typeof modItem.run === 'function') {
                modItem.run(modItem);
                console.log('[Keep Module] ' + modItem.name + ' is running...');
            }
        }, 2000);
        keepIntervals.set(modItem.name, intervalId);
        updateKeepStateStorage();
    }
    function stopKeepModule(modItem, modHeader) {
        const intervalId = keepIntervals.get(modItem.name);
        if (intervalId) {
            clearInterval(intervalId);
            keepIntervals.delete(modItem.name);
        }
        modHeader.classList.remove('active');
        keepActiveSet.delete(modItem.name);
        updateKeepStateStorage();
    }

    document.addEventListener('keydown', keyEvent => {
        if (keyEvent.ctrlKey || keyEvent.altKey || keyEvent.metaKey) return;
        const pressedKey = keyEvent.key.toLowerCase();
        for (const modItem of configuration.modules) {
            const bindVal = getModuleSetting(modItem, 'bind');
            if (!bindVal || bindVal === 'none') continue;
            if (bindVal.toLowerCase() === pressedKey) {
                if (!isModuleEnabledByWhitelist(modItem)) continue;
                keyEvent.preventDefault();
                handleModuleClick(modItem, null);
            }
        }
    });

    const panel = document.createElement('div');
    panel.id = 'ntr-panel';
    const savedPosition = localStorage.getItem('ntr-panel-position');
    if (savedPosition) {
        try {
            const parsedPosition = JSON.parse(savedPosition);
            if (parsedPosition.left && parsedPosition.top) {
                panel.style.left = parsedPosition.left;
                panel.style.top = parsedPosition.top;
            }
        } catch (e) { }
    }
    let dragging = false, dragOffsetX = 0, dragOffsetY = 0;
    function mouseDownHandler(e) {
        dragging = true;
        dragOffsetX = e.clientX - panel.offsetLeft;
        dragOffsetY = e.clientY - panel.offsetTop;
        e.preventDefault();
    }
    function mouseMoveHandler(e) {
        if (!dragging) return;
        panel.style.left = (e.clientX - dragOffsetX) + 'px';
        panel.style.top = (e.clientY - dragOffsetY) + 'px';
    }
    function mouseUpHandler() {
        dragging = false;
        localStorage.setItem('NTR-Panel-Position', JSON.stringify({
            left: panel.style.left,
            top: panel.style.top
        }));
    }
    document.addEventListener('mousemove', mouseMoveHandler);
    document.addEventListener('mouseup', mouseUpHandler);

    const titleBar = document.createElement('div');
    titleBar.className = 'ntr-titlebar';
    titleBar.textContent = 'NTR ToolBox';
    titleBar.addEventListener('mousedown', mouseDownHandler);
    panel.appendChild(titleBar);

    const panelBody = document.createElement('div');
    panelBody.className = 'ntr-panel-body';
    panel.appendChild(panelBody);

    function isModuleEnabledByWhitelist(modItem) {
        if (!modItem.whitelist || modItem.whitelist.trim() === '') return domainAllowed;
        const parts = modItem.whitelist.split(',').map(s => s.trim()).filter(Boolean);
        return domainAllowed && parts.some(p => {
            if (p.endsWith('/*')) {
                const basePath = p.slice(0, -2);
                return location.pathname.startsWith(basePath + '/');
            }
            return location.pathname.includes(p);
        });
    }
    function handleModuleClick(modItem, modHeader) {
        if (!domainAllowed || !isModuleEnabledByWhitelist(modItem)) return;
        if (modItem.type === 'onclick') {
            if (typeof modItem.run === 'function') {
                modItem.run(modItem);
            }
        } else if (modItem.type === 'keep') {
            const isActive = keepActiveSet.has(modItem.name);
            if (isActive) {
                if (modHeader) stopKeepModule(modItem, modHeader);
            } else {
                if (modHeader) startKeepModule(modItem, modHeader);
            }
        }
    }

    const headerMap = new Map();
    configuration.modules.forEach(modItem => {
        const moduleContainer = document.createElement('div');
        moduleContainer.className = 'ntr-module-container';
        const moduleHeader = document.createElement('div');
        moduleHeader.className = 'ntr-module-header';
        const nameSpan = document.createElement('span');
        nameSpan.textContent = modItem.name;
        moduleHeader.appendChild(nameSpan);
        const iconSpan = document.createElement('span');
        iconSpan.textContent = modItem.type === 'keep' ? '⇋' : '▶';
        iconSpan.style.marginLeft = '8px';
        moduleHeader.appendChild(iconSpan);
        const settingsContainer = document.createElement('div');
        settingsContainer.className = 'ntr-settings-container';
        settingsContainer.style.display = 'none';

        moduleHeader.oncontextmenu = function (e) {
            e.preventDefault();
            const currentDisplay = settingsContainer.style.display || window.getComputedStyle(settingsContainer).display;
            settingsContainer.style.display = currentDisplay === 'none' ? 'block' : 'none';
        };
        moduleHeader.onclick = function (e) {
            if (e.button === 0 && !e.ctrlKey && !e.altKey && !e.shiftKey) {
                if (e.target.classList.contains('ntr-bind-button')) return;
                handleModuleClick(modItem, moduleHeader);
            }
        };
        if (modItem.type === 'keep' && keepActiveSet.has(modItem.name)) {
            moduleHeader.classList.add('active');
        }
        if (Array.isArray(modItem.settings)) {
            modItem.settings.forEach(setObj => {
                const row = document.createElement('div');
                row.style.marginBottom = '8px';
                const label = document.createElement('label');
                label.style.display = 'inline-block';
                label.style.minWidth = '70px';
                label.style.color = '#ccc';
                label.textContent = setObj.name + ': ';
                row.appendChild(label);
                let inputElement;
                if (setObj.type === 'boolean') {
                    inputElement = document.createElement('input');
                    inputElement.type = 'checkbox';
                    inputElement.checked = Boolean(setObj.value);
                    inputElement.onchange = function () {
                        setObj.value = inputElement.checked;
                        saveConfiguration(configuration);
                    };
                } else if (setObj.type === 'number') {
                    inputElement = document.createElement('input');
                    inputElement.type = 'number';
                    inputElement.value = setObj.value;
                    inputElement.className = 'ntr-number-input';
                    inputElement.onchange = function () {
                        setObj.value = Number(inputElement.value) || 0;
                        saveConfiguration(configuration);
                    };
                } else if (setObj.type === 'string' && setObj.name === 'bind') {
                    inputElement = document.createElement('button');
                    inputElement.className = 'ntr-bind-button';
                    inputElement.textContent = setObj.value === 'none' ? '(None)' : '[' + setObj.value.toUpperCase() + ']';
                    inputElement.onclick = function () {
                        inputElement.textContent = '(Press any key)';
                        function handleKey(keyEvent) {
                            keyEvent.preventDefault();
                            if (keyEvent.key === 'Escape') {
                                setObj.value = 'none';
                                inputElement.textContent = '(None)';
                                saveConfiguration(configuration);
                                document.removeEventListener('keydown', handleKey, true);
                                keyEvent.stopPropagation();
                                return;
                            }
                            const pressedKey = keyEvent.key.toLowerCase();
                            setObj.value = pressedKey;
                            inputElement.textContent = '[' + pressedKey.toUpperCase() + ']';
                            saveConfiguration(configuration);
                            document.removeEventListener('keydown', handleKey, true);
                            keyEvent.stopPropagation();
                        }
                        document.addEventListener('keydown', handleKey, true);
                    };
                } else if (setObj.type === 'select' && Array.isArray(setObj.options)) {
                    inputElement = document.createElement('select');
                    setObj.options.forEach(opt => {
                        const optionEl = document.createElement('option');
                        optionEl.value = opt;
                        optionEl.textContent = opt;
                        if (opt === setObj.value) {
                            optionEl.selected = true;
                        }
                        inputElement.appendChild(optionEl);
                    });
                    inputElement.onchange = function () {
                        setObj.value = inputElement.value;
                        saveConfiguration(configuration);
                    };
                } else if (setObj.type === 'string') {
                    inputElement = document.createElement('input');
                    inputElement.type = 'text';
                    inputElement.value = setObj.value;
                    inputElement.className = 'ntr-input';
                    inputElement.onchange = function () {
                        setObj.value = inputElement.value;
                        saveConfiguration(configuration);
                    };
                } else {
                    inputElement = document.createElement('span');
                    inputElement.style.color = '#999';
                    inputElement.textContent = String(setObj.value);
                }
                row.appendChild(inputElement);
                settingsContainer.appendChild(row);
            });
        }
        moduleContainer.appendChild(moduleHeader);
        moduleContainer.appendChild(settingsContainer);
        panelBody.appendChild(moduleContainer);
        headerMap.set(modItem, moduleHeader);
    });

    // New: load keep module state from storage and auto-start
    function updateKeepStateStorage() {
        const state = {};
        keepActiveSet.forEach(moduleName => {
            state[moduleName] = true;
        });
        localStorage.setItem('NTR_KeepState', JSON.stringify(state));
    }
    function loadKeepStateStorage() {
        try {
            return JSON.parse(localStorage.getItem('NTR_KeepState') || '{}');
        } catch(e) {
            return {};
        }
    }
    const savedKeepState = loadKeepStateStorage();
    configuration.modules.forEach(mod => {
        if (mod.type === 'keep' && savedKeepState[mod.name]) {
            const modHeader = headerMap.get(mod);
            if (modHeader) {
                startKeepModule(mod, modHeader);
            }
        }
    });

    const infoText = document.createElement('div');
    infoText.className = 'ntr-info';
    infoText.textContent = '左鍵執行/切換 | 右鍵設定';
    panel.appendChild(infoText);
    document.body.appendChild(panel);

    const updateModuleVisibility = () => {
        configuration.modules.forEach(m => {
            const moduleHeader = headerMap.get(m);
            if (!moduleHeader) return;
            const moduleContainer = moduleHeader.parentElement;
            const allow = domainAllowed && isModuleEnabledByWhitelist(m);
            if (!allow) {
                moduleContainer.style.display = 'none';
                if (m.type === 'keep' && keepActiveSet.has(m.name)) stopKeepModule(m, moduleHeader);
            } else {
                moduleContainer.style.display = 'block';
            }
        });
    };
    updateModuleVisibility();
    setInterval(updateModuleVisibility, 250);
})();