番茄小说下载器

番茄小说下载

// ==UserScript==
// @name              番茄小说下载器
// @author            尘۝醉
// @version           2025.04.28.10
// @description       番茄小说下载
// @description:zh-cn 番茄小说下载
// @description:en    Fanqienovel Downloader (EPUB & TOC Support)
// @license           MIT
// @match             https://fanqienovel.com/*
// @require           https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @require           https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @icon              https://img.onlinedown.net/download/202102/152723-601ba1db7a29e.jpg
// @grant             GM_xmlhttpRequest
// @grant             GM_addStyle
// @connect           api5-normal-sinfonlineb.fqnovel.com
// @connect           i.snssdk.com
// @namespace         https://github.com/tampermonkey
// ==/UserScript==
(function() {
    'use strict';
    // 配置常量
    const CONFIG = {
        REG_KEY: "ac25c67ddd8f38c1b37a2348828e222e",
        INSTALL_ID: "4427064614339001",
        SERVER_DEVICE_ID: "4427064614334905",
        AID: "1967",
        VERSION_CODE: "62532",
        MAX_CONCURRENT: 20,
        RETRY_TIMES: 5,
        RETRY_DELAY: 500
    };

    // EPUB模板
    const EPUB_TEMPLATES = {
        MIMETYPE: 'application/epub+zip',
        CONTAINER: `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
    <rootfiles>
        <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
    </rootfiles>
</container>`,
        OPF: (metadata, manifest, spine, guide) => `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
    <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
        <dc:identifier id="bookid">urn:uuid:${metadata.uuid}</dc:identifier>
        <dc:title>${metadata.title}</dc:title>
        <dc:creator>${metadata.author}</dc:creator>
        <dc:language>zh-CN</dc:language>
        <meta property="dcterms:modified">${metadata.modified}</meta>
    </metadata>
    <manifest>
        ${manifest}
    </manifest>
    <spine>
        ${spine}
    </spine>
    <guide>
        ${guide}
    </guide>
</package>`
    };

    // 界面样式
    GM_addStyle(`
        .tamper-container {
            position: fixed;
            top: 220px;
            right: 20px;
            background: #fff;
            border-radius: 10px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
            padding: 15px;
            z-index: 9999;
            width: 200px;
            font-size: 14px;
            line-height: 1.3
        }
        .tamper-button {
            background: #ff6b00;
            color: #fff;
            border: none;
            border-radius: 20px;
            padding: 10px 20px;
            margin: 5px 0;
            cursor: pointer;
            font-size: 14px;
            font-weight: bold;
            transition: all 0.2s;
            width: 100%;
            text-align: center
        }
        .tamper-button:hover {
            background: #ff5500
        }
        .tamper-button:disabled {
            background: #ccc;
            cursor: not-allowed
        }
        .tamper-button.txt {
            background: #4CAF50;
        }
        .tamper-button.epub {
            background: #2196F3;
        }
        .stats-container {
            display: flex;
            justify-content: space-between;
            margin-top: 15px;
            font-size: 12px
        }
        .stat-item {
            display: flex;
            flex-direction: column;
            align-items: center;
            flex: 1;
            padding: 5px
        }
        .stat-label {
            margin-bottom: 5px;
            color: #666
        }
        .stat-value {
            font-weight: bold;
            font-size: 16px
        }
        .total-value {
            color: #333
        }
        .success-value {
            color: #4CAF50
        }
        .failed-value {
            color: #F44336
        }
        .tamper-notification {
            position: fixed;
            bottom: 40px;
            right: 40px;
            background-color: #4CAF50;
            color: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 8px 16px rgba(0,0,0,0.2);
            z-index: 9999;
            font-size: 28px;
            animation: fadeIn 0.5s;
        }
        @keyframes fadeIn {
            from { opacity: 0; }
            to { opacity: 1; }
        }
    `);

    // 加密解密类
    class FqCrypto {
        constructor(key) {
            this.key = this.hexToBytes(key);
            if (this.key.length !== 16) {
                throw new Error(`Invalid key length! Expected 16 bytes, got ${this.key.length}`);
            }
            this.cipherMode = { name: 'AES-CBC' };
        }
        hexToBytes(hex) {
            const bytes = [];
            for (let i = 0; i < hex.length; i += 2) {
                bytes.push(parseInt(hex.substr(i, 2), 16));
            }
            return new Uint8Array(bytes);
        }
        bytesToHex(bytes) {
            return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
        }
        async encrypt(data, iv) {
            const cryptoKey = await crypto.subtle.importKey(
                'raw',
                this.key,
                { name: 'AES-CBC' },
                false,
                ['encrypt']
            );
            const encrypted = await crypto.subtle.encrypt(
                { name: 'AES-CBC', iv },
                cryptoKey,
                this.pkcs7Pad(data)
            );
            return new Uint8Array(encrypted);
        }
        async decrypt(data) {
            const iv = data.slice(0, 16);
            const ct = data.slice(16);
            const cryptoKey = await crypto.subtle.importKey(
                'raw',
                this.key,
                { name: 'AES-CBC' },
                false,
                ['decrypt']
            );
            const decrypted = await crypto.subtle.decrypt(
                { name: 'AES-CBC', iv },
                cryptoKey,
                ct
            );
            return this.pkcs7Unpad(new Uint8Array(decrypted));
        }
        pkcs7Pad(data) {
            const blockSize = 16;
            const padding = blockSize - (data.length % blockSize);
            const padded = new Uint8Array(data.length + padding);
            padded.set(data);
            for (let i = data.length; i < padded.length; i++) {
                padded[i] = padding;
            }
            return padded;
        }
        pkcs7Unpad(data) {
            const padding = data[data.length - 1];
            if (padding > 16) return data;
            for (let i = data.length - padding; i < data.length; i++) {
                if (data[i] !== padding) return data;
            }
            return data.slice(0, data.length - padding);
        }
        async generateRegisterContent(deviceId, strVal = "0") {
            if (!/^\d+$/.test(deviceId) || !/^\d+$/.test(strVal)) {
                throw new Error("Invalid device ID or value");
            }
            /* global BigInt */
            const deviceIdBytes = new Uint8Array(8);
            const deviceIdNum = BigInt(deviceId);
            for (let i = 0; i < 8; i++) {
                deviceIdBytes[i] = Number((deviceIdNum >> BigInt(i * 8)) & BigInt(0xFF));
            }
            const strValBytes = new Uint8Array(8);
            const strValNum = BigInt(strVal);
            for (let i = 0; i < 8; i++) {
                strValBytes[i] = Number((strValNum >> BigInt(i * 8)) & BigInt(0xFF));
            }
            const combined = new Uint8Array([...deviceIdBytes, ...strValBytes]);
            const iv = crypto.getRandomValues(new Uint8Array(16));
            const encrypted = await this.encrypt(combined, iv);
            const result = new Uint8Array([...iv, ...encrypted]);
            return btoa(String.fromCharCode(...result));
        }
    }
    // API客户端类
    class FqClient {
        constructor(config) {
            this.config = config;
            this.crypto = new FqCrypto(config.REG_KEY);
            this.dynamicKey = null;
            this.keyExpireTime = 0;
            this.requestQueue = [];
            this.activeRequests = 0;
        }
        async throttledApiRequest(method, endpoint, params = {}, data = null) {
            return new Promise((resolve, reject) => {
                const execute = async () => {
                    try {
                        this.activeRequests++;
                        const result = await this._apiRequest(method, endpoint, params, data);
                        resolve(result);
                    } catch (error) {
                        reject(error);
                    } finally {
                        this.activeRequests--;
                        this.processQueue();
                    }
                };
                if (this.activeRequests < CONFIG.MAX_CONCURRENT) {
                    execute();
                } else {
                    this.requestQueue.push(execute);
                }
            });
        }
        processQueue() {
            while (this.requestQueue.length > 0 && this.activeRequests < CONFIG.MAX_CONCURRENT) {
                const nextRequest = this.requestQueue.shift();
                nextRequest();
            }
        }
        async _apiRequest(method, endpoint, params = {}, data = null) {
            const url = new URL(`https://api5-normal-sinfonlineb.fqnovel.com${endpoint}`);
            Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
            const headers = {
                "Cookie": `install_id=${this.config.INSTALL_ID}`,
                "User-Agent": "okhttp/4.9.3"
            };
            if (data) {
                headers["Content-Type"] = "application/json";
            }
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: method,
                    url: url.toString(),
                    headers: headers,
                    data: data ? JSON.stringify(data) : undefined,
                    onload: (response) => {
                        if (response.status >= 200 && response.status < 300) {
                            try {
                                resolve(JSON.parse(response.responseText));
                            } catch (e) {
                                reject(new Error(`Failed to parse response: ${e.message}`));
                            }
                        } else {
                            reject(new Error(`API request failed with status ${response.status}`));
                        }
                    },
                    onerror: (error) => {
                        reject(new Error(`API request error: ${error.error}`));
                    },
                    timeout: 10000
                });
            });
        }
        async getContentKeys(itemIds) {
            const itemIdsStr = Array.isArray(itemIds) ? itemIds.join(',') : itemIds;
            return this.throttledApiRequest(
                "GET",
                "/reading/reader/batch_full/v",
                {
                    item_ids: itemIdsStr,
                    req_type: "1",
                    aid: this.config.AID,
                    update_version_code: this.config.VERSION_CODE
                }
            );
        }
        async getDecryptionKey() {
            const now = Date.now();
            if (this.dynamicKey && this.keyExpireTime > now) {
                return this.dynamicKey;
            }
            const content = await this.crypto.generateRegisterContent(this.config.SERVER_DEVICE_ID);
            const payload = {
                content: content,
                keyver: 1
            };
            const result = await this.throttledApiRequest(
                "POST",
                "/reading/crypt/registerkey",
                { aid: this.config.AID },
                payload
            );
            const encryptedKey = Uint8Array.from(atob(result.data.key), c => c.charCodeAt(0));
            const decryptedKey = await this.crypto.decrypt(encryptedKey);
            this.dynamicKey = this.crypto.bytesToHex(decryptedKey);
            this.keyExpireTime = now + 3600000;
            return this.dynamicKey;
        }
        async decryptContent(encryptedContent) {
            const dynamicKey = await this.getDecryptionKey();
            const contentCrypto = new FqCrypto(dynamicKey);
            const decoded = Uint8Array.from(atob(encryptedContent), c => c.charCodeAt(0));
            const decrypted = await contentCrypto.decrypt(decoded);
            const decompressed = await this.gunzip(decrypted);
            return new TextDecoder().decode(decompressed);
        }
        /* global DecompressionStream */
        async gunzip(data) {
            const ds = new DecompressionStream('gzip');
            const writer = ds.writable.getWriter();
            writer.write(data);
            writer.close();
            return new Response(ds.readable).arrayBuffer().then(arrayBuffer => new Uint8Array(arrayBuffer));
        }
    }
    // 辅助函数
    function decodeHtmlEntities(str) {
        const entities={'&#34;':'"','&#39;':"'",'&amp;':'&','&lt;':'<','&gt;':'>'};
        return str.replace(/&#34;|&#39;|&amp;|&lt;|&gt;/g, match => entities[match]);
    }
    function sanitizeFilename(name) {
        return name.replace(/[\\/*?:"<>|]/g, '').trim();
    }
    function showNotification(message, isSuccess = true) {
        const notification = document.createElement('div');
        notification.className = 'tamper-notification';
        notification.style.cssText = `position:fixed;bottom:40px;right:40px;background-color:${isSuccess ? '#4CAF50' : '#F44336'};color:white;padding:30px;border-radius:10px;box-shadow:0 8px 16px rgba(0,0,0,0.2);z-index:9999;font-size:28px;animation:fadeIn 0.5s`;
        notification.textContent = message;
        document.body.appendChild(notification);
        setTimeout(() => {
            notification.style.opacity = '0';
            setTimeout(() => notification.remove(), 500);
        }, 3000);
        return notification;
    }
    function formatContent(content) {
        let decoded = decodeHtmlEntities(content);
        return decoded.replace(/<p><\/p>/g,'').replace(/<p>/g,'').replace(/<br\/?>/g,'\n').replace(/<\/p>/g,'\n').replace(/<[^>]+>/g,'').replace(/^\s+|\s+$/g,'').replace(/\n{3,}/g, '\n');
    }
    function createDownloadUI() {
        const container = document.createElement('div');
        container.className = 'tamper-container';
        const txtBtn = document.createElement('button');
        txtBtn.className = 'tamper-button txt';
        txtBtn.textContent = '下载TXT';
        container.appendChild(txtBtn);

        const epubBtn = document.createElement('button');
        epubBtn.className = 'tamper-button epub';
        epubBtn.textContent = '下载EPUB';
        epubBtn.style.marginTop = '10px';
        container.appendChild(epubBtn);
        const statsContainer = document.createElement('div');
        statsContainer.className = 'stats-container';
        const totalStat = document.createElement('div');
        totalStat.className = 'stat-item';
        totalStat.innerHTML = `
            <div class="stat-label">总章节</div>
            <div class="stat-value total-value">0</div>
        `;
        const successStat = document.createElement('div');
        successStat.className = 'stat-item';
        successStat.innerHTML = `
            <div class="stat-label">成功</div>
            <div class="stat-value success-value">0</div>
        `;
        const failedStat = document.createElement('div');
        failedStat.className = 'stat-item';
        failedStat.innerHTML = `
            <div class="stat-label">失败</div>
            <div class="stat-value failed-value">0</div>
        `;
        statsContainer.appendChild(totalStat);
        statsContainer.appendChild(successStat);
        statsContainer.appendChild(failedStat);
        container.appendChild(statsContainer);
        document.body.appendChild(container);
        return {
            container,
            txtBtn,
            epubBtn,
            updateStats: (total, success, failed) => {
                totalStat.querySelector('.stat-value').textContent = total;
                successStat.querySelector('.stat-value').textContent = success;
                failedStat.querySelector('.stat-value').textContent = failed;
            }
        };
    }
    async function getBookInfo(bookId) {
        const url = `https://i.snssdk.com/reading/bookapi/multi-detail/v/?aid=1967&book_id=${bookId}`;
        const response = await new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                headers: { 'User-Agent': 'okhttp/4.9.3' },
                onload: resolve,
                onerror: reject,
                timeout: 8000
            });
        });
        if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
        const data = JSON.parse(response.responseText);
        if (!data.data || !data.data[0]) throw new Error('未获取到书籍信息');
        const book = data.data[0];
        return {
            title: sanitizeFilename(book.book_name),
            author: sanitizeFilename(book.author),
            abstract: book.abstract,
            wordCount: book.word_number,
            chapterCount: book.serial_count,
            thumb_url: book.thumb_url,
            infoText: `书名:${book.book_name}\n作者:${book.author}\n字数:${parseInt(book.word_number)/10000}万字\n章节数:${book.serial_count}\n简介:${book.abstract}\n免责声明:本小说下载器仅为个人学习、研究或欣赏目的提供便利,下载的小说版权归原作者及版权方所有。若因使用本下载器导致任何版权纠纷或法律问题,使用者需自行承担全部责任。`
        };
    }
    async function getChapters(bookId) {
        const url = `https://fanqienovel.com/api/reader/directory/detail?bookId=${bookId}`;
        const response = await new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                headers: { 'User-Agent': 'okhttp/4.9.3' },
                onload: resolve,
                onerror: reject,
                timeout: 8000
            });
        });
        if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
        const text = response.responseText;
        const chapterListMatch = text.match(/"chapterListWithVolume":\[(.*?)\]]/);
        if (!chapterListMatch) throw new Error('未找到章节列表');
        const chapterListStr = chapterListMatch[1];
        const itemIds = chapterListStr.match(/"itemId":"(.*?)"/g).map(m => m.match(/"itemId":"(.*?)"/)[1]);
        const titles = chapterListStr.match(/"title":"(.*?)"/g).map(m => m.match(/"title":"(.*?)"/)[1]);
        return itemIds.map((id, index) => ({
            id: id,
            title: titles[index] || `第${index+1}章`
        }));
    }
    async function downloadChapter(client, chapter) {
        try {
            const encrypted = await client.getContentKeys(chapter.id);
            if (!encrypted.data || !encrypted.data[chapter.id]) {
                throw new Error('未获取到章节内容');
            }
            const decrypted = await client.decryptContent(encrypted.data[chapter.id].content);
            return {
                title: chapter.title,
                content: formatContent(decrypted),
                success: true
            };
        } catch (error) {
            console.error(`下载章节 ${chapter.title} 失败:`, error);
            return {
                title: chapter.title,
                content: `[下载失败: ${chapter.title}]`,
                success: false
            };
        }
    }
    /* global JSZip */
    async function generateEPUB(bookInfo, chapters, contents, coverUrl) {
        const zip = new JSZip();
        const uuid = URL.createObjectURL(new Blob([])).split('/').pop();
        const now = new Date().toISOString().replace(/\.\d+Z$/, 'Z');

        // 1. 必须包含的文件
        zip.file('mimetype', EPUB_TEMPLATES.MIMETYPE, { compression: 'STORE' });

        // 2. 容器文件
        const metaInf = zip.folder('META-INF');
        metaInf.file('container.xml', EPUB_TEMPLATES.CONTAINER);

        // 3. 内容文件夹
        const oebps = zip.folder('OEBPS');

        // 创建Text文件夹
        const textFolder = oebps.folder('Text');

        // 4. CSS样式(增强阅读体验)
        const cssContent = `body { font-family: "Microsoft Yahei", serif; line-height: 1.8; margin: 2em auto; padding: 0 20px; color: #333; text-align: justify; background-color: #f8f4e8; }
h1 { font-size: 1.4em; margin: 1.2em 0; color: #0057BD; }
h2 { font-size: 1.0em; margin: 0.8em 0; color: #0057BD; }
.pic { margin: 50% 30% 0 30%; padding: 2px 2px; border: 1px solid #f5f5dc; background-color: rgba(250,250,250, 0); border-radius: 1px; }
p { text-indent: 2em; margin: 0.8em 0; hyphens: auto; }
.book-info { margin: 1em 0; padding: 1em; background: #f8f8f8; border-radius: 5px; }
.book-info p { text-indent: 0; }`;
        oebps.file('Styles/main.css', cssContent);

        // 5. 封面处理
        let coverImage;
        if (coverUrl) {
            try {
                coverImage = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: coverUrl,
                        responseType: 'blob',
                        onload: (r) => resolve(r.response),
                        onerror: reject
                    });
                });
                oebps.file('Images/cover.jpg', coverImage, { binary: true });

                // 生成封面页面
                const coverHtml = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">
<head>
    <title>封面</title>
    <link href="../Styles/main.css" rel="stylesheet"/></head><body><div class="pic"><img src="../Images/cover.jpg" alt="${bookInfo.title}封面" style="max-height: 60vh;"/></div><h1 style="margin-top: 2em;">${bookInfo.title}</h1><h2>${bookInfo.author}</h2>
</body></html>`;
                textFolder.file('cover.html', coverHtml);
            } catch (e) {
                console.warn('封面下载失败:', e);
            }
        }

        // 6. 生成书籍信息页面
        const infoHtml = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">
<head>
    <title>书籍信息</title>
    <link href="../Styles/main.css" rel="stylesheet"/></head><body><h1>${bookInfo.title}</h1><div class="book-info"><p><strong>作者:</strong>${bookInfo.author}</p><p><strong>字数:</strong>${parseInt(bookInfo.wordCount)/10000}万字</p><p><strong>章节数:</strong>${bookInfo.chapterCount}</p></div><h2>简介</h2><p>${bookInfo.abstract.replace(/\n/g, '</p><p>')}</p><h2>免责声明</h2><p>本小说下载器仅为个人学习、研究或欣赏目的提供便利,下载的小说版权归原作者及版权方所有。若因使用本下载器导致任何版权纠纷或法律问题,使用者需自行承担全部责任。</p></body></html>`;
        textFolder.file('info.html', infoHtml);

        // 7. 生成章节文件
        const manifestItems = [
            '<item id="css" href="Styles/main.css" media-type="text/css"/>',
            '<item id="nav" href="Text/nav.html" media-type="application/html+xml" properties="nav"/>',
            coverImage ? '<item id="cover" href="Text/cover.html" media-type="application/html+xml"/>' : '',
            '<item id="info" href="Text/info.html" media-type="application/html+xml"/>',
            coverImage ? '<item id="cover-image" href="Images/cover.jpg" media-type="image/jpeg"/>' : ''
        ].filter(Boolean);

        const spineItems = [
            coverImage ? '<itemref idref="cover"/>' : '',
            '<itemref idref="info"/>'
        ];

        const navItems = [];

        // 生成章节内容
        chapters.forEach((chapter, index) => {
            const filename = `chapter_${index}.html`;
            const safeContent = contents[index]
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/\n/g, '</p><p>');

            const chapterContent = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">
<head>
    <title>${chapter.title}</title>
    <link href="../Styles/main.css" rel="stylesheet"/></head><body><h1>${chapter.title}</h1><p>${safeContent}</p></body></html>`;

            textFolder.file(filename, chapterContent);

            manifestItems.push(`<item id="chap${index}" href="Text/${filename}" media-type="application/html+xml"/>`);
            spineItems.push(`<itemref idref="chap${index}"/>`);
            navItems.push(`<li><a href="${filename}">${chapter.title}</a></li>`);
        });

        // 8. 生成导航文件
        const navContent = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">
<head>
    <title>目录</title>
    <link href="../Styles/main.css" rel="stylesheet"/></head><body><nav epub:type="toc"><h1>目录</h1><ol>${navItems.join('')}</ol></nav></body></html>`;
        textFolder.file('nav.html', navContent);

        // 9. 生成content.opf文件
        const opfContent = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="bookid">urn:uuid:${uuid}</dc:identifier><dc:title>${bookInfo.title}</dc:title><dc:creator>${bookInfo.author}</dc:creator><dc:language>zh-CN</dc:language><meta property="dcterms:modified">${now}</meta>${coverImage ? '<meta name="cover" content="cover-image"/>' : ''}</metadata><manifest>${manifestItems.join('\n        ')}</manifest><spine>${spineItems.join('\n        ')}</spine><guide>${coverImage ? '<reference type="cover" title="封面" href="Text/cover.html"/>' : ''}</guide></package>`;

        oebps.file('content.opf', opfContent);

        // 10. 生成EPUB文件
        const blob = await zip.generateAsync({
            type: 'blob',
            mimeType: 'application/epub+zip',
            compression: 'DEFLATE',
            compressionOptions: { level: 9 }
        });

        /* global saveAs */
        saveAs(blob, `${bookInfo.title}.epub`);
    }

    async function downloadAllChapters(client, chapters, format = 'txt', updateStats) {
        const startTime = Date.now();
        let downloaded = 0;
        let successCount = 0;
        let failedCount = 0;
        let contents = [];

        const batchSize = CONFIG.MAX_CONCURRENT;

        // 批量下载函数
        const downloadBatch = async (startIndex) => {
            const endIndex = Math.min(startIndex + batchSize, chapters.length);
            const batch = chapters.slice(startIndex, endIndex);
            const promises = batch.map(chapter => 
                downloadChapter(client, chapter)
                    .then(result => {
                        downloaded++;
                        if (result.success) {
                            successCount++;
                        } else {
                            failedCount++;
                        }
                        // 更新统计UI
                        if (updateStats) {
                            updateStats(chapters.length, successCount, failedCount);
                        }
                        return result;
                    })
            );
            return Promise.all(promises);
        };
        // 分批下载所有章节
        for (let i = 0; i < chapters.length; i += batchSize) {
            const batchResults = await downloadBatch(i);
            for (const result of batchResults) {
                contents.push(result.content);
            }
        }
        return {
            contents,
            successCount,
            failedCount,
            duration: ((Date.now() - startTime) / 1000).toFixed(1)
        };
    }

    // 阅读器功能
    async function handleReaderPage(client) {
        const toolbar = document.querySelector("#app > div > div > div > div.reader-toolbar > div > div.reader-toolbar-item.reader-toolbar-item-download");
        const text = toolbar?.querySelector('div:nth-child(2)');

        if (toolbar && text) {
            text.innerHTML = '加载中...';
        }

        document.title = document.title.replace(/在线免费阅读_番茄小说官网$/, '');
        var currentURL = window.location.href;
        setInterval(() => window.location.href !== currentURL ? location.reload() : null, 1000);

        let cdiv = document.getElementsByClassName('muye-reader-content noselect')[0];
        if (cdiv) {
            cdiv.classList = cdiv.classList[0];
        } else {
            const html0 = document.getElementById('html_0');
            if (!html0) return;
            cdiv = html0.children[2] || html0.children[0];
            if (!cdiv) return;
        }

        try {
            const url = window.location.href;
            const match = url.match(/\/(\d+)/);
            if (!match) return;

            const chapterId = match[1];
            const response = await client.getContentKeys(chapterId);

            if (!response.data || !response.data[chapterId]) {
                throw new Error('未找到章节的内容');
            }

            const decrypted = await client.decryptContent(response.data[chapterId].content);
            document.getElementsByClassName('muye-to-fanqie')[0]?.remove();
            document.getElementsByClassName('pay-page')[0]?.remove();
            cdiv.innerHTML = decrypted;
            document.getElementById('html_0')?.classList.remove('pay-page-html');

            if (toolbar && text) {
                toolbar.style.backgroundColor = '#B0E57C';
                text.innerHTML = '成功';
            }
        } catch (error) {
            console.error('错误:', error);
            if (toolbar && text) {
                toolbar.style.backgroundColor = 'pink';
                text.innerHTML = '失败';
            }
        }
    }

    async function handleBookPage(client, bookId) {
        // 获取书籍信息
        let bookInfo, chapters;
        try {
            bookInfo = await getBookInfo(bookId);
            chapters = await getChapters(bookId);
        } catch (error) {
            console.error('初始化失败:', error);
            showNotification('获取书籍信息失败', false);
            return;
        }
        // 创建下载UI
        const ui = createDownloadUI();
        ui.updateStats(chapters.length, 0, 0);

        // TXT下载按钮事件
        ui.txtBtn.addEventListener('click', async () => {
            if (ui.txtBtn.disabled) return;
            ui.txtBtn.disabled = true;
            ui.txtBtn.textContent = '准备下载...';
            if (!confirm(`即将下载《${bookInfo.title}》全本TXT,共${chapters.length}章,是否继续?`)) {
                ui.txtBtn.disabled = false;
                ui.txtBtn.textContent = '下载TXT';
                return;
            }
            ui.txtBtn.textContent = '下载中...';
            try {
                const notification = showNotification('开始下载TXT...', true);

                // 下载所有章节
                const { contents, successCount, failedCount, duration } = await downloadAllChapters(
                    client,
                    chapters,
                    'txt',
                    ui.updateStats // 传入更新统计的函数
                );

                // 生成TXT文件
                let txtContent = `${bookInfo.infoText}\n\n`;
                contents.forEach((content, index) => {
                    txtContent += `\n\n${chapters[index].title}\n${content}`;
                });

                const blob = new Blob([txtContent], { type: 'text/plain;charset=utf-8' });
                saveAs(blob, `${bookInfo.title}.txt`);

                notification.textContent = `TXT下载完成!共${chapters.length}章,成功${successCount}章,失败${failedCount}章,耗时${duration}秒`;
                notification.style.backgroundColor = '#4CAF50';
                ui.txtBtn.textContent = '下载完成';
            } catch (error) {
                console.error('下载失败:', error);
                showNotification('TXT下载失败: ' + error.message, false);
                ui.txtBtn.textContent = '下载失败';
            } finally {
                ui.txtBtn.disabled = false;
            }
        });

        // EPUB下载按钮事件
        ui.epubBtn.addEventListener('click', async () => {
            if (ui.epubBtn.disabled) return;
            ui.epubBtn.disabled = true;
            ui.epubBtn.textContent = '准备下载...';

            if (!confirm(`即将下载《${bookInfo.title}》全本EPUB,共${chapters.length}章,是否继续?`)) {
                ui.epubBtn.disabled = false;
                ui.epubBtn.textContent = '下载EPUB';
                return;
            }

            ui.epubBtn.textContent = '下载中...';
            try {
                const notification = showNotification('开始下载EPUB...', true);

                // 下载所有章节
                const { contents, successCount, failedCount, duration } = await downloadAllChapters(
                    client,
                    chapters,
                    'epub',
                    ui.updateStats // 传入更新统计的函数
                );

                // 生成EPUB文件
                await generateEPUB(
                    bookInfo,
                    chapters,
                    contents,
                    bookInfo.thumb_url
                );

                notification.textContent = `EPUB生成完成!共${chapters.length}章,成功${successCount}章,失败${failedCount}章,耗时${duration}秒`;
                notification.style.backgroundColor = '#4CAF50';
                ui.epubBtn.textContent = '下载完成';
            } catch (error) {
                console.error('EPUB生成失败:', error);
                showNotification(`EPUB生成失败: ${error.message}`, false);
                ui.epubBtn.textContent = '下载失败';
            } finally {
                ui.epubBtn.disabled = false;
            }
        });
    }
    // 主入口
    async function main() {
        const pathMatch = window.location.pathname.match(/\/page\/(\d+)/);
        const isReaderPage = window.location.pathname.includes('/reader/');
        const client = new FqClient(CONFIG);

        if (isReaderPage) {
            await handleReaderPage(client);
        } else if (pathMatch) {
            const bookId = pathMatch[1];
            await handleBookPage(client, bookId);
        }
    }
    // 启动主逻辑
    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        setTimeout(main, 1000); // 增加延迟确保页面加载完成
    } else {
        document.addEventListener('DOMContentLoaded', main);
    }
})();
长期地址
遇到问题?请前往 GitHub 提 Issues,或加Q群1031348184

赞助商

Fishcpy

广告

Rainyun

注册一下就行

Rainyun

一年攒够 12 元