您需要先安装一个扩展,例如 篡改猴、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或关注我们的公众号极客氢云获取最新地址