// ==UserScript== // @name 番茄小说下载器 // @author 尘۝醉QQ:2510390189 // @version 2025.04.25.6 // @description 番茄小说下载 // @description:zh-cn 番茄小说下载 // @description:en Fanqienovel Downloader (Large Display) // @license MIT // @match https://fanqienovel.com/* // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js // @icon https://img.onlinedown.net/download/202102/152723-601ba1db7a29e.jpg // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect api5-normal-sinfonlineb.fqnovel.com // @connect i.snssdk.com // @namespace https://github.com/tampermonkey // @downloadURL none // ==/UserScript== (function() { 'use strict'; // 配置常量 const CONFIG = { REG_KEY: "ac25c67ddd8f38c1b37a2348828e222e", INSTALL_ID: "4427064614339001", SERVER_DEVICE_ID: "4427064614334905", AID: "1967", VERSION_CODE: "62532", MAX_CONCURRENT: 20, //批量下载数量 RETRY_TIMES: 5, //重试次数 RETRY_DELAY: 500 //重试延迟 }; // 界面样式 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:10px 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}.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}`); // 加密解密类 class FqCrypto { constructor(key) { this.key = this.hexToBytes(key); if (this.key.length !== 16) { throw new Error(`Invalid key length! Expected 16 bytes, got ${this.key.length}`); } this.cipherMode = { name: 'AES-CBC' }; } hexToBytes(hex) { const bytes = []; for (let i = 0; i < hex.length; i += 2) { bytes.push(parseInt(hex.substr(i, 2), 16)); } return new Uint8Array(bytes); } bytesToHex(bytes) { return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); } async encrypt(data, iv) { const cryptoKey = await crypto.subtle.importKey( 'raw', this.key, { name: 'AES-CBC' }, false, ['encrypt'] ); const encrypted = await crypto.subtle.encrypt( { name: 'AES-CBC', iv }, cryptoKey, this.pkcs7Pad(data) ); return new Uint8Array(encrypted); } async decrypt(data) { const iv = data.slice(0, 16); const ct = data.slice(16); const cryptoKey = await crypto.subtle.importKey( 'raw', this.key, { name: 'AES-CBC' }, false, ['decrypt'] ); const decrypted = await crypto.subtle.decrypt( { name: 'AES-CBC', iv }, cryptoKey, ct ); return this.pkcs7Unpad(new Uint8Array(decrypted)); } pkcs7Pad(data) { const blockSize = 16; const padding = blockSize - (data.length % blockSize); const padded = new Uint8Array(data.length + padding); padded.set(data); for (let i = data.length; i < padded.length; i++) { padded[i] = padding; } return padded; } pkcs7Unpad(data) { const padding = data[data.length - 1]; if (padding > 16) return data; for (let i = data.length - padding; i < data.length; i++) { if (data[i] !== padding) return data; } return data.slice(0, data.length - padding); } async generateRegisterContent(deviceId, strVal = "0") { if (!/^\d+$/.test(deviceId) || !/^\d+$/.test(strVal)) { throw new Error("Invalid device ID or value"); } const deviceIdBytes = new Uint8Array(8); /* global BigInt */ const deviceIdNum = BigInt(deviceId); for (let i = 0; i < 8; i++) { deviceIdBytes[i] = Number((deviceIdNum >> BigInt(i * 8)) & BigInt(0xFF)); } const strValBytes = new Uint8Array(8); const strValNum = BigInt(strVal); for (let i = 0; i < 8; i++) { strValBytes[i] = Number((strValNum >> BigInt(i * 8)) & BigInt(0xFF)); } const combined = new Uint8Array([...deviceIdBytes, ...strValBytes]); const iv = crypto.getRandomValues(new Uint8Array(16)); const encrypted = await this.encrypt(combined, iv); const result = new Uint8Array([...iv, ...encrypted]); return btoa(String.fromCharCode(...result)); } } // API客户端类 class FqClient { constructor(config) { this.config = config; this.crypto = new FqCrypto(config.REG_KEY); this.dynamicKey = null; this.keyExpireTime = 0; this.requestQueue = []; this.activeRequests = 0; } async throttledApiRequest(method, endpoint, params = {}, data = null) { return new Promise((resolve, reject) => { const execute = async () => { try { this.activeRequests++; const result = await this._apiRequest(method, endpoint, params, data); resolve(result); } catch (error) { reject(error); } finally { this.activeRequests--; this.processQueue(); } }; if (this.activeRequests < CONFIG.MAX_CONCURRENT) { execute(); } else { this.requestQueue.push(execute); } }); } processQueue() { while (this.requestQueue.length > 0 && this.activeRequests < CONFIG.MAX_CONCURRENT) { const nextRequest = this.requestQueue.shift(); nextRequest(); } } async _apiRequest(method, endpoint, params = {}, data = null) { const url = new URL(`https://api5-normal-sinfonlineb.fqnovel.com${endpoint}`); Object.keys(params).forEach(key => url.searchParams.append(key, params[key])); const headers = { "Cookie": `install_id=${this.config.INSTALL_ID}`, "User-Agent": "okhttp/4.9.3" }; if (data) { headers["Content-Type"] = "application/json"; } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: method, url: url.toString(), headers: headers, data: data ? JSON.stringify(data) : undefined, onload: (response) => { if (response.status >= 200 && response.status < 300) { try { resolve(JSON.parse(response.responseText)); } catch (e) { reject(new Error(`Failed to parse response: ${e.message}`)); } } else { reject(new Error(`API request failed with status ${response.status}`)); } }, onerror: (error) => { reject(new Error(`API request error: ${error.error}`)); }, timeout: 10000 }); }); } async getContentKeys(itemIds) { const itemIdsStr = Array.isArray(itemIds) ? itemIds.join(',') : itemIds; return this.throttledApiRequest( "GET", "/reading/reader/batch_full/v", { item_ids: itemIdsStr, req_type: "1", aid: this.config.AID, update_version_code: this.config.VERSION_CODE } ); } async getDecryptionKey() { const now = Date.now(); if (this.dynamicKey && this.keyExpireTime > now) { return this.dynamicKey; } const content = await this.crypto.generateRegisterContent(this.config.SERVER_DEVICE_ID); const payload = { content: content, keyver: 1 }; const result = await this.throttledApiRequest( "POST", "/reading/crypt/registerkey", { aid: this.config.AID }, payload ); const encryptedKey = Uint8Array.from(atob(result.data.key), c => c.charCodeAt(0)); const decryptedKey = await this.crypto.decrypt(encryptedKey); this.dynamicKey = this.crypto.bytesToHex(decryptedKey); this.keyExpireTime = now + 3600000; return this.dynamicKey; } async decryptContent(encryptedContent) { const dynamicKey = await this.getDecryptionKey(); const contentCrypto = new FqCrypto(dynamicKey); const decoded = Uint8Array.from(atob(encryptedContent), c => c.charCodeAt(0)); const decrypted = await contentCrypto.decrypt(decoded); const decompressed = await this.gunzip(decrypted); return new TextDecoder().decode(decompressed); } async gunzip(data) { /* global DecompressionStream */ const ds = new DecompressionStream('gzip'); const writer = ds.writable.getWriter(); writer.write(data); writer.close(); return new Response(ds.readable).arrayBuffer().then(arrayBuffer => new Uint8Array(arrayBuffer)); } } // 辅助函数 function decodeHtmlEntities(str) { const entities={'"':'"',''':"'",'&':'&','<':'<','>':'>'}; 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-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); } function formatContent(content) { let decoded = decodeHtmlEntities(content); return decoded.replace(/

<\/p>/g,'').replace(/

/g,'').replace(//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 btn = document.createElement('button'); btn.className = 'tamper-button'; btn.textContent = '下载全本'; container.appendChild(btn); const statsContainer = document.createElement('div'); statsContainer.className = 'stats-container'; const totalStat = document.createElement('div'); totalStat.className = 'stat-item'; totalStat.innerHTML = `

总章节
0
`; const successStat = document.createElement('div'); successStat.className = 'stat-item'; successStat.innerHTML = `
成功
0
`; const failedStat = document.createElement('div'); failedStat.className = 'stat-item'; failedStat.innerHTML = `
失败
0
`; statsContainer.appendChild(totalStat); statsContainer.appendChild(successStat); statsContainer.appendChild(failedStat); container.appendChild(statsContainer); document.body.appendChild(container); return { container, btn, updateStats: (total, success, failed) => { totalStat.querySelector('.stat-value').textContent = total; successStat.querySelector('.stat-value').textContent = success; failedStat.querySelector('.stat-value').textContent = failed; } }; } async function getBookInfo(bookId) { const url = `https://i.snssdk.com/reading/bookapi/multi-detail/v/?aid=1967&book_id=${bookId}`; const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, headers: { 'User-Agent': 'okhttp/4.9.3' }, onload: resolve, onerror: reject, timeout: 8000 }); }); if (response.status !== 200) throw new Error(`HTTP ${response.status}`); const data = JSON.parse(response.responseText); if (!data.data || !data.data[0]) throw new Error('未获取到书籍信息'); const book = data.data[0]; return { title: sanitizeFilename(book.book_name), author: sanitizeFilename(book.author), abstract: book.abstract, wordCount: book.word_number, chapterCount: book.serial_count, infoText: `书名:${book.book_name}\n作者:${book.author}\n字数:${parseInt(book.word_number)/10000}万字\n章节数:${book.serial_count}\n简介:${book.abstract}\n免责声明:本小说下载器仅为个人学习、研究或欣赏目的提供便利,下载的小说版权归原作者及版权方所有。若因使用本下载器导致任何版权纠纷或法律问题,使用者需自行承担全部责任。` }; } async function getChapters(bookId) { const url = `https://fanqienovel.com/api/reader/directory/detail?bookId=${bookId}`; const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, headers: { 'User-Agent': 'okhttp/4.9.3' }, onload: resolve, onerror: reject, timeout: 8000 }); }); if (response.status !== 200) throw new Error(`HTTP ${response.status}`); const text = response.responseText; const chapterListMatch = text.match(/"chapterListWithVolume":\[(.*?)\]]/); if (!chapterListMatch) throw new Error('未找到章节列表'); const chapterListStr = chapterListMatch[1]; const itemIds = chapterListStr.match(/"itemId":"(.*?)"/g).map(m => m.match(/"itemId":"(.*?)"/)[1]); const titles = chapterListStr.match(/"title":"(.*?)"/g).map(m => m.match(/"title":"(.*?)"/)[1]); return itemIds.map((id, index) => ({ id: id, title: titles[index] || `第${index+1}章` })); } async function downloadChapter(client, chapter) { try { const encrypted = await client.getContentKeys(chapter.id); if (!encrypted.data || !encrypted.data[chapter.id]) { throw new Error('未获取到章节内容'); } const decrypted = await client.decryptContent(encrypted.data[chapter.id].content); return { title: chapter.title, content: formatContent(decrypted), success: true }; } catch (error) { console.error(`下载章节 ${chapter.title} 失败:`, error); return { title: chapter.title, content: `[下载失败: ${chapter.title}]`, success: false }; } } async function downloadAllChapters(client, bookInfo, chapters, ui) { let content = `${bookInfo.infoText}\n\n`; const batchSize = CONFIG.MAX_CONCURRENT; let downloaded = 0; let successCount = 0; let failedCount = 0; // 初始化统计 ui.updateStats(chapters.length, 0, 0); // 批量下载函数 const downloadBatch = async (startIndex) => { const endIndex = Math.min(startIndex + batchSize, chapters.length); const batch = chapters.slice(startIndex, endIndex); const promises = batch.map(chapter => downloadChapter(client, chapter) .then(result => { downloaded++; if (result.success) { successCount++; } else { failedCount++; } ui.updateStats(chapters.length, successCount, failedCount); return result; }) ); return Promise.all(promises); }; // 分批下载所有章节 for (let i = 0; i < chapters.length; i += batchSize) { const batchResults = await downloadBatch(i); for (const result of batchResults) { content += `\n\n${result.title}\n${result.content}`; } } return content; } async function handleBookPage(client, bookId) { // 获取书籍信息 let bookInfo, chapters; try { bookInfo = await getBookInfo(bookId); chapters = await getChapters(bookId); } catch (error) { console.error('初始化失败:', error); showNotification('获取书籍信息失败', false); return; } // 创建下载UI const ui = createDownloadUI(); ui.btn.addEventListener('click', async () => { if (ui.btn.disabled) return; ui.btn.disabled = true; ui.btn.textContent = '准备下载...'; if (!confirm(`即将下载《${bookInfo.title}》全本,共${chapters.length}章,是否继续?`)) { ui.btn.disabled = false; ui.btn.textContent = '下载全本'; return; } ui.btn.textContent = '下载中...'; try { const startTime = Date.now(); const content = await downloadAllChapters(client, bookInfo, chapters, ui); const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); /* global saveAs */ saveAs(blob, `${bookInfo.title}.txt`); const duration = ((Date.now() - startTime) / 1000).toFixed(1); showNotification(`下载完成!共${chapters.length}章,耗时${duration}秒`); ui.btn.textContent = '下载完成'; } catch (error) { console.error('下载失败:', error); showNotification('下载失败: ' + error.message, false); ui.btn.textContent = '下载失败'; } finally { ui.btn.disabled = true; } }); } // 主入口 async function main() { const pathMatch = window.location.pathname.match(/\/page\/(\d+)/); if (!pathMatch) return; const bookId = pathMatch[1]; const client = new FqClient(CONFIG); await handleBookPage(client, bookId); } // 启动主逻辑 if (document.readyState === 'complete' || document.readyState === 'interactive') { setTimeout(main, 1000); } else { document.addEventListener('DOMContentLoaded', main); } })();