// ==UserScript==
// @name 晋江小说下载
// @namespace http://tampermonkey.net/
// @version 2024-09-18
// @description 支持下载晋江文学城小说(需要自定义解析字体加密)
// @author You
// @match https://m.jjwxc.net/book2/*/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=jjwxc.net
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// Your code here...
/**
* @typedef Chapter 章节信息
* @property {string} title 章节标题
* @property {string} url 章节地址
* @property {string[]} content 章节内容
* @property {string} html 章节网页
* @property {'init' | 'work' | 'ok' | 'fail'} status 章节状态
*/
waitForDownload()
.then(url => resolveIndex(url))
.then(chapters => resolveChapters(chapters, 2))
.then(chapters => saveChapters(chapters))
.catch(err => console.error(err))
.finally(() => window.location.reload());
function waitForDownload() {
let move = false;
let x = 0, y = 0;
// 创建按钮并设置样式
const btn = document.createElement("button");
Object.assign(btn.style, {
position: "fixed",
bottom: "20px", right: "20px",
width: "60px", height: "60px",
borderRadius: "50%"
})
btn.innerText = "下载"
document.body.appendChild(btn);
// 定义按钮移动事件
btn.addEventListener("mousedown", event => {
x = event.x;
y = event.y;
move = true;
});
document.addEventListener("mouseup", () => {
move = false;
});
document.addEventListener("mousemove", event => {
if (!move) {
return;
}
btn.style.bottom = parseInt(btn.style.bottom.replace("px", "")) - event.y + y + "px";
btn.style.right = parseInt(btn.style.right.replace("px", "")) - event.x + x + "px";
x = event.x;
y = event.y;
});
// 获取当前目录地址
function findIndexUrl() {
for (const a of document.querySelectorAll("a")) {
if ("目录" === a.innerText) {
return a.href + "?more=0&whole=1";
}
}
throw new Error("没找到目录地址");
}
return new Promise(resolve => {
const url = findIndexUrl();
btn.addEventListener("click", () => resolve(url));
});
}
function readBlobAsText(blob, charset) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsText(blob, charset);
reader.onload = () => resolve(reader.result);
reader.onerror = err => reject(err);
});
}
async function downloadPage(url, charset = "gbk") {
const res = await fetch(url);
// 读取文本
if (res.status >= 200 && res.status < 300) {
const blob = await res.blob();
const text = await readBlobAsText(blob, charset);
return "//@url=" + url + "\n" + text;
}
// 抛出异常
const error = new Error(res.statusText);
error.name = res.status.toString();
error.response = res;
throw error;
}
/**
* 解析主页, 并提取出章节列表
*
* @param {string} indexUrl 主页地址
* @returns {Promise<Chapter[]>} 章节列表
*/
async function resolveIndex(indexUrl) {
function numOfLinkCount(ele, map) {
if (ele.nodeName === "A") {
map.set(ele, 1);
return 1;
}
let count = 0;
for (const node of ele.children) {
count += numOfLinkCount(node, map);
}
map.set(ele, count);
return count;
}
function chapterOf(title, url) {
return {title, url: url.split("?")[0], content: '', html: '', status: 'init'};
}
const html = await downloadPage(indexUrl);
// 解析出文档对象
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// 统计每个节点下的超链接个数
const map = new Map();
const count = numOfLinkCount(doc.body, map);
// 查找主干节点
const main = mainNode(doc.body, map, count);
// 获取主干节点下的超链接, 并解析成章节对象
const chapters = [];
main.querySelectorAll('a').forEach(a => chapters.push(chapterOf(a.innerText, a.href)))
return chapters;
}
/**
* 查找主干节点
*
* @param {Element} ele 根节点
* @param {Map<Element, number>} map 节点数值映射
* @param {number} count 根节点数值
* @returns {Element} 主干节点
*/
function mainNode(ele, map, count) {
const half = count / 2;
for (const node of ele.children) {
const num = map.get(node) || 0;
if (num > half) {
return mainNode(node, map, num);
}
}
return ele;
}
function resolveChapters(chapters, limit = 5) {
function findChapter(html) {
const url = /@url=(.+?)\n/.exec(html)[1]
for (const chapter of chapters) {
if (chapter.url === url) {
chapter.html = html;
return chapter;
}
}
// 不可能抛出的异常
throw 'chapter not found';
}
return new Promise(resolve => {
let working = 0, complete = 0;
showProgress(chapters);
function submit() {
if (working >= limit || complete >= chapters.length) {
return;
}
for (const chapter of chapters.filter(o => o.status === 'init')) {
if (chapter.status !== 'init') {
continue;
}
chapter.status = 'work';
downloadPage(chapter.url)
.then(html => findChapter(html))
.then(chapter => renderAndGetContent(chapter))
.then(chapter => setFont(chapter))
.finally(() => {
++complete === chapters.length && resolve(chapters);
working--;
submit();
});
if (++working >= limit) {
break;
}
}
}
submit();
});
}
function showProgress(chapters) {
const dialog = document.createElement("dialog");
dialog.style.width = "300px";
dialog.id = "modal";
dialog.innerText = "正在解析章节, 请稍等...";
document.body.appendChild(dialog);
// 显示对话框, 并每隔1秒钟刷新一次信息
dialog.showModal();
const id = setInterval(() => {
const fail = chapters.filter(o => o.status === 'fail').length;
const ok = chapters.filter(o => o.status === 'ok').length;
dialog.innerText = `正在解析内容(${fail + ok}/${chapters.length}), 失败 ${fail}, 成功 ${ok}`;
if (ok + fail === chapters.length) {
dialog.close();
document.body.removeChild(dialog);
clearInterval(id);
}
}, 1000);
}
function renderAndGetContent(chapter) {
return new Promise((resolve, reject) => {
// 创建一个frame用于加载文档
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
// 加载文档内容
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(removeImages(chapter.html));
doc.close();
// iframe加载完毕后执行
iframe.onload = () => {
clearAllInterval(iframe.contentWindow);
try {
chapter.title = doc.querySelector('h2').innerText;
chapter.content = getContent(doc);
chapter.status = 'ok';
resolve(chapter);
} catch (err) {
chapter.status = 'fail';
reject(err);
} finally {
document.body.removeChild(iframe);
}
};
// iframe加载失败
iframe.onerror = (err) => {
chapter.status = 'fail';
document.body.removeChild(iframe);
reject(err);
}
});
}
function removeImages(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// 移除全部图片
const arr = doc.querySelectorAll("img");
for (let i = arr.length - 1; i >= 0; i--) {
arr[i].remove();
}
// 获取网页文本
return doc.documentElement.outerHTML;
}
function clearAllInterval(win) {
// 清除全部定时器
const id = win.setInterval(() => {
}, 1000);
for (let i = 0; i <= id; i++) {
win.clearInterval(i);
}
}
function getContent(doc) {
function pseudoElement(node, style) {
const content = window.getComputedStyle(node, style).getPropertyValue("content");
return content.replaceAll("none", "").replaceAll("\"", "").trim();
}
function mainText(ele, result) {
for (const node of ele.childNodes) {
if ("script" === node.nodeName.toLowerCase()) {
continue;
}
if (node.nodeType === Node.TEXT_NODE) {
const str = node.wholeText.trim();
str.length > 0 && result.push(str);
} else if (node.nodeType === Node.ELEMENT_NODE) {
const before = pseudoElement(node, "before");
before.length > 0 && result.push(before);
mainText(node, result);
const after = pseudoElement(node, "after");
after.length > 0 && result.push(after);
}
if (["br", "p"].includes(node.nodeName.toLowerCase())) {
result.push("\n");
}
}
return result;
}
// 查找主干节点
const main = doc.querySelector('.content_ul');
// 收集主干节点下的全部文本
return mainText(main, []);
}
function saveChapters(chapters) {
const tmpName = /(?<=《).+(?=》)/.exec(document.title)[0].replace(/[/\\?%*:|"<>]/g, " ");
const name = prompt("章节下载成功, 请输入小说名", tmpName);
// 合并章节内容
const content = mergeChapters(chapters);
// 保存为文件
const a = document.createElement("a");
const url = URL.createObjectURL(new Blob([content], {type: "text/plain"}));
a.href = url;
a.download = name.endsWith(".txt") ? name : name + ".txt";
a.click();
URL.revokeObjectURL(url);
// 清理章节对象
for (let i = 0; i < chapters.length; i++) {
chapters[i] = null;
}
}
function mergeChapters(chapters) {
const result = [];
chapters.forEach(chapter => {
result.push(chapter.title);
chapter.status ? result.push(chapter.content.join('')) : result.push("下载失败: " + chapter.url);
});
const fail = chapters.filter(chapter => chapter.status === 'fail').length;
fail > 0 && result.unshift(`下载失败(${fail}/${chapters.length})`);
return result.join("\n")
}
function setFont(chapter) {
if (!chapter.url.includes("/vip/")) {
return chapter;
}
const group = /fonts\/(.+?)\.woff2/.exec(chapter.html);
if (group) {
chapter.content.unshift(`//@font=${group[1]}\n`);
}
return chapter;
}
})();