您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
ToolBox for Novel Translate bot website
当前为
// ==UserScript== // @name NTR ToolBox // @namespace http://tampermonkey.net/ // @version v0.5 // @author TheNano // @description ToolBox for Novel Translate bot website // @match https://books.fishhawk.top/* // @match https://books1.fishhawk.top/* // @icon https://github.com/LittleSurvival/NTR-ToolBox/blob/main/icon.jpg?raw=true // @grant GM_openInTab // @license All Rights Reserved // ==/UserScript== (function () { 'use strict'; if (window._NTRToolBoxInstance) { return; } window._NTRToolBoxInstance = true; const CONFIG_VERSION = 20; const VERSION = 'v0.5'; const CONFIG_STORAGE_KEY = 'NTR_ToolBox_Config'; const IS_MOBILE = /Mobi|Android/i.test(navigator.userAgent); const domainAllowed = (location.hostname === 'books.fishhawk.top' || location.hostname === 'books1.fishhawk.top'); // ----------------------------------- // Module settings // ----------------------------------- 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(mod, key) { if (!mod.settings) return undefined; const found = mod.settings.find(s => s.name === key); return found ? found.value : undefined; } function isModuleEnabledByWhitelist(modItem) { if (!modItem.whitelist) { return domainAllowed; } const whitelist = modItem.whitelist; const parts = Array.isArray(whitelist) ? whitelist : [whitelist]; return domainAllowed && parts.some(p => { if (typeof p === 'string') { if (p.endsWith('/*')) { const base = p.slice(0, -2); return location.pathname.startsWith(base) || location.pathname === base; } return location.pathname.includes(p); } return false; }); } // ----------------------------------- // Module definitions // ----------------------------------- const moduleAddSakuraTranslator = { name: '添加Sakura翻譯器', type: 'onclick', whitelist: '/workspace/sakura', settings: [ newNumberSetting('數量', 5), newStringSetting('名稱', 'NTR translator '), newStringSetting('鏈接', 'https://sakura-share.one'), newStringSetting('bind', 'none'), ], run: async function (cfg) { const totalCount = getModuleSetting(cfg, '數量') || 1; const namePrefix = getModuleSetting(cfg, '名稱') || ''; const linkValue = getModuleSetting(cfg, '鏈接') || ''; StorageUtils.addSakuraWorker(namePrefix, linkValue, totalCount); } } const moduleAddGPTTranslator = { name: '添加GPT翻譯器', type: 'onclick', whitelist: '/workspace/gpt', settings: [ 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 (cfg) { const totalCount = getModuleSetting(cfg, '數量') || 1; const namePrefix = getModuleSetting(cfg, '名稱') || ''; const model = getModuleSetting(cfg, '模型') || ''; const apiKey = getModuleSetting(cfg, 'Key') || ''; const apiUrl = getModuleSetting(cfg, '鏈接') || ''; StorageUtils.addGPTWorker(namePrefix, model, apiUrl, apiKey, totalCount); } }; const moduleDeleteTranslator = { name: '刪除翻譯器', type: 'onclick', whitelist: '/workspace', settings: [ newStringSetting('排除', '共享,本机,AutoDL'), newStringSetting('bind', 'none'), ], run: async function (cfg) { const excludeStr = getModuleSetting(cfg, '排除') || ''; const excludeArr = excludeStr.split(',').filter(x => x); if (location.href.endsWith('gpt')) { StorageUtils.removeAllWorkers(StorageUtils.gpt, excludeArr); } else if (location.href.endsWith('sakura')) { StorageUtils.removeAllWorkers(StorageUtils.sakura, excludeArr); } } }; const moduleLaunchTranslator = { name: '啟動翻譯器', type: 'onclick', whitelist: '/workspace', settings: [ newNumberSetting('延遲間隔', 50), newNumberSetting('最多啟動', 999), newBooleanSetting('避免無效啟動', true), newStringSetting('排除', '本机,AutoDL'), newStringSetting('bind', 'none'), ], run: async function (cfg, auto) { const intervalVal = getModuleSetting(cfg, '延遲間隔') || 50; const maxClick = getModuleSetting(cfg, '最多啟動') || 999; const noEmptyLaunch = getModuleSetting(cfg, '避免無效啟動'); const allBtns = [...document.querySelectorAll('button')].filter(btn => { if (!auto && noEmptyLaunch) return true; const listItem = btn.closest('.n-list-item'); if (listItem) { const errorMessages = listItem.querySelectorAll('div'); return !Array.from(errorMessages).some(div => div.textContent.includes("TypeError: Failed to fetch")); } return true; }); const delay = ms => new Promise(r => setTimeout(r, ms)); let idx = 0, clickCount = 0, lastRunning = 0, emptyCheck = 0; async function nextClick() { while (idx < allBtns.length && clickCount < maxClick) { const btn = allBtns[idx++]; if (btn.textContent.includes('启动')) { btn.click(); clickCount++; await delay(intervalVal); } if (noEmptyLaunch) { let running = [...document.querySelectorAll('button')].filter(btn => btn.textContent.includes('停止')).length; if (running == lastRunning) emptyCheck++; if (emptyCheck > 3) break; } } } await nextClick(); } }; const moduleQueueSakuraV2 = { name: '排隊Sakura v2', type: 'onclick', whitelist: ['/wenku', '/novel', '/favorite'], progress: { percentage: 0, info: '' }, settings: [ newNumberSetting('單次擷取web數量(可破限)', 20), newNumberSetting('擷取單頁wenku數量(deving)', 20), newSelectSetting('模式', ['常規', '過期', '重翻'], '常規'), newSelectSetting('分段', ['智能', '固定'], '智能'), newNumberSetting('智能均分任務上限', 1000), newNumberSetting('智能均分章節下限', 5), newNumberSetting('固定均分任務', 6), newBooleanSetting('R18(需登入)', true), newStringSetting('bind', 'none'), ], run: async function (cfg) { const webCatchLimit = getModuleSetting(cfg, '單次擷取web數量(可破限)') || 20; const wenkuCatchLimit = getModuleSetting(cfg, '擷取單頁wenku數量(deving)') || 20; const pair = getModuleSetting(cfg, '固定均分任務') || 6; const smartJobLimit = getModuleSetting(cfg, '智能均分任務上限') || 1000; const smartChapterLimit = getModuleSetting(cfg, '智能均分章節下限') || 5; const type = TaskUtils.getTypeString(window.location.pathname); const mode = getModuleSetting(cfg, '模式') || '常規'; const sepMode = getModuleSetting(cfg, '分段') || '智能'; const r18Bypass = getModuleSetting(cfg, 'R18(需登入)'); let results = []; let errorFlag = false; const maxRetries = 3; const modeMap = { '常規': '常规', '過期': '过期', '重翻': '重翻' }; const cnMode = modeMap[mode] || '常规'; switch (type) { case 'wenkus': { const wenkuIds = TaskUtils.wenkuIds(); const apiEndpoint = `/api/wenku/`; await Promise.all( wenkuIds.map(async (id) => { let attempts = 0; let success = false; while (attempts < maxRetries && !success) { try { const response = await script.fetch(`${window.location.origin}${apiEndpoint}${id}`, r18Bypass); if (!response.ok) throw new Error('Network response was not ok'); const data = await response.json(); const volumeIds = data.volumeJp.map(volume => volume.volumeId); volumeIds.forEach(name => results.push({ task: TaskUtils.wenkuLinkBuilder(id, name, SettingUtils.getTranslateMode(mode)), description: name })) success = true; } catch (error) { NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}.`); attempts++; if (attempts < maxRetries) { await new Promise(resolve => setTimeout(resolve, 1000)); } } } }) ); await StorageUtils.addJobs(StorageUtils.sakura, results); break; }; case 'wenku': { await TaskUtils.clickButtons(cnMode); await TaskUtils.clickButtons('排队Sakura'); break; } case 'novels': { const apiUrl = TaskUtils.webSearchApi(webCatchLimit); try { const response = await script.fetch(`${window.location.origin}${apiUrl}`, r18Bypass); if (!response.ok) throw new Error('Network response was not ok'); const data = await response.json(); const novels = data.items.map(item => { const title = item.titleZh ?? item.titleJp; return { url: `/${item.providerId}/${item.novelId}`, description: title, total: item.total, sakura: item.sakura }; }); results = sepMode == '智能' ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode)) : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode)); await StorageUtils.addJobs(StorageUtils.sakura, results); } catch (error) { errorFlag = true; NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}.`) } break; } case 'novel': { try { const targetSpan = Array.from(document.querySelectorAll('span.n-text')).find(span => /总计 (\d+) \/ 百度 (\d+) \/ 有道 (\d+) \/ GPT (\d+) \/ Sakura (\d+)/.test(span.textContent)); const [_, total, , , , sakura] = targetSpan.textContent.match(/总计 (\d+) \/ 百度 (\d+) \/ 有道 (\d+) \/ GPT (\d+) \/ Sakura (\d+)/); const url = window.location.pathname.split('/novel')[1]; const title = document.title; if (title.includes('轻小说机翻机器人')) throw Error('小說頁尚未載入'); const novels = [{ url: url, total: total, sakura: sakura, description: title }]; results = sepMode == '智能' ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode)) : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode)); await StorageUtils.addJobs(StorageUtils.sakura, results); } catch (error) { errorFlag = true; NotificationUtils.showError(`Failed to fetch data for ${title}.`); } break; } case 'favorite-web': { const url = new URL(window.location.href); //get folder id const id = url.pathname.endsWith('/web') ? 'default' : url.pathname.split('/').pop(); let tries = 0; let page = 0; while (true) { const apiUrl = `${url.origin}/api/user/favored-web/${id}?page=${page}&pageSize=90&sort=update`; let tasks = []; let novelCount = 0; try { const response = await script.fetch(apiUrl); const data = await response.json(); const novels = data.items.map(item => { const title = item.titleZh ?? item.titleJp; return { url: `/${item.providerId}/${item.novelId}`, description: title, total: item.total, sakura: item.sakura }; }); novelCount = novels.length; tasks = sepMode == '智能' ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode)) : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode)); await StorageUtils.addJobs(StorageUtils.sakura, tasks); results.push(tasks); NotificationUtils.showSuccess(`成功排隊 ${3 * page + 1}-${3 * page + 3}頁, 共${tasks.length}個任務`); } catch (error) { console.log(error); NotificationUtils.showError(`Failed to fetch data for ${id}, page ${page + 1}.`); if (tries++ > 3) break; continue; } if (novelCount < 90) break; else page++; } break; } case 'favorite-wenku': { const url = new URL(window.location.href); //get folder id const id = url.pathname.endsWith('/wenku') ? 'default' : url.pathname.split('/').pop(); let page = 0; let tries = 0; while (true) { const apiUrl = `${url.origin}/api/user/favored-wenku/${id}?page=${page}&pageSize=72&sort=update`; let tasks = []; let novelCount = 0; try { const response = await script.fetch(apiUrl); const data = await response.json(); const wenkuIds = data.items.map(novel => novel.id); novelCount = wenkuIds.length; await Promise.all( wenkuIds.map(async (id) => { let attempts = 0; let success = false; const apiEndpoint = `/api/wenku/`; while (attempts < maxRetries && !success) { try { const response = await script.fetch(`${window.location.origin}${apiEndpoint}${id}`, r18Bypass); if (!response.ok) throw new Error('Network response was not ok'); const data = await response.json(); const volumeIds = data.volumeJp.map(volume => volume.volumeId); volumeIds.forEach(name => tasks.push({ task: TaskUtils.wenkuLinkBuilder(id, name, mode), description: name })) success = true; } catch (error) { NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}:`); attempts++; if (attempts < maxRetries) { await new Promise(resolve => setTimeout(resolve, 1000)); } } } }) ); await StorageUtils.addJobs(StorageUtils.sakura, tasks); results.push(tasks); NotificationUtils.showSuccess(`成功排隊 ${3 * page + 1}-${3 * page + 3}頁, 共${tasks.length}本小說`); } catch (error) { console.log(error); NotificationUtils.showError(`Failed to fetch data for ${id}, page ${page + 1}.`); if (tries > 3) break; continue; } if (novelCount < 72) break; else page++; } break; } default: { } } if (errorFlag) return; const novels = new Set(results.map(result => result.description)); NotificationUtils.showSuccess(`排隊成功 : 共 ${novels.size} 本小說, 均分 ${results.length} 分段.`); } } const moduleQueueGPTV2 = { name: '排隊GPT v2', type: 'onclick', whitelist: ['/wenku', '/novel', '/favorite/web'], progress: { percentage: 0, info: '' }, settings: [ newNumberSetting('單次擷取web數量(可破限)', 20), newNumberSetting('擷取單頁wenku數量(deving)', 20), newSelectSetting('模式', ['常規', '過期', '重翻'], '常規'), newSelectSetting('分段', ['智能', '固定'], '智能'), newNumberSetting('智能均分任務上限', 1000), newNumberSetting('智能均分章節下限', 5), newNumberSetting('固定均分任務', 6), newBooleanSetting('R18(需登入)', true), newStringSetting('bind', 'none'), ], run: async function (cfg) { const webCatchLimit = getModuleSetting(cfg, '單次擷取web數量(可破限)') || 20; const wenkuCatchLimit = getModuleSetting(cfg, '擷取單頁wenku數量(deving)') || 20; const pair = getModuleSetting(cfg, '固定均分任務') || 6; const smartJobLimit = getModuleSetting(cfg, '智能均分任務上限') || 1000; const smartChapterLimit = getModuleSetting(cfg, '智能均分章節下限') || 5; const type = TaskUtils.getTypeString(window.location.pathname); const mode = getModuleSetting(cfg, '模式') || '常規'; const sepMode = getModuleSetting(cfg, '分段') || '智能'; const r18Bypass = getModuleSetting(cfg, 'R18(需登入)'); let results = []; const maxRetries = 3; let errorFlag = false; const modeMap = { '常規': '常规', '過期': '过期', '重翻': '重翻' }; const cnMode = modeMap[mode] || '常规'; switch (type) { case 'wenkus': { const wenkuIds = TaskUtils.wenkuIds(); const apiEndpoint = `/api/wenku/`; await Promise.all( wenkuIds.map(async (id) => { let attempts = 0; let success = false; while (attempts < maxRetries && !success) { try { const response = await script.fetch(`${window.location.origin}${apiEndpoint}${id}`, r18Bypass); if (!response.ok) throw new Error('Network response was not ok'); const data = await response.json(); const volumeIds = data.volumeJp.map(volume => volume.volumeId); volumeIds.forEach(name => results.push({ task: TaskUtils.wenkuLinkBuilder(id, name, mode), description: name })) success = true; } catch (error) { NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}:`); attempts++; if (attempts < maxRetries) { await new Promise(resolve => setTimeout(resolve, 1000)); } } } }) ); await StorageUtils.addJobs(StorageUtils.gpt, results); break; }; case 'wenku': { await TaskUtils.clickButtons(cnMode); await TaskUtils.clickButtons('排队Sakura'); break; } case 'novels': { const apiUrl = TaskUtils.webSearchApi(webCatchLimit); try { const response = await script.fetch(`${window.location.origin}${apiUrl}`, r18Bypass) if (!response.ok) throw new Error('Network response was not ok'); const data = await response.json(); const novels = data.items.map(item => { const title = item.titleZh ?? item.titleJp; return { url: `/${item.providerId}/${item.novelId}`, description: title, total: item.total, gpt: item.gpt }; }); results = sepMode == '智能' ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode)) : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode)); await StorageUtils.addJobs(StorageUtils.gpt, results); } catch (error) { errorFlag = true; NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}:`); } break; } case 'novel': { try { const targetSpan = Array.from(document.querySelectorAll('span.n-text')).find(span => /总计 (\d+) \/ 百度 (\d+) \/ 有道 (\d+) \/ GPT (\d+) \/ Sakura (\d+)/.test(span.textContent)); const [_, total, , , gpt] = targetSpan.textContent.match(/总计 (\d+) \/ 百度 (\d+) \/ 有道 (\d+) \/ GPT (\d+) \/ Sakura (\d+)/); const url = window.location.pathname.split('/novel')[1]; const title = document.title; if (title.includes('轻小说机翻机器人')) throw Error('小說頁尚未載入'); const novels = [{ url: url, total: total, gpt: gpt, description: title }] results = sepMode == '智能' ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode)) : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode)); await StorageUtils.addJobs(StorageUtils.gpt, results); } catch (error) { errorFlag = true; NotificationUtils.showError(`Failed to fetch data for ${title}.`); } break; } case 'favorite-web': { const url = new URL(window.location.href); //get folder id const id = url.pathname.endsWith('/web') ? 'default' : url.pathname.split('/').pop(); let tries = 0; let page = 0; while (true) { const apiUrl = `${url.origin}/api/user/favored-web/${id}?page=${page}&pageSize=90&sort=update`; let tasks = []; let novelCount = 0; try { const response = await script.fetch(apiUrl); const data = await response.json(); const novels = data.items.map(item => { const title = item.titleZh ?? item.titleJp; return { url: `/${item.providerId}/${item.novelId}`, description: title, total: item.total, gpt: item.gpt }; }); novelCount = novels.length; tasks = sepMode == '智能' ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode)) : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode)); await StorageUtils.addJobs(StorageUtils.gpt, tasks); results.push(tasks); NotificationUtils.showSuccess(`成功排隊 ${3 * page + 1}-${3 * page + 3}頁, 共${novelCount}本小說`); } catch (error) { console.log(error); NotificationUtils.showError(`Failed to fetch data for ${id}, page ${page + 1}.`); if (tries++ > 3) break; continue; } if (novelCount < 90) break; else page++; } break; } case 'favorite-wenku': { const url = new URL(window.location.href); //get folder id const id = url.pathname.endsWith('/wenku') ? 'default' : url.pathname.split('/').pop(); let page = 0; let tries = 0; while (true) { const apiUrl = `${url.origin}/api/user/favored-wenku/${id}?page=${page}&pageSize=72&sort=update`; let tasks = []; let novelCount = 0; try { const response = await script.fetch(apiUrl); const data = await response.json(); const wenkuIds = data.items.map(novel => novel.id); novelCount = wenkuIds.length; await Promise.all( wenkuIds.map(async (id) => { let attempts = 0; let success = false; const apiEndpoint = `/api/wenku/`; while (attempts < maxRetries && !success) { try { const response = await script.fetch(`${window.location.origin}${apiEndpoint}${id}`, r18Bypass); if (!response.ok) throw new Error('Network response was not ok'); const data = await response.json(); const volumeIds = data.volumeJp.map(volume => volume.volumeId); volumeIds.forEach(name => tasks.push({ task: TaskUtils.wenkuLinkBuilder(id, name, mode), description: name })) success = true; } catch (error) { NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}:`); attempts++; if (attempts < maxRetries) { await new Promise(resolve => setTimeout(resolve, 1000)); } } } }) ); await StorageUtils.addJobs(StorageUtils.gpt, tasks); results.push(tasks); NotificationUtils.showSuccess(`成功排隊 ${3 * page + 1}-${3 * page + 3}頁, 共${tasks.length}本小說`); } catch (error) { console.log(error); NotificationUtils.showError(`Failed to fetch data for ${id}, page ${page + 1}.`); if (tries > 3) break; continue; } if (novelCount < 72) break; else page++; } break; } default: { } } if (errorFlag) return; const novels = new Set(results.map(result => result.description)); NotificationUtils.showSuccess(`排隊成功 : 共 ${novels.size} 本小說, 均分 ${results.length} 分段.`); } } const moduleAutoRetry = { name: '自動重試', type: 'keep', whitelist: '/workspace/*', settings: [ newNumberSetting('最大重試次數', 99), newBooleanSetting('置頂重試任務', false), newBooleanSetting('重啟翻譯器', true), ], _attempts: 0, _lastRun: 0, _interval: 1000, run: async function (cfg) { const now = Date.now(); if (now - this._lastRun < this._interval) return; this._lastRun = now; const maxAttempts = getModuleSetting(cfg, '最大重試次數') || 99; const relaunch = getModuleSetting(cfg, '重啟翻譯器') || 3; const moveToTop = getModuleSetting(cfg, '置頂重試任務'); if (!this._boundClickHandler) { this._boundClickHandler = (e) => { if (e.target.tagName === 'button') { this._attempts = 0; } }; document.addEventListener('click', this._boundClickHandler); } const listItems = document.querySelectorAll('.n-list-item'); const unfinished = [...listItems].filter(item => { const desc = item.querySelector('.n-thing-main__description'); return desc && desc.textContent.includes('未完成'); }); async function retryTasks(attempts) { const hasStop = [...document.querySelectorAll('button')].some(b => b.textContent === '停止'); if (!hasStop) { const retryBtns = [...document.querySelectorAll('button')].filter(b => b.textContent.includes('重试未完成任务')); if (retryBtns[0]) { const clickCount = Math.min(unfinished.length, listItems.length); for (let i = 0; i < clickCount; i++) { retryBtns[0].click(); } if (moveToTop) { TaskUtils.clickTaskMoveToTop(unfinished.length); } attempts++; } } return attempts; } if (unfinished.length > 0 && this._attempts < maxAttempts) { this._attempts = await retryTasks(this._attempts); script.delay(10); if (relaunch) { script.runModule('啟動翻譯器'); } } } }; const moduleSyncStorage = { name: '資料同步', type: 'onclick', whitelist: '/workspace/*', hidden: true, settings: [ newStringSetting('bind', 'none') ], run: async function (cfg) { } } const defaultModules = [ moduleAddSakuraTranslator, moduleAddGPTTranslator, moduleDeleteTranslator, moduleLaunchTranslator, moduleQueueSakuraV2, moduleQueueGPTV2, moduleAutoRetry, moduleSyncStorage, ]; // ----------------------------------- // Setting Utils // ----------------------------------- class SettingUtils { static getTranslateMode(mode) { const map = { '常規': 'normal', '過期': 'expire', '重翻': 'all' }; return map[mode]; } } // ----------------------------------- // TaskUtils Utils // ----------------------------------- class TaskUtils { static getTypeString = (url) => { const patterns = { 'wenkus': new RegExp(`^/wenku(\\?.*)?$`), // Matches /wenku and /wenku?params 'wenku': new RegExp(`^/wenku\\/.*(\\?.*)?$`), // Matches /wenku/* and /wenku/*?params 'novels': new RegExp(`^/novel(\\?.*)?$`), // Matches /novel and /novel?params 'novel': new RegExp(`^/novel\\/.*(\\?.*)?$`), // Matches /novel/*/* and /novel/*/*?params 'favorite-web': new RegExp(`^/favorite/web(/.*)?(\\?.*)?$`), // Matches /favorite/web and /favorite/web/* and /favorite/web?params 'favorite-wenku': new RegExp(`^/favorite/wenku(/.*)?(\\?.*)?$`), // Matches /favorite/wenku and /favorite/wenku/* and /favorite/wenku?params 'favorite-local': new RegExp(`^/favorite/local(/.*)?(\\?.*)?$`) // Matches /favorite/local and /favorite/local/* and /favorite/local?params }; for (const [key, pattern] of Object.entries(patterns)) { if (pattern.test(url)) { return key; } } return null; }; static wenkuLinkBuilder(series, name, mode) { return `wenku/${series}/${name}?level=${mode}&forceMetadata=false&startIndex=0&endIndex=65536` } static webLinkBuilder(url, from = 0, to = 65536, mode) { return `web${url}?level=${mode}&forceMetadata=false&startIndex=${from}&endIndex=${to}` } //return "id" static wenkuIds() { const links = [...document.querySelectorAll('a[href^="/wenku/"]')]; return links.map(link => link.getAttribute('href').split('/wenku/')[1]); } //return api link static webSearchApi(limit = 20) { const urlParams = new URLSearchParams(location.search), page = Math.max(urlParams.get('page') - 1 || 0, 0); const input = document.querySelector('input[placeholder="中/日文标题或作者"]'); let rawQuery = input ? input.value.trim() : ''; const query = encodeURIComponent(rawQuery); const selected = [...document.querySelectorAll('.n-text.__text-dark-131ezvy-p')].map(e => e.textContent.trim()); const sourceMap = { Kakuyomu: 'kakuyomu', '成为小说家吧': 'syosetu', Novelup: 'novelup', Hameln: 'hameln', Pixiv: 'pixiv', Alphapolis: 'alphapolis' }; const typeMap = { '连载中': '1', '已完结': '2', '短篇': '3', '全部': '0' }; const levelMap = { '一般向': '1', 'R18': '2', '全部': '0' }; const translateMap = { 'GPT': '1', 'Sakura': '2', '全部': '0' }; const sortMap = { '更新': '0', '点击': '1', '相关': '2' }; const providers = Object.keys(sourceMap) .filter(k => selected.includes(k)) .map(k => sourceMap[k]) .join(',') || 'kakuyomu,syosetu,novelup,hameln,pixiv,alphapolis'; const tKey = Object.keys(typeMap).find(x => selected.includes(x)) || '全部'; const lKey = Object.keys(levelMap).find(x => selected.includes(x)) || '全部'; const trKey = Object.keys(translateMap).find(x => selected.includes(x)) || '全部'; const sKey = Object.keys(sortMap).find(x => selected.includes(x)) || '更新'; return `/api/novel?page=${page}&pageSize=${limit}&query=${query}` + `&provider=${encodeURIComponent(providers)}&type=${typeMap[tKey]}&level=${levelMap[lKey]}` + `&translate=${translateMap[trKey]}&sort=${sortMap[sKey]}`; } //return { task, description } static async assignTasksSmart(novels, smartJobLimit, smartChapterLimit, mode) { function undone(n) { if (mode === "normal") { const sOrG = (n.sakura ?? n.gpt) || 0; //Using max to deal with some total > sakura situation return Math.max(n.total - sOrG, 0); } return n.total; } const totalChapters = novels.reduce((acc, n) => acc + undone(n), 0); const potentialMaxTask = Math.floor(totalChapters / smartChapterLimit); let maxTasks = Math.min(potentialMaxTask, smartJobLimit); if (maxTasks <= 0 && totalChapters > 0) { maxTasks = smartJobLimit; } if (totalChapters === 0) { return []; } const chunkSize = Math.ceil(totalChapters / (maxTasks || 1)); const sorted = [...novels].sort((a, b) => undone(b) - undone(a)); const result = []; let usedTasks = 0; for (const novel of sorted) { let remain = undone(novel); if (remain <= 0) continue; let startIndex = (mode === "normal") ? (novel.total - remain) : 0; while (remain > 0 && usedTasks < smartJobLimit) { const thisChunk = Math.min(remain, chunkSize); const endIndex = startIndex + thisChunk; result.push({ task: TaskUtils.webLinkBuilder(novel.url, startIndex, endIndex, mode), description: novel.description }); usedTasks++; remain -= thisChunk; startIndex = endIndex; if (usedTasks >= smartJobLimit) { break; } } if (usedTasks >= smartJobLimit) { break; } } return result; } //return { task, description } static async assignTasksStatic(novels, parts, mode) { function undone(n) { if (mode === "normal") { const sOrG = (n.sakura ?? n.gpt) || 0; return n.total - sOrG; } return n.total; } const result = []; for (const novel of novels) { const totalChapters = undone(novel); if (totalChapters <= 0) continue; const startBase = (mode === "normal") ? (novel.total - totalChapters) : 0; const chunkSize = Math.ceil(totalChapters / parts); for (let i = 0; i < parts; i++) { const chunkStart = startBase + i * chunkSize; const chunkEnd = (i === parts - 1) ? (startBase + totalChapters) : (chunkStart + chunkSize); if (chunkStart < startBase + totalChapters) { result.push({ task: TaskUtils.webLinkBuilder(novel.url, chunkStart, chunkEnd, mode), description: novel.description }); } } } return result; } static async clickTaskMoveToTop(count, reserve=true) { const extras = document.querySelectorAll('.n-thing-header__extra'); for (let i = 0; i < count;i++) { const offset = reserve ? extras.length - i - 1 : i; const container = extras[offset]; const buttons = container.querySelectorAll('button'); if (buttons.length) { buttons[0].click(); } } } static async clickButtons(name = '') { const btns = document.querySelectorAll('button'); btns.forEach(btn => { if (name === '' || btn.textContent.includes(name)) { btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); } }); } } // ----------------------------------- // Storage Utils // ----------------------------------- class StorageUtils { static sakura = 'sakura-workspace'; static gpt = 'gpt-workspace'; static updateUrl = [ 'workspace/sakura', 'workspace/gpt' ]; static async update() { const storageKey = (window.location.pathname.includes('workspace/sakura') ? this.sakura : (window.location.pathname.includes('workspace/gpt') ? this.gpt : null)); if (!storageKey) return; const data = await this._getData(storageKey); await this._setData(storageKey, data); } static async _setData(key, data) { localStorage.setItem(key, JSON.stringify(data)); window.dispatchEvent(new StorageEvent('storage', { key: key, newValue: JSON.stringify(data), url: window.location.href, storageArea: localStorage })); } static async _getData(key) { let raw = localStorage.getItem(key); if (raw) { return JSON.parse(raw); } return { workers: [], jobs: [], uncompletedJobs: [] }; } static async addSakuraWorker(id, endpoint, amount = null, prevSegLength = 500, segLength = 500) { const total = amount ?? -1; let data = await this._getData(this.sakura); function _dataInsert(id, endpoint, prevSegLength, segLength) { const worker = { id, endpoint, prevSegLength, segLength }; const existingIndex = data.workers.findIndex(w => w.id === id); if (existingIndex !== -1) { data.workers[existingIndex] = worker; } else { data.workers.push(worker); } } if (total == -1) { _dataInsert(id, endpoint, prevSegLength, segLength); } else { for (let i = 1; i < total + 1; i++) { _dataInsert(id + i, endpoint, prevSegLength, segLength); } } await this._setData(this.sakura, data); } static async addGPTWorker(id, model, endpoint, key, amount = null) { const total = amount ?? -1; let data = await this._getData(this.gpt); function _dataInsert(id, model, endpoint, key) { const worker = { id, type: 'api', model, endpoint, key }; const existingIndex = data.workers.findIndex(w => w.id === id); if (existingIndex !== -1) { data.workers[existingIndex] = worker; } else { data.workers.push(worker); } } if (total == -1) { _dataInsert(id, model, endpoint, key); } else { for (let i = 1; i < total + 1; i++) { _dataInsert(id + i, model, endpoint, key); } } await this._setData(this.gpt, data); } static async removeWorker(key, id) { let data = await this._getData(key); data.workers = data.workers.filter(w => w.id !== id); await this._setData(key, data); } static async removeAllWorkers(key, exclude = []) { let data = await this._getData(key); data.workers = data.workers.filter(w => exclude.includes(w.id)); await this._setData(key, data); } static async addJob(key, task, description, createAt = Date.now()) { const job = { task, description, createAt }; let data = await this._getData(key); data.jobs.push(job); await this._setData(key, data); } static async addJobs(key, jobs = [], createAt = Date.now()) { let data = await this._getData(key); const existingTasks = new Set(data.jobs.map(job => job.task)); jobs.forEach(({ task, description }) => { if (!existingTasks.has(task)) { const job = { task, description, createAt }; data.jobs.push(job); } }); await this._setData(key, data); } static async getUncompletedJobs(key) { return (await this._getData(key)).uncompletedJobs; } } class NotificationUtils { static _initContainer() { if (!this._container) { this._container = document.createElement('div'); this._container.className = 'ntr-notification-container'; document.body.appendChild(this._container); } } static showSuccess(text) { this._show(text, '✅'); } static showWarning(text) { this._show(text, '⚠️'); } static showError(text) { this._show(text, '❌'); } static _show(msg, icon) { this._initContainer(); const box = document.createElement('div'); box.className = 'ntr-notification-message'; const iconSpan = document.createElement('span'); iconSpan.className = 'ntr-icon'; iconSpan.textContent = icon; const textNode = document.createTextNode(msg); box.appendChild(iconSpan); box.appendChild(textNode); this._container.appendChild(box); setTimeout(() => { box.classList.add('fade-out'); setTimeout(() => box.remove(), 300); }, 1000); } } // ----------------------------------- // Main Toolbox // ----------------------------------- class NTRToolBox { constructor() { this.configuration = this.loadConfiguration(); this.keepActiveSet = new Set(); this.headerMap = new Map(); this._pollTimer = null; this.token = this.initToken(); this._lastKeepRun = 0; this._lastVisRun = 0; this._lastEndPoint = window.location.href; this.buildGUI(); this.attachGlobalKeyBindings(); this.loadKeepStateAndStart(); this.scheduleNextPoll(); } static cloneDefaultModules() { return defaultModules.map(m => ({ ...m, settings: m.settings ? m.settings.map(s => ({ ...s })) : [], _lastRun: 0 })); } static DragHandler = class { constructor(panel, title) { this.panel = panel; this.title = title; this.dragging = false; this.offsetX = 0; this.offsetY = 0; this.init(); } init() { this.title.addEventListener('mousedown', (e) => { if (e.button !== 0) return; // Disable transitions while dragging this.panel.style.transition = 'none'; this.dragging = true; this.offsetX = e.clientX - this.panel.offsetLeft; this.offsetY = e.clientY - this.panel.offsetTop; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!this.dragging) return; const newLeft = e.clientX - this.offsetX; const newTop = e.clientY - this.offsetY; this.panel.style.left = newLeft + 'px'; this.panel.style.top = newTop + 'px'; this.clampPosition(); }); document.addEventListener('mouseup', () => { if (!this.dragging) return; this.dragging = false; // Re-enable transitions this.panel.style.transition = 'width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease'; const rect = this.panel.getBoundingClientRect(); let left = rect.left; let top = rect.top; left = Math.min(Math.max(left, 0), window.innerWidth - rect.width); top = Math.min(Math.max(top, 0), window.innerHeight - rect.height); this.panel.style.left = left + 'px'; this.panel.style.top = top + 'px'; localStorage.setItem('ntr-panel-position', JSON.stringify({ left: this.panel.style.left, top: this.panel.style.top })); }); // Touch events for mobile this.title.addEventListener('touchstart', (e) => { // Disable transitions while dragging this.panel.style.transition = 'none'; this.dragging = true; const touch = e.touches[0]; this.offsetX = touch.clientX - this.panel.offsetLeft; this.offsetY = touch.clientY - this.panel.offsetTop; e.preventDefault(); }, { passive: false }); document.addEventListener('touchmove', (e) => { if (!this.dragging) return; const touch = e.touches[0]; const newLeft = touch.clientX - this.offsetX; const newTop = touch.clientY - this.offsetY; this.panel.style.left = newLeft + 'px'; this.panel.style.top = newTop + 'px'; this.clampPosition(); e.preventDefault(); }, { passive: false }); document.addEventListener('touchend', (e) => { if (!this.dragging) return; this.dragging = false; // Re-enable transitions this.panel.style.transition = 'width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease'; const rect = this.panel.getBoundingClientRect(); let left = rect.left; let top = rect.top; left = Math.min(Math.max(left, 0), window.innerWidth - rect.width); top = Math.min(Math.max(top, 0), window.innerHeight - rect.height); this.panel.style.left = left + 'px'; this.panel.style.top = top + 'px'; localStorage.setItem('ntr-panel-position', JSON.stringify({ left: this.panel.style.left, top: this.panel.style.top })); }, { passive: false }); } clampPosition() { const rect = this.panel.getBoundingClientRect(); let left = parseFloat(this.panel.style.left) || 0; let top = parseFloat(this.panel.style.top) || 0; const maxLeft = window.innerWidth - rect.width; const maxTop = window.innerHeight - rect.height; if (left < 0) left = 0; if (top < 0) top = 0; if (left > maxLeft) left = maxLeft; if (top > maxTop) top = maxTop; this.panel.style.left = left + 'px'; this.panel.style.top = top + 'px'; } } initToken() { const authInfo = localStorage.getItem('authInfo'); if (authInfo) { const parsedInfo = JSON.parse(authInfo); return parsedInfo.profile.token; } return null; } loadConfiguration() { let stored; try { stored = JSON.parse(localStorage.getItem(CONFIG_STORAGE_KEY)); } catch (e) { } if (!stored || stored.version !== CONFIG_VERSION) { const fresh = NTRToolBox.cloneDefaultModules(); return { version: CONFIG_VERSION, modules: fresh }; } const loaded = NTRToolBox.cloneDefaultModules(); stored.modules.forEach(storedMod => { const defMod = loaded.find(m => m.name === storedMod.name); if (defMod) { for (const k in storedMod) { if ( defMod.hasOwnProperty(k) && typeof defMod[k] === typeof storedMod[k] && storedMod[k] !== undefined ) { defMod[k] = storedMod[k]; } } } }); if (loaded.length !== defaultModules.length) { const fresh = NTRToolBox.cloneDefaultModules(); localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify({ version: CONFIG_VERSION, modules: fresh })); return { version: CONFIG_VERSION, modules: fresh }; } else { const defNames = defaultModules.map(x => x.name).sort().join(','); const storedNames = loaded.map(x => x.name).sort().join(','); if (defNames !== storedNames) { const fresh = NTRToolBox.cloneDefaultModules(); localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify({ version: CONFIG_VERSION, modules: fresh })); return { version: CONFIG_VERSION, modules: fresh }; } } // Reattach run loaded.forEach(m => { const found = defaultModules.find(d => d.name === m.name); if (found && typeof found.run === 'function') { for (const p in found) { if (!m.hasOwnProperty(p)) { m[p] = found[p]; } } m.run = found.run; } }); return { version: CONFIG_VERSION, modules: loaded }; } saveConfiguration() { localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(this.configuration)); } buildGUI() { this.panel = document.createElement('div'); this.panel.id = 'ntr-panel'; // restore from localStorage const savedPos = localStorage.getItem('ntr-panel-position'); if (savedPos) { try { const parsed = JSON.parse(savedPos); if (parsed.left && parsed.top) { this.panel.style.left = parsed.left; this.panel.style.top = parsed.top; } } catch (e) { } } this.isMinimized = false; this.titleBar = document.createElement('div'); this.titleBar.className = 'ntr-titlebar'; this.titleBar.innerHTML = 'NTR ToolBox ' + VERSION; this.toggleSpan = document.createElement('span'); this.toggleSpan.style.float = 'right'; this.toggleSpan.textContent = '[-]'; this.titleBar.appendChild(this.toggleSpan); this.panel.appendChild(this.titleBar); this.panelBody = document.createElement('div'); this.panelBody.className = 'ntr-panel-body'; this.panel.appendChild(this.panelBody); this.infoBar = document.createElement('div'); this.infoBar.className = 'ntr-info'; const leftInfo = document.createElement('span'); const rightInfo = document.createElement('span'); leftInfo.textContent = IS_MOBILE ? '單擊執行 | ⚙️設定' : '左鍵執行/切換 | 右鍵設定'; rightInfo.textContent = 'Author: TheNano(百合仙人)'; this.infoBar.appendChild(leftInfo); this.infoBar.appendChild(rightInfo); this.panel.appendChild(this.infoBar); document.body.appendChild(this.panel); // set up drag this.dragHandler = new NTRToolBox.DragHandler(this.panel, this.titleBar); this.buildModules(); setTimeout(() => { this.expandedWidth = this.panel.offsetWidth; this.expandedHeight = this.panel.offsetHeight; const wasMin = this.isMinimized; if (!wasMin) this.panel.classList.add('minimized'); const h0 = this.panel.offsetHeight; if (!wasMin) this.panel.classList.remove('minimized'); this.minimizedWidth = this.panel.offsetWidth; this.minimizedHeight = h0; }, 150); if (IS_MOBILE) { // On mobile, single tap toggles minimized state. this.titleBar.addEventListener('click', e => { if (!this.dragHandler.dragging) { e.preventDefault(); this.setMinimizedState(!this.isMinimized); } }); } else { this.titleBar.addEventListener('contextmenu', e => { e.preventDefault(); this.setMinimizedState(!this.isMinimized); }); } } buildModules() { this.panelBody.innerHTML = ''; this.headerMap.clear(); this.configuration.modules.forEach(mod => { const container = document.createElement('div'); container.className = 'ntr-module-container'; const header = document.createElement('div'); header.className = 'ntr-module-header'; const nameSpan = document.createElement('span'); nameSpan.textContent = mod.name; header.appendChild(nameSpan); if (!IS_MOBILE) { const iconSpan = document.createElement('span'); iconSpan.textContent = (mod.type === 'keep') ? '⇋' : '▶'; iconSpan.style.marginLeft = '8px'; header.appendChild(iconSpan); } const settingsDiv = document.createElement('div'); settingsDiv.className = 'ntr-settings-container'; settingsDiv.style.display = 'none'; if (IS_MOBILE) { const btn = document.createElement('button'); btn.textContent = '⚙️'; btn.style.color = 'white'; btn.style.float = 'right'; btn.onclick = e => { e.stopPropagation(); const styleVal = window.getComputedStyle(settingsDiv).display; settingsDiv.style.display = (styleVal === 'none' ? 'block' : 'none'); }; header.appendChild(btn); header.onclick = e => { if (e.target.classList.contains('ntr-bind-button') || e.target === btn) return; this.handleModuleClick(mod, header); }; } else { header.oncontextmenu = e => { e.preventDefault(); const styleVal = window.getComputedStyle(settingsDiv).display; settingsDiv.style.display = (styleVal === 'none' ? 'block' : 'none'); }; header.onclick = e => { if (e.button === 0 && !e.ctrlKey && !e.altKey && !e.shiftKey) { if (e.target.classList.contains('ntr-bind-button')) return; this.handleModuleClick(mod, header); } }; } if (Array.isArray(mod.settings)) { mod.settings.forEach(s => { 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 = s.name + ': '; row.appendChild(label); let inputEl; switch (s.type) { case 'boolean': { inputEl = document.createElement('input'); inputEl.type = 'checkbox'; inputEl.checked = !!s.value; inputEl.onchange = () => { s.value = inputEl.checked; this.saveConfiguration(); }; break; } case 'number': { inputEl = document.createElement('input'); inputEl.type = 'number'; inputEl.value = s.value; inputEl.className = 'ntr-number-input'; inputEl.onchange = () => { s.value = Number(inputEl.value) || 0; this.saveConfiguration(); }; break; } case 'select': { inputEl = document.createElement('select'); if (Array.isArray(s.options)) { s.options.forEach(opt => { const optEl = document.createElement('option'); optEl.value = opt; optEl.textContent = opt; if (opt === s.value) optEl.selected = true; inputEl.appendChild(optEl); }); } inputEl.onchange = () => { s.value = inputEl.value; this.saveConfiguration(); }; break; } case 'string': { if (s.name === 'bind') { inputEl = document.createElement('button'); inputEl.className = 'ntr-bind-button'; inputEl.textContent = (s.value === 'none') ? '(None)' : `[${s.value.toUpperCase()}]`; inputEl.onclick = () => { inputEl.textContent = '(Press any key)'; const handler = ev => { ev.preventDefault(); if (ev.key === 'Escape') { s.value = 'none'; inputEl.textContent = '(None)'; } else { s.value = ev.key.toLowerCase(); inputEl.textContent = `[${ev.key.toUpperCase()}]`; } this.saveConfiguration(); document.removeEventListener('keydown', handler, true); ev.stopPropagation(); }; document.addEventListener('keydown', handler, true); }; } else { inputEl = document.createElement('input'); inputEl.type = 'text'; inputEl.value = s.value; inputEl.className = 'ntr-input'; inputEl.onchange = () => { s.value = inputEl.value; this.saveConfiguration(); }; } break; } default: { inputEl = document.createElement('span'); inputEl.style.color = '#999'; inputEl.textContent = String(s.value); } } row.appendChild(inputEl); settingsDiv.appendChild(row); }); } container.appendChild(header); container.appendChild(settingsDiv); this.panelBody.appendChild(container); this.headerMap.set(mod, header); }); } attachGlobalKeyBindings() { document.addEventListener('keydown', e => { if (e.ctrlKey || e.altKey || e.metaKey) return; const pk = e.key.toLowerCase(); this.configuration.modules.forEach(mod => { const bind = mod.settings.find(s => s.name === 'bind'); if (!bind || bind.value === 'none') return; if (bind.value.toLowerCase() === pk) { if (!isModuleEnabledByWhitelist(mod)) return; e.preventDefault(); this.handleModuleClick(mod, null); } }); }); } handleModuleClick(mod, header) { if (!domainAllowed || !isModuleEnabledByWhitelist(mod)) return; try { if (mod.type === 'onclick') { if (typeof mod.run === 'function') { Promise.resolve(mod.run(mod)).catch(console.error); } } else if (mod.type === 'keep') { const active = this.keepActiveSet.has(mod.name); if (active) { if (header) this.stopKeepModule(mod, header); } else { if (header) this.startKeepModule(mod, header); } } } catch (err) { console.error('Error running module:', mod.name, err); } } startKeepModule(mod, header) { if (this.keepActiveSet.has(mod.name)) return; header.classList.add('active'); this.keepActiveSet.add(mod.name); this.updateKeepStateStorage(); } stopKeepModule(mod, header) { header.classList.remove('active'); this.keepActiveSet.delete(mod.name); this.updateKeepStateStorage(); } updateKeepStateStorage() { const st = {}; this.keepActiveSet.forEach(n => { st[n] = true; }); localStorage.setItem('NTR_KeepState', JSON.stringify(st)); } loadKeepStateAndStart() { let saved = {}; try { saved = JSON.parse(localStorage.getItem('NTR_KeepState') || '{}'); } catch (e) { } this.configuration.modules.forEach(mod => { if (mod.type === 'keep' && saved[mod.name]) { const hdr = this.headerMap.get(mod); if (hdr) { this.startKeepModule(mod, hdr); } } }); } scheduleNextPoll() { const now = Date.now(); if (now - this._lastKeepRun >= 100) { this.pollKeepModules(); this._lastKeepRun = now; } if (now - this._lastVisRun >= 250) { this.updateModuleVisibility(); if (this._lastEndPoint != window.location.href) { StorageUtils.update(); this._lastEndPoint = window.location.href; } this._lastVisRun = now; } this._pollTimer = setTimeout(() => { this.scheduleNextPoll(); }, 10); } pollKeepModules() { this.configuration.modules.forEach(mod => { if (mod.type === 'keep' && this.keepActiveSet.has(mod.name) && typeof mod.run === 'function') { mod.run(mod); } }); } runModule(name) { this.configuration.modules.filter(mod => mod.name == name).forEach(mod => { if (typeof mod.run === 'function') { mod.run(mod, true); } }); } updateModuleVisibility() { this.configuration.modules.forEach(mod => { const hdr = this.headerMap.get(mod); if (!hdr) return; const cont = hdr.parentElement; const allowed = domainAllowed && isModuleEnabledByWhitelist(mod) && !mod.hidden; if (!allowed) { cont.style.display = 'none'; if (mod.type === 'keep' && this.keepActiveSet.has(mod.name)) { this.stopKeepModule(mod, hdr); } } else { cont.style.display = 'block'; } }); } getAnchorCornerInfo(rect) { const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const horizontal = (centerX < window.innerWidth / 2) ? 'left' : 'right'; const vertical = (centerY < window.innerHeight / 2) ? 'top' : 'bottom'; return { corner: vertical + '-' + horizontal, x: (horizontal === 'left' ? rect.left : rect.right), y: (vertical === 'top' ? rect.top : rect.bottom) }; } setMinimizedState(newVal) { if (this.isMinimized === newVal) return; const rect = this.panel.getBoundingClientRect(); const anchor = this.getAnchorCornerInfo(rect); this.isMinimized = newVal; if (this.isMinimized) { this.panel.classList.add('minimized'); this.toggleSpan.textContent = '[+]'; this.panelBody.style.display = 'none'; this.infoBar.style.display = 'none'; } else { this.panel.classList.remove('minimized'); this.toggleSpan.textContent = '[-]'; this.panelBody.style.display = 'block'; this.infoBar.style.display = 'flex'; } setTimeout(() => { const newRect = this.panel.getBoundingClientRect(); let left, top; switch (anchor.corner) { case 'top-left': left = anchor.x; top = anchor.y; break; case 'top-right': left = anchor.x - newRect.width; top = anchor.y; break; case 'bottom-left': left = anchor.x; top = anchor.y - newRect.height; break; case 'bottom-right': left = anchor.x - newRect.width; top = anchor.y - newRect.height; break; default: left = parseFloat(this.panel.style.left) || newRect.left; top = parseFloat(this.panel.style.top) || newRect.top; } left = Math.min(Math.max(left, 0), window.innerWidth - newRect.width); top = Math.min(Math.max(top, 0), window.innerHeight - newRect.height); this.panel.style.left = left + 'px'; this.panel.style.top = top + 'px'; localStorage.setItem('ntr-panel-position', JSON.stringify({ left: this.panel.style.left, top: this.panel.style.top })); }, 310); } async fetch(url, bypass = true) { if (bypass && this.token) { const response = await fetch(url, { method: 'GET', headers: { 'Authorization': `Bearer ${this.token}` } }); return response; } else { return await fetch(url); } } delay(ms) { return new Promise(r => setTimeout(r, ms)); } } const css = document.createElement('style'); css.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; transition: width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease; } #ntr-panel.minimized { width: 200px; } .ntr-titlebar { font-weight: bold; padding: 10px; cursor: move; background: #292929; border-radius: 6px; color: #CCC; user-select: none; } .ntr-panel-body { padding: 6px; background: #232323; border-radius: 4px; overflow-y: auto; max-height: 80vh; transition: max-height 0.3s ease; } #ntr-panel.minimized .ntr-panel-body { max-height: 0; } .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 { display: flex; justify-content: space-between; font-size: 10px; color: #888; margin-top: 8px; } .ntr-module-header.active { background: #63E2B7 !important; color: #fff !important; } .ntr-notification-container { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; align-items: flex-start; } .ntr-notification-message { display: flex; align-items: center; min-width: 200px; margin-top: 8px; padding: 4px 8px; border-radius: 4px; background-color: #2A2A2A; color: #fff; font-size: 14px; font-family: sans-serif; opacity: 1; transition: opacity 0.3s ease; } .ntr-notification-message .ntr-icon { margin-right: 4px; font-size: 16px; } .ntr-notification-message.fade-out { opacity: 0; } @media only screen and (max-width:600px) { #ntr-panel { transform: scale(0.6); transform-origin: top left; } } `; document.head.appendChild(css); // ----------------------------------- // Init Script // ----------------------------------- const script = new NTRToolBox(); })();