Qidian Chapter Downloader

Download chapter content from Qidian (qidian.com)

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name                Qidian Chapter Downloader
// @name:zh-CN          起点(qidian.com)章节下载器
// @namespace           http://tampermonkey.net/
// @version             0.8
// @description         Download chapter content from Qidian (qidian.com)
// @description:zh-CN   从起点下载章节文本
// @author              oovz
// @match               https://www.qidian.com/chapter/*
// @grant               none
// @source              https://gist.github.com/oovz/3257e1acd16ef2fa2913b430d95dc283
// @source              https://greasyfork.org/en/scripts/531290-qidian-chapter-downloader
// @license             MIT
// ==/UserScript==

(function() {
    'use strict';

    // Configure your XPath here
    const TITLE_XPATH = '//div[contains(@class, "chapter-wrapper")]//div[contains(@class, "print")]//h1'; // Fill this with your XPath
    const CONTENT_XPATH = '//div[contains(@class, "chapter-wrapper")]//div[contains(@class, "print")]//main/p'; // Base path to p elements
    const CONTENT_SPAN_XPATH = '//div[contains(@class, "chapter-wrapper")]//div[contains(@class, "print")]//main/p/span[@class="content-text"]'; // For p with span structure
    const CHAPTER_WRAPPER_XPATH = '//div[contains(@class, "chapter-wrapper")]';
    const NEXT_CHAPTER_BUTTON_XPATH = '//div[@class="nav-btn-group"]/a[last()]';
    const AUTHOR_SAY_XPATH = '//section[@id="r-authorSay"]//p[@class="trans"]'; // XPath for author say

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

    // State variable for author say inclusion
    let includeAuthorSay = true;

    // Create GUI elements
    const gui = document.createElement('div');
    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;
    `;

    // Add CSS for custom resize handle at top-left
    const style = document.createElement('style');
    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);

    // Create resize handle
    const resizeHandle = document.createElement('div');
    resizeHandle.className = 'resize-handle';
    
    const output = document.createElement('textarea');
    output.style.cssText = `
        width: 100%;
        flex: 1;
        margin-bottom: 8px;
        resize: none;
        overflow: auto;
        box-sizing: border-box;
        min-height: 180px;
    `;
    output.readOnly = true;

    // Create button container for horizontal layout
    const buttonContainer = document.createElement('div');
    buttonContainer.style.cssText = `
        display: flex;
        justify-content: center;
        gap: 10px;
        margin-bottom: 2px;
    `;

    // Create toggle author say button
    const toggleAuthorSayButton = document.createElement('button');
    toggleAuthorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay;
    toggleAuthorSayButton.style.cssText = `
        padding: 4px 12px;
        cursor: pointer;
        background-color: #fbbc05; /* Yellow */
        color: white;
        border: none;
        border-radius: 15px;
        font-weight: bold;
        font-size: 0.9em;
    `;

    const copyButton = document.createElement('button');
    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;
    `;

    // Create next chapter button
    const nextChapterButton = document.createElement('button');
    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;
    `;

    // Add buttons to container
    buttonContainer.appendChild(toggleAuthorSayButton);
    buttonContainer.appendChild(copyButton);
    buttonContainer.appendChild(nextChapterButton);

    // Create spinner overlay for better positioning
    const spinnerOverlay = document.createElement('div');
    spinnerOverlay.className = 'spinner-overlay';
    
    // Create spinner
    const spinner = document.createElement('div');
    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);

    // Add elements to GUI
    gui.appendChild(resizeHandle);
    gui.appendChild(output);
    gui.appendChild(buttonContainer);
    gui.appendChild(spinnerOverlay);
    document.body.appendChild(gui);

    // Custom resize functionality
    let isResizing = false;
    let originalWidth, originalHeight, originalX, originalY;

    resizeHandle.addEventListener('mousedown', (e) => {
        e.preventDefault();
        isResizing = true;
        originalWidth = parseFloat(getComputedStyle(gui).width);
        originalHeight = parseFloat(getComputedStyle(gui).height);
        originalX = e.clientX;
        originalY = e.clientY;
        
        document.addEventListener('mousemove', resize);
        document.addEventListener('mouseup', stopResize);
    });

    function resize(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';
            // Keep right position fixed and adjust left position
            gui.style.right = getComputedStyle(gui).right;
        }
        
        if (height > 250 && height < window.innerHeight * 0.8) {
            gui.style.height = height + 'px';
            // Keep bottom position fixed and adjust top position
            gui.style.bottom = getComputedStyle(gui).bottom;
        }
    }

    function stopResize() {
        isResizing = false;
        document.removeEventListener('mousemove', resize);
        document.removeEventListener('mouseup', stopResize);
    }

    // Extract text function
    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) {
                // Get only direct text content and exclude child elements for title
                if (xpath === TITLE_XPATH) {
                    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;
                        }
                    }
                    directTextContent = directTextContent.trim();
                    if (directTextContent) {
                        results.push(directTextContent);
                    }
                } else {
                    // For content and author say, get full text content including children, preserving whitespace
                    const textContent = node.textContent; // Keep original whitespace
                    // Only push if the content is not just whitespace
                    if (textContent && textContent.trim()) {
                        results.push(textContent);
                    }
                }
            }
        }
        return results;
    }

    // Initial extraction
    function updateTitleOutput() {
        const elements = getElementsByXpath(TITLE_XPATH);
        return elements.join('\n');
    }

    function updateContentOutput(includeAuthorSayFlag) {
        // Try to get content from spans first
        let elements = getElementsByXpath(CONTENT_SPAN_XPATH);
        
        // If no spans found, try direct p tags but filter out those with spans to avoid duplications
        if (elements.length === 0) {
            // First, get all p elements
            const pElements = document.evaluate(
                CONTENT_XPATH,
                document,
                null,
                XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
                null
            );
            
            for (let i = 0; i < pElements.snapshotLength; i++) {
                const pNode = pElements.snapshotItem(i);
                // Check if this p element has span children
                const hasSpans = pNode.querySelectorAll('span').length > 0;
                
                if (!hasSpans) {
                    // Only get text from p elements that don't have spans
                    const textContent = pNode.textContent; // Keep original whitespace
                    // Only push if the content is not just whitespace
                    if (textContent && textContent.trim()) {
                        elements.push(textContent);
                    }
                }
            }
        }
        
        // Join elements, do not trim here to preserve first line indentation
        let content = elements.join('\n');

        // Append author say if requested
        if (includeAuthorSayFlag) {
            const authorSayElements = getElementsByXpath(AUTHOR_SAY_XPATH);
            if (authorSayElements.length > 0) {
                // Join author say elements, do not trim here
                const authorSayContent = authorSayElements.join('\n');
                // Add separation if both content and author say exist and are not just whitespace
                if (content.trim() && authorSayContent.trim()) {
                    content += '\n\n---\n\n' + authorSayContent; // Add separator
                } else if (authorSayContent.trim()) { // Only author say exists (and is not just whitespace)
                    content = authorSayContent;
                }
            }
        }

        return content; // Return potentially leading-whitespace content
    }

    // Async update function
    async function updateOutput() {
        // Show spinner overlay
        spinnerOverlay.style.display = 'flex';
        
        // Use setTimeout to make it async and not block the UI
        setTimeout(() => {
            try {
                const title = updateTitleOutput();
                const content = updateContentOutput(includeAuthorSay); // Pass the state
                output.value = title ? title + '\n\n' + content : content;
            } catch (error) {
                console.error('Error updating output:', error);
            } finally {
                // Hide spinner when done
                spinnerOverlay.style.display = 'none';
            }
        }, 0);
    }

    // Run initial extraction
    updateOutput();

    // Add event listener for toggle author say button
    toggleAuthorSayButton.addEventListener('click', () => {
        includeAuthorSay = !includeAuthorSay; // Toggle state
        toggleAuthorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay;
        updateOutput(); // Update the content
    });

    // Add event listener for copy button
    copyButton.addEventListener('click', () => {
        output.select();
        document.execCommand('copy');
        copyButton.textContent = i18n.copiedText;
        setTimeout(() => {
            copyButton.textContent = i18n.copyText;
        }, 1000);
    });

    // Add event listener for next chapter button
    nextChapterButton.addEventListener('click', () => {
        // Find the next chapter link using the provided XPath
        const nextChapterQuery = document.evaluate(
            NEXT_CHAPTER_BUTTON_XPATH,
            document,
            null,
            XPathResult.FIRST_ORDERED_NODE_TYPE,
            null
        );
        
        const nextChapterLink = nextChapterQuery.singleNodeValue;
        
        if (nextChapterLink) {
            // Navigate to the next chapter
            window.location.href = nextChapterLink.href;
        } else {
            // Show a message if there's no next chapter
            nextChapterButton.textContent = i18n.noNextChapter;
            nextChapterButton.style.backgroundColor = '#ea4335';
            
            setTimeout(() => {
                nextChapterButton.textContent = i18n.nextChapter;
                nextChapterButton.style.backgroundColor = '#34a853';
            }, 2000);
        }
    });

    // Find the chapter wrapper element to observe
    const chapterWrapperQuery = document.evaluate(
        CHAPTER_WRAPPER_XPATH,
        document,
        null,
        XPathResult.FIRST_ORDERED_NODE_TYPE,
        null
    );
    
    const chapterWrapper = chapterWrapperQuery.singleNodeValue;
    
    // Update when the chapter wrapper changes
    if (chapterWrapper) {
        const observer = new MutationObserver(() => {
            updateOutput();
        });
        
        observer.observe(chapterWrapper, {
            childList: true,
            subtree: true,
            characterData: true
        });
    } else {
        console.error('Chapter wrapper element not found.');
    }
})();