油猴添加脚本教程
方法一:通过管理面板直接创建
这是最常用和便捷的方式,适合大多数用户。
- 打开管理面板:点击浏览器工具栏中的油猴插件图标,在弹出的菜单中选择“管理面板”或“仪表盘”。
- 新建脚本:在管理面板中,点击“添加新脚本”或“创建一个新脚本”按钮,系统会打开一个脚本编辑器13。
- 粘贴代码:将您自己编写好的脚本代码(通常以
// ==UserScript==开头)全部粘贴到编辑器中,覆盖默认的模板内容16。 - 保存脚本:点击编辑器右上角的“保存”按钮(或使用快捷键
Ctrl+S),油猴会自动完成脚本的安装并提示成功17。 17
方法二:通过导入本地文件
如果您已经将脚本保存为 .user.js 文件,可以使用此方法导入。
- 打开管理面板:同样先点击油猴图标,进入“管理面板”。
- 进入脚本页面:在管理面板中,切换到“脚本”标签页18。
- 从文件安装:点击左上角的“实用工具”菜单(或类似选项),选择“从文件安装…”1。
- 选择文件:在弹出的系统对话框中,找到并选中您本地电脑上的
.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]-某西红柿下载小说 油猴插件-暴富社区](https://test.fukit.cn/autoupload/f/cYrp9hTdWD7eQWbwlhdB5diO_OyvX7mIgxFBfDMDErs/20260301/irzo/675X1500/image.png/webp)
© 版权声明
THE END












暂无评论内容