您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
从晋江下载章节文本
- // ==UserScript==
- // @name Jinjiang Chapter Downloader
- // @name:zh-CN 晋江章节下载器
- // @namespace http://tampermonkey.net/
- // @version 0.7
- // @description Download chapter content from JinJiang (jjwxc.net)
- // @description:zh-CN 从晋江下载章节文本
- // @author oovz
- // @match *://www.jjwxc.net/onebook.php?novelid=*&chapterid=*
- // @match *://my.jjwxc.net/onebook_vip.php?novelid=*&chapterid=*
- // @grant none
- // @source https://gist.github.com/oovz/5eaabb8adecadac515d13d261fbb93b5
- // @source https://gf.qytechs.cn/en/scripts/532897-jinjiang-chapter-downloader
- // @license MIT
- // ==/UserScript==
- (function () {
- "use strict";
- // --- Configuration ---
- const TITLE_XPATH = '//div[@class="novelbody"]//h2';
- const CONTENT_CONTAINER_SELECTOR =
- '.novelbody > div[style*="font-size: 16px"]'; // Selector for the main content div
- const CONTENT_START_TITLE_DIV_SELECTOR = 'div[align="center"]'; // Title div within the container
- const CONTENT_START_CLEAR_DIV_SELECTOR = 'div[style*="clear:both"]'; // Div marking start of content after title div
- const CONTENT_END_DIV_TAG = "DIV"; // First DIV tag encountered after content starts marks the end
- const CONTENT_END_FALLBACK_SELECTOR_1 = "#favoriteshow_3"; // Fallback end marker
- const CONTENT_END_FALLBACK_SELECTOR_2 = "#note_danmu_wrapper"; // Fallback end marker (author say wrapper)
- const CONTENT_CONTAINER_SELECTOR_VIP = "div[id^=content_]"; // Selector for the main content div
- const AUTHOR_SAY_HIDDEN_XPATH = '//div[@id="note_str"]'; // Hidden div containing raw author say HTML
- const AUTHOR_SAY_CUTOFF_TEXT = "谢谢各位大人的霸王票"; // Text to truncate author say at
- const NEXT_CHAPTER_XPATH =
- '//div[@id="oneboolt"]/div[@class="noveltitle"]/span/a[span][last()]'; // Next chapter link
- const CHAPTER_WRAPPER_XPATH = '//div[@class="novelbody"]'; // Wrapper for MutationObserver
- const AD_1 = "@无限好文,尽在晋江文学城";
- // Additional advertisement texts that might appear in chapters
- const ADVERTISEMENT_TEXTS = [AD_1];
- // --- Internationalization ---
- const isZhCN =
- navigator.language.toLowerCase() === "zh-cn" ||
- document.documentElement.lang.toLowerCase() === "zh-cn";
- const i18n = {
- copyText: isZhCN ? "复制文本" : "Copy Content",
- copiedText: isZhCN ? "已复制!" : "Copied!",
- nextChapter: isZhCN ? "下一章" : "Next Chapter",
- noNextChapter: isZhCN ? "没有下一章" : "No Next Chapter",
- includeAuthorSay: isZhCN ? "包含作话" : "Include Author Say",
- excludeAuthorSay: isZhCN ? "排除作话" : "Exclude Author Say",
- authorSaySeparator: isZhCN ? "--- 作者有话说 ---" : "--- Author Say ---",
- };
- // --- State ---
- let includeAuthorSay = true; // Default to including author say
- // --- Utilities ---
- /**
- * Extracts text content from elements matching an XPath.
- * Special handling for title to trim whitespace.
- */
- function getElementsByXpath(xpath) {
- const results = [];
- const query = document.evaluate(
- xpath,
- document,
- null,
- XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
- null
- );
- for (let i = 0; i < query.snapshotLength; i++) {
- const node = query.snapshotItem(i);
- if (node) {
- let directTextContent = "";
- for (let j = 0; j < node.childNodes.length; j++) {
- const childNode = node.childNodes[j];
- if (childNode.nodeType === Node.TEXT_NODE) {
- directTextContent += childNode.textContent;
- }
- }
- if (xpath === TITLE_XPATH) {
- directTextContent = directTextContent.trim();
- }
- if (directTextContent) {
- results.push(directTextContent);
- }
- }
- }
- return results;
- }
- // --- GUI Creation ---
- const gui = document.createElement("div");
- const style = document.createElement("style");
- const resizeHandle = document.createElement("div");
- const errorMessage = document.createElement("div");
- const output = document.createElement("textarea");
- const buttonContainer = document.createElement("div");
- const copyButton = document.createElement("button");
- const authorSayButton = document.createElement("button");
- const nextChapterButton = document.createElement("button");
- const spinnerOverlay = document.createElement("div");
- const spinner = document.createElement("div");
- function setupGUI() {
- gui.style.cssText = `
- position: fixed; bottom: 20px; right: 20px; background: white; padding: 15px;
- border: 1px solid #ccc; border-radius: 5px; box-shadow: 0 0 10px rgba(0,0,0,0.1);
- z-index: 9999; resize: both; overflow: visible; min-width: 350px; min-height: 250px;
- max-width: 100vw; max-height: 80vh; resize-origin: top-left; display: flex; flex-direction: column;
- `;
- style.textContent = `
- @keyframes spin { to { transform: rotate(360deg); } }
- .resize-handle {
- position: absolute; width: 14px; height: 14px; top: 0; left: 0; cursor: nwse-resize;
- z-index: 10000; background-color: #888; border-top-left-radius: 5px;
- border-right: 1px solid #ccc; border-bottom: 1px solid #ccc;
- }
- .spinner-overlay {
- position: absolute; top: 0; left: 0; width: 100%; height: 100%;
- background-color: rgba(240, 240, 240, 0.8); display: none; justify-content: center;
- align-items: center; z-index: 10001;
- }
- .font-error-message {
- background-color: #ffeaa7; border: 1px solid #fdcb6e; border-radius: 4px;
- padding: 8px 12px; margin-bottom: 8px; font-size: 0.9em; color: #2d3436;
- display: none; line-height: 1.4;
- }
- `;
- document.head.appendChild(style);
- resizeHandle.className = "resize-handle";
- output.style.cssText = `
- width: 100%; flex: 1; margin-bottom: 8px; resize: none; overflow: auto;
- box-sizing: border-box; min-height: 180px;
- `;
- output.readOnly = true;
- buttonContainer.style.cssText = `display: flex; justify-content: center; gap: 10px; margin-bottom: 2px;`;
- copyButton.textContent = i18n.copyText;
- 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;`;
- authorSayButton.textContent = includeAuthorSay
- ? i18n.excludeAuthorSay
- : i18n.includeAuthorSay;
- 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;`;
- authorSayButton.disabled = true;
- nextChapterButton.textContent = i18n.nextChapter;
- 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;`;
- buttonContainer.appendChild(authorSayButton);
- buttonContainer.appendChild(copyButton);
- buttonContainer.appendChild(nextChapterButton);
- errorMessage.className = "font-error-message";
- errorMessage.innerHTML = isZhCN
- ? "⚠️ VIP章节字体解密表未找到,内容可能无法正确解密。"
- : "⚠️ VIP chapter font table not found, content may not be properly decrypted.";
- spinnerOverlay.className = "spinner-overlay";
- 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;`;
- spinnerOverlay.appendChild(spinner);
- gui.appendChild(resizeHandle);
- gui.appendChild(errorMessage);
- gui.appendChild(output);
- gui.appendChild(buttonContainer);
- gui.appendChild(spinnerOverlay);
- document.body.appendChild(gui);
- }
- // --- Advertisement Text Removal ---
- /**
- * Removes advertisement text from content
- * @param {string} content - The content to clean
- * @param {string[]} adTexts - Array of advertisement texts to remove
- * @returns {string} Cleaned content
- */ function removeAdvertisementText(content, adTexts = ADVERTISEMENT_TEXTS) {
- if (!content || !adTexts || adTexts.length === 0) {
- return content;
- }
- let cleanedContent = content;
- let removedCount = 0;
- for (const adText of adTexts) {
- if (!adText) continue;
- // Count occurrences before removal
- const beforeLength = cleanedContent.length;
- // Remove exact matches of the advertisement text
- cleanedContent = cleanedContent.replaceAll(adText, "");
- // Also remove the advertisement text with common surrounding punctuation/whitespace
- const adPatterns = [
- new RegExp(`\\s*${escapeRegExp(adText)}\\s*`, "g"),
- new RegExp(`^\\s*${escapeRegExp(adText)}\\s*`, "gm"), // At start of line
- new RegExp(`\\s*${escapeRegExp(adText)}\\s*$`, "gm"), // At end of line
- ];
- for (const pattern of adPatterns) {
- cleanedContent = cleanedContent.replace(pattern, "");
- }
- // Check if any removal occurred
- const afterLength = cleanedContent.length;
- if (afterLength < beforeLength) {
- removedCount++;
- console.log(`[Advertisement Removal] Removed "${adText}" from content`);
- }
- }
- // Clean up any excessive whitespace that might remain after ad removal
- cleanedContent = cleanedContent.replace(/\n{3,}/g, "\n\n"); // Collapse 3+ newlines into 2
- cleanedContent = cleanedContent.replace(/^[ \t\r\n]+/, ""); // Remove leading whitespace
- cleanedContent = cleanedContent.replace(/[\s\r\n]+$/, ""); // Remove trailing whitespace
- if (removedCount > 0) {
- console.log(
- `[Advertisement Removal] Successfully removed ${removedCount} advertisement patterns from content`
- );
- }
- return cleanedContent;
- }
- /**
- * Escapes special regex characters for use in RegExp constructor
- * @param {string} string - String to escape
- * @returns {string} Escaped string
- */
- function escapeRegExp(string) {
- return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- }
- // --- VIP Font Decryption Functions ---
- /** Detect font name and URL from VIP chapter CSS styles */
- function detectVipFont() {
- // Method 1: Check CSS rules in style sheets
- const styles = document.querySelectorAll("body > style");
- for (const style of styles) {
- if (style.sheet && style.sheet.cssRules) {
- try {
- const rules = style.sheet.cssRules;
- for (let i = 0; i < rules.length; i++) {
- const rule = rules[i];
- if (rule.cssText) {
- const fontNameMatch = rule.cssText.match(/jjwxcfont_[\d\w]+/);
- const cssContentMatch = rule.cssText.match(/{(.*)}/);
- if (fontNameMatch && cssContentMatch) {
- const fontName = fontNameMatch[0];
- const cssContent = cssContentMatch[1];
- // Look for font URL in CSS content
- for (const part of cssContent.split(",")) {
- if (part.includes('format("woff2")')) {
- const urlMatch = part.match(/url\("(.*)"\)\s/);
- if (urlMatch) {
- const fontUrl = document.location.protocol + urlMatch[1];
- return { fontName, fontUrl };
- }
- }
- }
- }
- }
- }
- } catch (e) {
- console.debug("Error accessing stylesheet:", e);
- }
- }
- }
- // Method 2: Check div.noveltext classes for font name
- const noveltextDiv = document.querySelector("div.noveltext");
- if (noveltextDiv && noveltextDiv.classList) {
- const fontClass = Array.from(noveltextDiv.classList).find((className) =>
- className.startsWith("jjwxcfont_")
- );
- if (fontClass) {
- const fontUrl = `${document.location.protocol}//static.jjwxc.net/tmp/fonts/${fontClass}.woff2?h=my.jjwxc.net`;
- return { fontName: fontClass, fontUrl };
- }
- }
- return null;
- }
- /** Fetch font mapping table from remote repository */
- async function fetchFontTable(fontName) {
- const url = `https://fastly.jsdelivr.net/gh/404-novel-project/jinjiang_font_tables@master/${fontName}.woff2.json`;
- const fontLink = `https://static.jjwxc.net/tmp/fonts/${fontName}.woff2?h=my.jjwxc.net`;
- console.log(`[VIP Font] Fetching font table for ${fontName}`);
- let retryCount = 3;
- while (retryCount > 0) {
- try {
- const response = await fetch(url);
- if (response.ok) {
- const fontTable = await response.json();
- console.log(
- `[VIP Font] Successfully loaded font table for ${fontName}`
- );
- return fontTable;
- } else if (response.status === 404) {
- console.warn(
- `[VIP Font] Font table not found for ${fontName}. Please submit font link to https://github.com/404-novel-project/jinjiang_font_tables: ${fontLink}`
- );
- return null;
- }
- } catch (error) {
- console.error(`[VIP Font] Error fetching font table:`, error);
- retryCount--;
- if (retryCount > 0) {
- await new Promise((resolve) => setTimeout(resolve, 2000));
- }
- }
- }
- console.error(
- `[VIP Font] Failed to fetch font table for ${fontName} after retries`
- );
- return null;
- }
- /** Replace encrypted characters using font mapping table */
- function replaceEncryptedCharacters(text, fontTable) {
- if (!fontTable) return text;
- let output = text;
- // Replace each encrypted character with its normal equivalent
- for (const encryptedChar in fontTable) {
- if (fontTable.hasOwnProperty(encryptedChar)) {
- const normalChar = fontTable[encryptedChar];
- output = output.replaceAll(encryptedChar, normalChar);
- }
- }
- // Remove zero-width non-joiner characters (ZWNJ)
- output = output.replace(/\u200c/g, "");
- output = output.replace(/‌/g, "");
- return output;
- }
- /** Main function to decrypt VIP chapter content */
- async function decryptVipContent(rawContent) {
- const fontInfo = detectVipFont();
- if (!fontInfo) {
- console.log(
- "[VIP Font] No font encryption detected, returning original content"
- );
- return { content: rawContent, fontTableMissing: false };
- }
- console.log(`[VIP Font] Detected encrypted font: ${fontInfo.fontName}`);
- const fontTable = await fetchFontTable(fontInfo.fontName);
- if (!fontTable) {
- console.warn(
- "[VIP Font] Could not load font table. Replacing encrypted characters (char + ZWNJ) with placeholder."
- );
- let modifiedContent = rawContent;
- // Replace a character followed by ‌ with [加密字符]
- modifiedContent = modifiedContent.replace(/.(?:‌)/g, "[加密字符]");
- // Replace a character followed by \u200c with [加密字符]
- modifiedContent = modifiedContent.replace(/.(?:\u200c)/g, "[加密字符]");
- return {
- content: modifiedContent,
- fontTableMissing: true,
- fontName: fontInfo.fontName,
- };
- }
- const decryptedContent = replaceEncryptedCharacters(rawContent, fontTable);
- console.log(`[VIP Font] Successfully decrypted content using font table`);
- return { content: decryptedContent, fontTableMissing: false };
- }
- // --- Data Extraction ---
- /** Gets the chapter title */
- function updateTitleOutput() {
- const elements = getElementsByXpath(TITLE_XPATH);
- return elements.join("\n");
- }
- /** Extracts the main chapter content */
- async function updateContentOutput() {
- let container = document.querySelector(CONTENT_CONTAINER_SELECTOR);
- let isVipChapter = false;
- // If regular container not found, assume it's a VIP chapter
- if (!container) {
- container = document.querySelector(CONTENT_CONTAINER_SELECTOR_VIP);
- isVipChapter = true;
- }
- if (!container) {
- console.error(
- "Could not find the main content container (neither regular nor VIP)."
- );
- return "[Error: Cannot find content container]";
- }
- const contentParts = [];
- let processingContent = false;
- let foundTitleDiv = false;
- let foundTitleClearDiv = false; // For VIP chapters, use simpler extraction logic
- if (isVipChapter) {
- // For VIP chapters, extract all text content directly
- const walker = document.createTreeWalker(
- container,
- NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
- {
- acceptNode: function (node) {
- if (node.nodeType === Node.TEXT_NODE) {
- return NodeFilter.FILTER_ACCEPT;
- } else if (node.nodeName === "BR") {
- return NodeFilter.FILTER_ACCEPT;
- }
- return NodeFilter.FILTER_SKIP;
- },
- }
- );
- let node;
- while ((node = walker.nextNode())) {
- if (node.nodeType === Node.TEXT_NODE) {
- const text = node.textContent.trim();
- if (text) {
- contentParts.push(text);
- }
- } else if (node.nodeName === "BR") {
- contentParts.push("\n");
- }
- }
- } else {
- // Original logic for regular chapters
- const endMarkerFallback1 = container.querySelector(
- CONTENT_END_FALLBACK_SELECTOR_1
- );
- const endMarkerFallback2 = container.querySelector(
- CONTENT_END_FALLBACK_SELECTOR_2
- );
- for (const childNode of container.childNodes) {
- // --- Fallback End Marker Check ---
- if (
- (endMarkerFallback1 && childNode === endMarkerFallback1) ||
- (endMarkerFallback2 && childNode === endMarkerFallback2)
- ) {
- processingContent = false;
- break;
- }
- // --- State Management for Start ---
- if (
- !foundTitleDiv &&
- childNode.nodeType === Node.ELEMENT_NODE &&
- childNode.matches(CONTENT_START_TITLE_DIV_SELECTOR)
- ) {
- foundTitleDiv = true;
- continue;
- }
- if (
- foundTitleDiv &&
- !foundTitleClearDiv &&
- childNode.nodeType === Node.ELEMENT_NODE &&
- childNode.matches(CONTENT_START_CLEAR_DIV_SELECTOR)
- ) {
- foundTitleClearDiv = true;
- continue;
- }
- // Start processing *after* the clear:both div is found, unless the next node is already the end div
- if (foundTitleClearDiv && !processingContent) {
- if (
- childNode.nodeType === Node.ELEMENT_NODE &&
- childNode.tagName === CONTENT_END_DIV_TAG
- ) {
- break; // No content between clear:both and the first div
- }
- processingContent = true;
- }
- // --- Content Extraction & Primary End Check ---
- if (processingContent) {
- if (childNode.nodeType === Node.TEXT_NODE) {
- contentParts.push(childNode.textContent);
- } else if (childNode.nodeName === "BR") {
- // Handle BR tags, allowing max two consecutive newlines
- if (
- contentParts.length === 0 ||
- !contentParts[contentParts.length - 1].endsWith("\n")
- ) {
- contentParts.push("\n");
- } else if (
- contentParts.length > 0 &&
- contentParts[contentParts.length - 1].endsWith("\n")
- ) {
- const lastPart = contentParts[contentParts.length - 1];
- if (!lastPart.endsWith("\n\n")) {
- contentParts.push("\n");
- }
- }
- } else if (
- childNode.nodeType === Node.ELEMENT_NODE &&
- childNode.tagName === CONTENT_END_DIV_TAG
- ) {
- // Stop processing when the first DIV element is encountered after content starts
- processingContent = false;
- break;
- }
- // Ignore other element types within the content
- }
- }
- } // Join and clean up
- let result = contentParts.join("");
- result = result.replace(/^[ \t\r\n]+/, ""); // Remove leading standard whitespace only
- result = result.replace(/\n{3,}/g, "\n\n"); // Collapse 3+ newlines into 2
- result = result.replace(/[\s\r\n]+$/, ""); // Remove trailing standard whitespace
- // Apply font decryption for VIP chapters
- if (isVipChapter) {
- const decryptResult = await decryptVipContent(result);
- // Apply advertisement removal to decrypted content
- if (decryptResult.content) {
- decryptResult.content = removeAdvertisementText(decryptResult.content);
- }
- return decryptResult;
- }
- // Apply advertisement removal to regular chapter content
- result = removeAdvertisementText(result);
- return { content: result, fontTableMissing: false };
- }
- /** Gets the raw author say HTML from the hidden div */
- function getRawAuthorSayHtml() {
- const authorSayQuery = document.evaluate(
- AUTHOR_SAY_HIDDEN_XPATH,
- document,
- null,
- XPathResult.FIRST_ORDERED_NODE_TYPE,
- null
- );
- const authorSayNode = authorSayQuery.singleNodeValue;
- return authorSayNode ? authorSayNode.innerHTML.trim() : null;
- }
- /** Processes the raw author say HTML (removes cutoff text, converts <br>) */
- function processAuthorSayHtml(html) {
- if (!html) return "";
- let processedHtml = html;
- const cutoffIndex = processedHtml.indexOf(AUTHOR_SAY_CUTOFF_TEXT);
- if (cutoffIndex !== -1) {
- processedHtml = processedHtml.substring(0, cutoffIndex);
- }
- let processedText = processedHtml.replace(/<br\s*\/?>/g, "\n").trim();
- // Apply advertisement removal to author say content
- processedText = removeAdvertisementText(processedText);
- return processedText;
- }
- /** Main function to update the output textarea */
- async function updateOutput() {
- spinnerOverlay.style.display = "flex";
- setTimeout(async () => {
- let finalOutput = "";
- let rawAuthorSayHtml = null;
- let showFontError = false;
- try {
- const title = updateTitleOutput();
- const contentResult = await updateContentOutput();
- const content = contentResult.content || contentResult; // Handle both new and old format
- showFontError = contentResult.fontTableMissing || false;
- rawAuthorSayHtml = getRawAuthorSayHtml(); // Get from hidden div
- const processedAuthorSay = processAuthorSayHtml(rawAuthorSayHtml);
- finalOutput = title ? title + "\n\n" + content : content;
- if (
- includeAuthorSay &&
- processedAuthorSay &&
- processedAuthorSay.length > 0
- ) {
- finalOutput +=
- "\n\n" + i18n.authorSaySeparator + "\n\n" + processedAuthorSay;
- }
- output.value = finalOutput;
- } catch (error) {
- console.error("Error updating output:", error);
- output.value = "Error extracting content: " + error.message;
- } finally {
- // Show/hide font error message
- errorMessage.style.display = showFontError ? "block" : "none";
- // Update Author Say button state
- const authorSayExists = rawAuthorSayHtml && rawAuthorSayHtml.length > 0;
- authorSayButton.disabled = !authorSayExists;
- authorSayButton.style.backgroundColor = authorSayExists
- ? "#fbbc05"
- : "#ccc";
- authorSayButton.style.cursor = authorSayExists
- ? "pointer"
- : "not-allowed";
- authorSayButton.textContent = includeAuthorSay
- ? i18n.excludeAuthorSay
- : i18n.includeAuthorSay;
- spinnerOverlay.style.display = "none";
- }
- }, 0);
- }
- // --- Event Handlers ---
- // Custom resize functionality
- let isResizing = false;
- let originalWidth, originalHeight, originalX, originalY;
- function handleResizeMouseDown(e) {
- e.preventDefault();
- isResizing = true;
- originalWidth = parseFloat(getComputedStyle(gui).width);
- originalHeight = parseFloat(getComputedStyle(gui).height);
- originalX = e.clientX;
- originalY = e.clientY;
- document.addEventListener("mousemove", handleResizeMouseMove);
- document.addEventListener("mouseup", handleResizeMouseUp);
- }
- function handleResizeMouseMove(e) {
- if (!isResizing) return;
- const width = originalWidth - (e.clientX - originalX);
- const height = originalHeight - (e.clientY - originalY);
- if (width > 300 && width < window.innerWidth * 0.8) {
- gui.style.width = width + "px";
- gui.style.right = getComputedStyle(gui).right; // Keep right fixed
- }
- if (height > 250 && height < window.innerHeight * 0.8) {
- gui.style.height = height + "px";
- gui.style.bottom = getComputedStyle(gui).bottom; // Keep bottom fixed
- }
- }
- function handleResizeMouseUp() {
- isResizing = false;
- document.removeEventListener("mousemove", handleResizeMouseMove);
- document.removeEventListener("mouseup", handleResizeMouseUp);
- }
- function handleCopyClick() {
- output.select();
- document.execCommand("copy");
- copyButton.textContent = i18n.copiedText;
- setTimeout(() => {
- copyButton.textContent = i18n.copyText;
- }, 1000);
- }
- function handleAuthorSayToggle() {
- if (authorSayButton.disabled) return;
- includeAuthorSay = !includeAuthorSay;
- authorSayButton.textContent = includeAuthorSay
- ? i18n.excludeAuthorSay
- : i18n.includeAuthorSay;
- updateOutput(); // Re-render
- }
- function handleNextChapterClick() {
- const nextChapterQuery = document.evaluate(
- NEXT_CHAPTER_XPATH,
- document,
- null,
- XPathResult.FIRST_ORDERED_NODE_TYPE,
- null
- );
- const nextChapterLink = nextChapterQuery.singleNodeValue;
- if (nextChapterLink && nextChapterLink.href) {
- window.location.href = nextChapterLink.href;
- } else {
- nextChapterButton.textContent = i18n.noNextChapter;
- nextChapterButton.style.backgroundColor = "#ea4335";
- setTimeout(() => {
- nextChapterButton.textContent = i18n.nextChapter;
- nextChapterButton.style.backgroundColor = "#34a853";
- }, 2000);
- }
- }
- // --- Initialization ---
- setupGUI(); // Create and append GUI elements
- // Add event listeners
- resizeHandle.addEventListener("mousedown", handleResizeMouseDown);
- copyButton.addEventListener("click", handleCopyClick);
- authorSayButton.addEventListener("click", handleAuthorSayToggle);
- nextChapterButton.addEventListener("click", handleNextChapterClick);
- // Initial content extraction
- updateOutput();
- // Set up MutationObserver to re-run extraction if chapter content changes dynamically
- const chapterWrapperQuery = document.evaluate(
- CHAPTER_WRAPPER_XPATH,
- document,
- null,
- XPathResult.FIRST_ORDERED_NODE_TYPE,
- null
- );
- const chapterWrapper = chapterWrapperQuery.singleNodeValue;
- if (chapterWrapper) {
- const observer = new MutationObserver(() => {
- console.log("Chapter wrapper mutation detected, updating output.");
- updateOutput();
- });
- observer.observe(chapterWrapper, {
- childList: true,
- subtree: true,
- characterData: true,
- });
- } else {
- console.error("Chapter wrapper element not found for MutationObserver.");
- }
- })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址