// ==UserScript== // @name Fanqie Novel Free Reading // @namespace https://github.com/SmashPhoenix272 // @version 6.0.0 // @description 番茄小说免费网页阅读 不用客户端 可下载小说 // @description:zh-cn 番茄小说免费网页阅读 不用客户端 可下载小说 // @description:en Fanqie Novel Reading, No Need for a Client, Novels Available for Download // @author ibxff, SmashPhoenix272 // @license MIT License // @match https://fanqienovel.com/* // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js // @icon data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDQ4IDQ4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0zNS40Mjg2IDQuODg0MzVDMzkuNjQ2MyA0Ljg4NDM1IDQzLjA4MTYgOC4zMTk3MyA0My4wODE2IDEyLjUzNzRWMzUuNDI4NkM0My4wODE2IDM5LjY0NjMgMzkuNjQ2MyA0My4wODE2IDM1LjQyODYgNDMuMDgxNkgxMi41Mzc0QzguMzE5NzMgNDMuMDgxNiA0Ljg4NDM1IDM5LjY0NjMgNC44ODQzNSAzNS40Mjg2VjEyLjUzNzRDNC44ODQzNSA4LjMxOTczIDguMzE5NzMgNC44ODQzNSAxMi41Mzc0IDQuODg0MzVIMzUuNDI4NlpNMzUuNDI4NiA0SDEyLjUzNzRDNy44MDk1MiA0IDQgNy44MDk1MiA0IDEyLjUzNzRWMzUuNDI4NkM0IDQwLjE1NjUgNy44MDk1MiA0My45NjYgMTIuNTM3NCA0My45NjZIMzUuNDI4NkM0MC4xNTY1IDQzLjk2NiA0My45NjYgNDAuMTU2NSA0My45NjYgMzUuNDI4NlYxMi41Mzc0QzQ0IDcuODA5NTIgNDAuMTU2NSA0IDM1LjQyODYgNFoiIGZpbGw9IiMzMzMiLz48cGF0aCBkPSJNMjkuMTAxNiA0VjEyLjQwMTRMMzIuMzMyOSAxMC41NjQ2TDM1LjU2NDEgMTIuNDAxNFY0SDI5LjEwMTZaIiBmaWxsPSIjMzMzIi8+PHBhdGggZD0iTTI0LjAzNCAxOC4yODU4QzE1LjgzNjcgMTguMjg1OCA4LjU1NzgyIDIxLjg1NzIgNCAyNy4zNjc0VjM1LjQyODZDNCA0MC4xNTY1IDcuODA5NTIgNDMuOTY2IDEyLjUzNzQgNDMuOTY2SDM1LjQyODZDNDAuMTU2NSA0My45NjYgNDMuOTY2IDQwLjE1NjUgNDMuOTY2IDM1LjQyODZWMjcuMjY1NEMzOS40MDgyIDIxLjc4OTIgMzIuMTk3MyAxOC4yODU4IDI0LjAzNCAxOC4yODU4Wk0xNC42MTIyIDM3LjY3MzVDMTMuMTE1NiAzNy42NzM1IDEyLjQwMTQgMzcuMTI5MyAxMi40MDE0IDM2LjQxNUMxMi40MDE0IDM1LjcwMDcgMTMuMDgxNiAzNS4xMjI1IDE0LjU3ODIgMzUuMTIyNUMxNi4wNzQ4IDM1LjEyMjUgMTcuODc3NiAzNi4zODEgMTcuODc3NiAzNi4zODFDMTcuODc3NiAzNi4zODEgMTYuMTA4OCAzNy42NzM1IDE0LjYxMjIgMzcuNjczNVpNMTUuODM2NyAzMS4yMTA5QzE0Ljc0ODMgMzAuMTU2NSAxNC42NDYzIDI5LjI3MjIgMTUuMTU2NSAyOC43NjJDMTUuNjY2NyAyOC4yNTE4IDE2LjU1MSAyOC4zMTk4IDE3LjYzOTUgMjkuNDA4MkMxOC43Mjc5IDMwLjQ2MjYgMTkuMDY4IDMyLjYwNTUgMTkuMDY4IDMyLjYwNTVDMTkuMDY4IDMyLjYwNTUgMTYuODkxMiAzMi4yNjU0IDE1LjgzNjcgMzEuMjEwOVpNMjQuMDM0IDMwLjQ2MjZDMjQuMDM0IDMwLjQ2MjYgMjIuNzQxNSAyOC43Mjc5IDIyLjcwNzUgMjcuMTk3M0MyMi43MDc1IDI1LjcwMDcgMjMuMjUxNyAyNC45ODY0IDIzLjk2NiAyNC45ODY0QzI0LjY4MDMgMjQuOTg2NCAyNS4yNTg1IDI1LjY2NjcgMjUuMjU4NSAyNy4xNjMzQzI1LjI5MjUgMjguNjkzOSAyNC4wMzQgMzAuNDYyNiAyNC4wMzQgMzAuNDYyNlpNMzAuMzYwNSAyOS4zNzQyQzMxLjQ0OSAyOC4zMTk4IDMyLjMzMzMgMjguMjUxOCAzMi44NDM1IDI4LjcyNzlDMzMuMzUzNyAyOS4yMzgxIDMzLjI1MTcgMzAuMTIyNSAzMi4xNjMzIDMxLjE3NjlDMzEuMDc0OCAzMi4yMzEzIDI4LjkzMiAzMi41Mzc1IDI4LjkzMiAzMi41Mzc1QzI4LjkzMiAzMi41Mzc1IDI5LjI3MjEgMzAuNDI4NiAzMC4zNjA1IDI5LjM3NDJaTTMzLjM1MzcgMzcuNjczNUMzMS44NTcxIDM3LjY3MzUgMzAuMDg4NCAzNi4zNDcgMzAuMDg4NCAzNi4zNDdDMzAuMDg4NCAzNi4zNDcgMzEuODU3MSAzNS4wODg1IDMzLjM4NzggMzUuMDg4NUMzNC44ODQ0IDM1LjA4ODUgMzUuNTk4NiAzNS43MDA3IDM1LjU2NDYgMzYuMzgxQzM1LjU2NDYgMzcuMTI5MyAzNC44NTAzIDM3LjY3MzUgMzMuMzUzNyAzNy42NzM1WiIgZmlsbD0iIzMzMyIvPjwvc3ZnPg== // @grant GM_xmlhttpRequest // @updateURL // @downloadURL none // ==/UserScript== // Configuration const CONFIG = { REG_KEY: "ac25c67ddd8f38c1b37a2348828e222e", INSTALL_ID: "4427064614339001", SERVER_DEVICE_ID: "4427064614334905", AID: "1967", VERSION_CODE: "62532" }; // FqCrypto class for encryption/decryption 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); 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 Client class class FqClient { constructor(config) { this.config = config; this.crypto = new FqCrypto(config.REG_KEY); this.dynamicKey = null; this.keyExpireTime = 0; } async getContentKeys(itemIds) { const itemIdsStr = Array.isArray(itemIds) ? itemIds.join(',') : itemIds; return this._apiRequest( "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._apiRequest( "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 _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 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) { 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)); } } // UI and Helper Functions const styleElement = document.createElement("style"); const cssRule = ` @keyframes hideAnimation { 0% { opacity: 1; } 50% { opacity: 0.75; } 100% { opacity: 0; display: none; } } option:checked { background-color: #ffb144; color: white; } `; styleElement.innerHTML = cssRule; document.head.appendChild(styleElement); function hideElement(ele) { if (!ele) return; ele.style.animation = "hideAnimation 1.5s ease"; ele.addEventListener("animationend", function () { ele.style.display = "none"; }); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } const mark = (ele) => { if (ele) ele.style.boxShadow = "0px 0px 50px rgba(0, 0, 0, 0.2)"; }; // Common function to process chapter content function processChapterContent(content) { // Clean up content first const cleanContent = content .replace(/
.*?<\/header>/g, '') // Remove header .replace(/