🎉 欢迎访问GreasyFork.Org 镜像站!本镜像站由公众号【爱吃馍】搭建,用于分享脚本。联系邮箱📮

Greasy fork 爱吃馍镜像

番茄小说下载器

番茄小说下载

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

// ==UserScript==
// @name          番茄小说下载器
// @author        尘۝醉
// @version       2025.09.07.17
// @description   番茄小说下载
// @description:zh-cn 番茄小说下载
// @description:en    Fanqienovel Downloader
// @license       MIT
// @match         https://fanqienovel.com/page/*
// @match         https://changdunovel.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.9.1/jszip.min.js
// @icon          https://img.onlinedown.net/download/202102/152723-601ba1db7a29e.jpg
// @grant         GM_xmlhttpRequest
// @grant         GM_addStyle
// @namespace     https://github.com/tampermonkey
// ==/UserScript==

(function() {
    'use strict';
    // 检查是否为书籍信息页面
    let bookId = null;
    // 检查fanqienovel.com的页面
    const fanqienovelMatch = window.location.pathname.match(/^\/page\/(\d+)$/);
    if (fanqienovelMatch) {
        bookId = fanqienovelMatch[1];
    }
    // 检查changdunovel.com的页面
    if (!bookId && window.location.hostname === 'changdunovel.com') {
        const changdunovelMatch = window.location.href.match(/book_id=(\d{19})/);
        if (changdunovelMatch) {
            bookId = changdunovelMatch[1];
        }
    }
    if (!bookId) {
        console.log('番茄小说下载器: 当前页面不是书籍信息页面,不显示下载按钮');
        return;
    }
    // 常量定义
    const BATCH_SIZE = 30; // 批量请求的章节数量
    // 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>`
    };

    // 界面样式
    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; }
        }
        .progress-bar {
            width: 100%;
            height: 10px;
            background-color: #f0f0f0;
            border-radius: 5px;
            margin-top: 10px;
            overflow: hidden;
        }
        .progress-fill {
            height: 100%;
            background-color: #4CAF50;
            transition: width 0.3s ease;
        }
    `);

    // 辅助函数
    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 progressContainer = document.createElement('div');
        progressContainer.style.marginTop = '10px';
        progressContainer.style.display = 'none';
        const progressBar = document.createElement('div');
        progressBar.className = 'progress-bar';
        const progressFill = document.createElement('div');
        progressFill.className = 'progress-fill';
        progressFill.style.width = '0%';
        progressBar.appendChild(progressFill);
        progressContainer.appendChild(progressBar);
        container.appendChild(progressContainer);
        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,
            progressContainer,
            progressFill,
            updateStats: (total, success, failed) => {
                totalStat.querySelector('.stat-value').textContent = total;
                successStat.querySelector('.stat-value').textContent = success;
                failedStat.querySelector('.stat-value').textContent = failed;
            },
            updateProgress: (percentage) => {
                progressFill.style.width = `${percentage}%`;
            },
            showProgress: () => {
                progressContainer.style.display = 'block';
            },
            hideProgress: () => {
                progressContainer.style.display = 'none';
            }
        };
    }

    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 downloadChaptersBatch(chapterIds) {
        try {
            const url = `http://localhost:9999/batch_full?item_ids=${chapterIds.join(',')}`;
            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: 30000 // 增加超时时间,因为批量请求可能更耗时
                });
            });
            if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
            const data = JSON.parse(response.responseText);
            if (!data || !data.data) throw new Error('无效的响应格式');
            return data.data.map(chapter => ({
                title: chapter.title,
                content: formatContent(chapter.content || ''),
                success: true
            }));
        } catch (error) {
            console.error(`批量下载章节失败:`, error);
            // 返回失败的结果
            return chapterIds.map(() => ({
                title: '未知章节',
                content: '[下载失败]',
                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="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
            coverImage ? '<item id="cover" href="Text/cover.html" media-type="application/xhtml+xml"/>' : '',
            '<item id="info" href="Text/info.html" media-type="application/xhtml+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 navPoints = [];

        // 生成章节内容
        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/xhtml+xml"/>`);
            spineItems.push(`<itemref idref="chap${index}"/>`);
            
            // 生成导航点
            navPoints.push(`<navPoint id="navpoint-${index+3}" playOrder="${index+3}">
    <navLabel><text>${chapter.title}</text></navLabel>
    <content src="Text/${filename}"/>
</navPoint>`);
        });

        // 8. 生成toc.ncx文件
        const tocNcx = `<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
    <head>
        <meta name="dtb:uid" content="urn:uuid:${uuid}"/>
        <meta name="dtb:depth" content="1"/>
        <meta name="dtb:totalPageCount" content="0"/>
        <meta name="dtb:maxPageNumber" content="0"/>
        <meta name="dtb:modified" content="${now}"/>
    </head>
    <docTitle>
        <text>${bookInfo.title}</text>
    </docTitle>
    <navMap>
        <navPoint id="navpoint-1" playOrder="1">
            <navLabel><text>封面</text></navLabel>
            <content src="Text/cover.html"/>
        </navPoint>
        <navPoint id="navpoint-2" playOrder="2">
            <navLabel><text>书籍信息</text></navLabel>
            <content src="Text/info.html"/>
        </navPoint>
        ${navPoints.join('\n        ')}
    </navMap>
</ncx>`;
        oebps.file('toc.ncx', tocNcx);

        // 9. 生成content.opf
        const contentOpf = `<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="uid">
    <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
        <dc:identifier id="uid">urn:uuid:${uuid}</dc:identifier>
        <dc:title>${bookInfo.title}</dc:title>
        <dc:creator>${bookInfo.author}</dc:creator>
        <dc:language>zh-CN</dc:language>
        <meta name="cover" content="cover-image"/>
    </metadata>
    <manifest>
        ${manifestItems.join('\n        ')}
    </manifest>
    <spine toc="ncx">
        ${spineItems.join('\n        ')}
    </spine>
    <guide>
        <reference type="cover" title="封面" href="Text/cover.html"/>
        <reference type="toc" title="目录" href="toc.ncx"/>
    </guide>
</package>`;
        oebps.file('content.opf', contentOpf);

        return await zip.generateAsync({
            type: 'blob',
            mimeType: 'application/epub+zip',
            compression: 'DEFLATE',
            compressionOptions: { level: 9 }
        });
    }

    // 主函数
    async function main() {
        const ui = createDownloadUI();
        let bookInfo, chapters;
        try {
            bookInfo = await getBookInfo(bookId);
            chapters = await getChapters(bookId);
            ui.updateStats(chapters.length, 0, 0);
        } catch (error) {
            showNotification('获取书籍信息失败: ' + error.message, false);
            return;
        }

        let isDownloading = false;
        let successCount = 0;
        let failedCount = 0;
        let contents = [];

        async function startDownload(format) {
            if (isDownloading) return;
            isDownloading = true;
            ui.txtBtn.disabled = true;
            ui.epubBtn.disabled = true;
            ui.showProgress();
            successCount = 0;
            failedCount = 0;
            contents = Array(chapters.length).fill('');
            showNotification('开始批量下载章节内容...');
            // 分批下载章节
            const batchCount = Math.ceil(chapters.length / BATCH_SIZE);
            for (let i = 0; i < batchCount; i++) {
                const startIndex = i * BATCH_SIZE;
                const endIndex = Math.min(startIndex + BATCH_SIZE, chapters.length);
                const batchChapters = chapters.slice(startIndex, endIndex);
                const chapterIds = batchChapters.map(ch => ch.id);
                try {
                    const batchResults = await downloadChaptersBatch(chapterIds);
                    // 处理批量结果 - 使用for循环避免函数作用域问题
                    for (let j = 0; j < batchResults.length; j++) {
                        const result = batchResults[j];
                        const globalIndex = startIndex + j;
                        contents[globalIndex] = result.content;
                        if (result.success) {
                            successCount++;
                        } else {
                            failedCount++;
                        }
                    }
                    // 更新UI
                    ui.updateStats(chapters.length, successCount, failedCount);
                    ui.updateProgress(((i + 1) / batchCount) * 100);
                } catch (error) {
                    console.error(`批量下载第 ${i + 1} 批章节失败:`, error);
                    // 标记这一批章节为失败
                    for (let j = startIndex; j < endIndex; j++) {
                        contents[j] = `[下载失败: ${chapters[j].title}]`;
                        failedCount++;
                    }
                    ui.updateStats(chapters.length, successCount, failedCount);
                }
            }
            if (format === 'txt') {
                // 生成带章节标题的TXT内容
                let txtContent = bookInfo.infoText + '\n\n';
                for (let i = 0; i < chapters.length; i++) {
                    txtContent += `${chapters[i].title}\n`;
                    txtContent += `${contents[i]}\n\n`;
                }
                const blob = new Blob([txtContent], { type: 'text/plain;charset=utf-8' });
                saveAs(blob, `${bookInfo.title}.txt`);
            } else if (format === 'epub') {
                try {
                    const epubBlob = await generateEPUB(bookInfo, chapters, contents, bookInfo.thumb_url);
                    /* global saveAs */
                    saveAs(epubBlob, `${bookInfo.title}.epub`);
                } catch (error) {
                    showNotification('生成EPUB失败: ' + error.message, false);
                }
            }
            showNotification(`下载完成!成功: ${successCount}, 失败: ${failedCount}`);
            ui.txtBtn.disabled = false;
            ui.epubBtn.disabled = false;
            ui.hideProgress();
            isDownloading = false;
        }
        ui.txtBtn.addEventListener('click', () => startDownload('txt'));
        ui.epubBtn.addEventListener('click', () => startDownload('epub'));
    }

    // 启动脚本
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main();
    }
})();