晋江章节下载器

从晋江下载章节文本

  1. // ==UserScript==
  2. // @name Jinjiang Chapter Downloader
  3. // @name:zh-CN 晋江章节下载器
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.7
  6. // @description Download chapter content from JinJiang (jjwxc.net)
  7. // @description:zh-CN 从晋江下载章节文本
  8. // @author oovz
  9. // @match *://www.jjwxc.net/onebook.php?novelid=*&chapterid=*
  10. // @match *://my.jjwxc.net/onebook_vip.php?novelid=*&chapterid=*
  11. // @grant none
  12. // @source https://gist.github.com/oovz/5eaabb8adecadac515d13d261fbb93b5
  13. // @source https://gf.qytechs.cn/en/scripts/532897-jinjiang-chapter-downloader
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. "use strict";
  19.  
  20. // --- Configuration ---
  21. const TITLE_XPATH = '//div[@class="novelbody"]//h2';
  22. const CONTENT_CONTAINER_SELECTOR =
  23. '.novelbody > div[style*="font-size: 16px"]'; // Selector for the main content div
  24. const CONTENT_START_TITLE_DIV_SELECTOR = 'div[align="center"]'; // Title div within the container
  25. const CONTENT_START_CLEAR_DIV_SELECTOR = 'div[style*="clear:both"]'; // Div marking start of content after title div
  26. const CONTENT_END_DIV_TAG = "DIV"; // First DIV tag encountered after content starts marks the end
  27. const CONTENT_END_FALLBACK_SELECTOR_1 = "#favoriteshow_3"; // Fallback end marker
  28. const CONTENT_END_FALLBACK_SELECTOR_2 = "#note_danmu_wrapper"; // Fallback end marker (author say wrapper)
  29. const CONTENT_CONTAINER_SELECTOR_VIP = "div[id^=content_]"; // Selector for the main content div
  30. const AUTHOR_SAY_HIDDEN_XPATH = '//div[@id="note_str"]'; // Hidden div containing raw author say HTML
  31. const AUTHOR_SAY_CUTOFF_TEXT = "谢谢各位大人的霸王票"; // Text to truncate author say at
  32. const NEXT_CHAPTER_XPATH =
  33. '//div[@id="oneboolt"]/div[@class="noveltitle"]/span/a[span][last()]'; // Next chapter link
  34. const CHAPTER_WRAPPER_XPATH = '//div[@class="novelbody"]'; // Wrapper for MutationObserver
  35.  
  36. const AD_1 = "@无限好文,尽在晋江文学城";
  37.  
  38. // Additional advertisement texts that might appear in chapters
  39. const ADVERTISEMENT_TEXTS = [AD_1];
  40.  
  41. // --- Internationalization ---
  42. const isZhCN =
  43. navigator.language.toLowerCase() === "zh-cn" ||
  44. document.documentElement.lang.toLowerCase() === "zh-cn";
  45.  
  46. const i18n = {
  47. copyText: isZhCN ? "复制文本" : "Copy Content",
  48. copiedText: isZhCN ? "已复制!" : "Copied!",
  49. nextChapter: isZhCN ? "下一章" : "Next Chapter",
  50. noNextChapter: isZhCN ? "没有下一章" : "No Next Chapter",
  51. includeAuthorSay: isZhCN ? "包含作话" : "Include Author Say",
  52. excludeAuthorSay: isZhCN ? "排除作话" : "Exclude Author Say",
  53. authorSaySeparator: isZhCN ? "--- 作者有话说 ---" : "--- Author Say ---",
  54. };
  55.  
  56. // --- State ---
  57. let includeAuthorSay = true; // Default to including author say
  58.  
  59. // --- Utilities ---
  60.  
  61. /**
  62. * Extracts text content from elements matching an XPath.
  63. * Special handling for title to trim whitespace.
  64. */
  65. function getElementsByXpath(xpath) {
  66. const results = [];
  67. const query = document.evaluate(
  68. xpath,
  69. document,
  70. null,
  71. XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
  72. null
  73. );
  74.  
  75. for (let i = 0; i < query.snapshotLength; i++) {
  76. const node = query.snapshotItem(i);
  77. if (node) {
  78. let directTextContent = "";
  79. for (let j = 0; j < node.childNodes.length; j++) {
  80. const childNode = node.childNodes[j];
  81. if (childNode.nodeType === Node.TEXT_NODE) {
  82. directTextContent += childNode.textContent;
  83. }
  84. }
  85.  
  86. if (xpath === TITLE_XPATH) {
  87. directTextContent = directTextContent.trim();
  88. }
  89.  
  90. if (directTextContent) {
  91. results.push(directTextContent);
  92. }
  93. }
  94. }
  95. return results;
  96. }
  97. // --- GUI Creation ---
  98. const gui = document.createElement("div");
  99. const style = document.createElement("style");
  100. const resizeHandle = document.createElement("div");
  101. const errorMessage = document.createElement("div");
  102. const output = document.createElement("textarea");
  103. const buttonContainer = document.createElement("div");
  104. const copyButton = document.createElement("button");
  105. const authorSayButton = document.createElement("button");
  106. const nextChapterButton = document.createElement("button");
  107. const spinnerOverlay = document.createElement("div");
  108. const spinner = document.createElement("div");
  109.  
  110. function setupGUI() {
  111. gui.style.cssText = `
  112. position: fixed; bottom: 20px; right: 20px; background: white; padding: 15px;
  113. border: 1px solid #ccc; border-radius: 5px; box-shadow: 0 0 10px rgba(0,0,0,0.1);
  114. z-index: 9999; resize: both; overflow: visible; min-width: 350px; min-height: 250px;
  115. max-width: 100vw; max-height: 80vh; resize-origin: top-left; display: flex; flex-direction: column;
  116. `;
  117. style.textContent = `
  118. @keyframes spin { to { transform: rotate(360deg); } }
  119. .resize-handle {
  120. position: absolute; width: 14px; height: 14px; top: 0; left: 0; cursor: nwse-resize;
  121. z-index: 10000; background-color: #888; border-top-left-radius: 5px;
  122. border-right: 1px solid #ccc; border-bottom: 1px solid #ccc;
  123. }
  124. .spinner-overlay {
  125. position: absolute; top: 0; left: 0; width: 100%; height: 100%;
  126. background-color: rgba(240, 240, 240, 0.8); display: none; justify-content: center;
  127. align-items: center; z-index: 10001;
  128. }
  129. .font-error-message {
  130. background-color: #ffeaa7; border: 1px solid #fdcb6e; border-radius: 4px;
  131. padding: 8px 12px; margin-bottom: 8px; font-size: 0.9em; color: #2d3436;
  132. display: none; line-height: 1.4;
  133. }
  134. `;
  135. document.head.appendChild(style);
  136.  
  137. resizeHandle.className = "resize-handle";
  138.  
  139. output.style.cssText = `
  140. width: 100%; flex: 1; margin-bottom: 8px; resize: none; overflow: auto;
  141. box-sizing: border-box; min-height: 180px;
  142. `;
  143. output.readOnly = true;
  144.  
  145. buttonContainer.style.cssText = `display: flex; justify-content: center; gap: 10px; margin-bottom: 2px;`;
  146.  
  147. copyButton.textContent = i18n.copyText;
  148. copyButton.style.cssText = `padding: 4px 12px; cursor: pointer; background-color: #4285f4; color: white; border: none; border-radius: 15px; font-weight: bold; font-size: 0.9em;`;
  149.  
  150. authorSayButton.textContent = includeAuthorSay
  151. ? i18n.excludeAuthorSay
  152. : i18n.includeAuthorSay;
  153. authorSayButton.style.cssText = `padding: 4px 12px; cursor: pointer; background-color: #fbbc05; color: white; border: none; border-radius: 15px; font-weight: bold; font-size: 0.9em; margin-right: 5px;`;
  154. authorSayButton.disabled = true;
  155.  
  156. nextChapterButton.textContent = i18n.nextChapter;
  157. nextChapterButton.style.cssText = `padding: 4px 12px; cursor: pointer; background-color: #34a853; color: white; border: none; border-radius: 15px; font-weight: bold; font-size: 0.9em;`;
  158. buttonContainer.appendChild(authorSayButton);
  159. buttonContainer.appendChild(copyButton);
  160. buttonContainer.appendChild(nextChapterButton);
  161.  
  162. errorMessage.className = "font-error-message";
  163. errorMessage.innerHTML = isZhCN
  164. ? "⚠️ VIP章节字体解密表未找到,内容可能无法正确解密。"
  165. : "⚠️ VIP chapter font table not found, content may not be properly decrypted.";
  166.  
  167. spinnerOverlay.className = "spinner-overlay";
  168. spinner.style.cssText = `width: 30px; height: 30px; border: 4px solid rgba(0,0,0,0.1); border-radius: 50%; border-top-color: #333; animation: spin 1s ease-in-out infinite;`;
  169. spinnerOverlay.appendChild(spinner);
  170.  
  171. gui.appendChild(resizeHandle);
  172. gui.appendChild(errorMessage);
  173. gui.appendChild(output);
  174. gui.appendChild(buttonContainer);
  175. gui.appendChild(spinnerOverlay);
  176. document.body.appendChild(gui);
  177. }
  178.  
  179. // --- Advertisement Text Removal ---
  180. /**
  181. * Removes advertisement text from content
  182. * @param {string} content - The content to clean
  183. * @param {string[]} adTexts - Array of advertisement texts to remove
  184. * @returns {string} Cleaned content
  185. */ function removeAdvertisementText(content, adTexts = ADVERTISEMENT_TEXTS) {
  186. if (!content || !adTexts || adTexts.length === 0) {
  187. return content;
  188. }
  189.  
  190. let cleanedContent = content;
  191. let removedCount = 0;
  192.  
  193. for (const adText of adTexts) {
  194. if (!adText) continue;
  195.  
  196. // Count occurrences before removal
  197. const beforeLength = cleanedContent.length;
  198.  
  199. // Remove exact matches of the advertisement text
  200. cleanedContent = cleanedContent.replaceAll(adText, "");
  201.  
  202. // Also remove the advertisement text with common surrounding punctuation/whitespace
  203. const adPatterns = [
  204. new RegExp(`\\s*${escapeRegExp(adText)}\\s*`, "g"),
  205. new RegExp(`^\\s*${escapeRegExp(adText)}\\s*`, "gm"), // At start of line
  206. new RegExp(`\\s*${escapeRegExp(adText)}\\s*$`, "gm"), // At end of line
  207. ];
  208.  
  209. for (const pattern of adPatterns) {
  210. cleanedContent = cleanedContent.replace(pattern, "");
  211. }
  212.  
  213. // Check if any removal occurred
  214. const afterLength = cleanedContent.length;
  215. if (afterLength < beforeLength) {
  216. removedCount++;
  217. console.log(`[Advertisement Removal] Removed "${adText}" from content`);
  218. }
  219. }
  220.  
  221. // Clean up any excessive whitespace that might remain after ad removal
  222. cleanedContent = cleanedContent.replace(/\n{3,}/g, "\n\n"); // Collapse 3+ newlines into 2
  223. cleanedContent = cleanedContent.replace(/^[ \t\r\n]+/, ""); // Remove leading whitespace
  224. cleanedContent = cleanedContent.replace(/[\s\r\n]+$/, ""); // Remove trailing whitespace
  225.  
  226. if (removedCount > 0) {
  227. console.log(
  228. `[Advertisement Removal] Successfully removed ${removedCount} advertisement patterns from content`
  229. );
  230. }
  231.  
  232. return cleanedContent;
  233. }
  234.  
  235. /**
  236. * Escapes special regex characters for use in RegExp constructor
  237. * @param {string} string - String to escape
  238. * @returns {string} Escaped string
  239. */
  240. function escapeRegExp(string) {
  241. return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  242. }
  243.  
  244. // --- VIP Font Decryption Functions ---
  245.  
  246. /** Detect font name and URL from VIP chapter CSS styles */
  247. function detectVipFont() {
  248. // Method 1: Check CSS rules in style sheets
  249. const styles = document.querySelectorAll("body > style");
  250. for (const style of styles) {
  251. if (style.sheet && style.sheet.cssRules) {
  252. try {
  253. const rules = style.sheet.cssRules;
  254. for (let i = 0; i < rules.length; i++) {
  255. const rule = rules[i];
  256. if (rule.cssText) {
  257. const fontNameMatch = rule.cssText.match(/jjwxcfont_[\d\w]+/);
  258. const cssContentMatch = rule.cssText.match(/{(.*)}/);
  259.  
  260. if (fontNameMatch && cssContentMatch) {
  261. const fontName = fontNameMatch[0];
  262. const cssContent = cssContentMatch[1];
  263.  
  264. // Look for font URL in CSS content
  265. for (const part of cssContent.split(",")) {
  266. if (part.includes('format("woff2")')) {
  267. const urlMatch = part.match(/url\("(.*)"\)\s/);
  268. if (urlMatch) {
  269. const fontUrl = document.location.protocol + urlMatch[1];
  270. return { fontName, fontUrl };
  271. }
  272. }
  273. }
  274. }
  275. }
  276. }
  277. } catch (e) {
  278. console.debug("Error accessing stylesheet:", e);
  279. }
  280. }
  281. }
  282.  
  283. // Method 2: Check div.noveltext classes for font name
  284. const noveltextDiv = document.querySelector("div.noveltext");
  285. if (noveltextDiv && noveltextDiv.classList) {
  286. const fontClass = Array.from(noveltextDiv.classList).find((className) =>
  287. className.startsWith("jjwxcfont_")
  288. );
  289. if (fontClass) {
  290. const fontUrl = `${document.location.protocol}//static.jjwxc.net/tmp/fonts/${fontClass}.woff2?h=my.jjwxc.net`;
  291. return { fontName: fontClass, fontUrl };
  292. }
  293. }
  294.  
  295. return null;
  296. }
  297.  
  298. /** Fetch font mapping table from remote repository */
  299. async function fetchFontTable(fontName) {
  300. const url = `https://fastly.jsdelivr.net/gh/404-novel-project/jinjiang_font_tables@master/${fontName}.woff2.json`;
  301. const fontLink = `https://static.jjwxc.net/tmp/fonts/${fontName}.woff2?h=my.jjwxc.net`;
  302.  
  303. console.log(`[VIP Font] Fetching font table for ${fontName}`);
  304.  
  305. let retryCount = 3;
  306. while (retryCount > 0) {
  307. try {
  308. const response = await fetch(url);
  309. if (response.ok) {
  310. const fontTable = await response.json();
  311. console.log(
  312. `[VIP Font] Successfully loaded font table for ${fontName}`
  313. );
  314. return fontTable;
  315. } else if (response.status === 404) {
  316. console.warn(
  317. `[VIP Font] Font table not found for ${fontName}. Please submit font link to https://github.com/404-novel-project/jinjiang_font_tables: ${fontLink}`
  318. );
  319. return null;
  320. }
  321. } catch (error) {
  322. console.error(`[VIP Font] Error fetching font table:`, error);
  323. retryCount--;
  324. if (retryCount > 0) {
  325. await new Promise((resolve) => setTimeout(resolve, 2000));
  326. }
  327. }
  328. }
  329.  
  330. console.error(
  331. `[VIP Font] Failed to fetch font table for ${fontName} after retries`
  332. );
  333. return null;
  334. }
  335.  
  336. /** Replace encrypted characters using font mapping table */
  337. function replaceEncryptedCharacters(text, fontTable) {
  338. if (!fontTable) return text;
  339.  
  340. let output = text;
  341.  
  342. // Replace each encrypted character with its normal equivalent
  343. for (const encryptedChar in fontTable) {
  344. if (fontTable.hasOwnProperty(encryptedChar)) {
  345. const normalChar = fontTable[encryptedChar];
  346. output = output.replaceAll(encryptedChar, normalChar);
  347. }
  348. }
  349.  
  350. // Remove zero-width non-joiner characters (ZWNJ)
  351. output = output.replace(/\u200c/g, "");
  352. output = output.replace(/&zwnj;/g, "");
  353.  
  354. return output;
  355. }
  356.  
  357. /** Main function to decrypt VIP chapter content */
  358. async function decryptVipContent(rawContent) {
  359. const fontInfo = detectVipFont();
  360. if (!fontInfo) {
  361. console.log(
  362. "[VIP Font] No font encryption detected, returning original content"
  363. );
  364. return { content: rawContent, fontTableMissing: false };
  365. }
  366.  
  367. console.log(`[VIP Font] Detected encrypted font: ${fontInfo.fontName}`);
  368.  
  369. const fontTable = await fetchFontTable(fontInfo.fontName);
  370. if (!fontTable) {
  371. console.warn(
  372. "[VIP Font] Could not load font table. Replacing encrypted characters (char + ZWNJ) with placeholder."
  373. );
  374. let modifiedContent = rawContent;
  375. // Replace a character followed by &zwnj; with [加密字符]
  376. modifiedContent = modifiedContent.replace(/.(?:&zwnj;)/g, "[加密字符]");
  377. // Replace a character followed by \u200c with [加密字符]
  378. modifiedContent = modifiedContent.replace(/.(?:\u200c)/g, "[加密字符]");
  379. return {
  380. content: modifiedContent,
  381. fontTableMissing: true,
  382. fontName: fontInfo.fontName,
  383. };
  384. }
  385.  
  386. const decryptedContent = replaceEncryptedCharacters(rawContent, fontTable);
  387. console.log(`[VIP Font] Successfully decrypted content using font table`);
  388.  
  389. return { content: decryptedContent, fontTableMissing: false };
  390. }
  391.  
  392. // --- Data Extraction ---
  393. /** Gets the chapter title */
  394. function updateTitleOutput() {
  395. const elements = getElementsByXpath(TITLE_XPATH);
  396. return elements.join("\n");
  397. }
  398. /** Extracts the main chapter content */
  399. async function updateContentOutput() {
  400. let container = document.querySelector(CONTENT_CONTAINER_SELECTOR);
  401. let isVipChapter = false;
  402.  
  403. // If regular container not found, assume it's a VIP chapter
  404. if (!container) {
  405. container = document.querySelector(CONTENT_CONTAINER_SELECTOR_VIP);
  406. isVipChapter = true;
  407. }
  408.  
  409. if (!container) {
  410. console.error(
  411. "Could not find the main content container (neither regular nor VIP)."
  412. );
  413. return "[Error: Cannot find content container]";
  414. }
  415.  
  416. const contentParts = [];
  417. let processingContent = false;
  418. let foundTitleDiv = false;
  419. let foundTitleClearDiv = false; // For VIP chapters, use simpler extraction logic
  420. if (isVipChapter) {
  421. // For VIP chapters, extract all text content directly
  422. const walker = document.createTreeWalker(
  423. container,
  424. NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
  425. {
  426. acceptNode: function (node) {
  427. if (node.nodeType === Node.TEXT_NODE) {
  428. return NodeFilter.FILTER_ACCEPT;
  429. } else if (node.nodeName === "BR") {
  430. return NodeFilter.FILTER_ACCEPT;
  431. }
  432. return NodeFilter.FILTER_SKIP;
  433. },
  434. }
  435. );
  436.  
  437. let node;
  438. while ((node = walker.nextNode())) {
  439. if (node.nodeType === Node.TEXT_NODE) {
  440. const text = node.textContent.trim();
  441. if (text) {
  442. contentParts.push(text);
  443. }
  444. } else if (node.nodeName === "BR") {
  445. contentParts.push("\n");
  446. }
  447. }
  448. } else {
  449. // Original logic for regular chapters
  450. const endMarkerFallback1 = container.querySelector(
  451. CONTENT_END_FALLBACK_SELECTOR_1
  452. );
  453. const endMarkerFallback2 = container.querySelector(
  454. CONTENT_END_FALLBACK_SELECTOR_2
  455. );
  456.  
  457. for (const childNode of container.childNodes) {
  458. // --- Fallback End Marker Check ---
  459. if (
  460. (endMarkerFallback1 && childNode === endMarkerFallback1) ||
  461. (endMarkerFallback2 && childNode === endMarkerFallback2)
  462. ) {
  463. processingContent = false;
  464. break;
  465. }
  466.  
  467. // --- State Management for Start ---
  468. if (
  469. !foundTitleDiv &&
  470. childNode.nodeType === Node.ELEMENT_NODE &&
  471. childNode.matches(CONTENT_START_TITLE_DIV_SELECTOR)
  472. ) {
  473. foundTitleDiv = true;
  474. continue;
  475. }
  476. if (
  477. foundTitleDiv &&
  478. !foundTitleClearDiv &&
  479. childNode.nodeType === Node.ELEMENT_NODE &&
  480. childNode.matches(CONTENT_START_CLEAR_DIV_SELECTOR)
  481. ) {
  482. foundTitleClearDiv = true;
  483. continue;
  484. }
  485. // Start processing *after* the clear:both div is found, unless the next node is already the end div
  486. if (foundTitleClearDiv && !processingContent) {
  487. if (
  488. childNode.nodeType === Node.ELEMENT_NODE &&
  489. childNode.tagName === CONTENT_END_DIV_TAG
  490. ) {
  491. break; // No content between clear:both and the first div
  492. }
  493. processingContent = true;
  494. }
  495.  
  496. // --- Content Extraction & Primary End Check ---
  497. if (processingContent) {
  498. if (childNode.nodeType === Node.TEXT_NODE) {
  499. contentParts.push(childNode.textContent);
  500. } else if (childNode.nodeName === "BR") {
  501. // Handle BR tags, allowing max two consecutive newlines
  502. if (
  503. contentParts.length === 0 ||
  504. !contentParts[contentParts.length - 1].endsWith("\n")
  505. ) {
  506. contentParts.push("\n");
  507. } else if (
  508. contentParts.length > 0 &&
  509. contentParts[contentParts.length - 1].endsWith("\n")
  510. ) {
  511. const lastPart = contentParts[contentParts.length - 1];
  512. if (!lastPart.endsWith("\n\n")) {
  513. contentParts.push("\n");
  514. }
  515. }
  516. } else if (
  517. childNode.nodeType === Node.ELEMENT_NODE &&
  518. childNode.tagName === CONTENT_END_DIV_TAG
  519. ) {
  520. // Stop processing when the first DIV element is encountered after content starts
  521. processingContent = false;
  522. break;
  523. }
  524. // Ignore other element types within the content
  525. }
  526. }
  527. } // Join and clean up
  528. let result = contentParts.join("");
  529. result = result.replace(/^[ \t\r\n]+/, ""); // Remove leading standard whitespace only
  530. result = result.replace(/\n{3,}/g, "\n\n"); // Collapse 3+ newlines into 2
  531. result = result.replace(/[\s\r\n]+$/, ""); // Remove trailing standard whitespace
  532.  
  533. // Apply font decryption for VIP chapters
  534. if (isVipChapter) {
  535. const decryptResult = await decryptVipContent(result);
  536. // Apply advertisement removal to decrypted content
  537. if (decryptResult.content) {
  538. decryptResult.content = removeAdvertisementText(decryptResult.content);
  539. }
  540. return decryptResult;
  541. }
  542.  
  543. // Apply advertisement removal to regular chapter content
  544. result = removeAdvertisementText(result);
  545. return { content: result, fontTableMissing: false };
  546. }
  547.  
  548. /** Gets the raw author say HTML from the hidden div */
  549. function getRawAuthorSayHtml() {
  550. const authorSayQuery = document.evaluate(
  551. AUTHOR_SAY_HIDDEN_XPATH,
  552. document,
  553. null,
  554. XPathResult.FIRST_ORDERED_NODE_TYPE,
  555. null
  556. );
  557. const authorSayNode = authorSayQuery.singleNodeValue;
  558. return authorSayNode ? authorSayNode.innerHTML.trim() : null;
  559. }
  560. /** Processes the raw author say HTML (removes cutoff text, converts <br>) */
  561. function processAuthorSayHtml(html) {
  562. if (!html) return "";
  563.  
  564. let processedHtml = html;
  565. const cutoffIndex = processedHtml.indexOf(AUTHOR_SAY_CUTOFF_TEXT);
  566. if (cutoffIndex !== -1) {
  567. processedHtml = processedHtml.substring(0, cutoffIndex);
  568. }
  569.  
  570. let processedText = processedHtml.replace(/<br\s*\/?>/g, "\n").trim();
  571.  
  572. // Apply advertisement removal to author say content
  573. processedText = removeAdvertisementText(processedText);
  574.  
  575. return processedText;
  576. }
  577. /** Main function to update the output textarea */
  578. async function updateOutput() {
  579. spinnerOverlay.style.display = "flex";
  580. setTimeout(async () => {
  581. let finalOutput = "";
  582. let rawAuthorSayHtml = null;
  583. let showFontError = false;
  584. try {
  585. const title = updateTitleOutput();
  586. const contentResult = await updateContentOutput();
  587. const content = contentResult.content || contentResult; // Handle both new and old format
  588. showFontError = contentResult.fontTableMissing || false;
  589.  
  590. rawAuthorSayHtml = getRawAuthorSayHtml(); // Get from hidden div
  591. const processedAuthorSay = processAuthorSayHtml(rawAuthorSayHtml);
  592.  
  593. finalOutput = title ? title + "\n\n" + content : content;
  594.  
  595. if (
  596. includeAuthorSay &&
  597. processedAuthorSay &&
  598. processedAuthorSay.length > 0
  599. ) {
  600. finalOutput +=
  601. "\n\n" + i18n.authorSaySeparator + "\n\n" + processedAuthorSay;
  602. }
  603.  
  604. output.value = finalOutput;
  605. } catch (error) {
  606. console.error("Error updating output:", error);
  607. output.value = "Error extracting content: " + error.message;
  608. } finally {
  609. // Show/hide font error message
  610. errorMessage.style.display = showFontError ? "block" : "none";
  611. // Update Author Say button state
  612. const authorSayExists = rawAuthorSayHtml && rawAuthorSayHtml.length > 0;
  613. authorSayButton.disabled = !authorSayExists;
  614. authorSayButton.style.backgroundColor = authorSayExists
  615. ? "#fbbc05"
  616. : "#ccc";
  617. authorSayButton.style.cursor = authorSayExists
  618. ? "pointer"
  619. : "not-allowed";
  620. authorSayButton.textContent = includeAuthorSay
  621. ? i18n.excludeAuthorSay
  622. : i18n.includeAuthorSay;
  623.  
  624. spinnerOverlay.style.display = "none";
  625. }
  626. }, 0);
  627. }
  628.  
  629. // --- Event Handlers ---
  630.  
  631. // Custom resize functionality
  632. let isResizing = false;
  633. let originalWidth, originalHeight, originalX, originalY;
  634.  
  635. function handleResizeMouseDown(e) {
  636. e.preventDefault();
  637. isResizing = true;
  638. originalWidth = parseFloat(getComputedStyle(gui).width);
  639. originalHeight = parseFloat(getComputedStyle(gui).height);
  640. originalX = e.clientX;
  641. originalY = e.clientY;
  642. document.addEventListener("mousemove", handleResizeMouseMove);
  643. document.addEventListener("mouseup", handleResizeMouseUp);
  644. }
  645.  
  646. function handleResizeMouseMove(e) {
  647. if (!isResizing) return;
  648. const width = originalWidth - (e.clientX - originalX);
  649. const height = originalHeight - (e.clientY - originalY);
  650. if (width > 300 && width < window.innerWidth * 0.8) {
  651. gui.style.width = width + "px";
  652. gui.style.right = getComputedStyle(gui).right; // Keep right fixed
  653. }
  654. if (height > 250 && height < window.innerHeight * 0.8) {
  655. gui.style.height = height + "px";
  656. gui.style.bottom = getComputedStyle(gui).bottom; // Keep bottom fixed
  657. }
  658. }
  659.  
  660. function handleResizeMouseUp() {
  661. isResizing = false;
  662. document.removeEventListener("mousemove", handleResizeMouseMove);
  663. document.removeEventListener("mouseup", handleResizeMouseUp);
  664. }
  665.  
  666. function handleCopyClick() {
  667. output.select();
  668. document.execCommand("copy");
  669. copyButton.textContent = i18n.copiedText;
  670. setTimeout(() => {
  671. copyButton.textContent = i18n.copyText;
  672. }, 1000);
  673. }
  674.  
  675. function handleAuthorSayToggle() {
  676. if (authorSayButton.disabled) return;
  677. includeAuthorSay = !includeAuthorSay;
  678. authorSayButton.textContent = includeAuthorSay
  679. ? i18n.excludeAuthorSay
  680. : i18n.includeAuthorSay;
  681. updateOutput(); // Re-render
  682. }
  683.  
  684. function handleNextChapterClick() {
  685. const nextChapterQuery = document.evaluate(
  686. NEXT_CHAPTER_XPATH,
  687. document,
  688. null,
  689. XPathResult.FIRST_ORDERED_NODE_TYPE,
  690. null
  691. );
  692. const nextChapterLink = nextChapterQuery.singleNodeValue;
  693. if (nextChapterLink && nextChapterLink.href) {
  694. window.location.href = nextChapterLink.href;
  695. } else {
  696. nextChapterButton.textContent = i18n.noNextChapter;
  697. nextChapterButton.style.backgroundColor = "#ea4335";
  698. setTimeout(() => {
  699. nextChapterButton.textContent = i18n.nextChapter;
  700. nextChapterButton.style.backgroundColor = "#34a853";
  701. }, 2000);
  702. }
  703. }
  704.  
  705. // --- Initialization ---
  706.  
  707. setupGUI(); // Create and append GUI elements
  708.  
  709. // Add event listeners
  710. resizeHandle.addEventListener("mousedown", handleResizeMouseDown);
  711. copyButton.addEventListener("click", handleCopyClick);
  712. authorSayButton.addEventListener("click", handleAuthorSayToggle);
  713. nextChapterButton.addEventListener("click", handleNextChapterClick);
  714.  
  715. // Initial content extraction
  716. updateOutput();
  717.  
  718. // Set up MutationObserver to re-run extraction if chapter content changes dynamically
  719. const chapterWrapperQuery = document.evaluate(
  720. CHAPTER_WRAPPER_XPATH,
  721. document,
  722. null,
  723. XPathResult.FIRST_ORDERED_NODE_TYPE,
  724. null
  725. );
  726. const chapterWrapper = chapterWrapperQuery.singleNodeValue;
  727. if (chapterWrapper) {
  728. const observer = new MutationObserver(() => {
  729. console.log("Chapter wrapper mutation detected, updating output.");
  730. updateOutput();
  731. });
  732. observer.observe(chapterWrapper, {
  733. childList: true,
  734. subtree: true,
  735. characterData: true,
  736. });
  737. } else {
  738. console.error("Chapter wrapper element not found for MutationObserver.");
  739. }
  740. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址