- // ==UserScript==
- // @name Jinjiang Chapter Downloader
- // @name:zh-CN 晋江章节下载器
- // @namespace http://tampermonkey.net/
- // @version 0.6
- // @description Download chapter content from JinJiang (jjwxc.net)
- // @description:zh-CN 从晋江下载章节文本
- // @author oovz
- // @match *://www.jjwxc.net/onebook.php?novelid=*&chapterid=*
- // @grant none
- // @source https://gist.github.com/oovz/5eaabb8adecadac515d13d261fbb93b5
- // @source https://greasyfork.org/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 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
-
- // --- 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 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;
- }
- `;
- 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);
-
- 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(output);
- gui.appendChild(buttonContainer);
- gui.appendChild(spinnerOverlay);
- document.body.appendChild(gui);
- }
-
- // --- Data Extraction ---
-
- /** Gets the chapter title */
- function updateTitleOutput() {
- const elements = getElementsByXpath(TITLE_XPATH);
- return elements.join('\n');
- }
-
- /** Extracts the main chapter content */
- function updateContentOutput() {
- const container = document.querySelector(CONTENT_CONTAINER_SELECTOR);
- if (!container) {
- console.error("Could not find the main content container.");
- return "[Error: Cannot find content container]";
- }
-
- const contentParts = [];
- let processingContent = false;
- let foundTitleDiv = false;
- let foundTitleClearDiv = false;
-
- 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
-
- return result;
- }
-
- /** 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);
- }
-
- return processedHtml
- .replace(/<br\s*\/?>/g, '\n')
- .trim();
- }
-
- /** Main function to update the output textarea */
- function updateOutput() {
- spinnerOverlay.style.display = 'flex';
-
- setTimeout(() => {
- let finalOutput = '';
- let rawAuthorSayHtml = null;
- try {
- const title = updateTitleOutput();
- const content = updateContentOutput();
- 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 {
- // 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.');
- }
-
- })();