NTR ToolBox

ToolBox for Novel Translate bot website

  1. // ==UserScript==
  2. // @name NTR ToolBox
  3. // @namespace http://tampermonkey.net/
  4. // @version v0.5
  5. // @author TheNano
  6. // @description ToolBox for Novel Translate bot website
  7. // @match https://books.fishhawk.top/*
  8. // @match https://books1.fishhawk.top/*
  9. // @icon https://github.com/LittleSurvival/NTR-ToolBox/blob/main/icon.jpg?raw=true
  10. // @grant GM_openInTab
  11. // @license All Rights Reserved
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. 'use strict';
  16.  
  17. if (window._NTRToolBoxInstance) {
  18. return;
  19. }
  20.  
  21. window._NTRToolBoxInstance = true;
  22.  
  23. const CONFIG_VERSION = 20;
  24. const VERSION = 'v0.5';
  25. const CONFIG_STORAGE_KEY = 'NTR_ToolBox_Config';
  26. const IS_MOBILE = /Mobi|Android/i.test(navigator.userAgent);
  27. const domainAllowed = (location.hostname === 'books.fishhawk.top' || location.hostname === 'books1.fishhawk.top');
  28.  
  29. // -----------------------------------
  30. // Module settings
  31. // -----------------------------------
  32.  
  33. function newBooleanSetting(nameDefault, boolDefault) {
  34. return { name: nameDefault, type: 'boolean', value: Boolean(boolDefault) };
  35. }
  36. function newNumberSetting(nameDefault, numDefault) {
  37. return { name: nameDefault, type: 'number', value: Number(numDefault || 0) };
  38. }
  39. function newStringSetting(nameDefault, strDefault) {
  40. return { name: nameDefault, type: 'string', value: String(strDefault == null ? '' : strDefault) };
  41. }
  42. function newSelectSetting(nameDefault, arrOptions, valDefault) {
  43. return { name: nameDefault, type: 'select', value: valDefault, options: arrOptions };
  44. }
  45. function getModuleSetting(mod, key) {
  46. if (!mod.settings) return undefined;
  47. const found = mod.settings.find(s => s.name === key);
  48. return found ? found.value : undefined;
  49. }
  50. function isModuleEnabledByWhitelist(modItem) {
  51. if (!modItem.whitelist) {
  52. return domainAllowed;
  53. }
  54. const whitelist = modItem.whitelist;
  55. const parts = Array.isArray(whitelist) ? whitelist : [whitelist];
  56. return domainAllowed && parts.some(p => {
  57. if (typeof p === 'string') {
  58. if (p.endsWith('/*')) {
  59. const base = p.slice(0, -2);
  60. return location.pathname.startsWith(base) || location.pathname === base;
  61. }
  62. return location.pathname.includes(p);
  63. }
  64. return false;
  65. });
  66. }
  67.  
  68. // -----------------------------------
  69. // Module definitions
  70. // -----------------------------------
  71. const moduleAddSakuraTranslator = {
  72. name: '添加Sakura翻譯器',
  73. type: 'onclick',
  74. whitelist: '/workspace/sakura',
  75. settings: [
  76. newNumberSetting('數量', 5),
  77. newStringSetting('名稱', 'NTR translator '),
  78. newStringSetting('鏈接', 'https://sakura-share.one'),
  79. newStringSetting('bind', 'none'),
  80. ],
  81. run: async function (cfg) {
  82. const totalCount = getModuleSetting(cfg, '數量') || 1;
  83. const namePrefix = getModuleSetting(cfg, '名稱') || '';
  84. const linkValue = getModuleSetting(cfg, '鏈接') || '';
  85.  
  86. StorageUtils.addSakuraWorker(namePrefix, linkValue, totalCount);
  87. }
  88. }
  89.  
  90. const moduleAddGPTTranslator = {
  91. name: '添加GPT翻譯器',
  92. type: 'onclick',
  93. whitelist: '/workspace/gpt',
  94. settings: [
  95. newNumberSetting('數量', 5),
  96. newStringSetting('名稱', 'NTR translator '),
  97. newStringSetting('模型', 'deepseek-chat'),
  98. newStringSetting('鏈接', 'https://api.deepseek.com'),
  99. newStringSetting('Key', 'sk-wait-for-input'),
  100. newStringSetting('bind', 'none'),
  101. ],
  102. run: async function (cfg) {
  103. const totalCount = getModuleSetting(cfg, '數量') || 1;
  104. const namePrefix = getModuleSetting(cfg, '名稱') || '';
  105. const model = getModuleSetting(cfg, '模型') || '';
  106. const apiKey = getModuleSetting(cfg, 'Key') || '';
  107. const apiUrl = getModuleSetting(cfg, '鏈接') || '';
  108.  
  109. StorageUtils.addGPTWorker(namePrefix, model, apiUrl, apiKey, totalCount);
  110. }
  111. };
  112.  
  113. const moduleDeleteTranslator = {
  114. name: '刪除翻譯器',
  115. type: 'onclick',
  116. whitelist: '/workspace',
  117. settings: [
  118. newStringSetting('排除', '共享,本机,AutoDL'),
  119. newStringSetting('bind', 'none'),
  120. ],
  121. run: async function (cfg) {
  122. const excludeStr = getModuleSetting(cfg, '排除') || '';
  123. const excludeArr = excludeStr.split(',').filter(x => x);
  124.  
  125. if (location.href.endsWith('gpt')) {
  126. StorageUtils.removeAllWorkers(StorageUtils.gpt, excludeArr);
  127. } else if (location.href.endsWith('sakura')) {
  128. StorageUtils.removeAllWorkers(StorageUtils.sakura, excludeArr);
  129. }
  130. }
  131. };
  132.  
  133. const moduleLaunchTranslator = {
  134. name: '啟動翻譯器',
  135. type: 'onclick',
  136. whitelist: '/workspace',
  137. settings: [
  138. newNumberSetting('延遲間隔', 50),
  139. newNumberSetting('最多啟動', 999),
  140. newBooleanSetting('避免無效啟動', true),
  141. newStringSetting('排除', '本机,AutoDL'),
  142. newStringSetting('bind', 'none'),
  143. ],
  144. run: async function (cfg, auto) {
  145. const intervalVal = getModuleSetting(cfg, '延遲間隔') || 50;
  146. const maxClick = getModuleSetting(cfg, '最多啟動') || 999;
  147. const noEmptyLaunch = getModuleSetting(cfg, '避免無效啟動');
  148. const allBtns = [...document.querySelectorAll('button')].filter(btn => {
  149. if (!auto && noEmptyLaunch) return true;
  150. const listItem = btn.closest('.n-list-item');
  151. if (listItem) {
  152. const errorMessages = listItem.querySelectorAll('div');
  153. return !Array.from(errorMessages).some(div => div.textContent.includes("TypeError: Failed to fetch"));
  154. }
  155. return true;
  156. });
  157. const delay = ms => new Promise(r => setTimeout(r, ms));
  158. let idx = 0, clickCount = 0, lastRunning = 0, emptyCheck = 0;
  159.  
  160. async function nextClick() {
  161. while (idx < allBtns.length && clickCount < maxClick) {
  162. const btn = allBtns[idx++];
  163. if (btn.textContent.includes('启动')) {
  164. btn.click();
  165. clickCount++;
  166. await delay(intervalVal);
  167. }
  168. if (noEmptyLaunch) {
  169. let running = [...document.querySelectorAll('button')].filter(btn => btn.textContent.includes('停止')).length;
  170. if (running == lastRunning) emptyCheck++;
  171. if (emptyCheck > 3) break;
  172. }
  173. }
  174. }
  175. await nextClick();
  176. }
  177. };
  178.  
  179. const moduleQueueSakuraV2 = {
  180. name: '排隊Sakura v2',
  181. type: 'onclick',
  182. whitelist: ['/wenku', '/novel', '/favorite'],
  183. progress: { percentage: 0, info: '' },
  184. settings: [
  185. newNumberSetting('單次擷取web數量(可破限)', 20),
  186. newNumberSetting('擷取單頁wenku數量(deving)', 20),
  187. newSelectSetting('模式', ['常規', '過期', '重翻'], '常規'),
  188. newSelectSetting('分段', ['智能', '固定'], '智能'),
  189. newNumberSetting('智能均分任務上限', 1000),
  190. newNumberSetting('智能均分章節下限', 5),
  191. newNumberSetting('固定均分任務', 6),
  192. newBooleanSetting('R18(需登入)', true),
  193. newStringSetting('bind', 'none'),
  194. ],
  195. run: async function (cfg) {
  196. const webCatchLimit = getModuleSetting(cfg, '單次擷取web數量(可破限)') || 20;
  197. const wenkuCatchLimit = getModuleSetting(cfg, '擷取單頁wenku數量(deving)') || 20;
  198. const pair = getModuleSetting(cfg, '固定均分任務') || 6;
  199. const smartJobLimit = getModuleSetting(cfg, '智能均分任務上限') || 1000;
  200. const smartChapterLimit = getModuleSetting(cfg, '智能均分章節下限') || 5;
  201. const type = TaskUtils.getTypeString(window.location.pathname);
  202. const mode = getModuleSetting(cfg, '模式') || '常規';
  203. const sepMode = getModuleSetting(cfg, '分段') || '智能';
  204. const r18Bypass = getModuleSetting(cfg, 'R18(需登入)');
  205.  
  206. let results = [];
  207. let errorFlag = false;
  208. const maxRetries = 3;
  209.  
  210. const modeMap = { '常規': '常规', '過期': '过期', '重翻': '重翻' };
  211. const cnMode = modeMap[mode] || '常规';
  212.  
  213. switch (type) {
  214. case 'wenkus': {
  215. const wenkuIds = TaskUtils.wenkuIds();
  216. const apiEndpoint = `/api/wenku/`;
  217.  
  218. await Promise.all(
  219. wenkuIds.map(async (id) => {
  220. let attempts = 0;
  221. let success = false;
  222.  
  223. while (attempts < maxRetries && !success) {
  224. try {
  225. const response = await script.fetch(`${window.location.origin}${apiEndpoint}${id}`, r18Bypass);
  226. if (!response.ok) throw new Error('Network response was not ok');
  227. const data = await response.json();
  228. const volumeIds = data.volumeJp.map(volume => volume.volumeId);
  229.  
  230. volumeIds.forEach(name => results.push({ task: TaskUtils.wenkuLinkBuilder(id, name, SettingUtils.getTranslateMode(mode)), description: name }))
  231. success = true;
  232. } catch (error) {
  233. NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}.`);
  234. attempts++;
  235. if (attempts < maxRetries) {
  236. await new Promise(resolve => setTimeout(resolve, 1000));
  237. }
  238. }
  239. }
  240. })
  241. );
  242. await StorageUtils.addJobs(StorageUtils.sakura, results);
  243. break;
  244. };
  245. case 'wenku': {
  246. await TaskUtils.clickButtons(cnMode);
  247. await TaskUtils.clickButtons('排队Sakura');
  248. break;
  249. }
  250. case 'novels': {
  251. const apiUrl = TaskUtils.webSearchApi(webCatchLimit);
  252. try {
  253. const response = await script.fetch(`${window.location.origin}${apiUrl}`, r18Bypass);
  254. if (!response.ok) throw new Error('Network response was not ok');
  255. const data = await response.json();
  256. const novels = data.items.map(item => {
  257. const title = item.titleZh ?? item.titleJp;
  258. return {
  259. url: `/${item.providerId}/${item.novelId}`,
  260. description: title,
  261. total: item.total,
  262. sakura: item.sakura
  263. };
  264. });
  265. results = sepMode == '智能'
  266. ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode))
  267. : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode));
  268.  
  269. await StorageUtils.addJobs(StorageUtils.sakura, results);
  270. } catch (error) {
  271. errorFlag = true;
  272. NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}.`)
  273. }
  274. break;
  275. }
  276. case 'novel': {
  277. try {
  278. const targetSpan = Array.from(document.querySelectorAll('span.n-text')).find(span => /总计 (\d+) \/ 百度 (\d+) \/ 有道 (\d+) \/ GPT (\d+) \/ Sakura (\d+)/.test(span.textContent));
  279. const [_, total, , , , sakura] = targetSpan.textContent.match(/总计 (\d+) \/ 百度 (\d+) \/ 有道 (\d+) \/ GPT (\d+) \/ Sakura (\d+)/);
  280. const url = window.location.pathname.split('/novel')[1];
  281. const title = document.title;
  282. if (title.includes('轻小说机翻机器人')) throw Error('小說頁尚未載入');
  283.  
  284. const novels = [{ url: url, total: total, sakura: sakura, description: title }];
  285. results = sepMode == '智能'
  286. ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode))
  287. : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode));
  288.  
  289. await StorageUtils.addJobs(StorageUtils.sakura, results);
  290. } catch (error) {
  291. errorFlag = true;
  292. NotificationUtils.showError(`Failed to fetch data for ${title}.`);
  293. }
  294. break;
  295. }
  296. case 'favorite-web': {
  297. const url = new URL(window.location.href);
  298. //get folder id
  299. const id = url.pathname.endsWith('/web') ? 'default' : url.pathname.split('/').pop();
  300. let tries = 0;
  301. let page = 0;
  302.  
  303. while (true) {
  304. const apiUrl = `${url.origin}/api/user/favored-web/${id}?page=${page}&pageSize=90&sort=update`;
  305. let tasks = [];
  306. let novelCount = 0;
  307. try {
  308. const response = await script.fetch(apiUrl);
  309. const data = await response.json();
  310. const novels = data.items.map(item => {
  311. const title = item.titleZh ?? item.titleJp;
  312. return {
  313. url: `/${item.providerId}/${item.novelId}`,
  314. description: title,
  315. total: item.total,
  316. sakura: item.sakura
  317. };
  318. });
  319. novelCount = novels.length;
  320. tasks = sepMode == '智能'
  321. ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode))
  322. : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode));
  323.  
  324. await StorageUtils.addJobs(StorageUtils.sakura, tasks);
  325. results.push(tasks);
  326. NotificationUtils.showSuccess(`成功排隊 ${3 * page + 1}-${3 * page + 3}頁, ${tasks.length}個任務`);
  327. } catch (error) {
  328. console.log(error);
  329. NotificationUtils.showError(`Failed to fetch data for ${id}, page ${page + 1}.`);
  330. if (tries++ > 3) break;
  331. continue;
  332. }
  333. if (novelCount < 90) break;
  334. else page++;
  335. }
  336. break;
  337. }
  338. case 'favorite-wenku': {
  339. const url = new URL(window.location.href);
  340. //get folder id
  341. const id = url.pathname.endsWith('/wenku') ? 'default' : url.pathname.split('/').pop();
  342. let page = 0;
  343. let tries = 0;
  344. while (true) {
  345. const apiUrl = `${url.origin}/api/user/favored-wenku/${id}?page=${page}&pageSize=72&sort=update`;
  346. let tasks = [];
  347. let novelCount = 0;
  348. try {
  349. const response = await script.fetch(apiUrl);
  350. const data = await response.json();
  351. const wenkuIds = data.items.map(novel => novel.id);
  352. novelCount = wenkuIds.length;
  353.  
  354. await Promise.all(
  355. wenkuIds.map(async (id) => {
  356. let attempts = 0;
  357. let success = false;
  358. const apiEndpoint = `/api/wenku/`;
  359.  
  360. while (attempts < maxRetries && !success) {
  361. try {
  362. const response = await script.fetch(`${window.location.origin}${apiEndpoint}${id}`, r18Bypass);
  363. if (!response.ok) throw new Error('Network response was not ok');
  364. const data = await response.json();
  365. const volumeIds = data.volumeJp.map(volume => volume.volumeId);
  366.  
  367. volumeIds.forEach(name => tasks.push({ task: TaskUtils.wenkuLinkBuilder(id, name, mode), description: name }))
  368. success = true;
  369. } catch (error) {
  370. NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}:`);
  371. attempts++;
  372. if (attempts < maxRetries) {
  373. await new Promise(resolve => setTimeout(resolve, 1000));
  374. }
  375. }
  376. }
  377. })
  378. );
  379. await StorageUtils.addJobs(StorageUtils.sakura, tasks);
  380. results.push(tasks);
  381. NotificationUtils.showSuccess(`成功排隊 ${3 * page + 1}-${3 * page + 3}頁, ${tasks.length}本小說`);
  382. } catch (error) {
  383. console.log(error);
  384. NotificationUtils.showError(`Failed to fetch data for ${id}, page ${page + 1}.`);
  385. if (tries > 3) break;
  386. continue;
  387. }
  388. if (novelCount < 72) break;
  389. else page++;
  390. }
  391. break;
  392. }
  393. default: { }
  394. }
  395. if (errorFlag) return;
  396. const novels = new Set(results.map(result => result.description));
  397. NotificationUtils.showSuccess(`排隊成功 : ${novels.size} 本小說, 均分 ${results.length} 分段.`);
  398. }
  399. }
  400.  
  401. const moduleQueueGPTV2 = {
  402. name: '排隊GPT v2',
  403. type: 'onclick',
  404. whitelist: ['/wenku', '/novel', '/favorite/web'],
  405. progress: { percentage: 0, info: '' },
  406. settings: [
  407. newNumberSetting('單次擷取web數量(可破限)', 20),
  408. newNumberSetting('擷取單頁wenku數量(deving)', 20),
  409. newSelectSetting('模式', ['常規', '過期', '重翻'], '常規'),
  410. newSelectSetting('分段', ['智能', '固定'], '智能'),
  411. newNumberSetting('智能均分任務上限', 1000),
  412. newNumberSetting('智能均分章節下限', 5),
  413. newNumberSetting('固定均分任務', 6),
  414. newBooleanSetting('R18(需登入)', true),
  415. newStringSetting('bind', 'none'),
  416. ],
  417. run: async function (cfg) {
  418. const webCatchLimit = getModuleSetting(cfg, '單次擷取web數量(可破限)') || 20;
  419. const wenkuCatchLimit = getModuleSetting(cfg, '擷取單頁wenku數量(deving)') || 20;
  420. const pair = getModuleSetting(cfg, '固定均分任務') || 6;
  421. const smartJobLimit = getModuleSetting(cfg, '智能均分任務上限') || 1000;
  422. const smartChapterLimit = getModuleSetting(cfg, '智能均分章節下限') || 5;
  423. const type = TaskUtils.getTypeString(window.location.pathname);
  424. const mode = getModuleSetting(cfg, '模式') || '常規';
  425. const sepMode = getModuleSetting(cfg, '分段') || '智能';
  426. const r18Bypass = getModuleSetting(cfg, 'R18(需登入)');
  427.  
  428. let results = [];
  429. const maxRetries = 3;
  430. let errorFlag = false;
  431.  
  432. const modeMap = { '常規': '常规', '過期': '过期', '重翻': '重翻' };
  433. const cnMode = modeMap[mode] || '常规';
  434.  
  435.  
  436. switch (type) {
  437. case 'wenkus': {
  438. const wenkuIds = TaskUtils.wenkuIds();
  439. const apiEndpoint = `/api/wenku/`;
  440.  
  441. await Promise.all(
  442. wenkuIds.map(async (id) => {
  443. let attempts = 0;
  444. let success = false;
  445.  
  446. while (attempts < maxRetries && !success) {
  447. try {
  448. const response = await script.fetch(`${window.location.origin}${apiEndpoint}${id}`, r18Bypass);
  449. if (!response.ok) throw new Error('Network response was not ok');
  450. const data = await response.json();
  451. const volumeIds = data.volumeJp.map(volume => volume.volumeId);
  452.  
  453. volumeIds.forEach(name => results.push({ task: TaskUtils.wenkuLinkBuilder(id, name, mode), description: name }))
  454. success = true;
  455. } catch (error) {
  456. NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}:`);
  457. attempts++;
  458. if (attempts < maxRetries) {
  459. await new Promise(resolve => setTimeout(resolve, 1000));
  460. }
  461. }
  462. }
  463. })
  464. );
  465. await StorageUtils.addJobs(StorageUtils.gpt, results);
  466. break;
  467. };
  468. case 'wenku': {
  469. await TaskUtils.clickButtons(cnMode);
  470. await TaskUtils.clickButtons('排队Sakura');
  471. break;
  472. }
  473. case 'novels': {
  474. const apiUrl = TaskUtils.webSearchApi(webCatchLimit);
  475. try {
  476. const response = await script.fetch(`${window.location.origin}${apiUrl}`, r18Bypass)
  477. if (!response.ok) throw new Error('Network response was not ok');
  478. const data = await response.json();
  479. const novels = data.items.map(item => {
  480. const title = item.titleZh ?? item.titleJp;
  481. return {
  482. url: `/${item.providerId}/${item.novelId}`,
  483. description: title,
  484. total: item.total,
  485. gpt: item.gpt
  486. };
  487. });
  488. results = sepMode == '智能'
  489. ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode))
  490. : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode));
  491.  
  492. await StorageUtils.addJobs(StorageUtils.gpt, results);
  493. } catch (error) {
  494. errorFlag = true;
  495. NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}:`);
  496. }
  497. break;
  498. }
  499. case 'novel': {
  500. try {
  501. const targetSpan = Array.from(document.querySelectorAll('span.n-text')).find(span => /总计 (\d+) \/ 百度 (\d+) \/ 有道 (\d+) \/ GPT (\d+) \/ Sakura (\d+)/.test(span.textContent));
  502. const [_, total, , , gpt] = targetSpan.textContent.match(/总计 (\d+) \/ 百度 (\d+) \/ 有道 (\d+) \/ GPT (\d+) \/ Sakura (\d+)/);
  503. const url = window.location.pathname.split('/novel')[1];
  504.  
  505. const title = document.title;
  506. if (title.includes('轻小说机翻机器人')) throw Error('小說頁尚未載入');
  507.  
  508. const novels = [{ url: url, total: total, gpt: gpt, description: title }]
  509.  
  510. results = sepMode == '智能'
  511. ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode))
  512. : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode));
  513.  
  514. await StorageUtils.addJobs(StorageUtils.gpt, results);
  515. } catch (error) {
  516. errorFlag = true;
  517. NotificationUtils.showError(`Failed to fetch data for ${title}.`);
  518. }
  519. break;
  520. }
  521. case 'favorite-web': {
  522. const url = new URL(window.location.href);
  523. //get folder id
  524. const id = url.pathname.endsWith('/web') ? 'default' : url.pathname.split('/').pop();
  525. let tries = 0;
  526. let page = 0;
  527.  
  528. while (true) {
  529. const apiUrl = `${url.origin}/api/user/favored-web/${id}?page=${page}&pageSize=90&sort=update`;
  530. let tasks = [];
  531. let novelCount = 0;
  532. try {
  533. const response = await script.fetch(apiUrl);
  534. const data = await response.json();
  535. const novels = data.items.map(item => {
  536. const title = item.titleZh ?? item.titleJp;
  537. return {
  538. url: `/${item.providerId}/${item.novelId}`,
  539. description: title,
  540. total: item.total,
  541. gpt: item.gpt
  542. };
  543. });
  544. novelCount = novels.length;
  545. tasks = sepMode == '智能'
  546. ? await TaskUtils.assignTasksSmart(novels, smartJobLimit, smartChapterLimit, SettingUtils.getTranslateMode(mode))
  547. : await TaskUtils.assignTasksStatic(novels, pair, SettingUtils.getTranslateMode(mode));
  548.  
  549. await StorageUtils.addJobs(StorageUtils.gpt, tasks);
  550. results.push(tasks);
  551. NotificationUtils.showSuccess(`成功排隊 ${3 * page + 1}-${3 * page + 3}頁, ${novelCount}本小說`);
  552. } catch (error) {
  553. console.log(error);
  554. NotificationUtils.showError(`Failed to fetch data for ${id}, page ${page + 1}.`);
  555. if (tries++ > 3) break;
  556. continue;
  557. }
  558. if (novelCount < 90) break;
  559. else page++;
  560. }
  561. break;
  562. }
  563. case 'favorite-wenku': {
  564. const url = new URL(window.location.href);
  565. //get folder id
  566. const id = url.pathname.endsWith('/wenku') ? 'default' : url.pathname.split('/').pop();
  567. let page = 0;
  568. let tries = 0;
  569. while (true) {
  570. const apiUrl = `${url.origin}/api/user/favored-wenku/${id}?page=${page}&pageSize=72&sort=update`;
  571. let tasks = [];
  572. let novelCount = 0;
  573. try {
  574. const response = await script.fetch(apiUrl);
  575. const data = await response.json();
  576. const wenkuIds = data.items.map(novel => novel.id);
  577. novelCount = wenkuIds.length;
  578.  
  579. await Promise.all(
  580. wenkuIds.map(async (id) => {
  581. let attempts = 0;
  582. let success = false;
  583. const apiEndpoint = `/api/wenku/`;
  584.  
  585. while (attempts < maxRetries && !success) {
  586. try {
  587. const response = await script.fetch(`${window.location.origin}${apiEndpoint}${id}`, r18Bypass);
  588. if (!response.ok) throw new Error('Network response was not ok');
  589. const data = await response.json();
  590. const volumeIds = data.volumeJp.map(volume => volume.volumeId);
  591.  
  592. volumeIds.forEach(name => tasks.push({ task: TaskUtils.wenkuLinkBuilder(id, name, mode), description: name }))
  593. success = true;
  594. } catch (error) {
  595. NotificationUtils.showError(`Failed to fetch data for ID ${id}, attempt ${attempts + 1}:`);
  596. attempts++;
  597. if (attempts < maxRetries) {
  598. await new Promise(resolve => setTimeout(resolve, 1000));
  599. }
  600. }
  601. }
  602. })
  603. );
  604. await StorageUtils.addJobs(StorageUtils.gpt, tasks);
  605. results.push(tasks);
  606. NotificationUtils.showSuccess(`成功排隊 ${3 * page + 1}-${3 * page + 3}頁, ${tasks.length}本小說`);
  607. } catch (error) {
  608. console.log(error);
  609. NotificationUtils.showError(`Failed to fetch data for ${id}, page ${page + 1}.`);
  610. if (tries > 3) break;
  611. continue;
  612. }
  613. if (novelCount < 72) break;
  614. else page++;
  615. }
  616. break;
  617. }
  618. default: { }
  619. }
  620. if (errorFlag) return;
  621. const novels = new Set(results.map(result => result.description));
  622. NotificationUtils.showSuccess(`排隊成功 : ${novels.size} 本小說, 均分 ${results.length} 分段.`);
  623. }
  624. }
  625.  
  626. const moduleAutoRetry = {
  627. name: '自動重試',
  628. type: 'keep',
  629. whitelist: '/workspace/*',
  630. settings: [
  631. newNumberSetting('最大重試次數', 99),
  632. newBooleanSetting('置頂重試任務', false),
  633. newBooleanSetting('重啟翻譯器', true),
  634. ],
  635. _attempts: 0,
  636. _lastRun: 0,
  637. _interval: 1000,
  638. run: async function (cfg) {
  639. const now = Date.now();
  640. if (now - this._lastRun < this._interval) return;
  641. this._lastRun = now;
  642.  
  643. const maxAttempts = getModuleSetting(cfg, '最大重試次數') || 99;
  644. const relaunch = getModuleSetting(cfg, '重啟翻譯器') || 3;
  645. const moveToTop = getModuleSetting(cfg, '置頂重試任務');
  646.  
  647. if (!this._boundClickHandler) {
  648. this._boundClickHandler = (e) => {
  649. if (e.target.tagName === 'button') {
  650. this._attempts = 0;
  651. }
  652. };
  653. document.addEventListener('click', this._boundClickHandler);
  654. }
  655.  
  656. const listItems = document.querySelectorAll('.n-list-item');
  657. const unfinished = [...listItems].filter(item => {
  658. const desc = item.querySelector('.n-thing-main__description');
  659. return desc && desc.textContent.includes('未完成');
  660. });
  661. async function retryTasks(attempts) {
  662. const hasStop = [...document.querySelectorAll('button')].some(b => b.textContent === '停止');
  663. if (!hasStop) {
  664. const retryBtns = [...document.querySelectorAll('button')].filter(b => b.textContent.includes('重试未完成任务'));
  665. if (retryBtns[0]) {
  666. const clickCount = Math.min(unfinished.length, listItems.length);
  667. for (let i = 0; i < clickCount; i++) {
  668. retryBtns[0].click();
  669. }
  670. if (moveToTop) {
  671. TaskUtils.clickTaskMoveToTop(unfinished.length);
  672. }
  673. attempts++;
  674. }
  675. }
  676. return attempts;
  677. }
  678.  
  679. if (unfinished.length > 0 && this._attempts < maxAttempts) {
  680. this._attempts = await retryTasks(this._attempts);
  681. script.delay(10);
  682. if (relaunch) {
  683. script.runModule('啟動翻譯器');
  684. }
  685. }
  686. }
  687. };
  688.  
  689. const moduleSyncStorage = {
  690. name: '資料同步',
  691. type: 'onclick',
  692. whitelist: '/workspace/*',
  693. hidden: true,
  694. settings: [
  695. newStringSetting('bind', 'none')
  696. ],
  697. run: async function (cfg) {
  698. }
  699. }
  700.  
  701. const defaultModules = [
  702. moduleAddSakuraTranslator,
  703. moduleAddGPTTranslator,
  704. moduleDeleteTranslator,
  705. moduleLaunchTranslator,
  706. moduleQueueSakuraV2,
  707. moduleQueueGPTV2,
  708. moduleAutoRetry,
  709. moduleSyncStorage,
  710. ];
  711.  
  712. // -----------------------------------
  713. // Setting Utils
  714. // -----------------------------------
  715. class SettingUtils {
  716. static getTranslateMode(mode) {
  717. const map = { '常規': 'normal', '過期': 'expire', '重翻': 'all' };
  718. return map[mode];
  719. }
  720. }
  721.  
  722. // -----------------------------------
  723. // TaskUtils Utils
  724. // -----------------------------------
  725. class TaskUtils {
  726. static getTypeString = (url) => {
  727. const patterns = {
  728. 'wenkus': new RegExp(`^/wenku(\\?.*)?$`), // Matches /wenku and /wenku?params
  729. 'wenku': new RegExp(`^/wenku\\/.*(\\?.*)?$`), // Matches /wenku/* and /wenku/*?params
  730. 'novels': new RegExp(`^/novel(\\?.*)?$`), // Matches /novel and /novel?params
  731. 'novel': new RegExp(`^/novel\\/.*(\\?.*)?$`), // Matches /novel/*/* and /novel/*/*?params
  732. 'favorite-web': new RegExp(`^/favorite/web(/.*)?(\\?.*)?$`), // Matches /favorite/web and /favorite/web/* and /favorite/web?params
  733. 'favorite-wenku': new RegExp(`^/favorite/wenku(/.*)?(\\?.*)?$`), // Matches /favorite/wenku and /favorite/wenku/* and /favorite/wenku?params
  734. 'favorite-local': new RegExp(`^/favorite/local(/.*)?(\\?.*)?$`) // Matches /favorite/local and /favorite/local/* and /favorite/local?params
  735. };
  736. for (const [key, pattern] of Object.entries(patterns)) {
  737. if (pattern.test(url)) {
  738. return key;
  739. }
  740. }
  741. return null;
  742. };
  743.  
  744. static wenkuLinkBuilder(series, name, mode) {
  745. return `wenku/${series}/${name}?level=${mode}&forceMetadata=false&startIndex=0&endIndex=65536`
  746. }
  747.  
  748. static webLinkBuilder(url, from = 0, to = 65536, mode) {
  749. return `web${url}?level=${mode}&forceMetadata=false&startIndex=${from}&endIndex=${to}`
  750. }
  751.  
  752. //return "id"
  753. static wenkuIds() {
  754. const links = [...document.querySelectorAll('a[href^="/wenku/"]')];
  755. return links.map(link => link.getAttribute('href').split('/wenku/')[1]);
  756. }
  757.  
  758. //return api link
  759. static webSearchApi(limit = 20) {
  760. const urlParams = new URLSearchParams(location.search), page = Math.max(urlParams.get('page') - 1 || 0, 0);
  761. const input = document.querySelector('input[placeholder="中/日文标题或作者"]');
  762. let rawQuery = input ? input.value.trim() : '';
  763.  
  764. const query = encodeURIComponent(rawQuery);
  765. const selected = [...document.querySelectorAll('.n-text.__text-dark-131ezvy-p')].map(e => e.textContent.trim());
  766.  
  767. const sourceMap = {
  768. Kakuyomu: 'kakuyomu',
  769. '成为小说家吧': 'syosetu',
  770. Novelup: 'novelup',
  771. Hameln: 'hameln',
  772. Pixiv: 'pixiv',
  773. Alphapolis: 'alphapolis'
  774. };
  775. const typeMap = { '连载中': '1', '已完结': '2', '短篇': '3', '全部': '0' };
  776. const levelMap = { '一般向': '1', 'R18': '2', '全部': '0' };
  777. const translateMap = { 'GPT': '1', 'Sakura': '2', '全部': '0' };
  778. const sortMap = { '更新': '0', '点击': '1', '相关': '2' };
  779. const providers = Object.keys(sourceMap)
  780. .filter(k => selected.includes(k))
  781. .map(k => sourceMap[k])
  782. .join(',') || 'kakuyomu,syosetu,novelup,hameln,pixiv,alphapolis';
  783. const tKey = Object.keys(typeMap).find(x => selected.includes(x)) || '全部';
  784. const lKey = Object.keys(levelMap).find(x => selected.includes(x)) || '全部';
  785. const trKey = Object.keys(translateMap).find(x => selected.includes(x)) || '全部';
  786. const sKey = Object.keys(sortMap).find(x => selected.includes(x)) || '更新';
  787.  
  788. return `/api/novel?page=${page}&pageSize=${limit}&query=${query}` +
  789. `&provider=${encodeURIComponent(providers)}&type=${typeMap[tKey]}&level=${levelMap[lKey]}` +
  790. `&translate=${translateMap[trKey]}&sort=${sortMap[sKey]}`;
  791. }
  792.  
  793. //return { task, description }
  794. static async assignTasksSmart(novels, smartJobLimit, smartChapterLimit, mode) {
  795. function undone(n) {
  796. if (mode === "normal") {
  797. const sOrG = (n.sakura ?? n.gpt) || 0;
  798. //Using max to deal with some total > sakura situation
  799. return Math.max(n.total - sOrG, 0);
  800. }
  801. return n.total;
  802. }
  803. const totalChapters = novels.reduce((acc, n) => acc + undone(n), 0);
  804. const potentialMaxTask = Math.floor(totalChapters / smartChapterLimit);
  805. let maxTasks = Math.min(potentialMaxTask, smartJobLimit);
  806.  
  807. if (maxTasks <= 0 && totalChapters > 0) {
  808. maxTasks = smartJobLimit;
  809. }
  810. if (totalChapters === 0) {
  811. return [];
  812. }
  813. const chunkSize = Math.ceil(totalChapters / (maxTasks || 1));
  814. const sorted = [...novels].sort((a, b) => undone(b) - undone(a));
  815.  
  816. const result = [];
  817. let usedTasks = 0;
  818.  
  819. for (const novel of sorted) {
  820. let remain = undone(novel);
  821. if (remain <= 0) continue;
  822.  
  823. let startIndex = (mode === "normal") ? (novel.total - remain) : 0;
  824.  
  825. while (remain > 0 && usedTasks < smartJobLimit) {
  826. const thisChunk = Math.min(remain, chunkSize);
  827. const endIndex = startIndex + thisChunk;
  828.  
  829. result.push({
  830. task: TaskUtils.webLinkBuilder(novel.url, startIndex, endIndex, mode),
  831. description: novel.description
  832. });
  833.  
  834. usedTasks++;
  835. remain -= thisChunk;
  836. startIndex = endIndex;
  837. if (usedTasks >= smartJobLimit) {
  838. break;
  839. }
  840. }
  841. if (usedTasks >= smartJobLimit) {
  842. break;
  843. }
  844. }
  845.  
  846. return result;
  847. }
  848.  
  849. //return { task, description }
  850. static async assignTasksStatic(novels, parts, mode) {
  851. function undone(n) {
  852. if (mode === "normal") {
  853. const sOrG = (n.sakura ?? n.gpt) || 0;
  854. return n.total - sOrG;
  855. }
  856. return n.total;
  857. }
  858.  
  859. const result = [];
  860.  
  861. for (const novel of novels) {
  862. const totalChapters = undone(novel);
  863. if (totalChapters <= 0) continue;
  864. const startBase = (mode === "normal")
  865. ? (novel.total - totalChapters)
  866. : 0;
  867.  
  868. const chunkSize = Math.ceil(totalChapters / parts);
  869.  
  870. for (let i = 0; i < parts; i++) {
  871. const chunkStart = startBase + i * chunkSize;
  872. const chunkEnd = (i === parts - 1)
  873. ? (startBase + totalChapters)
  874. : (chunkStart + chunkSize);
  875.  
  876. if (chunkStart < startBase + totalChapters) {
  877. result.push({
  878. task: TaskUtils.webLinkBuilder(novel.url, chunkStart, chunkEnd, mode),
  879. description: novel.description
  880. });
  881. }
  882. }
  883. }
  884. return result;
  885. }
  886.  
  887. static async clickTaskMoveToTop(count, reserve=true) {
  888. const extras = document.querySelectorAll('.n-thing-header__extra');
  889. for (let i = 0; i < count;i++) {
  890. const offset = reserve ? extras.length - i - 1 : i;
  891. const container = extras[offset];
  892. const buttons = container.querySelectorAll('button');
  893. if (buttons.length) {
  894. buttons[0].click();
  895. }
  896. }
  897. }
  898.  
  899. static async clickButtons(name = '') {
  900. const btns = document.querySelectorAll('button');
  901. btns.forEach(btn => {
  902. if (name === '' || btn.textContent.includes(name)) {
  903. btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
  904. }
  905. });
  906. }
  907. }
  908.  
  909. // -----------------------------------
  910. // Storage Utils
  911. // -----------------------------------
  912. class StorageUtils {
  913. static sakura = 'sakura-workspace';
  914. static gpt = 'gpt-workspace';
  915. static updateUrl = [
  916. 'workspace/sakura',
  917. 'workspace/gpt'
  918. ];
  919.  
  920. static async update() {
  921. const storageKey = (window.location.pathname.includes('workspace/sakura') ? this.sakura : (window.location.pathname.includes('workspace/gpt') ? this.gpt : null));
  922. if (!storageKey) return;
  923.  
  924. const data = await this._getData(storageKey);
  925. await this._setData(storageKey, data);
  926. }
  927.  
  928. static async _setData(key, data) {
  929. localStorage.setItem(key, JSON.stringify(data));
  930. window.dispatchEvent(new StorageEvent('storage', {
  931. key: key,
  932. newValue: JSON.stringify(data),
  933. url: window.location.href,
  934. storageArea: localStorage
  935. }));
  936. }
  937.  
  938. static async _getData(key) {
  939. let raw = localStorage.getItem(key);
  940. if (raw) {
  941. return JSON.parse(raw);
  942. }
  943. return { workers: [], jobs: [], uncompletedJobs: [] };
  944. }
  945.  
  946. static async addSakuraWorker(id, endpoint, amount = null, prevSegLength = 500, segLength = 500) {
  947. const total = amount ?? -1;
  948. let data = await this._getData(this.sakura);
  949.  
  950. function _dataInsert(id, endpoint, prevSegLength, segLength) {
  951. const worker = { id, endpoint, prevSegLength, segLength };
  952. const existingIndex = data.workers.findIndex(w => w.id === id);
  953. if (existingIndex !== -1) {
  954. data.workers[existingIndex] = worker;
  955. } else {
  956. data.workers.push(worker);
  957. }
  958. }
  959. if (total == -1) {
  960. _dataInsert(id, endpoint, prevSegLength, segLength);
  961. } else {
  962. for (let i = 1; i < total + 1; i++) {
  963. _dataInsert(id + i, endpoint, prevSegLength, segLength);
  964. }
  965. }
  966. await this._setData(this.sakura, data);
  967. }
  968.  
  969. static async addGPTWorker(id, model, endpoint, key, amount = null) {
  970. const total = amount ?? -1;
  971. let data = await this._getData(this.gpt);
  972.  
  973. function _dataInsert(id, model, endpoint, key) {
  974. const worker = { id, type: 'api', model, endpoint, key };
  975. const existingIndex = data.workers.findIndex(w => w.id === id);
  976. if (existingIndex !== -1) {
  977. data.workers[existingIndex] = worker;
  978. } else {
  979. data.workers.push(worker);
  980. }
  981. }
  982. if (total == -1) {
  983. _dataInsert(id, model, endpoint, key);
  984. } else {
  985. for (let i = 1; i < total + 1; i++) {
  986. _dataInsert(id + i, model, endpoint, key);
  987. }
  988. }
  989. await this._setData(this.gpt, data);
  990. }
  991.  
  992. static async removeWorker(key, id) {
  993. let data = await this._getData(key);
  994. data.workers = data.workers.filter(w => w.id !== id);
  995. await this._setData(key, data);
  996. }
  997.  
  998. static async removeAllWorkers(key, exclude = []) {
  999. let data = await this._getData(key);
  1000. data.workers = data.workers.filter(w => exclude.includes(w.id));
  1001. await this._setData(key, data);
  1002. }
  1003.  
  1004. static async addJob(key, task, description, createAt = Date.now()) {
  1005. const job = { task, description, createAt };
  1006. let data = await this._getData(key);
  1007. data.jobs.push(job);
  1008. await this._setData(key, data);
  1009. }
  1010.  
  1011. static async addJobs(key, jobs = [], createAt = Date.now()) {
  1012. let data = await this._getData(key);
  1013. const existingTasks = new Set(data.jobs.map(job => job.task));
  1014. jobs.forEach(({ task, description }) => {
  1015. if (!existingTasks.has(task)) {
  1016. const job = { task, description, createAt };
  1017. data.jobs.push(job);
  1018. }
  1019. });
  1020. await this._setData(key, data);
  1021. }
  1022.  
  1023. static async getUncompletedJobs(key) {
  1024. return (await this._getData(key)).uncompletedJobs;
  1025. }
  1026. }
  1027.  
  1028. class NotificationUtils {
  1029. static _initContainer() {
  1030. if (!this._container) {
  1031. this._container = document.createElement('div');
  1032. this._container.className = 'ntr-notification-container';
  1033. document.body.appendChild(this._container);
  1034. }
  1035. }
  1036.  
  1037. static showSuccess(text) {
  1038. this._show(text, '✅');
  1039. }
  1040.  
  1041. static showWarning(text) {
  1042. this._show(text, '⚠️');
  1043. }
  1044.  
  1045. static showError(text) {
  1046. this._show(text, '❌');
  1047. }
  1048.  
  1049. static _show(msg, icon) {
  1050. this._initContainer();
  1051. const box = document.createElement('div');
  1052. box.className = 'ntr-notification-message';
  1053.  
  1054. const iconSpan = document.createElement('span');
  1055. iconSpan.className = 'ntr-icon';
  1056. iconSpan.textContent = icon;
  1057.  
  1058. const textNode = document.createTextNode(msg);
  1059.  
  1060. box.appendChild(iconSpan);
  1061. box.appendChild(textNode);
  1062. this._container.appendChild(box);
  1063.  
  1064. setTimeout(() => {
  1065. box.classList.add('fade-out');
  1066. setTimeout(() => box.remove(), 300);
  1067. }, 1000);
  1068. }
  1069. }
  1070.  
  1071.  
  1072. // -----------------------------------
  1073. // Main Toolbox
  1074. // -----------------------------------
  1075. class NTRToolBox {
  1076. constructor() {
  1077. this.configuration = this.loadConfiguration();
  1078. this.keepActiveSet = new Set();
  1079. this.headerMap = new Map();
  1080. this._pollTimer = null;
  1081. this.token = this.initToken();
  1082.  
  1083. this._lastKeepRun = 0;
  1084. this._lastVisRun = 0;
  1085. this._lastEndPoint = window.location.href;
  1086.  
  1087. this.buildGUI();
  1088. this.attachGlobalKeyBindings();
  1089. this.loadKeepStateAndStart();
  1090. this.scheduleNextPoll();
  1091. }
  1092.  
  1093. static cloneDefaultModules() {
  1094. return defaultModules.map(m => ({
  1095. ...m,
  1096. settings: m.settings ? m.settings.map(s => ({ ...s })) : [],
  1097. _lastRun: 0
  1098. }));
  1099. }
  1100.  
  1101. static DragHandler = class {
  1102. constructor(panel, title) {
  1103. this.panel = panel;
  1104. this.title = title;
  1105. this.dragging = false;
  1106. this.offsetX = 0;
  1107. this.offsetY = 0;
  1108. this.init();
  1109. }
  1110.  
  1111. init() {
  1112. this.title.addEventListener('mousedown', (e) => {
  1113. if (e.button !== 0) return;
  1114. // Disable transitions while dragging
  1115. this.panel.style.transition = 'none';
  1116. this.dragging = true;
  1117. this.offsetX = e.clientX - this.panel.offsetLeft;
  1118. this.offsetY = e.clientY - this.panel.offsetTop;
  1119. e.preventDefault();
  1120. });
  1121.  
  1122. document.addEventListener('mousemove', (e) => {
  1123. if (!this.dragging) return;
  1124. const newLeft = e.clientX - this.offsetX;
  1125. const newTop = e.clientY - this.offsetY;
  1126. this.panel.style.left = newLeft + 'px';
  1127. this.panel.style.top = newTop + 'px';
  1128. this.clampPosition();
  1129. });
  1130.  
  1131. document.addEventListener('mouseup', () => {
  1132. if (!this.dragging) return;
  1133. this.dragging = false;
  1134. // Re-enable transitions
  1135. this.panel.style.transition = 'width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease';
  1136. const rect = this.panel.getBoundingClientRect();
  1137. let left = rect.left;
  1138. let top = rect.top;
  1139. left = Math.min(Math.max(left, 0), window.innerWidth - rect.width);
  1140. top = Math.min(Math.max(top, 0), window.innerHeight - rect.height);
  1141. this.panel.style.left = left + 'px';
  1142. this.panel.style.top = top + 'px';
  1143. localStorage.setItem('ntr-panel-position', JSON.stringify({
  1144. left: this.panel.style.left,
  1145. top: this.panel.style.top
  1146. }));
  1147. });
  1148. // Touch events for mobile
  1149. this.title.addEventListener('touchstart', (e) => {
  1150. // Disable transitions while dragging
  1151. this.panel.style.transition = 'none';
  1152. this.dragging = true;
  1153. const touch = e.touches[0];
  1154. this.offsetX = touch.clientX - this.panel.offsetLeft;
  1155. this.offsetY = touch.clientY - this.panel.offsetTop;
  1156. e.preventDefault();
  1157. }, { passive: false });
  1158.  
  1159. document.addEventListener('touchmove', (e) => {
  1160. if (!this.dragging) return;
  1161. const touch = e.touches[0];
  1162. const newLeft = touch.clientX - this.offsetX;
  1163. const newTop = touch.clientY - this.offsetY;
  1164. this.panel.style.left = newLeft + 'px';
  1165. this.panel.style.top = newTop + 'px';
  1166. this.clampPosition();
  1167. e.preventDefault();
  1168. }, { passive: false });
  1169.  
  1170. document.addEventListener('touchend', (e) => {
  1171. if (!this.dragging) return;
  1172. this.dragging = false;
  1173. // Re-enable transitions
  1174. this.panel.style.transition = 'width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease';
  1175. const rect = this.panel.getBoundingClientRect();
  1176. let left = rect.left;
  1177. let top = rect.top;
  1178. left = Math.min(Math.max(left, 0), window.innerWidth - rect.width);
  1179. top = Math.min(Math.max(top, 0), window.innerHeight - rect.height);
  1180. this.panel.style.left = left + 'px';
  1181. this.panel.style.top = top + 'px';
  1182. localStorage.setItem('ntr-panel-position', JSON.stringify({
  1183. left: this.panel.style.left,
  1184. top: this.panel.style.top
  1185. }));
  1186. }, { passive: false });
  1187. }
  1188.  
  1189. clampPosition() {
  1190. const rect = this.panel.getBoundingClientRect();
  1191. let left = parseFloat(this.panel.style.left) || 0;
  1192. let top = parseFloat(this.panel.style.top) || 0;
  1193. const maxLeft = window.innerWidth - rect.width;
  1194. const maxTop = window.innerHeight - rect.height;
  1195. if (left < 0) left = 0;
  1196. if (top < 0) top = 0;
  1197. if (left > maxLeft) left = maxLeft;
  1198. if (top > maxTop) top = maxTop;
  1199. this.panel.style.left = left + 'px';
  1200. this.panel.style.top = top + 'px';
  1201. }
  1202. }
  1203.  
  1204. initToken() {
  1205. const authInfo = localStorage.getItem('authInfo');
  1206. if (authInfo) {
  1207. const parsedInfo = JSON.parse(authInfo);
  1208. return parsedInfo.profile.token;
  1209. }
  1210. return null;
  1211. }
  1212.  
  1213. loadConfiguration() {
  1214. let stored;
  1215. try {
  1216. stored = JSON.parse(localStorage.getItem(CONFIG_STORAGE_KEY));
  1217. } catch (e) { }
  1218. if (!stored || stored.version !== CONFIG_VERSION) {
  1219. const fresh = NTRToolBox.cloneDefaultModules();
  1220. return { version: CONFIG_VERSION, modules: fresh };
  1221. }
  1222. const loaded = NTRToolBox.cloneDefaultModules();
  1223. stored.modules.forEach(storedMod => {
  1224. const defMod = loaded.find(m => m.name === storedMod.name);
  1225. if (defMod) {
  1226. for (const k in storedMod) {
  1227. if (
  1228. defMod.hasOwnProperty(k) &&
  1229. typeof defMod[k] === typeof storedMod[k] &&
  1230. storedMod[k] !== undefined
  1231. ) {
  1232. defMod[k] = storedMod[k];
  1233. }
  1234. }
  1235. }
  1236. });
  1237. if (loaded.length !== defaultModules.length) {
  1238. const fresh = NTRToolBox.cloneDefaultModules();
  1239. localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify({ version: CONFIG_VERSION, modules: fresh }));
  1240. return { version: CONFIG_VERSION, modules: fresh };
  1241. } else {
  1242. const defNames = defaultModules.map(x => x.name).sort().join(',');
  1243. const storedNames = loaded.map(x => x.name).sort().join(',');
  1244. if (defNames !== storedNames) {
  1245. const fresh = NTRToolBox.cloneDefaultModules();
  1246. localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify({ version: CONFIG_VERSION, modules: fresh }));
  1247. return { version: CONFIG_VERSION, modules: fresh };
  1248. }
  1249. }
  1250. // Reattach run
  1251. loaded.forEach(m => {
  1252. const found = defaultModules.find(d => d.name === m.name);
  1253. if (found && typeof found.run === 'function') {
  1254. for (const p in found) {
  1255. if (!m.hasOwnProperty(p)) {
  1256. m[p] = found[p];
  1257. }
  1258. }
  1259. m.run = found.run;
  1260. }
  1261. });
  1262. return { version: CONFIG_VERSION, modules: loaded };
  1263. }
  1264.  
  1265. saveConfiguration() {
  1266. localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(this.configuration));
  1267. }
  1268.  
  1269. buildGUI() {
  1270. this.panel = document.createElement('div');
  1271. this.panel.id = 'ntr-panel';
  1272.  
  1273. // restore from localStorage
  1274. const savedPos = localStorage.getItem('ntr-panel-position');
  1275. if (savedPos) {
  1276. try {
  1277. const parsed = JSON.parse(savedPos);
  1278. if (parsed.left && parsed.top) {
  1279. this.panel.style.left = parsed.left;
  1280. this.panel.style.top = parsed.top;
  1281. }
  1282. } catch (e) { }
  1283. }
  1284.  
  1285. this.isMinimized = false;
  1286. this.titleBar = document.createElement('div');
  1287. this.titleBar.className = 'ntr-titlebar';
  1288. this.titleBar.innerHTML = 'NTR ToolBox ' + VERSION;
  1289.  
  1290. this.toggleSpan = document.createElement('span');
  1291. this.toggleSpan.style.float = 'right';
  1292. this.toggleSpan.textContent = '[-]';
  1293. this.titleBar.appendChild(this.toggleSpan);
  1294.  
  1295. this.panel.appendChild(this.titleBar);
  1296.  
  1297. this.panelBody = document.createElement('div');
  1298. this.panelBody.className = 'ntr-panel-body';
  1299. this.panel.appendChild(this.panelBody);
  1300.  
  1301. this.infoBar = document.createElement('div');
  1302. this.infoBar.className = 'ntr-info';
  1303. const leftInfo = document.createElement('span');
  1304. const rightInfo = document.createElement('span');
  1305. leftInfo.textContent = IS_MOBILE
  1306. ? '單擊執行 | ⚙️設定'
  1307. : '左鍵執行/切換 | 右鍵設定';
  1308. rightInfo.textContent = 'Author: TheNano(百合仙人)';
  1309. this.infoBar.appendChild(leftInfo);
  1310. this.infoBar.appendChild(rightInfo);
  1311. this.panel.appendChild(this.infoBar);
  1312.  
  1313. document.body.appendChild(this.panel);
  1314.  
  1315. // set up drag
  1316. this.dragHandler = new NTRToolBox.DragHandler(this.panel, this.titleBar);
  1317.  
  1318. this.buildModules();
  1319.  
  1320. setTimeout(() => {
  1321. this.expandedWidth = this.panel.offsetWidth;
  1322. this.expandedHeight = this.panel.offsetHeight;
  1323.  
  1324. const wasMin = this.isMinimized;
  1325. if (!wasMin) this.panel.classList.add('minimized');
  1326. const h0 = this.panel.offsetHeight;
  1327. if (!wasMin) this.panel.classList.remove('minimized');
  1328.  
  1329. this.minimizedWidth = this.panel.offsetWidth;
  1330. this.minimizedHeight = h0;
  1331. }, 150);
  1332.  
  1333. if (IS_MOBILE) {
  1334. // On mobile, single tap toggles minimized state.
  1335. this.titleBar.addEventListener('click', e => {
  1336. if (!this.dragHandler.dragging) {
  1337. e.preventDefault();
  1338. this.setMinimizedState(!this.isMinimized);
  1339. }
  1340. });
  1341. } else {
  1342. this.titleBar.addEventListener('contextmenu', e => {
  1343. e.preventDefault();
  1344. this.setMinimizedState(!this.isMinimized);
  1345. });
  1346. }
  1347. }
  1348.  
  1349. buildModules() {
  1350. this.panelBody.innerHTML = '';
  1351. this.headerMap.clear();
  1352.  
  1353. this.configuration.modules.forEach(mod => {
  1354. const container = document.createElement('div');
  1355. container.className = 'ntr-module-container';
  1356.  
  1357. const header = document.createElement('div');
  1358. header.className = 'ntr-module-header';
  1359.  
  1360. const nameSpan = document.createElement('span');
  1361. nameSpan.textContent = mod.name;
  1362. header.appendChild(nameSpan);
  1363.  
  1364. if (!IS_MOBILE) {
  1365. const iconSpan = document.createElement('span');
  1366. iconSpan.textContent = (mod.type === 'keep') ? '⇋' : '▶';
  1367. iconSpan.style.marginLeft = '8px';
  1368. header.appendChild(iconSpan);
  1369. }
  1370.  
  1371. const settingsDiv = document.createElement('div');
  1372. settingsDiv.className = 'ntr-settings-container';
  1373. settingsDiv.style.display = 'none';
  1374.  
  1375. if (IS_MOBILE) {
  1376. const btn = document.createElement('button');
  1377. btn.textContent = '⚙️';
  1378. btn.style.color = 'white';
  1379. btn.style.float = 'right';
  1380. btn.onclick = e => {
  1381. e.stopPropagation();
  1382. const styleVal = window.getComputedStyle(settingsDiv).display;
  1383. settingsDiv.style.display = (styleVal === 'none' ? 'block' : 'none');
  1384. };
  1385. header.appendChild(btn);
  1386.  
  1387. header.onclick = e => {
  1388. if (e.target.classList.contains('ntr-bind-button') || e.target === btn) return;
  1389. this.handleModuleClick(mod, header);
  1390. };
  1391. } else {
  1392. header.oncontextmenu = e => {
  1393. e.preventDefault();
  1394. const styleVal = window.getComputedStyle(settingsDiv).display;
  1395. settingsDiv.style.display = (styleVal === 'none' ? 'block' : 'none');
  1396. };
  1397. header.onclick = e => {
  1398. if (e.button === 0 && !e.ctrlKey && !e.altKey && !e.shiftKey) {
  1399. if (e.target.classList.contains('ntr-bind-button')) return;
  1400. this.handleModuleClick(mod, header);
  1401. }
  1402. };
  1403. }
  1404. if (Array.isArray(mod.settings)) {
  1405. mod.settings.forEach(s => {
  1406. const row = document.createElement('div');
  1407. row.style.marginBottom = '8px';
  1408.  
  1409. const label = document.createElement('label');
  1410. label.style.display = 'inline-block';
  1411. label.style.minWidth = '70px';
  1412. label.style.color = '#ccc';
  1413. label.textContent = s.name + ': ';
  1414. row.appendChild(label);
  1415.  
  1416. let inputEl;
  1417. switch (s.type) {
  1418. case 'boolean': {
  1419. inputEl = document.createElement('input');
  1420. inputEl.type = 'checkbox';
  1421. inputEl.checked = !!s.value;
  1422. inputEl.onchange = () => {
  1423. s.value = inputEl.checked;
  1424. this.saveConfiguration();
  1425. };
  1426. break;
  1427. }
  1428. case 'number': {
  1429. inputEl = document.createElement('input');
  1430. inputEl.type = 'number';
  1431. inputEl.value = s.value;
  1432. inputEl.className = 'ntr-number-input';
  1433. inputEl.onchange = () => {
  1434. s.value = Number(inputEl.value) || 0;
  1435. this.saveConfiguration();
  1436. };
  1437. break;
  1438. }
  1439. case 'select': {
  1440. inputEl = document.createElement('select');
  1441. if (Array.isArray(s.options)) {
  1442. s.options.forEach(opt => {
  1443. const optEl = document.createElement('option');
  1444. optEl.value = opt;
  1445. optEl.textContent = opt;
  1446. if (opt === s.value) optEl.selected = true;
  1447. inputEl.appendChild(optEl);
  1448. });
  1449. }
  1450. inputEl.onchange = () => {
  1451. s.value = inputEl.value;
  1452. this.saveConfiguration();
  1453. };
  1454. break;
  1455. }
  1456. case 'string': {
  1457. if (s.name === 'bind') {
  1458. inputEl = document.createElement('button');
  1459. inputEl.className = 'ntr-bind-button';
  1460. inputEl.textContent = (s.value === 'none') ? '(None)' : `[${s.value.toUpperCase()}]`;
  1461. inputEl.onclick = () => {
  1462. inputEl.textContent = '(Press any key)';
  1463. const handler = ev => {
  1464. ev.preventDefault();
  1465. if (ev.key === 'Escape') {
  1466. s.value = 'none';
  1467. inputEl.textContent = '(None)';
  1468. } else {
  1469. s.value = ev.key.toLowerCase();
  1470. inputEl.textContent = `[${ev.key.toUpperCase()}]`;
  1471. }
  1472. this.saveConfiguration();
  1473. document.removeEventListener('keydown', handler, true);
  1474. ev.stopPropagation();
  1475. };
  1476. document.addEventListener('keydown', handler, true);
  1477. };
  1478. } else {
  1479. inputEl = document.createElement('input');
  1480. inputEl.type = 'text';
  1481. inputEl.value = s.value;
  1482. inputEl.className = 'ntr-input';
  1483. inputEl.onchange = () => {
  1484. s.value = inputEl.value;
  1485. this.saveConfiguration();
  1486. };
  1487. }
  1488. break;
  1489. }
  1490. default: {
  1491. inputEl = document.createElement('span');
  1492. inputEl.style.color = '#999';
  1493. inputEl.textContent = String(s.value);
  1494. }
  1495. }
  1496. row.appendChild(inputEl);
  1497. settingsDiv.appendChild(row);
  1498. });
  1499. }
  1500.  
  1501. container.appendChild(header);
  1502. container.appendChild(settingsDiv);
  1503.  
  1504. this.panelBody.appendChild(container);
  1505. this.headerMap.set(mod, header);
  1506. });
  1507. }
  1508.  
  1509. attachGlobalKeyBindings() {
  1510. document.addEventListener('keydown', e => {
  1511. if (e.ctrlKey || e.altKey || e.metaKey) return;
  1512. const pk = e.key.toLowerCase();
  1513. this.configuration.modules.forEach(mod => {
  1514. const bind = mod.settings.find(s => s.name === 'bind');
  1515. if (!bind || bind.value === 'none') return;
  1516. if (bind.value.toLowerCase() === pk) {
  1517. if (!isModuleEnabledByWhitelist(mod)) return;
  1518. e.preventDefault();
  1519. this.handleModuleClick(mod, null);
  1520. }
  1521. });
  1522. });
  1523. }
  1524.  
  1525. handleModuleClick(mod, header) {
  1526. if (!domainAllowed || !isModuleEnabledByWhitelist(mod)) return;
  1527. try {
  1528. if (mod.type === 'onclick') {
  1529. if (typeof mod.run === 'function') {
  1530. Promise.resolve(mod.run(mod)).catch(console.error);
  1531. }
  1532. } else if (mod.type === 'keep') {
  1533. const active = this.keepActiveSet.has(mod.name);
  1534. if (active) {
  1535. if (header) this.stopKeepModule(mod, header);
  1536. } else {
  1537. if (header) this.startKeepModule(mod, header);
  1538. }
  1539. }
  1540. } catch (err) {
  1541. console.error('Error running module:', mod.name, err);
  1542. }
  1543. }
  1544.  
  1545. startKeepModule(mod, header) {
  1546. if (this.keepActiveSet.has(mod.name)) return;
  1547. header.classList.add('active');
  1548. this.keepActiveSet.add(mod.name);
  1549. this.updateKeepStateStorage();
  1550. }
  1551.  
  1552. stopKeepModule(mod, header) {
  1553. header.classList.remove('active');
  1554. this.keepActiveSet.delete(mod.name);
  1555. this.updateKeepStateStorage();
  1556. }
  1557.  
  1558. updateKeepStateStorage() {
  1559. const st = {};
  1560. this.keepActiveSet.forEach(n => {
  1561. st[n] = true;
  1562. });
  1563. localStorage.setItem('NTR_KeepState', JSON.stringify(st));
  1564. }
  1565.  
  1566. loadKeepStateAndStart() {
  1567. let saved = {};
  1568. try {
  1569. saved = JSON.parse(localStorage.getItem('NTR_KeepState') || '{}');
  1570. } catch (e) { }
  1571. this.configuration.modules.forEach(mod => {
  1572. if (mod.type === 'keep' && saved[mod.name]) {
  1573. const hdr = this.headerMap.get(mod);
  1574. if (hdr) {
  1575. this.startKeepModule(mod, hdr);
  1576. }
  1577. }
  1578. });
  1579. }
  1580.  
  1581. scheduleNextPoll() {
  1582. const now = Date.now();
  1583. if (now - this._lastKeepRun >= 100) {
  1584. this.pollKeepModules();
  1585. this._lastKeepRun = now;
  1586. }
  1587. if (now - this._lastVisRun >= 250) {
  1588. this.updateModuleVisibility();
  1589. if (this._lastEndPoint != window.location.href) {
  1590. StorageUtils.update();
  1591. this._lastEndPoint = window.location.href;
  1592. }
  1593. this._lastVisRun = now;
  1594. }
  1595. this._pollTimer = setTimeout(() => {
  1596. this.scheduleNextPoll();
  1597. }, 10);
  1598. }
  1599.  
  1600. pollKeepModules() {
  1601. this.configuration.modules.forEach(mod => {
  1602. if (mod.type === 'keep' && this.keepActiveSet.has(mod.name) && typeof mod.run === 'function') {
  1603. mod.run(mod);
  1604. }
  1605. });
  1606. }
  1607.  
  1608. runModule(name) {
  1609. this.configuration.modules.filter(mod => mod.name == name).forEach(mod => {
  1610. if (typeof mod.run === 'function') {
  1611. mod.run(mod, true);
  1612. }
  1613. });
  1614. }
  1615.  
  1616. updateModuleVisibility() {
  1617. this.configuration.modules.forEach(mod => {
  1618. const hdr = this.headerMap.get(mod);
  1619. if (!hdr) return;
  1620. const cont = hdr.parentElement;
  1621. const allowed = domainAllowed && isModuleEnabledByWhitelist(mod) && !mod.hidden;
  1622. if (!allowed) {
  1623. cont.style.display = 'none';
  1624. if (mod.type === 'keep' && this.keepActiveSet.has(mod.name)) {
  1625. this.stopKeepModule(mod, hdr);
  1626. }
  1627. } else {
  1628. cont.style.display = 'block';
  1629. }
  1630. });
  1631. }
  1632.  
  1633. getAnchorCornerInfo(rect) {
  1634. const centerX = rect.left + rect.width / 2;
  1635. const centerY = rect.top + rect.height / 2;
  1636. const horizontal = (centerX < window.innerWidth / 2) ? 'left' : 'right';
  1637. const vertical = (centerY < window.innerHeight / 2) ? 'top' : 'bottom';
  1638. return {
  1639. corner: vertical + '-' + horizontal,
  1640. x: (horizontal === 'left' ? rect.left : rect.right),
  1641. y: (vertical === 'top' ? rect.top : rect.bottom)
  1642. };
  1643. }
  1644.  
  1645. setMinimizedState(newVal) {
  1646. if (this.isMinimized === newVal) return;
  1647. const rect = this.panel.getBoundingClientRect();
  1648. const anchor = this.getAnchorCornerInfo(rect);
  1649.  
  1650. this.isMinimized = newVal;
  1651. if (this.isMinimized) {
  1652. this.panel.classList.add('minimized');
  1653. this.toggleSpan.textContent = '[+]';
  1654. this.panelBody.style.display = 'none';
  1655. this.infoBar.style.display = 'none';
  1656. } else {
  1657. this.panel.classList.remove('minimized');
  1658. this.toggleSpan.textContent = '[-]';
  1659. this.panelBody.style.display = 'block';
  1660. this.infoBar.style.display = 'flex';
  1661. }
  1662.  
  1663. setTimeout(() => {
  1664. const newRect = this.panel.getBoundingClientRect();
  1665. let left, top;
  1666. switch (anchor.corner) {
  1667. case 'top-left':
  1668. left = anchor.x;
  1669. top = anchor.y;
  1670. break;
  1671. case 'top-right':
  1672. left = anchor.x - newRect.width;
  1673. top = anchor.y;
  1674. break;
  1675. case 'bottom-left':
  1676. left = anchor.x;
  1677. top = anchor.y - newRect.height;
  1678. break;
  1679. case 'bottom-right':
  1680. left = anchor.x - newRect.width;
  1681. top = anchor.y - newRect.height;
  1682. break;
  1683. default:
  1684. left = parseFloat(this.panel.style.left) || newRect.left;
  1685. top = parseFloat(this.panel.style.top) || newRect.top;
  1686. }
  1687. left = Math.min(Math.max(left, 0), window.innerWidth - newRect.width);
  1688. top = Math.min(Math.max(top, 0), window.innerHeight - newRect.height);
  1689. this.panel.style.left = left + 'px';
  1690. this.panel.style.top = top + 'px';
  1691. localStorage.setItem('ntr-panel-position', JSON.stringify({
  1692. left: this.panel.style.left,
  1693. top: this.panel.style.top
  1694. }));
  1695. }, 310);
  1696. }
  1697.  
  1698. async fetch(url, bypass = true) {
  1699. if (bypass && this.token) {
  1700. const response = await fetch(url, {
  1701. method: 'GET',
  1702. headers: {
  1703. 'Authorization': `Bearer ${this.token}`
  1704. }
  1705. });
  1706. return response;
  1707. } else {
  1708. return await fetch(url);
  1709. }
  1710. }
  1711.  
  1712. delay(ms) {
  1713. return new Promise(r => setTimeout(r, ms));
  1714. }
  1715. }
  1716.  
  1717. const css = document.createElement('style');
  1718. css.textContent = `
  1719. #ntr-panel {
  1720. position: fixed;
  1721. left: 20px;
  1722. top: 70px;
  1723. z-index: 9999;
  1724. background: #1E1E1E;
  1725. color: #BBB;
  1726. padding: 8px;
  1727. border-radius: 8px;
  1728. font-family: Arial, sans-serif;
  1729. width: 320px;
  1730. box-shadow: 2px 2px 12px rgba(0,0,0,0.5);
  1731. border: 1px solid #333;
  1732. transition: width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease;
  1733. }
  1734. #ntr-panel.minimized {
  1735. width: 200px;
  1736. }
  1737. .ntr-titlebar {
  1738. font-weight: bold;
  1739. padding: 10px;
  1740. cursor: move;
  1741. background: #292929;
  1742. border-radius: 6px;
  1743. color: #CCC;
  1744. user-select: none;
  1745. }
  1746. .ntr-panel-body {
  1747. padding: 6px;
  1748. background: #232323;
  1749. border-radius: 4px;
  1750. overflow-y: auto;
  1751. max-height: 80vh;
  1752. transition: max-height 0.3s ease;
  1753. }
  1754. #ntr-panel.minimized .ntr-panel-body {
  1755. max-height: 0;
  1756. }
  1757. .ntr-module-container {
  1758. margin-bottom: 12px;
  1759. border: 1px solid #444;
  1760. border-radius: 4px;
  1761. }
  1762. .ntr-module-header {
  1763. display: flex;
  1764. align-items: center;
  1765. justify-content: space-between;
  1766. background: #2E2E2E;
  1767. padding: 6px 8px;
  1768. border-radius: 3px 3px 0 0;
  1769. border-bottom: 1px solid #333;
  1770. cursor: pointer;
  1771. transition: background 0.3s;
  1772. }
  1773. .ntr-module-header:hover {
  1774. background: #3a3a3a;
  1775. }
  1776. .ntr-settings-container {
  1777. padding: 6px;
  1778. background: #1C1C1C;
  1779. display: none;
  1780. }
  1781. .ntr-input {
  1782. width: 120px;
  1783. padding: 4px;
  1784. border: 1px solid #555;
  1785. border-radius: 4px;
  1786. background: #2A2A2A;
  1787. color: #FFF;
  1788. }
  1789. .ntr-number-input {
  1790. width: 60px;
  1791. padding: 4px;
  1792. border: 1px solid #555;
  1793. border-radius: 4px;
  1794. background: #2A2A2A;
  1795. color: #FFF;
  1796. }
  1797. .ntr-bind-button {
  1798. padding: 4px 8px;
  1799. border: 1px solid #555;
  1800. border-radius: 4px;
  1801. background: #2A2A2A;
  1802. color: #FFF;
  1803. cursor: pointer;
  1804. }
  1805. .ntr-info {
  1806. display: flex;
  1807. justify-content: space-between;
  1808. font-size: 10px;
  1809. color: #888;
  1810. margin-top: 8px;
  1811. }
  1812. .ntr-module-header.active {
  1813. background: #63E2B7 !important;
  1814. color: #fff !important;
  1815. }
  1816. .ntr-notification-container {
  1817. position: fixed;
  1818. top: 20px;
  1819. left: 50%;
  1820. transform: translateX(-50%);
  1821. z-index: 9999;
  1822. display: flex;
  1823. flex-direction: column;
  1824. align-items: flex-start;
  1825. }
  1826. .ntr-notification-message {
  1827. display: flex;
  1828. align-items: center;
  1829. min-width: 200px;
  1830. margin-top: 8px;
  1831. padding: 4px 8px;
  1832. border-radius: 4px;
  1833. background-color: #2A2A2A;
  1834. color: #fff;
  1835. font-size: 14px;
  1836. font-family: sans-serif;
  1837. opacity: 1;
  1838. transition: opacity 0.3s ease;
  1839. }
  1840. .ntr-notification-message .ntr-icon {
  1841. margin-right: 4px;
  1842. font-size: 16px;
  1843. }
  1844. .ntr-notification-message.fade-out {
  1845. opacity: 0;
  1846. }
  1847. @media only screen and (max-width:600px) {
  1848. #ntr-panel {
  1849. transform: scale(0.6);
  1850. transform-origin: top left;
  1851. }
  1852. }
  1853. `;
  1854. document.head.appendChild(css);
  1855.  
  1856. // -----------------------------------
  1857. // Init Script
  1858. // -----------------------------------
  1859. const script = new NTRToolBox();
  1860. })();