某西红柿下载小说 油猴插件

油猴添加脚本教程

方法一:通过管理面板直接创建

这是最常用和便捷的方式,适合大多数用户。

  1. 打开管理面板:点击浏览器工具栏中的油猴插件图标,在弹出的菜单中选择“管理面板”或“仪表盘”。
  2. 新建脚本:在管理面板中,点击“添加新脚本”或“创建一个新脚本”按钮,系统会打开一个脚本编辑器13。
  3. 粘贴代码:将您自己编写好的脚本代码(通常以 // ==UserScript== 开头)全部粘贴到编辑器中,覆盖默认的模板内容16。
  4. 保存脚本:点击编辑器右上角的“保存”按钮(或使用快捷键 Ctrl+S),油猴会自动完成脚本的安装并提示成功17。 17

方法二:通过导入本地文件

如果您已经将脚本保存为 .user.js 文件,可以使用此方法导入。

  1. 打开管理面板:同样先点击油猴图标,进入“管理面板”。
  2. 进入脚本页面:在管理面板中,切换到“脚本”标签页18。
  3. 从文件安装:点击左上角的“实用工具”菜单(或类似选项),选择“从文件安装…”1。
  4. 选择文件:在弹出的系统对话框中,找到并选中您本地电脑上的 .user.js 脚本文件,点击“打开”即可完成导入

代码直接复制粘贴就行

// ==UserScript==
// @name          番茄小说下载器
// @author        123123
// @version       2026.01.23
// @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
// @downloadURL https://update.greasyfork.org/scripts/534014/%E7%95%AA%E8%8C%84%E5%B0%8F%E8%AF%B4%E4%B8%8B%E8%BD%BD%E5%99%A8.user.js
// @updateURL https://update.greasyfork.org/scripts/534014/%E7%95%AA%E8%8C%84%E5%B0%8F%E8%AF%B4%E4%B8%8B%E8%BD%BD%E5%99%A8.meta.js
// ==/UserScript==

(function() {
    'use strict';
    let bookId = null;
    const fanqienovelMatch = window.location.pathname.match(/^\/page\/(\d+)$/);
    if (fanqienovelMatch) {
        bookId = fanqienovelMatch[1];
    }
    if (!bookId && window.location.hostname === 'changdunovel.com') {
        const changdunovelMatch = window.location.href.match(/book_id=(\d{19})/);
        if (changdunovelMatch) {
            bookId = changdunovelMatch[1];
        }
    }
    const BATCH_SIZE = 30;
    const MAX_RETRY_COUNT = 30;
    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>`
    };

    const TOMATO_SVG = `data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE4Ljg3MjcgMEg1LjEyNzI3QzIuMjkwOTEgMCAwIDIuMjkwOTEgMCA1LjEyNzI3VjE4Ljg3MjdDMCAyMS43MDkxIDIuMjkwOTEgMjQgNS4xMjcyNyAyNEgxOC44NzI3QzIxLjcwOTEgMjQgMjQgMjEuNzA5MSAyNCAxOC44NzI3VjUuMTI3MjdDMjQgMi4yOTA5MSAyMS43MDkxIDAgMTguODcyNyAwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01LjEyNzI3IDBIMTguODcyN0MyMS43MDkxIDAgMjQgMi4yOTA5MSAyNCA1LjEyNzI3VjE4Ljg3MjdDMjQgMjEuNzA5MSAyMS43MDkxIDI0IDE4Ljg3MjcgMjRINS4xMjcyN0MyLjI5MDkxIDI0IDAgMjEuNzA5MSAwIDE4Ljg3MjdWNS4xMjcyN0MwIDIuMjkwOTEgMi4yOTA5MSAwIDUuMTI3MjcgMFpNMjMuNDc1NyA1LjEyNzI3QzIzLjQ3NTcgMi41OTYzNiAyMS40MDMgMC41MjM2MzYgMTguODcyIDAuNTIzNjM2SDUuMTI2NTlDMi41OTU2OCAwLjUyMzYzNiAwLjUyMjk0OSAyLjU5NjM2IDAuNTIyOTQ5IDUuMTI3MjdWMTguODcyN0MwLjUyMjk0OSAyMS40MDM2IDIuNTk1NjggMjMuNDc2NCA1LjEyNjU5IDIzLjQ3NjRIMTguODcyQzIxLjQwMyAyMy40NzY0IDIzLjQ3NTcgMjEuNDAzNiAyMy40NzU3IDE4Ljg3MjdWNS4xMjcyN1oiIGZpbGw9IiNFNkU2RTYiLz4KPHBhdGggZD0iTTE1LjA3NjIgMFY1LjA0TDE3LjAxOCAzLjkyNzI3TDE4Ljk1OTggNS4wNFYwSDE1LjA3NjJaIiBmaWxsPSIjRUU1NTI4Ii8+CjxwYXRoIGQ9Ik0yNCAxMy45NjM2QzIxLjI1MDkgMTAuNjkwOCAxNi45MDkxIDguNTc0NDYgMTIuMDIxOCA4LjU3NDQ2QzcuMDkwOTEgOC41NzQ0NiAyLjcyNzI3IDEwLjcxMjYgMCAxNC4wMjlWMTguODcyNkMwIDIxLjcwOSAyLjI5MDkxIDIzLjk5OTkgNS4xMjcyNyAyMy45OTk5SDE4Ljg3MjdDMjEuNzA5MSAyMy45OTk5IDI0IDIxLjcwOSAyNCAxOC44NzI2VjEzLjk2MzZaIiBmaWxsPSJ1cmwoI3BhaW50MF9yYWRpYWxfNjA3XzEyNTA1KSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTEyLjc4NTYgMTMuODk4MkMxMi43NjM4IDEzLjAwMzcgMTIuNDE0NyAxMi41ODkxIDEyLjAwMDIgMTIuNTg5MUMxMS41NjM4IDEyLjYxMDkgMTEuMjM2NSAxMy4wMjU1IDExLjI1ODQgMTMuODk4MkMxMS4yNTg0IDE0LjgxNDYgMTIuMDQzOCAxNS44NjE4IDEyLjAwNDQgMTUuODYxOEMxMi4wMDQ0IDE1Ljg2MTggMTIuNzg1NiAxNC44MTQ2IDEyLjc4NTYgMTMuODk4MlpNNi4zNDk0NiAxOC42NzYzQzcuMjY1ODMgMTguNjc2MyA4LjMxMzEgMTkuNDE4MSA4LjMxMzEgMTkuNDE4MUM4LjMxMzEgMTkuNDE4MSA3LjI2NTgzIDIwLjIwMzUgNi4zNDk0NiAyMC4yMDM1QzUuNDMzMSAyMC4yMDM1IDUuMDE4NTUgMTkuODc2MyA1LjAxODU1IDE5LjQzOTlDNS4wNDAzNyAxOS4wNDcyIDUuNDU0OTIgMTguNjk4MSA2LjM0OTQ2IDE4LjY3NjNaTTE4Ljk2MSAxOS40NjE5QzE4Ljk2MSAxOS44NzY0IDE4LjU0NjUgMjAuMjI1NSAxNy42MzAxIDIwLjIyNTVDMTYuNzEzOCAyMC4yMjU1IDE1LjY2NjUgMTkuNDQwMSAxNS42NjY1IDE5LjQ0MDFDMTUuNjY2NSAxOS40NDAxIDE2LjczNTYgMTguNjc2NCAxNy42MzAxIDE4LjY5ODJDMTguNTQ2NSAxOC42OTgyIDE4Ljk2MSAxOS4wMjU1IDE4Ljk2MSAxOS40NjE5Wk0xNy4zMDIyIDE0Ljg1ODFDMTcuNjA3NiAxNS4xNjM1IDE3LjU0MjIgMTUuNjg3MiAxNi44ODc2IDE2LjM0MTdDMTYuMjMzMSAxNi45NzQ1IDE0Ljk0NTggMTcuMTcwOCAxNC45NDU4IDE3LjE3MDhDMTQuOTQ1OCAxNy4xNzA4IDE1LjE2NCAxNS44ODM1IDE1LjgxODUgMTUuMjUwOEMxNi40NzMxIDE0LjU5NjMgMTcuMDE4NSAxNC41NTI2IDE3LjMwMjIgMTQuODU4MVpNOS4wNTU2NCAxNy4xNDkyQzkuMDU1NjQgMTcuMTQ5MiA4LjgzNzQ2IDE1Ljg2MiA4LjE4MjkxIDE1LjIyOTJDNy41MjgzNiAxNC41OTY1IDYuOTgyOTEgMTQuNTUyOSA2LjY5OTI3IDE0LjgzNjVDNi40MTU2NCAxNS4xNDIgNi40NTkyNyAxNS42ODc0IDcuMTEzODIgMTYuMzIwMUM3Ljc2ODM2IDE2Ljk1MjkgOS4wNTU2NCAxNy4xNDkyIDkuMDU1NjQgMTcuMTQ5MloiIGZpbGw9IndoaXRlIi8+CjxkZWZzPgo8cmFkaWFsR3JhZGllbnQgaWQ9InBhaW50MF9yYWRpYWxfNjA3XzEyNTA1IiBjeD0iMCIgY3k9IjAiIHI9IjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0idHJhbnNsYXRlKDEyLjA5ODkgMjQuMjEyKSBzY2FsZSgxNC41OTg0IDkuMzgyNzcpIj4KPHN0b3Agc3RvcC1jb2xvcj0iI0NDMDUwMCIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRjVGMDAiLz4KPC9yYWRpYWxHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4K`;

    GM_addStyle(`
        .tamper-float-btn {
            position: fixed;
            right: 20px;
            bottom: 80px;
            width: 56px;
            height: 56px;
            background: linear-gradient(135deg, #FF6B6B 0%, #EE5A52 100%);
            border-radius: 50%;
            box-shadow: 0 4px 20px rgba(255, 107, 107, 0.4);
            cursor: move;
            z-index: 9998;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            user-select: none;
            touch-action: none;
        }
        
        .tamper-float-btn:hover {
            transform: scale(1.1);
            box-shadow: 0 6px 25px rgba(255, 107, 107, 0.6);
        }
        
        .tamper-float-btn:active {
            transform: scale(0.95);
        }
        
        .tamper-float-btn.active {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
        }
        
        .tamper-float-btn.active:hover {
            background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%);
            box-shadow: 0 6px 25px rgba(102, 126, 234, 0.6);
        }
        
        .tamper-float-btn.active:active {
            transform: scale(0.95);
        }
        
        .tamper-float-btn-icon {
            width: 32px;
            height: 32px;
            pointer-events: none;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
        }
        
        .tamper-float-btn.active .tamper-float-btn-icon {
            transform: rotate(45deg);
        }
        
        .tamper-panel {
            position: fixed;
            right: 90px;
            bottom: 60px;
            width: 320px;
            max-height: 80vh;
            background: #fff;
            border-radius: 16px;
            box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
            z-index: 9997;
            opacity: 0;
            transform: translateX(20px) scale(0.95);
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            overflow: hidden;
            pointer-events: none;
        }
        
        .tamper-panel.active {
            opacity: 1;
            transform: translateX(0) scale(1);
            pointer-events: auto;
        }
        
        .tamper-panel-content {
            height: 100%;
            overflow-y: auto;
            padding: 20px;
        }
        
        .tamper-header {
            display: flex;
            align-items: center;
            margin-bottom: 20px;
            padding-bottom: 15px;
            border-bottom: 1px solid #f0f0f0;
        }
        
        .tamper-title {
            font-size: 18px;
            font-weight: 600;
            color: #333;
            margin: 0;
        }
        
        .tamper-version {
            font-size: 12px;
            color: #666;
            margin-left: 8px;
        }
        
        .buttons-container {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }
        
        .tamper-button {
            flex: 1;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: #fff;
            border: none;
            border-radius: 10px;
            padding: 14px 20px;
            cursor: pointer;
            font-size: 14px;
            font-weight: 600;
            transition: all 0.3s;
            text-align: center;
            position: relative;
            overflow: hidden;
        }
        
        .tamper-button:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 20px rgba(102, 126, 234, 0.3);
        }
        
        .tamper-button:disabled {
            background: linear-gradient(135deg, #cccccc 0%, #aaaaaa 100%);
            cursor: not-allowed;
            transform: none;
            box-shadow: none;
        }
        
        .tamper-button.txt {
            background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
        }
        
        .tamper-button.txt:hover:not(:disabled) {
            box-shadow: 0 6px 20px rgba(76, 175, 80, 0.3);
        }
        
        .tamper-button.epub {
            background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%);
        }
        
        .tamper-button.epub:hover:not(:disabled) {
            box-shadow: 0 6px 20px rgba(33, 150, 243, 0.3);
        }
        
        .stats-container {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 12px;
            margin-bottom: 20px;
            background: #f8f9fa;
            border-radius: 12px;
            padding: 16px;
        }
        
        .stat-item {
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 8px;
        }
        
        .stat-label {
            font-size: 12px;
            color: #666;
            margin-bottom: 8px;
            text-transform: uppercase;
            letter-spacing: 0.5px;
        }
        
        .stat-value {
            font-size: 24px;
            font-weight: 700;
        }
        
        .total-value {
            color: #333;
        }
        
        .success-value {
            color: #4CAF50;
        }
        
        .failed-value {
            color: #F44336;
        }
        
        .progress-section {
            margin-bottom: 20px;
        }
        
        .progress-info {
            display: flex;
            justify-content: space-between;
            margin-bottom: 10px;
            font-size: 13px;
            color: #555;
        }
        
        .progress-bar {
            width: 100%;
            height: 8px;
            background-color: #e9ecef;
            border-radius: 4px;
            overflow: hidden;
        }
        
        .progress-fill {
            height: 100%;
            background: linear-gradient(90deg, #4CAF50, #8BC34A);
            border-radius: 4px;
            transition: width 0.3s ease;
        }
        
        .batch-info {
            text-align: center;
            font-size: 12px;
            color: #777;
            margin-top: 10px;
            font-style: italic;
        }
        
        .retry-info {
            background: #FFF3E0;
            border-radius: 8px;
            padding: 12px;
            margin-top: 10px;
            font-size: 12px;
            color: #E65100;
            border: 1px solid #FFE0B2;
            text-align: center;
        }
        
        .status-indicator {
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            margin-bottom: 20px;
            padding: 12px;
            border-radius: 10px;
            font-size: 14px;
            font-weight: 500;
        }
        
        .status-ready {
            background: #E8F5E9;
            color: #2E7D32;
            border: 1px solid #C8E6C9;
        }
        
        .status-downloading {
            background: #E3F2FD;
            color: #1565C0;
            border: 1px solid #BBDEFB;
        }
        
        .status-complete {
            background: #F1F8E9;
            color: #689F38;
            border: 1px solid #DCEDC8;
        }
        
        .status-error {
            background: #FFEBEE;
            color: #C62828;
            border: 1px solid #FFCDD2;
        }
        
        .status-dot {
            width: 8px;
            height: 8px;
            border-radius: 50%;
        }
        
        .status-ready .status-dot {
            background: #4CAF50;
            animation: pulse 2s infinite;
        }
        
        .status-downloading .status-dot {
            background: #2196F3;
            animation: blink 1s infinite;
        }
        
        .status-complete .status-dot {
            background: #689F38;
        }
        
        .status-error .status-dot {
            background: #F44336;
        }
        
        @keyframes blink {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.3; }
        }
        
        @keyframes pulse {
            0% { transform: scale(1); }
            50% { transform: scale(1.2); }
            100% { transform: scale(1); }
        }
        
        .tamper-notification {
            position: fixed;
            bottom: 40px;
            right: 40px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 20px 30px;
            border-radius: 12px;
            box-shadow: 0 8px 30px rgba(0,0,0,0.2);
            z-index: 9999;
            font-size: 16px;
            font-weight: 500;
            animation: slideIn 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
            max-width: 400px;
            backdrop-filter: blur(10px);
        }
        
        @keyframes slideIn {
            from { 
                transform: translateX(100%) translateY(20px);
                opacity: 0;
            }
            to { 
                transform: translateX(0) translateY(0);
                opacity: 1;
            }
        }
        
        @media (max-width: 768px) {
            .tamper-panel {
                width: 300px;
                right: 10px;
                bottom: 70px;
            }
            
            .tamper-float-btn {
                right: 10px;
                bottom: 10px;
            }
        }
    `);

    function decodeHtmlEntities(str) {
        const entities={'"':'"',''':"'",'&':'&','<':'<','>':'>'};
        return str.replace(/"|'|&|<|>/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:linear-gradient(135deg,${isSuccess ? '#4CAF50' : '#F44336'} 0%,${isSuccess ? '#45a049' : '#d32f2f'} 100%);color:white;padding:20px 30px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.2);z-index:9999;font-size:16px;font-weight:500;animation:slideIn 0.5s cubic-bezier(0.68,-0.55,0.265,1.55);max-width:400px;backdrop-filter:blur(10px)`;
        notification.textContent = message;
        document.body.appendChild(notification);
        setTimeout(() => {
            notification.style.opacity = '0';
            notification.style.transform = 'translateX(100%) translateY(20px)';
            setTimeout(() => notification.remove(), 500);
        }, 3000);
        return notification;
    }

    function formatContent(content) {
        if (!content) return '';
        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 createDraggablePanel() {
        const floatBtn = document.createElement('div');
        floatBtn.className = 'tamper-float-btn';
        
        const floatIcon = document.createElement('img');
        floatIcon.className = 'tamper-float-btn-icon';
        floatIcon.src = TOMATO_SVG;
        floatIcon.alt = '番茄小说下载器';
        floatBtn.appendChild(floatIcon);
        
        const panel = document.createElement('div');
        panel.className = 'tamper-panel';
        
        const panelContent = document.createElement('div');
        panelContent.className = 'tamper-panel-content';
        panel.appendChild(panelContent);
        
        const header = document.createElement('div');
        header.className = 'tamper-header';
        
        const title = document.createElement('h2');
        title.className = 'tamper-title';
        title.textContent = '番茄小说下载器';
        
        const version = document.createElement('span');
        version.className = 'tamper-version';
        version.textContent = 'v2026.01.23';
        
        header.appendChild(title);
        header.appendChild(version);
        panelContent.appendChild(header);
        
        const statusIndicator = document.createElement('div');
        statusIndicator.className = 'status-indicator status-ready';
        statusIndicator.innerHTML = `
            <div class="status-dot"></div>
            <span>已获取</span>
        `;
        panelContent.appendChild(statusIndicator);
        
        const buttonsContainer = document.createElement('div');
        buttonsContainer.className = 'buttons-container';
        
        const txtBtn = document.createElement('button');
        txtBtn.className = 'tamper-button txt';
        txtBtn.textContent = '下载TXT';
        
        const epubBtn = document.createElement('button');
        epubBtn.className = 'tamper-button epub';
        epubBtn.textContent = '下载EPUB';
        
        buttonsContainer.appendChild(txtBtn);
        buttonsContainer.appendChild(epubBtn);
        panelContent.appendChild(buttonsContainer);
        
        const statsContainer = document.createElement('div');
        statsContainer.className = 'stats-container';
        statsContainer.innerHTML = `
            <div class="stat-item">
                <div class="stat-label">总章节</div>
                <div class="stat-value total-value">0</div>
            </div>
            <div class="stat-item">
                <div class="stat-label">成功</div>
                <div class="stat-value success-value">0</div>
            </div>
            <div class="stat-item">
                <div class="stat-label">失败</div>
                <div class="stat-value failed-value">0</div>
            </div>
        `;
        panelContent.appendChild(statsContainer);
        
        const progressSection = document.createElement('div');
        progressSection.className = 'progress-section';
        progressSection.style.display = 'none';
        
        const progressInfo = document.createElement('div');
        progressInfo.className = 'progress-info';
        
        const progressText = document.createElement('span');
        progressText.className = 'progress-text';
        progressText.textContent = '准备下载...';
        
        const progressPercent = document.createElement('span');
        progressPercent.className = 'progress-percent';
        progressPercent.textContent = '0%';
        
        progressInfo.appendChild(progressText);
        progressInfo.appendChild(progressPercent);
        progressSection.appendChild(progressInfo);
        
        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);
        progressSection.appendChild(progressBar);
        
        panelContent.appendChild(progressSection);
        
        const batchInfo = document.createElement('div');
        batchInfo.className = 'batch-info';
        batchInfo.textContent = '作者 • 尘۝醉';
        panelContent.appendChild(batchInfo);
        
        const retryInfo = document.createElement('div');
        retryInfo.className = 'retry-info';
        retryInfo.style.display = 'none';
        retryInfo.innerHTML = '正在重试失败章节 <span class="retry-count">0</span>/<span class="max-retry">30</span> 次';
        panelContent.appendChild(retryInfo);
        
        const disclaimer = document.createElement('div');
        disclaimer.className = 'retry-info';
        disclaimer.style.cssText = 'margin-top:15px;font-size:11px;line-height:1.4;color:#666;border:1px solid #ddd;background:#f9f9f9;';
        disclaimer.innerHTML = '<strong>免责声明:</strong>本小说下载器仅为个人学习、研究或欣赏目的提供便利,下载的小说版权归原作者及版权方所有。若因使用本下载器导致任何版权纠纷或法律问题,使用者需自行承担全部责任。';
        panelContent.appendChild(disclaimer);
        
        document.body.appendChild(floatBtn);
        document.body.appendChild(panel);
        
        let isDragging = false;
        let dragStartX = 0;
        let dragStartY = 0;
        let initialLeft = 0;
        let initialTop = 0;
        
        floatBtn.addEventListener('mousedown', startDrag);
        floatBtn.addEventListener('touchstart', startDragTouch, { passive: false });
        
        function startDrag(e) {
            e.preventDefault();
            e.stopPropagation();
            
            isDragging = true;
            dragStartX = e.clientX;
            dragStartY = e.clientY;
            
            const rect = floatBtn.getBoundingClientRect();
            initialLeft = rect.left;
            initialTop = rect.top;
            
            document.addEventListener('mousemove', onDrag);
            document.addEventListener('mouseup', stopDrag);
            
            document.body.style.userSelect = 'none';
        }
        
        function startDragTouch(e) {
            e.preventDefault();
            e.stopPropagation();
            
            if (e.touches.length === 1) {
                isDragging = true;
                const touch = e.touches[0];
                dragStartX = touch.clientX;
                dragStartY = touch.clientY;
                
                const rect = floatBtn.getBoundingClientRect();
                initialLeft = rect.left;
                initialTop = rect.top;
                
                document.addEventListener('touchmove', onDragTouch, { passive: false });
                document.addEventListener('touchend', stopDragTouch);
                document.addEventListener('touchcancel', stopDragTouch);
                
                document.body.style.userSelect = 'none';
            }
        }
        
        function onDrag(e) {
            if (!isDragging) return;
            e.preventDefault();
            e.stopPropagation();
            
            const deltaX = e.clientX - dragStartX;
            const deltaY = e.clientY - dragStartY;
            
            const newLeft = initialLeft + deltaX;
            const newTop = initialTop + deltaY;
            
            const maxX = window.innerWidth - floatBtn.offsetWidth;
            const maxY = window.innerHeight - floatBtn.offsetHeight;
            
            const clampedX = Math.max(0, Math.min(newLeft, maxX));
            const clampedY = Math.max(0, Math.min(newTop, maxY));
            
            floatBtn.style.left = `${clampedX}px`;
            floatBtn.style.top = `${clampedY}px`;
            floatBtn.style.right = 'auto';
            floatBtn.style.bottom = 'auto';
        }
        
        function onDragTouch(e) {
            if (!isDragging || e.touches.length !== 1) return;
            e.preventDefault();
            e.stopPropagation();
            
            const touch = e.touches[0];
            const deltaX = touch.clientX - dragStartX;
            const deltaY = touch.clientY - dragStartY;
            
            const newLeft = initialLeft + deltaX;
            const newTop = initialTop + deltaY;
            
            const maxX = window.innerWidth - floatBtn.offsetWidth;
            const maxY = window.innerHeight - floatBtn.offsetHeight;
            
            const clampedX = Math.max(0, Math.min(newLeft, maxX));
            const clampedY = Math.max(0, Math.min(newTop, maxY));
            
            floatBtn.style.left = `${clampedX}px`;
            floatBtn.style.top = `${clampedY}px`;
            floatBtn.style.right = 'auto';
            floatBtn.style.bottom = 'auto';
        }
        
        function stopDrag(e) {
            if (isDragging) {
                isDragging = false;
                
                document.removeEventListener('mousemove', onDrag);
                document.removeEventListener('mouseup', stopDrag);
                
                document.body.style.userSelect = '';
                
                if (e) {
                    const deltaX = e.clientX - dragStartX;
                    const deltaY = e.clientY - dragStartY;
                    const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
                    
                    if (distance < 5) {
                        togglePanel();
                    }
                }
            }
        }
        
        function stopDragTouch(e) {
            if (isDragging) {
                isDragging = false;
                
                document.removeEventListener('touchmove', onDragTouch);
                document.removeEventListener('touchend', stopDragTouch);
                document.removeEventListener('touchcancel', stopDragTouch);
                
                document.body.style.userSelect = '';
                
                if (e.changedTouches && e.changedTouches.length === 1) {
                    const touch = e.changedTouches[0];
                    const deltaX = touch.clientX - dragStartX;
                    const deltaY = touch.clientY - dragStartY;
                    const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
                    
                    if (distance < 5) {
                        togglePanel();
                    }
                }
            }
        }
        
        function togglePanel() {
            floatBtn.classList.toggle('active');
            panel.classList.toggle('active');
        }
        
        floatBtn.addEventListener('click', function(e) {
            if (isDragging) {
                return;
            }
            e.stopPropagation();
            togglePanel();
        });
        
        panel.addEventListener('click', function(e) {
            e.stopPropagation();
        });
        
        document.addEventListener('click', function(e) {
            if (panel.classList.contains('active') && 
                !panel.contains(e.target) && 
                !floatBtn.contains(e.target)) {
                floatBtn.classList.remove('active');
                panel.classList.remove('active');
            }
        });
        
        return {
            floatBtn,
            panel,
            txtBtn,
            epubBtn,
            progressSection,
            progressText,
            progressPercent,
            progressFill,
            statusIndicator,
            retryInfo,
            updateStats: (total, success, failed) => {
                statsContainer.querySelector('.total-value').textContent = total;
                statsContainer.querySelector('.success-value').textContent = success;
                statsContainer.querySelector('.failed-value').textContent = failed;
            },
            updateProgress: (text, percent) => {
                progressText.textContent = text;
                progressPercent.textContent = `${percent}%`;
                progressFill.style.width = `${percent}%`;
            },
            showProgress: () => {
                progressSection.style.display = 'block';
            },
            hideProgress: () => {
                progressSection.style.display = 'none';
            },
            updateStatus: (status, message) => {
                statusIndicator.className = `status-indicator status-${status}`;
                statusIndicator.querySelector('span').textContent = message;
            },
            showRetryInfo: (retryCount, maxRetry) => {
                retryInfo.querySelector('.retry-count').textContent = retryCount;
                retryInfo.querySelector('.max-retry').textContent = maxRetry;
                retryInfo.style.display = 'block';
            },
            hideRetryInfo: () => {
                retryInfo.style.display = 'none';
            }
        };
    }

    async function getBookInfo(bookId) {
        const url = `http://103.236.91.147:9999/api/detail?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.data) {
            throw new Error('未获取到书籍信息');
        }
        
        const book = data.data.data;
        
        return {
            title: sanitizeFilename(book.book_name),
            author: sanitizeFilename(book.author),
            abstract: book.abstract || '',
            wordCount: book.word_number || '0',
            chapterCount: book.serial_count || '0',
            thumb_url: book.thumb_url,
            infoText: `书名:${book.book_name}\n作者:${book.author}\n字数:${parseInt(book.word_number || 0)/10000}万字\n章节数:${book.serial_count || 0}\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 data = JSON.parse(response.responseText);
        if (!data.data || !data.data.chapterListWithVolume) {
            throw new Error('未找到章节列表');
        }
        
        const chapters = [];
        const chapterList = data.data.chapterListWithVolume;
        
        for (const volume of chapterList) {
            if (Array.isArray(volume)) {
                for (const chapter of volume) {
                    if (chapter && chapter.itemId && chapter.title) {
                        chapters.push({
                            id: chapter.itemId.toString(),
                            title: chapter.title
                        });
                    }
                }
            }
        }
        
        return chapters;
    }

    async function downloadChaptersBatch(chapterIds, bookId) {
        try {
            if (!chapterIds || chapterIds.length === 0) {
                return chapterIds.map(() => ({
                    content: '[下载失败: 无章节ID]',
                    success: false
                }));
            }
            
            const ids = chapterIds.join(',');
            const url = `http://103.236.91.147:9999/api/content?tab=批量&item_ids=${ids}&book_id=${bookId}`;
            
            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    headers: { 
                        'User-Agent': 'okhttp/4.9.3',
                        'Accept': 'application/json'
                    },
                    onload: resolve,
                    onerror: reject,
                    timeout: 30000
                });
            });
            
            if (response.status !== 200) {
                throw new Error(`HTTP ${response.status}`);
            }
            
            let data;
            try {
                data = JSON.parse(response.responseText);
            } catch (e) {
                throw new Error('响应不是有效的JSON格式');
            }
            
            if (!data || data.code !== 200) {
                throw new Error(data?.message || '接口返回错误');
            }
            
            if (!data.data || !Array.isArray(data.data.chapters)) {
                throw new Error('接口返回数据格式错误');
            }
            
            const chapterMap = new Map();
            for (const chapter of data.data.chapters) {
                if (chapter) {
                    let itemId = null;
                    
                    if (chapter.novel_data && chapter.novel_data.item_id) {
                        itemId = chapter.novel_data.item_id.toString();
                    } else if (chapter.item_id) {
                        itemId = chapter.item_id.toString();
                    }
                    
                    if (itemId && chapter.content) {
                        chapterMap.set(itemId, {
                            content: chapter.content,
                            success: true
                        });
                    } else if (itemId) {
                        chapterMap.set(itemId, {
                            content: '',
                            success: false
                        });
                    }
                }
            }
            
            const results = [];
            for (const chapterId of chapterIds) {
                const chapterIdStr = chapterId.toString();
                const chapterData = chapterMap.get(chapterIdStr);
                
                if (chapterData && chapterData.success && chapterData.content) {
                    results.push({
                        content: formatContent(chapterData.content),
                        success: true
                    });
                } else {
                    results.push({
                        content: '[下载失败: 章节内容为空]',
                        success: false
                    });
                }
            }
            
            return results;
            
        } catch (error) {
            return chapterIds.map(() => ({
                content: `[下载失败: ${error.message}]`,
                success: false
            }));
        }
    }

    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');

        zip.file('mimetype', EPUB_TEMPLATES.MIMETYPE, { compression: 'STORE' });

        const metaInf = zip.folder('META-INF');
        metaInf.file('container.xml', EPUB_TEMPLATES.CONTAINER);

        const oebps = zip.folder('OEBPS');
        const textFolder = oebps.folder('Text');

        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);

        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 });
            } catch (e) {
            }
        }

        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);

        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, '<')
                .replace(/>/g, '>')
                .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>`);
        });

        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);

        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);

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

    async function main() {
        const ui = createDraggablePanel();
        
        let bookInfo, chapters;
        
        try {
            ui.updateStatus('downloading', '正在获取书籍信息...');
            bookInfo = await getBookInfo(bookId);
            
            ui.updateStatus('downloading', '正在获取章节列表...');
            chapters = await getChapters(bookId);
            
            ui.updateStats(chapters.length, 0, 0);
            ui.updateStatus('ready', `已获取 • ${chapters.length}个章节`);
            showNotification(`找到 ${chapters.length} 个章节`);
        } catch (error) {
            ui.updateStatus('error', '获取信息失败');
            showNotification('获取书籍信息失败: ' + error.message, false);
            console.error('获取信息失败:', error);
            return;
        }

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

        async function retryFailedChapters() {
            if (failedChapters.length === 0) return false;
            
            retryCount++;
            ui.showRetryInfo(retryCount, MAX_RETRY_COUNT);
            
            const chapterIds = failedChapters.map(ch => ch.id);
            const batchResults = await downloadChaptersBatch(chapterIds, bookId);
            
            for (let i = 0; i < batchResults.length; i++) {
                const result = batchResults[i];
                const chapterIndex = failedChapters[i].index;
                
                if (result.success) {
                    contents[chapterIndex] = result.content;
                    failedChapters = failedChapters.filter((_, idx) => idx !== i);
                    i--;
                    
                    successCount++;
                    failedCount--;
                }
            }
            
            ui.updateStats(chapters.length, successCount, failedCount);
            
            return failedChapters.length > 0 && retryCount < MAX_RETRY_COUNT;
        }

        async function startDownload(format) {
            if (isDownloading) return;
            isDownloading = true;
            
            ui.txtBtn.disabled = true;
            ui.epubBtn.disabled = true;
            ui.showProgress();
            ui.updateStatus('downloading', '下载中...');
            
            successCount = 0;
            failedCount = 0;
            contents = Array(chapters.length).fill('');
            failedChapters = [];
            retryCount = 0;
            
            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);
                
                ui.updateProgress(`下载第 ${i+1}/${batchCount} 批`, Math.round(((i) / batchCount) * 100));
                
                try {
                    const batchResults = await downloadChaptersBatch(chapterIds, bookId);
                    
                    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++;
                            failedChapters.push({
                                id: chapterIds[j],
                                title: batchChapters[j].title,
                                index: globalIndex
                            });
                        }
                    }
                    
                    ui.updateStats(chapters.length, successCount, failedCount);
                    const percent = Math.round(((i + 1) / batchCount) * 100);
                    ui.updateProgress(`下载第 ${i+1}/${batchCount} 批`, percent);
                    
                    if (i < batchCount - 1) {
                        await new Promise(resolve => setTimeout(resolve, 500));
                    }
                    
                } catch (error) {
                    for (let j = startIndex; j < endIndex; j++) {
                        contents[j] = `[下载失败: ${chapters[j].title}]`;
                        failedCount++;
                        failedChapters.push({
                            id: chapters[j].id,
                            title: chapters[j].title,
                            index: j
                        });
                    }
                    
                    ui.updateStats(chapters.length, successCount, failedCount);
                }
            }
            
            let hasMoreRetries = true;
            while (failedCount > 0 && retryCount < MAX_RETRY_COUNT && hasMoreRetries) {
                hasMoreRetries = await retryFailedChapters();
                if (hasMoreRetries) {
                    await new Promise(resolve => setTimeout(resolve, 1000));
                }
            }
            
            ui.hideRetryInfo();
            
            if (format === 'txt') {
                try {
                    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`);
                    
                    showNotification(`TXT下载完成!成功: ${successCount}, 失败: ${failedCount}`);
                    
                } catch (error) {
                    showNotification('生成TXT文件失败: ' + error.message, false);
                    console.error('生成TXT失败:', error);
                }
                
            } else if (format === 'epub') {
                try {
                    const epubBlob = await generateEPUB(bookInfo, chapters, contents, bookInfo.thumb_url);
                    saveAs(epubBlob, `${bookInfo.title}.epub`);
                    
                    showNotification(`EPUB下载完成!成功: ${successCount}, 失败: ${failedCount}`);
                    
                } catch (error) {
                    showNotification('生成EPUB失败: ' + error.message, false);
                    console.error('生成EPUB失败:', error);
                }
            }
            
            if (failedCount === 0) {
                ui.updateStatus('complete', '下载完成!');
            } else {
                ui.updateStatus('error', `${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();
    }
})();

图片[1]-某西红柿下载小说 油猴插件-暴富社区
© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容