Jinjiang Chapter Downloader

Download chapter content from JinJiang (jjwxc.net)

// ==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.');
    }

})();