Qidian Chapter Downloader

Download chapter content from qidian

// ==UserScript==
// @name                Qidian Chapter Downloader
// @name:zh-CN          起点章节下载器
// @namespace           http://tampermonkey.net/
// @version             0.5
// @description         Download chapter content from qidian
// @description:zh-CN   从起点下载章节文本
// @author              oovz
// @match               https://www.qidian.com/chapter/*
// @grant               none
// @source              https://gist.github.com/oovz/3257e1acd16ef2fa2913b430d95dc283
// @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'; // For p with span structure
    const CHAPTER_WRAPPER_XPATH = '//div[contains(@class, "chapter-wrapper")]';

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

    // 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;
    `;

    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(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
                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;
                    }
                }
                
                // Only trim if it's the title (preserve indentation for content)
                if (xpath === TITLE_XPATH) {
                    directTextContent = directTextContent.trim();
                    if (directTextContent) {
                        results.push(directTextContent);
                    }
                } else {
                    // For content, preserve indentation
                    if (directTextContent) {
                        results.push(directTextContent);
                    }
                }
            }
        }
        return results;
    }

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

    function updateContentOutput() {
        // 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
                    elements.push(pNode.textContent);
                }
            }
        }
        
        return elements.join('\n');
    }

    // 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();
                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 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(
            '//div[@class="nav-btn-group"]/a[last()]',
            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.');
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址