// ==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
// ==/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>.*?<\/header>/g, '') // Remove header
.replace(/<footer>.*?<\/footer>/g, '') // Remove footer
.replace(/<article>/g, '') // Remove article opening tag
.replace(/<\/article>/g, '') // Remove article closing tag
.replace(/<p>/g, '') // Remove paragraph opening tags
.replace(/<\/p>/g, '\n') // Replace closing p tags with newlines
.replace(/ /g, ' ') // Replace HTML spaces
.replace(/</g, '<') // Replace HTML entities
.replace(/>/g, '>')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/\n\s*\n/g, '\n') // Remove multiple consecutive newlines
.replace(/^\s+|\s+$/gm, ''); // Trim each line
// Process paragraphs with indentation
return cleanContent
.split('\n')
.filter(para => para.trim()) // Remove empty paragraphs
.map(para => ' ' + para.trim()) // Add two full-width spaces for indentation
.join('\n');
}
function processContentForDownload(content) {
// Extract chapter title from content
const titleMatch = content.match(/<title>(.+?)<\/title>/);
let title = titleMatch ? titleMatch[1] : '';
title = title.replace(/在线免费阅读_番茄小说官网$/, '');
// Process content using common function
const processedContent = processChapterContent(content);
// Combine title and content with proper formatting
return `${title}\n${processedContent}`.trim();
}
// Main Script
(function() {
'use strict';
const client = new FqClient(CONFIG);
const path = window.location.href.match(/\/([^/]+)\/\d/)?.[1];
switch(path) {
case 'reader':
handleReaderPage(client);
break;
case 'page':
handleBookPage(client);
break;
}
})();
async function handleReaderPage(client) {
const toolbar = document.querySelector("#app > div > div > div > div.reader-toolbar > div > div.reader-toolbar-item.reader-toolbar-item-download");
const text = toolbar?.querySelector('div:nth-child(2)');
if (toolbar && text) {
mark(toolbar);
text.innerHTML = 'Processing';
}
document.title = document.title.replace(/在线免费阅读_番茄小说官网$/, '');
var currentURL = window.location.href;
setInterval(() => window.location.href !== currentURL ? location.reload() : null, 1000);
let cdiv = document.getElementsByClassName('muye-reader-content noselect')[0];
if (cdiv) {
cdiv.classList = cdiv.classList[0];
} else {
const html0 = document.getElementById('html_0');
if (!html0) return;
cdiv = html0.children[2] || html0.children[0];
if (!cdiv) return;
}
try {
const url = window.location.href;
const match = url.match(/\/(\d+)/);
if (!match) return;
const chapterId = match[1];
const response = await client.getContentKeys(chapterId);
if (!response.data || !response.data[chapterId]) {
throw new Error('No content found for chapter');
}
const decrypted = await client.decryptContent(response.data[chapterId].content);
document.getElementsByClassName('muye-to-fanqie')[0]?.remove();
document.getElementsByClassName('pay-page')[0]?.remove();
cdiv.innerHTML = decrypted;
document.getElementById('html_0')?.classList.remove('pay-page-html');
if (toolbar && text) {
toolbar.style.backgroundColor = '#B0E57C';
text.innerHTML = 'Success';
hideElement(toolbar);
}
} catch (error) {
console.error('Error:', error);
if (toolbar && text) {
toolbar.style.backgroundColor = 'pink';
text.innerHTML = 'Failed';
hideElement(toolbar);
}
}
}
async function handleBookPage(client) {
const infoName = document.querySelector("#app > div > div.muye.muye-page > div > div.page-wrap > div > div.page-header-info > div.info > div.info-name > h1")?.innerHTML;
const authorName = document.querySelector(".author-name-text")?.innerHTML;
const totalChapters = document.querySelector(".page-directory-header h3")?.textContent.match(/(\d+)章/)?.[1] || '';
const infoLabels = Array.from(document.querySelectorAll('.info-label span')).map(span => span.textContent).join(' ');
const wordCount = document.querySelector('.info-count-word')?.textContent.trim();
const lastUpdate = document.querySelector('.info-last')?.textContent.trim();
const abstract = document.querySelector("#app > div > div.muye.muye-page > div > div.page-body-wrap > div > div.page-abstract-content > p")?.innerHTML;
var content = 'Using Free Fanqie script download\n\n' +
'作者:' + authorName + '\n' +
'书名:' + infoName + '\n' +
'标签:' + infoLabels + '\n' +
'字数:' + wordCount + '\n' +
'更新:' + lastUpdate + '\n\n' +
'简介:' + abstract + '\n';
content = content.replace(/undefined|null|NaN/g,'');
await setupDownloadUI(client, content, infoName, totalChapters);
}
async function setupDownloadUI(client, content, infoName, totalChapters) {
await sleep(1500);
document.querySelector("#app > div > div.muye.muye-page > div > div.page-wrap > div > div.page-header-info > div.info > div.download-icon.muyeicon-tomato")?.remove();
const download = document.querySelector("#app > div > div.muye.muye-page > div > div.page-wrap > div > div.page-header-info > div.info > a");
if (!download) return;
const downloadSpan = download.querySelector('button > span');
if (downloadSpan) downloadSpan.innerHTML = '*Download Novel';
download.href = 'javascript:void 0';
const parentElement = document.querySelector("#app > div > div.muye.muye-page > div > div.page-wrap > div > div.page-header-info > div.info");
if (!parentElement) return;
const selectElement = createEncodingSelect();
parentElement.appendChild(selectElement);
const books = Array.from(document.getElementsByClassName('chapter-item'));
async function downloadChapter(chapterId, ele) {
try {
const response = await client.getContentKeys(chapterId);
if (!response.data || !response.data[chapterId]) {
throw new Error('No content found');
}
const chapterData = response.data[chapterId];
const decrypted = await client.decryptContent(chapterData.content);
const title = chapterData.title || ele.textContent;
const processedContent = processChapterContent(decrypted);
return `\n\n${title}\n${processedContent}`;
} catch (error) {
console.error(`Failed to download chapter ${chapterId}:`, error);
ele.style.backgroundColor = 'pink';
ele.style.border = "2px solid pink";
return null;
}
}
async function downloadNext() {
if (!books.length) return;
const ele = books[0].querySelector('a');
if (!ele) return;
ele.style.border = "3px solid navajowhite";
ele.style.borderRadius = "5px";
ele.style.backgroundColor = "navajowhite";
const match = ele.href.match(/\/(\d+)/);
if (!match) return;
const chapterId = match[1];
const chapterContent = await downloadChapter(chapterId, ele);
if (chapterContent) {
content += '\n\n' + chapterContent;
ele.style.backgroundColor = '#D2F9D1';
ele.style.border = "2px solid #D2F9D1";
}
books.shift();
if (!books.length) {
const charset = selectElement.value;
const blob = new Blob([new TextEncoder(charset).encode(content)],
{ type: `text/plain;charset=${charset}` });
const fileName = totalChapters ?
`${infoName}_${totalChapters}章.txt` :
`${infoName}.txt`;
saveAs(blob, fileName);
} else {
downloadNext();
}
}
download.addEventListener('click', downloadNext);
download.addEventListener('click', () => {
download.style.display = 'none';
selectElement.style.display = 'none';
});
}
function createEncodingSelect() {
const selectElement = document.createElement("select");
selectElement.className = "byte-btn byte-btn-primary byte-btn-size-large byte-btn-shape-square muye-button";
const options = ["UTF-8", "GBK", "UNICODE", "UTF-16", "ASCII"];
options.forEach(opt => {
const option = document.createElement("option");
option.text = opt;
option.value = opt;
selectElement.appendChild(option);
});
Object.assign(selectElement.style, {
position: "absolute",
left: "320px",
bottom: "0px",
height: "32px",
width: "80px",
fontSize: "15px"
});
return selectElement;
}