// ==UserScript==
// @name 轻小说文库下载 (优化版)
// @namespace wenku8_dl_ug
// @version 2.3.2
// @author HaoaW (Original) raventu (Refactor)
// @description wenku8_dl_ug is a userscript for downloading Wenku8 novels. wenku8 下载器, 支持批量下载、EPUB格式转换、简繁体转换等功能。
// @icon https://www.wenku8.net/favicon.ico
// @source https://github.com/Raven-tu/wenku8_dl_ug
// @match *://www.wenku8.net/*
// @match *://www.wenku8.cc/*
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/full.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js
// @connect wenku8.com
// @connect wenku8.cc
// @connect app.wenku8.com
// @connect dl.wenku8.com
// @connect img.wenku8.com
// @grant GM_info
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// ==/UserScript==
(function (fileSaver, JSZip$1) {
'use strict';
var _GM_xmlhttpRequest = /* @__PURE__ */ (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)();
var _unsafeWindow = /* @__PURE__ */ (() => typeof unsafeWindow != "undefined" ? unsafeWindow : void 0)();
const OpenCCConverter = {
COOKIE_KEY: "OpenCCwenku8",
TARGET_ENCODING_COOKIE_KEY: "targetEncodingCookie",
// 假设页面脚本定义了这个key
COOKIE_DAYS: 7,
buttonElement: null,
isConversionEnabled: false,
originalSimplized: null,
originalTraditionalized: null,
/**
* 初始化 OpenCC 转换功能
* @param {object} pageGlobals - 包含页面全局变量的对象 (如 unsafeWindow)
*/
init(pageGlobals) {
this.originalSimplized = pageGlobals.Simplized;
this.originalTraditionalized = pageGlobals.Traditionalized;
if (typeof pageGlobals.OpenCC === "undefined") {
console.warn("OpenCC库未加载,简繁转换功能可能受限。");
return;
}
this.isConversionEnabled = this.getCookie(this.COOKIE_KEY, pageGlobals) === "1";
this.buttonElement = document.createElement("a");
this.buttonElement.href = "javascript:void(0);";
this.buttonElement.addEventListener("click", () => this.toggleConversion(pageGlobals));
this.updateButtonText();
if (this.isConversionEnabled) {
if (typeof pageGlobals.Traditionalized !== "undefined") {
pageGlobals.Traditionalized = pageGlobals.OpenCC.Converter({ from: "cn", to: "tw" });
}
if (typeof pageGlobals.Simplized !== "undefined") {
pageGlobals.Simplized = pageGlobals.OpenCC.Converter({ from: "tw", to: "cn" });
}
if (this.getCookie(this.TARGET_ENCODING_COOKIE_KEY, pageGlobals) === "2" && typeof pageGlobals.translateBody === "function") {
pageGlobals.targetEncoding = "2";
pageGlobals.translateBody();
}
}
const translateButton = document.querySelector(`#${pageGlobals.translateButtonId}`);
if (translateButton && translateButton.parentElement) {
translateButton.parentElement.appendChild(document.createTextNode(" "));
translateButton.parentElement.appendChild(this.buttonElement);
} else {
console.warn("未能找到页面简繁转换按钮的挂载点。OpenCC切换按钮可能无法显示。");
}
},
toggleConversion(pageGlobals) {
if (this.isConversionEnabled) {
this.setCookie(this.COOKIE_KEY, "", this.COOKIE_DAYS, pageGlobals);
} else {
this.setCookie(this.TARGET_ENCODING_COOKIE_KEY, "2", this.COOKIE_DAYS, pageGlobals);
this.setCookie(this.COOKIE_KEY, "1", this.COOKIE_DAYS, pageGlobals);
}
location.reload();
},
updateButtonText() {
this.buttonElement.innerHTML = this.isConversionEnabled ? "关闭(OpenCC)" : "开启(OpenCC)";
},
// 保持与旧代码一致的 setCookie 和 getCookie (可能由页面提供)
setCookie(name2, value, days, pageGlobals) {
if (typeof pageGlobals.setCookie === "function") {
pageGlobals.setCookie(name2, value, days);
} else {
console.warn("pageGlobals.setCookie 未定义,OpenCC cookie 可能无法设置");
let expires = "";
if (days) {
const date = /* @__PURE__ */ new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1e3);
expires = `; expires=${date.toUTCString()}`;
}
document.cookie = `${name2}=${value || ""}${expires}; path=/`;
}
},
getCookie(name2, pageGlobals) {
if (typeof pageGlobals.getCookie === "function") {
return pageGlobals.getCookie(name2);
}
console.warn("pageGlobals.getCookie 未定义,OpenCC cookie 可能无法读取");
const nameEQ = `${name2}=`;
const ca = document.cookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === " ") c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0)
return c.substring(nameEQ.length, c.length);
}
return null;
}
};
const UILogger = {
_progressElements: null,
// DOM elements for progress display
_showButton: null,
// Button to show the progress bar after closing
init() {
if (this._progressElements)
return;
this._progressElements = {
text: document.createElement("span"),
image: document.createElement("span"),
// Not directly used in main display but useful to hold counts
error: document.createElement("div"),
// Use a div to hold log entries
main: document.createElement("div"),
// Main container div
controls: document.createElement("div")
// Container for control buttons
};
this._progressElements.main.id = "epubDownloaderProgress";
this._progressElements.main.style.cssText = "position: fixed; bottom: 0; left: 0; width: 100%; background-color: #f0f0f0; border-top: 1px solid #ccc; padding: 5px; z-index: 9999; font-size: 12px; color: #333; font-family: sans-serif;";
this._progressElements.error.style.cssText = "max-height: 100px; overflow-y: auto; margin-top: 5px; border-top: 1px dashed #ccc; padding-top: 5px;";
this._progressElements.controls.style.cssText = "position: absolute;right: 15px;top: 5px;display: flex;justify-content: flex-end;gap: 5px;margin-bottom: 5px;";
const closeButton = document.createElement("button");
closeButton.textContent = "-";
closeButton.id = "closeProgress";
this._progressElements.controls.appendChild(closeButton);
this._showButton = document.createElement("button");
this._showButton.textContent = "+";
this._showButton.style.cssText = "position: fixed; bottom: 10px; right: 10px; z-index: 9999; display: none;";
this._showButton.id = "showProgressButton";
this._progressElements.main.appendChild(this._progressElements.controls);
const titleElement = document.getElementById("title");
if (titleElement && titleElement.parentElement) {
titleElement.parentElement.insertBefore(this._progressElements.main, titleElement.nextSibling);
} else {
document.body.appendChild(this._progressElements.main);
console.warn("[UILogger] Could not find #title element for progress bar insertion.");
}
document.body.appendChild(this._showButton);
closeButton.addEventListener("click", () => this.closeProgress());
this._showButton.addEventListener("click", () => this.showProgress());
this.clearLog();
this.updateProgress({ Text: [], Images: [], totalTasksAdded: 0, tasksCompletedOrSkipped: 0 }, "ePub下载器就绪...");
},
_ensureInitialized() {
if (!this._progressElements || !document.getElementById("epubDownloaderProgress")) {
this.init();
}
},
closeProgress() {
this._progressElements.main.style.display = "none";
this._showButton.style.display = "block";
},
showProgress() {
this._progressElements.main.style.display = "block";
this._showButton.style.display = "none";
},
// updateProgress needs access to the bookInfo instance for counts
updateProgress(bookInfoInstance, message) {
this._ensureInitialized();
if (message) {
const time = (/* @__PURE__ */ new Date()).toLocaleTimeString();
const logEntry = document.createElement("div");
logEntry.className = "epub-log-entry";
logEntry.innerHTML = `[${time}] ${message}`;
this._progressElements.error.insertBefore(logEntry, this._progressElements.error.firstChild);
while (this._progressElements.error.children.length > 300) {
this._progressElements.error.removeChild(this._progressElements.error.lastChild);
}
}
const textDownloaded = bookInfoInstance.Text.filter((t) => t.content).length;
const totalTexts = bookInfoInstance.Text.length;
const imagesDownloaded = bookInfoInstance.Images.filter((img) => img.content).length;
const totalImages = bookInfoInstance.Images.length;
const totalTasks = bookInfoInstance.totalTasksAdded;
const completedTasks = bookInfoInstance.tasksCompletedOrSkipped;
const progressHtml = `
ePub生成进度:
文本 ${textDownloaded}/${totalTexts};
图片 ${imagesDownloaded}/${totalImages};
任务 ${completedTasks}/${totalTasks};
<br>最新日志:
`;
this._progressElements.main.innerHTML = progressHtml;
this._progressElements.main.appendChild(this._progressElements.controls);
if (this._progressElements.error.firstChild) {
const latestLogClone = this._progressElements.error.firstChild.cloneNode(true);
latestLogClone.style.display = "inline";
latestLogClone.style.fontWeight = "bold";
this._progressElements.main.appendChild(latestLogClone);
} else {
this._progressElements.main.appendChild(document.createTextNode("无"));
}
if (!this._progressElements.main.contains(this._progressElements.error)) {
this._progressElements.main.appendChild(this._progressElements.error);
}
},
logError(message) {
this._ensureInitialized();
console.error(`[UILogger] ${message}`);
this.updateProgress(this.getMinimalBookInfo(), `<span style="color:red;">错误: ${message}</span>`);
},
logWarn(message) {
this._ensureInitialized();
console.warn(`[UILogger] ${message}`);
this.updateProgress(this.getMinimalBookInfo(), `<span style="color:orange;">警告: ${message}</span>`);
},
logInfo(message) {
this._ensureInitialized();
console.log(`[UILogger] ${message}`);
this.updateProgress(this.getMinimalBookInfo(), `<span style="color:green;">${message}</span>`);
},
clearLog() {
this._ensureInitialized();
this._progressElements.error.innerHTML = "";
this._progressElements.main.innerHTML = "ePub生成进度: 文本 0/0;图片 0/0;任务 0/0;<br>最新日志: 无";
this._progressElements.main.appendChild(this._progressElements.controls);
if (!this._progressElements.main.contains(this._progressElements.error)) {
this._progressElements.main.appendChild(this._progressElements.error);
}
},
getMinimalBookInfo() {
return this._bookInfoInstance || {
Text: [],
Images: [],
totalTasksAdded: 0,
tasksCompletedOrSkipped: 0
};
}
};
const name = "wenku8_dl_ug";
const version = "2.3.2";
const author = "HaoaW (Original) raventu (Refactor)";
const repository = { "url": "https://github.com/Raven-tu/wenku8_dl_ug" };
const Package = {
name,
version,
author,
repository
};
const CURRENT_URL = new URL(_unsafeWindow.location.href);
const EPUB_EDITOR_CONFIG_UID = "24A08AE1-E132-458C-9E1D-6C998F16A666";
const IMG_LOCATION_FILENAME = "ImgLocation";
const XML_ILLEGAL_CHARACTERS_REGEX = /[\x00-\x08\v\f\x0E-\x1F]/g;
const APP_API_DOMAIN = "app.wenku8.com";
const APP_API_PATH = "/android.php";
const DOWNLOAD_DOMAIN = "dl.wenku8.com";
const IMAGE_DOMAIN = "img.wenku8.com";
const MAX_XHR_RETRIES = 3;
const XHR_TIMEOUT_MS = 2e4;
const XHR_RETRY_DELAY_MS = 500;
const VOLUME_ID_PREFIX = "Volume";
const IMAGE_FILE_PREFIX = "Img";
const TEXT_SPAN_PREFIX = "Txt";
const PROJECT_NAME = Package.name;
const PROJECT_AUTHOR = Package.author;
const PROJECT_VERSION = Package.version;
const PROJECT_REPO = Package.repository.url;
const XHRDownloadManager = {
_queue: [],
_activeDownloads: 0,
_maxConcurrentDownloads: 4,
// 并发下载数控制
_bookInfoInstance: null,
// 关联的EpubBuilder实例
hasCriticalFailure: false,
// 标记是否有关键下载失败
init(bookInfoInstance) {
this._bookInfoInstance = bookInfoInstance;
this._queue = [];
this._activeDownloads = 0;
this.hasCriticalFailure = false;
},
/**
* 添加一个下载任务到队列
* @param {object} xhrTask - 任务对象 {url, loadFun, data?, type?, isCritical?}
* - url: 请求URL (可选,对于非URL任务如appChapterList)
* - loadFun: 实际执行下载的异步函数 (接收 xhrTask 作为参数)
* - data: 任务相关数据
* - type: 任务类型 (用于日志和判断关键性)
* - isCritical: 是否为关键任务 (默认为true,图片等非关键任务可设为false)
*/
add(xhrTask) {
if (this.hasCriticalFailure) {
this._bookInfoInstance.logger.logWarn(`关键下载已失败,新任务 ${xhrTask.type || xhrTask.url} 被跳过。`);
this._bookInfoInstance.totalTasksAdded++;
this._bookInfoInstance.tasksCompletedOrSkipped++;
this._bookInfoInstance.tryBuildEpub();
return;
}
xhrTask.XHRRetryCount = 0;
xhrTask.isCritical = xhrTask.isCritical !== void 0 ? xhrTask.isCritical : true;
this._queue.push(xhrTask);
this._bookInfoInstance.totalTasksAdded++;
this._processQueue();
},
_processQueue() {
if (this.hasCriticalFailure)
return;
while (this._activeDownloads < this._maxConcurrentDownloads && this._queue.length > 0) {
const task = this._queue.shift();
this._activeDownloads++;
task.loadFun(task).then(() => {
}).catch((err) => {
this._bookInfoInstance.logger.logError(`任务 ${task.type || task.url} 执行时发生意外错误: ${err}`);
task.done = true;
this.taskFinished(task, task.isCritical);
});
}
},
/**
* 任务完成(成功或最终失败)时调用
* @param {object} task - 完成的任务对象
* @param {boolean} [isFinalFailure] - 任务是否最终失败
*/
taskFinished(task, isFinalFailure = false) {
if (task._finished)
return;
task._finished = true;
this._activeDownloads--;
this._bookInfoInstance.tasksCompletedOrSkipped++;
if (isFinalFailure && task.isCritical && !this.hasCriticalFailure) {
this.hasCriticalFailure = true;
this._bookInfoInstance.XHRFail = true;
this._bookInfoInstance.logger.logError("一个关键下载任务最终失败,后续部分任务可能被取消。");
this._queue = [];
}
this._processQueue();
this._bookInfoInstance.tryBuildEpub();
},
/**
* 任务需要重试时调用
* @param {object} xhrTask - 需要重试的任务对象
* @param {string} message - 重试原因消息
*/
retryTask(xhrTask, message) {
if (this.hasCriticalFailure) {
this._bookInfoInstance.logger.logWarn(`重试 ${xhrTask.type || xhrTask.url} 被跳过,因为关键下载已失败。`);
xhrTask.done = true;
this.taskFinished(xhrTask, true);
return;
}
xhrTask.XHRRetryCount = (xhrTask.XHRRetryCount || 0) + 1;
if (xhrTask.XHRRetryCount <= MAX_XHR_RETRIES) {
this._bookInfoInstance.refreshProgress(this._bookInfoInstance, `${message} (尝试次数 ${xhrTask.XHRRetryCount}/${MAX_XHR_RETRIES})`);
this._activeDownloads--;
this._queue.unshift(xhrTask);
setTimeout(() => this._processQueue(), XHR_RETRY_DELAY_MS * xhrTask.XHRRetryCount);
} else {
this._bookInfoInstance.refreshProgress(this._bookInfoInstance, `<span style="color:red;">${xhrTask.type || xhrTask.url} 超出最大重试次数, 下载失败!</span>`);
xhrTask.done = true;
this.taskFinished(xhrTask, xhrTask.isCritical);
}
},
/**
* 检查所有任务是否完成 (包括队列中和正在进行的)
* @returns {boolean}
*/
areAllTasksDone() {
return this._bookInfoInstance.tasksCompletedOrSkipped >= this._bookInfoInstance.totalTasksAdded && this._queue.length === 0 && this._activeDownloads === 0;
}
};
function gmXmlHttpRequestAsync(details) {
return new Promise((resolve, reject) => {
_GM_xmlhttpRequest({
...details,
onload: resolve,
onerror: (err) => {
console.error(`GM_xmlhttpRequest error for ${details.url}:`, err);
reject(err);
},
ontimeout: (err) => {
console.error(`GM_xmlhttpRequest timeout for ${details.url}:`, err);
reject(err);
}
});
});
}
async function fetchAsText(url, encoding = "gbk") {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status} for ${url}`);
}
const buffer = await response.arrayBuffer();
const decoder = new TextDecoder(encoding);
return decoder.decode(buffer);
}
function cleanXmlIllegalChars(text) {
return text.replace(XML_ILLEGAL_CHARACTERS_REGEX, "");
}
const AppApiService = {
volumeChapterData: /* @__PURE__ */ new Map(),
// 存储卷的章节列表 { vid: [{cid, cName, content}]}
chapterListXml: null,
// 缓存书籍的章节列表XML Document
isChapterListLoading: false,
chapterListWaitQueue: [],
// 等待章节列表的请求xhr包装对象
disableTraditionalChineseRequest: true,
// 默认禁用APP接口请求繁体,由前端OpenCC处理
_getApiLanguageParam(bookInfo) {
const targetEncoding = (bookInfo == null ? void 0 : bookInfo.targetEncoding) || _unsafeWindow.targetEncoding;
if (this.disableTraditionalChineseRequest || !targetEncoding) {
return "0";
}
return targetEncoding === "1" ? "1" : "0";
},
_encryptRequestBody(body) {
return `appver=1.0&timetoken=${Number(/* @__PURE__ */ new Date())}&request=${btoa(body)}`;
},
/**
* 从App接口获取书籍章节列表XML
* @param {EpubBuilderCoordinator} bookInfo - 协调器实例
* @returns {Promise<void>}
*/
async _fetchChapterList(bookInfo) {
if (this.isChapterListLoading)
return;
this.isChapterListLoading = true;
bookInfo.refreshProgress(bookInfo, `下载App章节目录...`);
const langParam = this._getApiLanguageParam(bookInfo);
const requestBody = this._encryptRequestBody(`action=book&do=list&aid=${bookInfo.aid}&t=${langParam}`);
const url = `http://${APP_API_DOMAIN}${APP_API_PATH}`;
try {
const response = await gmXmlHttpRequestAsync({
method: "POST",
url,
headers: { "content-type": "application/x-www-form-urlencoded;charset=utf-8" },
data: requestBody,
timeout: XHR_TIMEOUT_MS
});
if (response.status === 200) {
const parser = new DOMParser();
this.chapterListXml = parser.parseFromString(cleanXmlIllegalChars(response.responseText), "application/xml");
bookInfo.refreshProgress(bookInfo, `App章节目录下载完成。`);
this.chapterListWaitQueue.forEach((queuedXhr) => this.loadVolumeChapters(queuedXhr));
this.chapterListWaitQueue = [];
} else {
throw new Error(`Status ${response.status}`);
}
} catch (error) {
bookInfo.logger.logError(`App章节目录下载失败: ${error.message}`);
bookInfo.XHRManager.taskFinished({ type: "appChapterList", isCritical: true }, true);
} finally {
this.isChapterListLoading = false;
}
},
/**
* 加载指定分卷的章节内容 (从App接口)
* @param {object} xhrVolumeRequest - 卷的XHR任务对象 (由VolumeLoader或Coordinator创建)
* - bookInfo: 协调器实例
* - data: { vid, vcssText, Text }
* - dealVolume: 处理函数 (通常是 VolumeLoader.dealVolumeText)
*/
async loadVolumeChapters(xhrVolumeRequest) {
const { bookInfo, data: volumeData } = xhrVolumeRequest;
if (!this.chapterListXml) {
this.chapterListWaitQueue.push(xhrVolumeRequest);
if (!this.isChapterListLoading) {
this._fetchChapterList(bookInfo);
}
return;
}
const volumeElement = Array.from(this.chapterListXml.getElementsByTagName("volume")).find((vol) => vol.getAttribute("vid") === volumeData.vid);
if (!volumeElement) {
bookInfo.refreshProgress(bookInfo, `<span style="color:fuchsia;">App章节目录未找到分卷 ${volumeData.vid},无法生成ePub。</span>`);
bookInfo.XHRManager.taskFinished(xhrVolumeRequest, true);
return;
}
const chapters = Array.from(volumeElement.children).map((ch) => ({
cid: ch.getAttribute("cid"),
cName: ch.textContent,
content: null
// 稍后填充
}));
this.volumeChapterData.set(volumeData.vid, chapters);
chapters.forEach((chapter) => {
const chapterXhr = {
type: "appChapter",
// 自定义类型
url: `http://${APP_API_DOMAIN}${APP_API_PATH}`,
loadFun: (xhr) => this._fetchChapterContent(xhr),
// 指向内部方法
dealVolume: xhrVolumeRequest.dealVolume,
// 传递处理函数
data: { ...volumeData, cid: chapter.cid, cName: chapter.cName, isAppApi: true },
bookInfo,
isCritical: true
// 章节内容是关键任务
};
bookInfo.XHRManager.add(chapterXhr);
});
bookInfo.XHRManager.taskFinished(xhrVolumeRequest, false);
},
/**
* 从App接口获取单个章节内容
* @param {object} xhrChapterRequest - 章节的XHR任务对象
* - bookInfo: 协调器实例
* - data: { vid, cid, cName, isAppApi, Text }
* - dealVolume: 处理函数 (VolumeLoader.dealVolumeText)
*/
async _fetchChapterContent(xhrChapterRequest) {
const { bookInfo, data } = xhrChapterRequest;
const langParam = this._getApiLanguageParam(bookInfo);
const requestBody = this._encryptRequestBody(`action=book&do=text&aid=${bookInfo.aid}&cid=${data.cid}&t=${langParam}`);
const failureMessage = `${data.cName} 下载失败`;
try {
const response = await gmXmlHttpRequestAsync({
method: "POST",
url: xhrChapterRequest.url,
headers: { "content-type": "application/x-www-form-urlencoded;charset=utf-8" },
data: requestBody,
timeout: XHR_TIMEOUT_MS
});
if (response.status === 200) {
const chapterVolumeData = this.volumeChapterData.get(data.vid);
const chapterEntry = chapterVolumeData.find((c) => c.cid === data.cid);
if (chapterEntry) {
chapterEntry.content = response.responseText;
}
bookInfo.refreshProgress(bookInfo, `${data.cName} 下载完成。`);
if (chapterVolumeData.every((c) => c.content !== null)) {
let combinedVolumeText = "";
for (const chap of chapterVolumeData) {
if (!chap.content)
continue;
let content = chap.content;
content = content.replace(chap.cName, `<div class="chaptertitle"><a name="${chap.cid}">${chap.cName}</a></div><div class="chaptercontent">`);
content = content.replace(/\r\n/g, "<br />\r\n");
if (content.includes("<!--image-->http")) {
content = content.replace(/<!--image-->(http[\w:/.?@#&=%]+)<!--image-->/g, (match, p1) => `<div class="divimage" title="${p1}"></div>`);
}
content += `</div>`;
combinedVolumeText += content;
}
const pseudoVolumeXhr = {
bookInfo,
VolumeIndex: bookInfo.Text.findIndex((t) => t.vid === data.vid),
// 找到对应的卷索引
data: { ...data, Text: bookInfo.Text.find((t) => t.vid === data.vid) }
// 传递卷的Text对象
};
xhrChapterRequest.dealVolume(pseudoVolumeXhr, combinedVolumeText);
}
bookInfo.XHRManager.taskFinished(xhrChapterRequest, false);
} else {
throw new Error(`Status ${response.status}`);
}
} catch (error) {
bookInfo.logger.logError(`${failureMessage} 错误: ${error.message}`);
bookInfo.XHRManager.retryTask(xhrChapterRequest, failureMessage);
}
},
/**
* 对外暴露的接口,用于从App接口加载章节内容(通常用于版权受限页面)
* @param {object} bookInfo - 包含 aid, logger, refreshProgress 的对象
* @param {string} chapterId - 章节ID
* @param {HTMLElement} contentElement - 显示内容的DOM元素
* @param {Function} translateBodyFunc - 页面提供的翻译函数
*/
async fetchChapterForReading(bookInfo, chapterId, contentElement, translateBodyFunc) {
const langParam = this._getApiLanguageParam({ targetEncoding: _unsafeWindow.targetEncoding });
const requestBody = this._encryptRequestBody(`action=book&do=text&aid=${bookInfo.aid}&cid=${chapterId}&t=${langParam}`);
const url = `http://${APP_API_DOMAIN}${APP_API_PATH}`;
contentElement.innerHTML = "正在通过App接口加载内容,请稍候...";
try {
const response = await gmXmlHttpRequestAsync({
method: "POST",
url,
headers: { "content-type": "application/x-www-form-urlencoded;charset=utf-8" },
data: requestBody,
timeout: XHR_TIMEOUT_MS
});
if (response.status === 200) {
let rawText = response.responseText;
rawText = rawText.replace(/ {2}\S.*/, "");
rawText = rawText.replace(/\r\n/g, "<br />\r\n");
if (rawText.includes("<!--image-->http")) {
rawText = rawText.replace(/<!--image-->(http[\w:/.?@#&=%]+)<!--image-->/g, (m, p1) => `<div class="divimage"><a href="${p1}" target="_blank"><img src="${p1}" border="0" class="imagecontent"></a></div>`);
}
contentElement.innerHTML = rawText;
if (typeof translateBodyFunc === "function") {
translateBodyFunc(contentElement);
}
} else {
contentElement.innerHTML = `通过App接口加载内容失败,状态码: ${response.status}`;
}
} catch (error) {
contentElement.innerHTML = `通过App接口加载内容失败: ${error.message}`;
console.error("App接口内容加载失败:", error);
}
}
};
const VolumeLoader = {
/**
* 加载网页版分卷文本内容
* @param {object} xhr - XHR任务对象
* - bookInfo: 协调器实例
* - url: 下载URL
* - VolumeIndex: 卷在 bookInfo.nav_toc 和 bookInfo.Text 中的索引
* - data: { vid, vcssText, Text }
*/
async loadWebVolumeText(xhr) {
const { bookInfo, url, VolumeIndex, data } = xhr;
const volumeInfo = bookInfo.nav_toc[VolumeIndex];
const failureMessage = `${volumeInfo.volumeName} 下载失败`;
try {
const response = await gmXmlHttpRequestAsync({ method: "GET", url, timeout: XHR_TIMEOUT_MS });
if (response.status === 200) {
bookInfo.refreshProgress(bookInfo, `${volumeInfo.volumeName} 网页版内容下载完成。`);
this.dealVolumeText(xhr, response.responseText);
bookInfo.XHRManager.taskFinished(xhr, false);
} else if (response.status === 404) {
bookInfo.refreshProgress(bookInfo, `${volumeInfo.volumeName} 网页版404,尝试使用App接口下载...`);
xhr.dealVolume = this.dealVolumeText.bind(this);
AppApiService.loadVolumeChapters(xhr);
bookInfo.XHRManager.taskFinished(xhr, false);
} else {
throw new Error(`Status ${response.status}`);
}
} catch (error) {
bookInfo.logger.logError(`${failureMessage} 错误: ${error.message}`);
bookInfo.XHRManager.retryTask(xhr, failureMessage);
}
},
/**
* 加载书籍内容简介
* @param {object} xhr - XHR任务对象
* - bookInfo: 协调器实例
* - url: 下载URL
*/
async loadBookDescription(xhr) {
const { bookInfo, url } = xhr;
const failureMessage = `内容简介下载失败`;
try {
const text = await fetchAsText(url, "gbk");
const parser = new DOMParser();
const doc = parser.parseFromString(text, "text/html");
const descSpan = doc.evaluate("//span[@class='hottext' and contains(text(),'内容简介:')]/following-sibling::span", doc, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null).singleNodeValue;
if (descSpan) {
bookInfo.description = descSpan.textContent.trim();
}
bookInfo.refreshProgress(bookInfo, `内容简介下载完成。`);
bookInfo.XHRManager.taskFinished(xhr, false);
} catch (error) {
bookInfo.logger.logError(`${failureMessage} 错误: ${error.message}`);
bookInfo.XHRManager.retryTask(xhr, failureMessage);
}
},
/**
* 加载图片资源
* @param {object} xhr - XHR任务对象
* - bookInfo: 协调器实例
* - url: 图片URL
* - images: 图片条目信息 {path, content, id, idName, TextId, coverImgChk?, smallCover?}
*/
async loadImage(xhr) {
const { bookInfo, url, images: imageInfo } = xhr;
const failureMessage = `${imageInfo.idName} 下载失败`;
try {
const response = await gmXmlHttpRequestAsync({ method: "GET", url, responseType: "arraybuffer", timeout: XHR_TIMEOUT_MS });
if (response.status === 200) {
imageInfo.content = response.response;
if (imageInfo.coverImgChk && !bookInfo.Images.some((i) => i.coverImg)) {
try {
imageInfo.Blob = new Blob([imageInfo.content], { type: "image/jpeg" });
imageInfo.ObjectURL = URL.createObjectURL(imageInfo.Blob);
const img = new Image();
img.onload = () => {
imageInfo.coverImg = img.naturalHeight / img.naturalWidth > 1.2;
URL.revokeObjectURL(imageInfo.ObjectURL);
delete imageInfo.Blob;
delete imageInfo.ObjectURL;
bookInfo.refreshProgress(bookInfo, `${imageInfo.idName} 下载完成${imageInfo.coverImg ? " (设为封面候选)" : ""}。`);
bookInfo.XHRManager.taskFinished(xhr, false);
};
img.onerror = () => {
URL.revokeObjectURL(imageInfo.ObjectURL);
delete imageInfo.Blob;
delete imageInfo.ObjectURL;
bookInfo.logger.logError(`${imageInfo.idName} 图片对象加载失败。`);
bookInfo.XHRManager.taskFinished(xhr, false);
};
img.src = imageInfo.ObjectURL;
} catch (e) {
bookInfo.logger.logError(`${imageInfo.idName} 创建Blob/ObjectURL失败: ${e.message}`);
bookInfo.XHRManager.taskFinished(xhr, false);
}
} else {
bookInfo.refreshProgress(bookInfo, `${imageInfo.idName} 下载完成。`);
bookInfo.XHRManager.taskFinished(xhr, false);
}
} else {
throw new Error(`Status ${response.status}`);
}
} catch (error) {
bookInfo.logger.logError(`${failureMessage} 错误: ${error.message}`);
bookInfo.XHRManager.retryTask(xhr, failureMessage);
}
},
/**
* 处理下载到的分卷文本内容 (网页版或App版合并后的)
* @param {object} xhr - 原始的卷XHR任务对象 (或AppService伪造的对象)
* - bookInfo: 协调器实例
* - VolumeIndex: 卷在 bookInfo.nav_toc 和 bookInfo.Text 中的索引
* - data: { vid, vcssText, Text }
* @param {string} htmlText - 下载到的HTML或合并后的章节文本
*/
dealVolumeText(xhr, htmlText) {
const { bookInfo, VolumeIndex, data } = xhr;
const volumeTextData = data.Text;
const navTocEntry = volumeTextData.navToc;
let chapterCounter = 0;
let imageCounter = 0;
let textNodeCounter = 0;
const parser = new DOMParser();
const tempDoc = parser.parseFromString(
`<html><head><meta charset="utf-8"/></head><body></body></html>`,
"text/html"
);
tempDoc.body.innerHTML = htmlText;
if (typeof _unsafeWindow.currentEncoding !== "undefined" && typeof _unsafeWindow.targetEncoding !== "undefined" && _unsafeWindow.currentEncoding !== _unsafeWindow.targetEncoding && typeof _unsafeWindow.translateBody === "function") {
_unsafeWindow.translateBody(tempDoc.body);
}
const elementsToRemove = [];
Array.from(tempDoc.body.children).forEach((child) => {
if (child.tagName === "UL" && child.id === "contentdp") {
elementsToRemove.push(child);
} else if (child.tagName === "DIV" && child.className === "chaptertitle") {
chapterCounter++;
const chapterTitleText = child.textContent.trim();
const chapterDivId = `chapter_${chapterCounter}`;
child.innerHTML = `<div id="${chapterDivId}"><h3>${cleanXmlIllegalChars(chapterTitleText)}</h3></div>`;
if (navTocEntry) {
navTocEntry.chapterArr.push({
chapterName: chapterTitleText,
chapterID: chapterDivId,
chapterHref: `${navTocEntry.volumeHref}#${chapterDivId}`
});
}
const titleSpan = tempDoc.createElement("span");
titleSpan.id = `${TEXT_SPAN_PREFIX}_${chapterDivId}`;
titleSpan.className = "txtDropEnable";
titleSpan.setAttribute("ondragover", "return false");
child.parentElement.insertBefore(titleSpan, child);
titleSpan.appendChild(child);
} else if (child.tagName === "DIV" && child.className === "chaptercontent") {
Array.from(child.childNodes).forEach((contentNode) => {
if (contentNode.nodeType === Node.TEXT_NODE && contentNode.textContent.trim() !== "") {
textNodeCounter++;
const textSpan = tempDoc.createElement("span");
textSpan.id = `${TEXT_SPAN_PREFIX}_${VolumeIndex}_${textNodeCounter}`;
textSpan.className = "txtDropEnable";
textSpan.setAttribute("ondragover", "return false");
child.insertBefore(textSpan, contentNode);
textSpan.appendChild(contentNode);
} else if (contentNode.tagName === "DIV" && contentNode.className === "divimage" && contentNode.hasAttribute("title")) {
const imgSrc = contentNode.getAttribute("title");
if (imgSrc) {
const imgUrl = new URL(imgSrc);
const imgFileName = imgUrl.pathname.split("/").pop();
const imgPathInEpub = `Images${imgUrl.pathname}`;
contentNode.innerHTML = `<img src="../${imgPathInEpub}" alt="${cleanXmlIllegalChars(imgFileName)}"/>`;
imageCounter++;
const imageId = `${IMAGE_FILE_PREFIX}_${VolumeIndex}_${imageCounter}`;
const imageEntry = {
path: imgPathInEpub,
content: null,
// 稍后下载
id: imageId,
idName: imgFileName,
// 文件名作为标识
TextId: volumeTextData.id,
// 关联到分卷ID
coverImgChk: VolumeIndex === 0 && imageCounter <= 2
// 前两张图作为封面候选
};
if (!bookInfo.Images.some((img) => img.path === imageEntry.path)) {
bookInfo.Images.push(imageEntry);
bookInfo.XHRManager.add({
type: "image",
url: imgSrc,
loadFun: (imgXhr) => VolumeLoader.loadImage(imgXhr),
// 静态方法调用
images: imageEntry,
// 传递图片条目信息
bookInfo,
isCritical: false
// 图片不是关键任务
});
}
}
}
});
}
});
elementsToRemove.forEach((el) => el.parentElement.removeChild(el));
volumeTextData.content = tempDoc.body.innerHTML;
if (VolumeIndex === 0 && !bookInfo.thumbnailImageAdded) {
const pathParts = CURRENT_URL.pathname.replace("novel", "image").split("/");
pathParts.pop();
const bookNumericId = pathParts.find((p) => /^\d+$/.test(p));
if (bookNumericId) {
const thumbnailImageId = `${bookNumericId}s`;
const thumbnailSrc = `https://${IMAGE_DOMAIN}${pathParts.join("/")}/${thumbnailImageId}.jpg`;
const thumbnailPathInEpub = `Images/${bookNumericId}/${thumbnailImageId}.jpg`;
const thumbnailEntry = {
path: thumbnailPathInEpub,
content: null,
id: thumbnailImageId,
// 使用图片本身的ID
idName: `${thumbnailImageId}.jpg`,
TextId: "",
// 不关联特定卷,作为通用封面候选
smallCover: true,
// 标记为缩略图封面候选
isCritical: false
// 缩略图不是关键任务
};
if (!bookInfo.Images.some((img) => img.id === thumbnailEntry.id)) {
bookInfo.Images.push(thumbnailEntry);
bookInfo.XHRManager.add({
type: "image",
url: thumbnailSrc,
loadFun: (thumbXhr) => VolumeLoader.loadImage(thumbXhr),
images: thumbnailEntry,
bookInfo,
isCritical: false
});
bookInfo.thumbnailImageAdded = true;
}
}
}
if (!bookInfo.descriptionXhrInitiated) {
bookInfo.descriptionXhrInitiated = true;
bookInfo.XHRManager.add({
type: "description",
url: `/book/${bookInfo.aid}.htm`,
// 相对路径
loadFun: (descXhr) => VolumeLoader.loadBookDescription(descXhr),
bookInfo,
isCritical: true
// 简介是关键任务
});
}
bookInfo.tryBuildEpub();
}
};
const EpubEditor = {
novelTableElement: null,
config: {
UID: EPUB_EDITOR_CONFIG_UID,
aid: _unsafeWindow.article_id,
// 来自页面全局变量
pathname: CURRENT_URL.pathname,
ImgLocation: []
},
imgLocationRegex: [/img/i, /插图/, /插圖/, /\.jpg/i, /\.png/i],
styleLinks: [],
editorRootElement: null,
lastClickedVolumeLI: null,
_bookInfoInstance: null,
// 保存EpubBuilder实例引用
_currentVolumeImageMap: /* @__PURE__ */ new Map(),
// 当前卷可用的图片映射 {idName: imgElement}
/**
* 初始化ePub编辑器
* @param {EpubBuilderCoordinator} bookInfoInstance - 协调器实例
*/
init(bookInfoInstance) {
this._bookInfoInstance = bookInfoInstance;
document.querySelectorAll(".DownloadAll").forEach((el) => el.style.pointerEvents = "none");
this.novelTableElement = document.body.getElementsByTagName("table")[0];
if (this.novelTableElement)
this.novelTableElement.style.display = "none";
const editorCss = document.createElement("link");
editorCss.type = "text/css";
editorCss.rel = "stylesheet";
editorCss.href = "/themes/wenku8/style.css";
document.head.appendChild(editorCss);
this.styleLinks.push(editorCss);
this.editorRootElement = document.createElement("div");
this.editorRootElement.id = "ePubEidter";
this.editorRootElement.style.display = "none";
editorCss.onload = () => {
this.editorRootElement.style.display = "";
};
this.editorRootElement.innerHTML = this._getEditorHtmlTemplate();
if (this.novelTableElement && this.novelTableElement.parentElement) {
this.novelTableElement.parentElement.insertBefore(this.editorRootElement, this.novelTableElement);
} else {
document.body.appendChild(this.editorRootElement);
console.warn("未能找到合适的编辑器挂载点,编辑器已追加到body。");
}
document.getElementById("EidterBuildBtn").addEventListener("click", (event) => this.handleBuildEpubClick(event));
document.getElementById("EidterImportBtn").addEventListener("click", (event) => this.handleImportConfigClick(event));
document.getElementById("VolumeImg").addEventListener("drop", (event) => this.handleImageDeleteDrop(event));
document.getElementById("VolumeImg").addEventListener("dragover", (event) => event.preventDefault());
this.config.ImgLocation = bookInfoInstance.ImgLocation;
document.getElementById("CfgArea").value = JSON.stringify(this.config, null, " ");
this._populateVolumeList();
},
/**
* 销毁编辑器DOM和事件监听器
*/
destroy() {
if (this.editorRootElement && this.editorRootElement.parentElement) {
this.editorRootElement.parentElement.removeChild(this.editorRootElement);
}
this.styleLinks.forEach((link) => link.parentElement && link.parentElement.removeChild(link));
if (this.novelTableElement)
this.novelTableElement.style.display = "";
document.querySelectorAll(".DownloadAll").forEach((el) => el.style.pointerEvents = "auto");
this.editorRootElement = null;
this._bookInfoInstance = null;
this._currentVolumeImageMap.clear();
},
/**
* 填充分卷列表
*/
_populateVolumeList() {
const volumeUl = document.getElementById("VolumeUL");
if (!volumeUl)
return;
volumeUl.innerHTML = "";
let firstLi = null;
this._bookInfoInstance.Text.forEach((textEntry) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "javascript:void(0);";
a.id = textEntry.id;
a.textContent = textEntry.volumeName;
li.appendChild(a);
li.addEventListener("click", (event) => this.handleVolumeClick(event, textEntry));
volumeUl.appendChild(li);
if (!firstLi)
firstLi = li;
});
if (firstLi)
firstLi.click();
},
/**
* 处理分卷列表点击事件
* @param {MouseEvent} event
* @param {object} textEntry - 当前卷的文本数据 {path, content, id, vid, volumeName, navToc}
*/
handleVolumeClick(event, textEntry) {
if (this.lastClickedVolumeLI) {
this.lastClickedVolumeLI.firstElementChild.style.color = "";
}
this.lastClickedVolumeLI = event.currentTarget;
this.lastClickedVolumeLI.firstElementChild.style.color = "fuchsia";
const volumeTextDiv = document.getElementById("VolumeText");
if (!volumeTextDiv)
return;
volumeTextDiv.style.display = "none";
volumeTextDiv.innerHTML = textEntry.content;
this._populateImageListForVolume(textEntry);
this._populateGuessedImageLocations(textEntry);
this._populateChapterNavForVolume(textEntry);
volumeTextDiv.style.display = "";
volumeTextDiv.scrollTop = 0;
const volumeImgDiv = document.getElementById("VolumeImg");
if (volumeImgDiv)
volumeImgDiv.scrollTop = 0;
},
/**
* 填充当前卷可用的图片列表
* @param {object} textEntry - 当前卷的文本数据
*/
_populateImageListForVolume(textEntry) {
const volumeImgDiv = document.getElementById("VolumeImg");
if (!volumeImgDiv)
return;
volumeImgDiv.innerHTML = "";
this._currentVolumeImageMap.clear();
this._bookInfoInstance.Images.filter((img) => img.TextId === textEntry.id || img.smallCover).forEach((imageInfo) => {
if (!imageInfo.ObjectURL && imageInfo.content) {
try {
imageInfo.Blob = new Blob([imageInfo.content], { type: "image/jpeg" });
imageInfo.ObjectURL = URL.createObjectURL(imageInfo.Blob);
} catch (e) {
console.error(`创建图片Blob失败 for ${imageInfo.idName}:`, e);
return;
}
} else if (!imageInfo.ObjectURL && !imageInfo.content) {
console.warn(`图片 ${imageInfo.idName} 既无ObjectURL也无content,无法显示。`);
return;
}
const div = document.createElement("div");
div.className = "editor-image-item";
const img = document.createElement("img");
img.setAttribute("imgID", imageInfo.idName);
img.src = imageInfo.ObjectURL;
img.height = 127;
img.draggable = true;
img.addEventListener("dragstart", (event) => this.handleImageDragStart(event, imageInfo));
this._currentVolumeImageMap.set(imageInfo.idName, img);
div.appendChild(img);
div.appendChild(document.createElement("br"));
div.appendChild(document.createTextNode(imageInfo.id));
volumeImgDiv.appendChild(div);
});
},
/**
* 填充推测的插图位置列表并在文本中标记
* @param {object} textEntry - 当前卷的文本数据
*/
_populateGuessedImageLocations(textEntry) {
const imgUl = document.getElementById("ImgUL");
if (!imgUl)
return;
imgUl.innerHTML = "";
const currentVolumeLocations = this._bookInfoInstance.ImgLocation.filter((loc) => loc.vid === textEntry.vid);
document.querySelectorAll("#VolumeText .txtDropEnable").forEach((dropTargetSpan) => {
dropTargetSpan.addEventListener("drop", (event) => this.handleTextDrop(event, textEntry));
dropTargetSpan.addEventListener("dragover", (event) => event.preventDefault());
currentVolumeLocations.filter((loc) => loc.spanID === dropTargetSpan.id).forEach((loc) => {
const draggedImgElement = this._currentVolumeImageMap.get(loc.imgID);
if (draggedImgElement) {
const divImage = document.createElement("div");
divImage.className = "divimageM";
divImage.innerHTML = draggedImgElement.outerHTML;
const actualImgInDiv = divImage.firstElementChild;
actualImgInDiv.id = `${loc.spanID}_${loc.imgID}`;
actualImgInDiv.draggable = true;
actualImgInDiv.addEventListener("dragstart", (ev) => this.handlePlacedImageDragStart(ev, loc));
dropTargetSpan.parentNode.insertBefore(divImage, dropTargetSpan);
}
});
if (!dropTargetSpan.firstElementChild || dropTargetSpan.firstElementChild.className !== "chaptertitle") {
for (const regex of this.imgLocationRegex) {
if (regex.test(dropTargetSpan.textContent)) {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "javascript:void(0);";
a.setAttribute("SpanID", dropTargetSpan.id);
a.textContent = `${dropTargetSpan.textContent.replace(/\s/g, "").substring(0, 12)}...`;
li.appendChild(a);
li.addEventListener("click", () => this.handleGuessedLocationClick(dropTargetSpan));
imgUl.appendChild(li);
dropTargetSpan.style.color = "fuchsia";
break;
}
}
}
});
},
/**
* 填充章节导航列表
* @param {object} textEntry - 当前卷的文本数据
*/
_populateChapterNavForVolume(textEntry) {
const chapterUl = document.getElementById("ChapterUL");
if (!chapterUl)
return;
chapterUl.innerHTML = "";
const tocEntry = this._bookInfoInstance.nav_toc.find((toc) => toc.volumeID === textEntry.id);
if (tocEntry && tocEntry.chapterArr) {
tocEntry.chapterArr.forEach((chapter) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "javascript:void(0);";
a.setAttribute("chapterID", chapter.chapterID);
a.textContent = chapter.chapterName;
li.appendChild(a);
li.addEventListener("click", () => this.handleChapterNavClick(chapter.chapterID));
chapterUl.appendChild(li);
});
}
},
/**
* 处理从图片列表拖动图片开始事件
* @param {DragEvent} event
* @param {object} imageInfo - 图片条目信息
*/
handleImageDragStart(event, imageInfo) {
event.dataTransfer.setData("text/plain", imageInfo.idName);
event.dataTransfer.setData("sourceType", "newImage");
},
/**
* 处理从文本中拖动已放置图片开始事件
* @param {DragEvent} event
* @param {object} imgLocation - 图片位置配置 {vid, spanID, imgID}
*/
handlePlacedImageDragStart(event, imgLocation) {
event.dataTransfer.setData("text/plain", JSON.stringify(imgLocation));
event.dataTransfer.setData("sourceType", "placedImage");
event.dataTransfer.setData("elementId", event.target.id);
},
/**
* 处理拖动图片到文本区域的放置事件
* @param {DragEvent} event
* @param {object} textEntry - 当前卷的文本数据
*/
handleTextDrop(event, textEntry) {
event.preventDefault();
const sourceType = event.dataTransfer.getData("sourceType");
if (sourceType === "newImage") {
const imgIdName = event.dataTransfer.getData("text/plain");
const dropTargetSpan = event.currentTarget;
const newLocation = {
vid: textEntry.vid,
spanID: dropTargetSpan.id,
imgID: imgIdName
};
if (this._bookInfoInstance.ImgLocation.some((loc) => loc.vid === newLocation.vid && loc.spanID === newLocation.spanID && loc.imgID === newLocation.imgID)) {
this._bookInfoInstance.logger.logWarn(`尝试在 ${newLocation.spanID} 放置重复图片 ${newLocation.imgID}。`);
return;
}
this._bookInfoInstance.ImgLocation.push(newLocation);
const draggedImgElement = this._currentVolumeImageMap.get(imgIdName);
if (draggedImgElement) {
const divImage = document.createElement("div");
divImage.className = "divimageM";
divImage.innerHTML = draggedImgElement.outerHTML;
const actualImgInDiv = divImage.firstElementChild;
actualImgInDiv.id = `${newLocation.spanID}_${newLocation.imgID}`;
actualImgInDiv.draggable = true;
actualImgInDiv.addEventListener("dragstart", (ev) => this.handlePlacedImageDragStart(ev, newLocation));
dropTargetSpan.parentNode.insertBefore(divImage, dropTargetSpan);
}
this._updateConfigTextarea();
}
},
/**
* 处理拖动已放置图片到删除区域的放置事件
* @param {DragEvent} event
*/
handleImageDeleteDrop(event) {
event.preventDefault();
const sourceType = event.dataTransfer.getData("sourceType");
if (sourceType === "placedImage") {
const imgLocationJson = event.dataTransfer.getData("text/plain");
const elementIdToRemove = event.dataTransfer.getData("elementId");
try {
const locToRemove = JSON.parse(imgLocationJson);
this._bookInfoInstance.ImgLocation = this._bookInfoInstance.ImgLocation.filter(
(loc) => !(loc.vid === locToRemove.vid && loc.spanID === locToRemove.spanID && loc.imgID === locToRemove.imgID)
);
const domElementToRemove = document.getElementById(elementIdToRemove);
if (domElementToRemove && domElementToRemove.parentElement.className === "divimageM") {
domElementToRemove.parentElement.remove();
}
this._updateConfigTextarea();
this._bookInfoInstance.logger.logInfo(`已移除图片 ${locToRemove.imgID} 在 ${locToRemove.spanID} 的位置配置。`);
} catch (e) {
console.error("解析拖放数据失败:", e);
this._bookInfoInstance.logger.logError("删除图片配置失败。");
}
}
},
/**
* 处理推测插图位置列表点击事件 (滚动到文本位置)
* @param {HTMLElement} dropTargetSpan - 对应的文本span元素
*/
handleGuessedLocationClick(dropTargetSpan) {
const volumeTextDiv = document.getElementById("VolumeText");
if (volumeTextDiv && dropTargetSpan) {
volumeTextDiv.scroll({ top: dropTargetSpan.offsetTop - 130, behavior: "smooth" });
}
},
/**
* 处理章节导航列表点击事件 (滚动到章节位置)
* @param {string} chapterDomId - 章节对应的DOM ID
*/
handleChapterNavClick(chapterDomId) {
const targetElement = document.getElementById(chapterDomId);
if (targetElement) {
const volumeTextDiv = document.getElementById("VolumeText");
if (volumeTextDiv) {
volumeTextDiv.scroll({ top: targetElement.offsetTop, behavior: "smooth" });
}
}
},
/**
* 处理“生成ePub”按钮点击事件
* @param {MouseEvent} event
*/
handleBuildEpubClick(event) {
var _a, _b;
event.currentTarget.disabled = true;
this._bookInfoInstance.ePubEidtDone = true;
this._bookInfoInstance.tryBuildEpub();
if (((_a = document.getElementById("SendArticle")) == null ? void 0 : _a.checked) && this._bookInfoInstance.ImgLocation.length > 0) {
this._sendConfigToServer();
}
if ((_b = document.getElementById("ePubEditerClose")) == null ? void 0 : _b.checked) {
setTimeout(() => {
if (this._bookInfoInstance && this._bookInfoInstance.XHRManager.areAllTasksDone()) {
this.destroy();
} else {
this._bookInfoInstance.logger.logInfo("ePub生成未完成或失败,编辑器未自动关闭。");
}
}, 3e3);
}
},
/**
* 处理“导入配置”按钮点击事件
* @param {MouseEvent} event
*/
handleImportConfigClick(event) {
event.currentTarget.disabled = true;
const cfgArea = document.getElementById("CfgArea");
if (!cfgArea) {
event.currentTarget.disabled = false;
return;
}
try {
const importedCfg = JSON.parse(cfgArea.value);
if (importedCfg && importedCfg.UID === this.config.UID && importedCfg.aid === this.config.aid && Array.isArray(importedCfg.ImgLocation) && importedCfg.ImgLocation.length > 0) {
let importedCount = 0;
importedCfg.ImgLocation.forEach((iCfg) => {
if (!this._bookInfoInstance.Text.some((t) => t.vid === iCfg.vid)) {
console.warn(`导入配置跳过无效卷ID: ${iCfg.vid}`);
return;
}
if (!iCfg.spanID || !iCfg.imgID) {
console.warn(`导入配置跳过无效项: ${JSON.stringify(iCfg)}`);
return;
}
if (!this._bookInfoInstance.ImgLocation.some(
(loc) => loc.vid === iCfg.vid && loc.spanID === iCfg.spanID && loc.imgID === iCfg.imgID
)) {
this._bookInfoInstance.ImgLocation.push({
vid: String(iCfg.vid),
// 确保vid是字符串,与内部存储一致
spanID: String(iCfg.spanID),
imgID: String(iCfg.imgID)
});
importedCount++;
}
});
this._updateConfigTextarea();
this._bookInfoInstance.logger.logInfo(`成功导入 ${importedCount} 条插图位置配置。`);
if (this.lastClickedVolumeLI)
this.lastClickedVolumeLI.click();
} else {
console.error("导入的配置格式不正确或与当前书籍不匹配。");
}
} catch (e) {
console.error("导入配置失败:JSON格式错误。");
console.error("导入配置解析错误:", e);
}
event.currentTarget.disabled = false;
},
/**
* 更新配置文本框内容
*/
_updateConfigTextarea() {
this.config.ImgLocation = this._bookInfoInstance.ImgLocation;
const cfgArea = document.getElementById("CfgArea");
if (cfgArea)
cfgArea.value = JSON.stringify(this.config, null, " ");
},
/**
* 将配置发送到书评区
*/
_sendConfigToServer() {
const cfgToSend = { ...this.config };
const imgLocJson = JSON.stringify(this._bookInfoInstance.ImgLocation);
const zip = new JSZip();
zip.file(IMG_LOCATION_FILENAME, imgLocJson, {
compression: "DEFLATE",
compressionOptions: { level: 9 }
});
zip.generateAsync({ type: "base64", mimeType: "application/zip" }).then((base64Data) => {
cfgToSend.ImgLocationBase64 = base64Data;
delete cfgToSend.ImgLocation;
const uniqueVolumeNames = [...new Set(
this._bookInfoInstance.ImgLocation.map((loc) => this._bookInfoInstance.nav_toc.find((toc) => toc.vid === loc.vid)).filter(Boolean).map((toc) => toc.volumeName)
)];
const postContent = `包含分卷列表:${uniqueVolumeNames.join(", ")}
[code]${JSON.stringify(cfgToSend)}[/code]`;
const postData = /* @__PURE__ */ new Map([
["ptitle", "ePub插图位置 (优化版脚本)"],
["pcontent", postContent]
]);
const postUrl = `/modules/article/reviews.php?aid=${this._bookInfoInstance.aid}`;
const iframe = document.createElement("iframe");
iframe.style.display = "none";
document.body.appendChild(iframe);
const iframeDoc = iframe.contentWindow.document;
const form = iframeDoc.createElement("form");
form.acceptCharset = "gbk";
form.method = "POST";
form.action = postUrl;
postData.forEach((value, key) => {
const input = iframeDoc.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value;
form.appendChild(input);
});
iframeDoc.body.appendChild(form);
form.submit();
setTimeout(() => iframe.remove(), 5e3);
this._bookInfoInstance.logger.logInfo("插图配置已尝试发送到书评区。");
}).catch((err) => {
this._bookInfoInstance.logger.logError(`压缩配置失败,无法发送到书评区: ${err.message}`);
console.error("Zip config error:", err);
});
},
/**
* 获取编辑器HTML模板字符串
* @returns {string} HTML模板
*/
_getEditorHtmlTemplate() {
return `
<style>
.editor-image-item { float: left; text-align: center; height: 155px; overflow: hidden; margin: 0 2px; border: 1px solid #ccc; padding: 2px; }
.editor-image-item img { cursor: grab; }
#VolumeImg[ondragover="return false"] { border: 2px dashed #ccc; padding: 5px; background-color: #f0f0f0; min-height: 160px;}
.divimageM { border: 1px dotted blue; padding: 2px; margin: 2px 0; display: inline-block; }
.divimageM img { display: block; max-width: 100%; }
#ePubEidter .main { width: 1200px; margin: 0 auto; } /* 居中 */
#ePubEidter #left { float: left; width: 200px; margin-right: 10px; }
#ePubEidter #centerm { overflow: hidden; } /* 占据剩余空间 */
#ePubEidter .block { border: 1px solid #ccc; margin-bottom: 10px; }
#ePubEidter .blocktitle { background-color: #eee; padding: 5px; font-weight: bold; }
#ePubEidter .blockcontent { padding: 5px; }
#ePubEidter .ulrow { list-style: none; padding: 0; margin: 0; }
#ePubEidter .ulrow li { margin-bottom: 5px; }
#ePubEidter .ulrow a { text-decoration: none; color: #333; }
#ePubEidter .ulrow a:hover { text-decoration: underline; }
#ePubEidter .cb { clear: both; }
#ePubEidter .textarea { width: 95%; }
#ePubEidter .button { margin-right: 5px; }
#ePubEidter .grid { border-collapse: collapse; width: 100%; }
#ePubEidter .grid td { border: 1px solid #ccc; padding: 5px; vertical-align: top; }
#ePubEidter .grid caption { font-weight: bold; margin-bottom: 5px; }
#ePubEidter #VolumeText { height:500px; overflow: hidden scroll ;max-width: 900px; } /* 限制宽度 */
</style>
<div class="main">
<!--左 章节-->
<div id="left">
<div class="block" style="min-height: 230px;">
<div class="blocktitle"><span class="txt">操作设置</span><span class="txtr"></span></div>
<div class="blockcontent"><div style="padding-left:10px">
<ul class="ulrow">
<li><label for="SendArticle">将配置发送到书评:</label><input type="checkbox" id="SendArticle" /></li>
<li><label for="ePubEditerClose">生成后自动关闭:</label><input type="checkbox" id="ePubEditerClose" checked="true" /></li>
<li>配置内容:</li>
<li><textarea id="CfgArea" class="textarea" style="height:100px;"></textarea></li>
<li><input type="button" id="EidterImportBtn" class="button" value="导入配置" /></li>
<li><input type="button" id="EidterBuildBtn" class="button" value="生成ePub" /></li>
</ul><div class="cb"></div>
</div></div>
</div>
<div class="block" style="min-height: 230px;">
<div class="blocktitle"><span class="txt">分卷</span><span class="txtr"></span></div>
<div class="blockcontent"><div style="padding-left:10px">
<ul id="VolumeUL" class="ulrow"></ul><div class="cb"></div>
</div></div>
</div>
</div>
<!--左 章节-->
<div id="left">
<div class="block" style="min-height: 230px;">
<div class="blocktitle"><span class="txt">推测插图位置</span><span class="txtr"></span></div>
<div class="blockcontent"><div style="padding-left:10px">
<ul id="ImgUL" class="ulrow" style="max-height: 200px; overflow-y: auto;"></ul><div class="cb"></div>
</div></div>
</div>
<div class="block" style="min-height: 230px;">
<div class="blocktitle"><span class="txt">章节</span><span class="txtr"></span></div>
<div class="blockcontent"><div style="padding-left:10px">
<ul id="ChapterUL" class="ulrow" style="max-height: 200px; overflow-y: auto;"></ul><div class="cb"></div>
</div></div>
</div>
</div>
<!--右 内容-->
<div id="centerm">
<div id="content">
<table class="grid" width="100%" align="center"><tbody>
<tr>
<td width="4%" align="center"><span style="font-size:16px;">分<br>卷<br>插<br>图<br><br>(可拖拽图片到下方文本)<br><br>(将已放置图片拖到此处可删除)</span></td>
<td><div ondragover="return false" id="VolumeImg" style="height:155px;overflow:auto"></div></td>
</tr>
</tbody></table>
<table class="grid" width="100%" align="center">
<caption>分卷内容 (可拖入图片)</caption><tbody>
<tr><td><div id="VolumeText" style="height:500px;overflow: hidden scroll ;max-width: 900px;"></div></td></tr>
</tbody></table>
</div>
</div>
</div>`;
}
};
const EpubFileBuilder = {
MIMETYPE: "application/epub+zip",
CONTAINER_XML: `<?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>`,
DEFAULT_CSS: {
content: `nav#landmarks, nav#page-list { display:none; } ol { list-style-type: none; } .volumetitle, .chaptertitle { text-align: center; } .divimage { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; } .divimage img { max-width: 100%; height: auto; vertical-align: middle; }`,
id: "default_css_id",
path: "Styles/default.css"
},
NAV_XHTML_TEMPLATE: {
content: `<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="zh-CN" xml:lang="zh-CN"><head><title>目录</title><meta charset="utf-8"/><link href="../Styles/default.css" rel="stylesheet" type="text/css"/></head><body epub:type="frontmatter"><nav epub:type="toc" id="toc" role="doc-toc"><h2><a href="#toc">目录</a></h2></nav></body></html>`,
path: `Text/nav.xhtml`,
id: `nav_xhtml_id`
},
/**
* 构建并生成ePub文件
* @param {EpubBuilderCoordinator} bookInfo - 协调器实例
*/
async build(bookInfo) {
var _a;
if (bookInfo.XHRManager.hasCriticalFailure) {
bookInfo.refreshProgress(bookInfo, `<span style="color:red;">关键文件下载失败,无法生成ePub。</span>`);
const buildBtn = document.getElementById("EidterBuildBtn");
if (buildBtn)
buildBtn.disabled = false;
return;
}
if (!bookInfo.XHRManager.areAllTasksDone()) {
bookInfo.refreshProgress(bookInfo, `等待下载任务完成... (${bookInfo.tasksCompletedOrSkipped}/${bookInfo.totalTasksAdded})`);
return;
}
if (bookInfo.ePubEidt && !bookInfo.ePubEidtDone) {
bookInfo.refreshProgress(bookInfo, `等待用户编辑插图位置...`);
if (!EpubEditor.editorRootElement) {
EpubEditor.init(bookInfo);
}
return;
}
bookInfo.refreshProgress(bookInfo, `开始生成ePub文件...`);
_unsafeWindow._isUnderConstruction = true;
const zip = new JSZip$1();
zip.file("mimetype", this.MIMETYPE, { compression: "STORE" });
zip.file("META-INF/container.xml", this.CONTAINER_XML);
const oebpsFolder = zip.folder("OEBPS");
const contentOpfDoc = this._createContentOpfDocument(bookInfo);
const manifest = contentOpfDoc.querySelector("manifest");
const spine = contentOpfDoc.querySelector("spine");
this._addManifestItem(manifest, this.DEFAULT_CSS.id, this.DEFAULT_CSS.path, "text/css");
oebpsFolder.file(this.DEFAULT_CSS.path, this.DEFAULT_CSS.content);
const navXhtmlContent = this._generateNavXhtml(bookInfo.nav_toc);
const navItem = this._addManifestItem(manifest, this.NAV_XHTML_TEMPLATE.id, this.NAV_XHTML_TEMPLATE.path, "application/xhtml+xml");
navItem.setAttribute("properties", "nav");
this._addSpineItem(spine, this.NAV_XHTML_TEMPLATE.id, "no");
oebpsFolder.file(this.NAV_XHTML_TEMPLATE.path, navXhtmlContent);
bookInfo.Text.forEach((textEntry) => {
this._addManifestItem(manifest, textEntry.id, textEntry.path, "application/xhtml+xml");
this._addSpineItem(spine, textEntry.id);
const finalHtml = this._processAndCleanVolumeHtml(textEntry, bookInfo);
oebpsFolder.file(textEntry.path, finalHtml);
});
let coverImageId = null;
const userCover = bookInfo.ImgLocation.find((loc) => loc.isCover);
if (userCover) {
const imgEntry = bookInfo.Images.find((img) => img.idName === userCover.imgID);
if (imgEntry)
coverImageId = imgEntry.id;
}
if (!coverImageId) {
const coverImage = bookInfo.Images.find((img) => img.coverImg) || bookInfo.Images.find((img) => img.coverImgChk) || bookInfo.Images.find((img) => img.smallCover);
if (coverImage)
coverImageId = coverImage.id;
}
bookInfo.Images.forEach((imgEntry) => {
if (imgEntry.content) {
const item = this._addManifestItem(manifest, imgEntry.id, imgEntry.path, "image/jpeg");
if (imgEntry.id === coverImageId) {
item.setAttribute("properties", "cover-image");
}
oebpsFolder.file(imgEntry.path, imgEntry.content, { binary: true });
} else {
bookInfo.logger.logWarn(`图片 ${imgEntry.idName} 内容为空,未打包进ePub。`);
}
});
if (bookInfo.ImgLocation && bookInfo.ImgLocation.length > 0) {
const editorCfg = {
UID: EPUB_EDITOR_CONFIG_UID,
aid: bookInfo.aid,
pathname: CURRENT_URL.pathname,
ImgLocation: bookInfo.ImgLocation
};
const cfgJson = JSON.stringify(editorCfg, null, " ");
oebpsFolder.file("Other/ePubEditorCfg.json", cfgJson);
this._addManifestItem(manifest, "editor_cfg_json", "Other/ePubEditorCfg.json", "application/json");
bookInfo.logger.logInfo("编辑器配置已保存到ePub。");
}
this._populateMetadata(contentOpfDoc, bookInfo, coverImageId);
const serializer = new XMLSerializer();
let contentOpfString = serializer.serializeToString(contentOpfDoc);
contentOpfString = contentOpfString.replace(/ xmlns=""/g, "");
const content_opf_final = `${contentOpfString}`;
oebpsFolder.file("content.opf", content_opf_final);
let epubFileName = `${bookInfo.title}.${bookInfo.nav_toc[0].volumeName}`;
if (bookInfo.nav_toc.length > 1) {
epubFileName += `-${bookInfo.nav_toc[bookInfo.nav_toc.length - 1].volumeName}`;
}
epubFileName = epubFileName.replace(/[\\/:*?"<>|]/g, "_");
try {
const blob = await zip.generateAsync({ type: "blob", mimeType: "application/epub+zip" });
fileSaver.saveAs(blob, `${epubFileName}.epub`);
bookInfo.refreshProgress(bookInfo, `<span style="color:green;">ePub生成完成, 文件名:${epubFileName}.epub</span>`);
} catch (err) {
bookInfo.logger.logError(`ePub压缩或保存失败: ${err.message}`);
bookInfo.refreshProgress(bookInfo, `<span style="color:red;">ePub生成失败: ${err.message}</span>`);
} finally {
const buildBtn = document.getElementById("EidterBuildBtn");
if (buildBtn)
buildBtn.disabled = false;
}
if (bookInfo.ePubEidtDone && EpubEditor.editorRootElement && ((_a = document.getElementById("ePubEditerClose")) == null ? void 0 : _a.checked)) {
setTimeout(() => EpubEditor.destroy(), 1e3);
}
return true;
},
/**
* 创建 content.opf 的 DOM 文档
* @param {EpubBuilderCoordinator} bookInfo - 协调器实例
* @returns {Document} OPF DOM 文档
*/
_createContentOpfDocument(bookInfo) {
const parser = new DOMParser();
const opfString = `<?xml version="1.0" encoding="utf-8"?>
<package version="3.0" unique-identifier="BookId" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:language>zh-CN</dc:language>
<dc:title>${cleanXmlIllegalChars(bookInfo.title)}.${cleanXmlIllegalChars(bookInfo.nav_toc[0].volumeName)}</dc:title>
<dc:identifier id="BookId">urn:uuid:${globalThis.crypto.randomUUID()}</dc:identifier>
<dc:creator>${cleanXmlIllegalChars(bookInfo.creator || "未知作者")}</dc:creator>
<meta property="dcterms:modified">${`${(/* @__PURE__ */ new Date()).toISOString().split(".")[0]}Z`}</meta>
<meta builder="${PROJECT_NAME}" author="${PROJECT_AUTHOR}" version="${PROJECT_VERSION}">${PROJECT_REPO}</meta>
${bookInfo.description ? `<dc:description>${cleanXmlIllegalChars(bookInfo.description)}</dc:description>` : ""}
</metadata>
<manifest></manifest>
<spine></spine>
</package>`;
const doc = parser.parseFromString(opfString, "text/xml");
const parseError = doc.getElementsByTagName("parsererror");
if (parseError.length > 0) {
console.error("解析OPF XML时出错:", parseError[0].textContent);
bookInfo.logger.logError("内部错误:创建OPF文档失败。");
throw new Error("Failed to create OPF document due to parser error.");
}
return doc;
},
/**
* 填充 metadata 元素
* @param {Element} metadata - metadata DOM 元素
* @param {EpubBuilderCoordinator} bookInfo - 协调器实例
* @param {string} coverImageId - 封面图片的 manifest ID
*/
_populateMetadata(metadata, bookInfo, coverImageId) {
const _metadata = metadata.querySelector("metadata");
if (coverImageId) {
const coverMeta = metadata.createElement("meta");
coverMeta.setAttribute("name", "cover");
coverMeta.setAttribute("content", coverImageId);
coverMeta.removeAttribute("xmlns");
_metadata.appendChild(coverMeta);
}
},
/**
* 向 manifest 添加 item
* @param {Element} manifestElement - manifest DOM 元素
* @param {string} id - item ID
* @param {string} href - item 路径
* @param {string} mediaType - item MIME 类型
* @returns {Element} 创建的 item 元素
*/
_addManifestItem(manifestElement, id, href, mediaType) {
const item = manifestElement.ownerDocument.createElement("item");
item.setAttribute("id", id);
item.setAttribute("href", href);
item.setAttribute("media-type", mediaType);
manifestElement.appendChild(item);
return item;
},
/**
* 向 spine 添加 itemref
* @param {Element} spineElement - spine DOM 元素
* @param {string} idref - 关联的 manifest item ID
* @param {string} [linear] - 是否线性阅读
* @returns {Element} 创建的 itemref 元素
*/
_addSpineItem(spineElement, idref, linear = "yes") {
const itemref = spineElement.ownerDocument.createElement("itemref");
itemref.setAttribute("idref", idref);
if (linear === "no") {
itemref.setAttribute("linear", "no");
}
spineElement.appendChild(itemref);
return itemref;
},
/**
* 生成导航文件 (nav.xhtml) 的内容
* @param {Array<object>} navTocEntries - 导航目录数据 (bookInfo.nav_toc)
* @returns {string} nav.xhtml 内容字符串
*/
_generateNavXhtml(navTocEntries) {
const parser = new DOMParser();
const doc = parser.parseFromString(cleanXmlIllegalChars(this.NAV_XHTML_TEMPLATE.content), "application/xhtml+xml");
const tocNavElement = doc.getElementById("toc");
const ol = doc.createElement("ol");
navTocEntries.forEach((volumeToc) => {
const vLi = doc.createElement("li");
const vA = doc.createElement("a");
vA.href = volumeToc.volumeHref;
vA.textContent = volumeToc.volumeName;
vLi.appendChild(vA);
if (volumeToc.chapterArr && volumeToc.chapterArr.length > 0) {
const cOl = doc.createElement("ol");
volumeToc.chapterArr.forEach((chapterToc) => {
const cLi = doc.createElement("li");
const cA = doc.createElement("a");
cA.href = chapterToc.chapterHref;
cA.textContent = chapterToc.chapterName;
cLi.appendChild(cA);
cOl.appendChild(cLi);
});
vLi.appendChild(cOl);
}
ol.appendChild(vLi);
});
tocNavElement.appendChild(ol);
const serializer = new XMLSerializer();
let xhtmlString = serializer.serializeToString(doc.documentElement);
xhtmlString = xhtmlString.replace(/ xmlns=""/g, "");
return `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
${xhtmlString}`;
},
/**
* 处理并清理分卷HTML内容,插入图片等
* @param {object} textEntry - 当前卷的文本数据 {path, content, id, vid, ...}
* @param {EpubBuilderCoordinator} bookInfo - 协调器实例
* @returns {string} 处理后的 XHTML 字符串
*/
_processAndCleanVolumeHtml(textEntry, bookInfo) {
const parser = new DOMParser();
const initialHtml = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="zh-CN" xml:lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>${cleanXmlIllegalChars(textEntry.volumeName)}</title>
<link href="../Styles/default.css" rel="stylesheet" type="text/css"/>
</head>
<body>
<div class="volumetitle"><h2>${cleanXmlIllegalChars(textEntry.volumeName)}</h2></div><br />
${textEntry.content}
</body>
</html>`;
const doc = parser.parseFromString(cleanXmlIllegalChars(initialHtml), "text/html");
const body = doc.body;
const volumeSpecificLocations = bookInfo.ImgLocation.filter((loc) => loc.vid === textEntry.vid);
const volumeImages = bookInfo.Images.filter((img) => img.TextId === textEntry.id || img.smallCover);
Array.from(body.querySelectorAll(".txtDropEnable")).forEach((span) => {
const spanId = span.id;
const childNodes = Array.from(span.childNodes);
volumeSpecificLocations.filter((loc) => loc.spanID === spanId).forEach((loc) => {
const imgEntry = volumeImages.find((img) => img.idName === loc.imgID && img.content);
if (imgEntry) {
const divImage = doc.createElement("div");
divImage.setAttribute("class", "divimage");
const imgTag = doc.createElement("img");
imgTag.setAttribute("src", `../${imgEntry.path}`);
imgTag.setAttribute("alt", cleanXmlIllegalChars(imgEntry.idName));
imgTag.setAttribute("loading", "lazy");
divImage.appendChild(imgTag);
span.parentNode.insertBefore(divImage, span);
} else {
bookInfo.logger.logWarn(`配置的图片 ${loc.imgID} 在卷 ${textEntry.volumeName} (span: ${loc.spanID}) 未找到或未下载,未插入。`);
}
});
childNodes.forEach((node) => {
span.parentNode.insertBefore(node, span);
});
span.parentNode.removeChild(span);
});
Array.from(body.getElementsByTagName("p")).forEach((p) => {
if (p.innerHTML.trim() === "" || p.innerHTML.trim() === " ") {
p.parentNode.removeChild(p);
}
});
Array.from(body.getElementsByTagName("img")).forEach((img) => {
if (!img.closest("div.divimage")) {
const wrapper = doc.createElement("div");
wrapper.className = "divimage";
img.parentNode.insertBefore(wrapper, img);
wrapper.appendChild(img);
}
});
const serializer = new XMLSerializer();
let xhtmlString = serializer.serializeToString(doc.documentElement);
xhtmlString = xhtmlString.replace(/ xmlns=""/g, "");
return `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
${xhtmlString}`;
}
};
class EpubBuilderCoordinator {
/**
* @param {boolean} isEditingMode - 是否进入编辑模式
* @param {boolean} downloadAllVolumes - 是否下载全部分卷
*/
constructor(isEditingMode = false, downloadAllVolumes = false) {
var _a, _b, _c, _d, _e;
this.aid = _unsafeWindow.article_id;
this.title = ((_c = (_b = (_a = document.getElementById("title")) == null ? void 0 : _a.childNodes[0]) == null ? void 0 : _b.textContent) == null ? void 0 : _c.trim()) || "未知标题";
this.creator = ((_e = (_d = document.getElementById("info")) == null ? void 0 : _d.textContent) == null ? void 0 : _e.trim()) || "未知作者";
this.description = "";
this.bookUrl = CURRENT_URL.href;
this.targetEncoding = _unsafeWindow.targetEncoding;
this.nav_toc = [];
this.Text = [];
this.Images = [];
this.ImgLocation = [];
this.ePubEidt = isEditingMode;
this.ePubEidtDone = false;
this.descriptionXhrInitiated = false;
this.thumbnailImageAdded = false;
this.isDownloadAll = downloadAllVolumes;
this.XHRManager = Object.create(XHRDownloadManager);
this.XHRManager.init(this);
this.logger = UILogger;
this.totalTasksAdded = 0;
this.tasksCompletedOrSkipped = 0;
this.XHRFail = false;
this.VOLUME_ID_PREFIX = VOLUME_ID_PREFIX;
}
/**
* 启动下载和构建流程
* @param {HTMLElement} eventTarget - 触发下载的DOM元素 (用于单卷下载时定位)
*/
start(eventTarget) {
this.logger.clearLog();
this.refreshProgress(this, "开始处理书籍...");
this._loadExternalImageConfigs();
const volumeElements = this.isDownloadAll ? Array.from(document.querySelectorAll(".vcss")) : [eventTarget.closest("td.vcss")];
if (volumeElements.some((el) => !el)) {
this.logger.logError("未能确定下载目标分卷,请检查页面结构。");
const buildBtn = document.getElementById("EidterBuildBtn");
if (buildBtn)
buildBtn.disabled = false;
return;
}
volumeElements.forEach((vcss, index) => {
var _a, _b, _c, _d;
const volumeName = (_b = (_a = vcss.childNodes[0]) == null ? void 0 : _a.textContent) == null ? void 0 : _b.trim();
const volumePageId = vcss.getAttribute("vid");
const firstChapterLink = (_d = (_c = vcss.parentElement) == null ? void 0 : _c.nextElementSibling) == null ? void 0 : _d.querySelector('a[href*=".htm"]');
const firstChapterHref = firstChapterLink == null ? void 0 : firstChapterLink.getAttribute("href");
const firstChapterId = firstChapterHref ? firstChapterHref.split(".")[0] : null;
if (!volumeName || !volumePageId || !firstChapterId) {
this.logger.logWarn(`分卷 ${index + 1} 信息不完整 (名称: ${volumeName}, 页面ID: ${volumePageId}, 首章ID: ${firstChapterId}),跳过此卷。`);
return;
}
const volumeDomId = `${this.VOLUME_ID_PREFIX}_${index}`;
const volumeHref = `${volumeDomId}.xhtml`;
const navTocEntry = {
volumeName,
vid: volumePageId,
// 实际的卷标识,用于ImgLocation等
volumeID: volumeDomId,
volumeHref,
chapterArr: []
// 章节列表稍后填充
};
this.nav_toc.push(navTocEntry);
const textEntry = {
path: `Text/${volumeHref}`,
content: "",
// 稍后填充
id: volumeDomId,
vid: volumePageId,
// 关联到实际卷标识
volumeName,
navToc: navTocEntry
// 引用,方便处理
};
this.Text.push(textEntry);
const downloadUrl = `https://${DOWNLOAD_DOMAIN}/pack.php?aid=${this.aid}&vid=${firstChapterId}`;
this.XHRManager.add({
type: "webVolume",
// 自定义类型
url: downloadUrl,
loadFun: (xhr) => VolumeLoader.loadWebVolumeText(xhr),
// 使用VolumeLoader的方法
VolumeIndex: index,
// 用于在回调中定位nav_toc和Text中的条目
data: { vid: volumePageId, vcssText: volumeName, Text: textEntry },
// 传递给处理函数的信息
bookInfo: this,
// 传递EpubBuilderCoordinator实例
isCritical: true
// 卷内容是关键任务
});
});
if (this.Text.length === 0) {
this.refreshProgress(this, "没有有效的分卷被添加到下载队列。");
const buildBtn = document.getElementById("EidterBuildBtn");
if (buildBtn)
buildBtn.disabled = false;
return;
}
this.tryBuildEpub();
}
/**
* 通知协调器一个任务已完成 (由 XHRManager 调用)
*/
handleTaskCompletion() {
new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1e3);
}).then(() => {
_unsafeWindow._isUnderConstruction = false;
});
}
/**
* 尝试触发ePub构建流程
* 只有当所有下载任务完成且没有关键失败时才会真正构建
*/
tryBuildEpub() {
EpubFileBuilder.build(this).then((result) => {
if (!result || this.XHRFail) {
return;
}
this.refreshProgress(this, "ePub文件已成功生成。");
this.logger.logInfo("ePub文件已成功生成。", result);
const buildBtn = document.getElementById("EidterBuildBtn");
if (buildBtn)
buildBtn.disabled = false;
}).finally(() => {
this.handleTaskCompletion();
});
}
/**
* 更新进度显示和日志 (适配旧的调用方式)
* @param {EpubBuilderCoordinator} instance - 协调器实例 (通常是 this)
* @param {string} [message] - 日志消息
*/
refreshProgress(instance, message) {
this.logger.updateProgress(instance, message);
}
/**
* 从 unsafeWindow.ImgLocationCfgRef 加载外部图片配置
*/
_loadExternalImageConfigs() {
if (Array.isArray(_unsafeWindow.ImgLocationCfgRef) && _unsafeWindow.ImgLocationCfgRef.length > 0) {
let loadedCount = 0;
_unsafeWindow.ImgLocationCfgRef.forEach((cfg) => {
if (cfg.UID === EPUB_EDITOR_CONFIG_UID && cfg.aid === this.aid && Array.isArray(cfg.ImgLocation) && cfg.ImgLocation.length > 0) {
cfg.ImgLocation.forEach((loc) => {
if (loc.vid && loc.spanID && loc.imgID) {
if (!this.ImgLocation.some(
(existing) => existing.vid === loc.vid && existing.spanID === loc.spanID && existing.imgID === loc.imgID
)) {
this.ImgLocation.push({
vid: String(loc.vid),
// 确保类型一致
spanID: String(loc.spanID),
imgID: String(loc.imgID)
});
loadedCount++;
}
}
});
}
});
if (loadedCount > 0) {
this.refreshProgress(this, `已加载 ${loadedCount} 条来自书评的插图位置配置。`);
}
_unsafeWindow.ImgLocationCfgRef = [];
}
}
}
function initializeUserScript() {
if (typeof _unsafeWindow.OpenCC !== "undefined" && typeof _unsafeWindow.translateButtonId !== "undefined") {
OpenCCConverter.init(_unsafeWindow);
} else {
console.warn("OpenCC或页面翻译环境未准备好,增强的简繁转换未初始化。");
}
if (typeof _unsafeWindow.article_id === "undefined") {
console.log("非书籍或目录页面,脚本主要功能不激活。");
initializeReviewPageFeatures();
return;
}
UILogger.init();
if (typeof _unsafeWindow.chapter_id === "undefined" || _unsafeWindow.chapter_id === null || _unsafeWindow.chapter_id === "0") {
if (document.querySelector("#title") && document.querySelectorAll(".vcss").length > 0) {
addDownloadButtonsToCatalogPage();
} else {
console.log("页面缺少chapter_id,且不像是标准目录页,脚本核心功能不激活。");
}
} else {
handleContentPage();
}
initializeReviewPageFeatures();
loadConfigFromUrlIfPresent();
}
function addDownloadButtonsToCatalogPage() {
const titleElement = document.getElementById("title");
if (!titleElement)
return;
const bookTitle = titleElement.textContent.trim();
const targetCharset = _unsafeWindow.targetEncoding === "1" ? "big5" : "utf8";
const txtHref = `https://${DOWNLOAD_DOMAIN}/down.php?type=${targetCharset}&id=${_unsafeWindow.article_id}&fname=${encodeURIComponent(bookTitle)}`;
const txtTitle = `全本文本下载(${targetCharset})`;
const allTxtLink = createTxtDownloadButton(txtTitle, txtHref);
titleElement.appendChild(allTxtLink);
const epubAllButton = createDownloadButton(" ePub下载(全本)", false, true);
const epubAllEditButton = createDownloadButton(" (调整插图)", true, true);
titleElement.appendChild(epubAllButton);
titleElement.appendChild(epubAllEditButton);
const aEleSubEpub = createTxtDownloadButton(" 分卷ePub下载(全本)", "javascript:void(0);", true);
aEleSubEpub.className = "DownloadAllSub";
aEleSubEpub.addEventListener("click", (e) => loopDownloadSub());
titleElement.append(aEleSubEpub);
document.querySelectorAll("td.vcss").forEach((vcssCell) => {
var _a, _b;
const volumeName = (_b = (_a = vcssCell.childNodes[0]) == null ? void 0 : _a.textContent) == null ? void 0 : _b.trim();
const volumeId = vcssCell.getAttribute("vid");
if (!volumeName || !volumeId)
return;
const txtHref2 = `https://${DOWNLOAD_DOMAIN}/packtxt.php?aid=${_unsafeWindow.article_id}&vid=${volumeId}&aname=${encodeURIComponent(bookTitle)}&vname=${encodeURIComponent(volumeName)}&charset=${targetCharset.replace("utf8", "utf-8")}`;
const txtTitle2 = ` 文本下载(${targetCharset.replace("utf8", "utf-8")})`;
const volTxtLink = createTxtDownloadButton(txtTitle2, txtHref2);
vcssCell.appendChild(volTxtLink);
const epubVolButton = createDownloadButton(" ePub下载(本卷)", false, false);
const epubVolEditButton = createDownloadButton(" (调整插图)", true, false);
vcssCell.appendChild(epubVolButton);
vcssCell.appendChild(epubVolEditButton);
});
}
function createTxtDownloadButton(title, href, otherType = false) {
const button = document.createElement("a");
button.href = href;
button.textContent = title;
button.style.marginLeft = "5px";
button.style.display = "inline-block";
button.style.padding = "5px 10px";
button.style.textDecoration = "none";
button.style.borderRadius = "3px";
button.style.cursor = "pointer";
button.style.marginLeft = "5px";
button.style.fontSize = "14px";
button.style.lineHeight = "normal";
if (otherType) {
button.style.borderColor = "#ffe0b2";
button.style.backgroundColor = "#fff8e1";
button.style.color = "#fb602d";
} else {
button.style.borderColor = "#00bcd4";
button.style.backgroundColor = "#b2ebf2";
button.style.color = "#0047a7";
}
return button;
}
function createDownloadButton(text, isEditMode, isDownloadAll) {
const button = document.createElement("a");
button.href = "javascript:void(0);";
button.textContent = text;
button.style.display = "inline-block";
button.style.padding = "5px 10px";
button.style.border = "1px solid #ccc";
button.style.backgroundColor = "#f0f0f0";
button.style.color = "#333";
button.style.textDecoration = "none";
button.style.borderRadius = "3px";
button.style.cursor = "pointer";
button.style.marginLeft = "5px";
button.style.fontSize = "14px";
button.style.lineHeight = "normal";
if (isEditMode) {
button.style.borderColor = "#ff9800";
button.style.backgroundColor = "#fff3e0";
button.style.color = "#e65100";
button.className = "";
button.classList.add("EditMode");
} else if (isDownloadAll) {
button.style.borderColor = "#4caf50";
button.style.backgroundColor = "#e8f5e9";
button.style.color = "#1b5e20";
button.className = "";
button.classList.add("DownloadAll");
} else {
button.className = "ePubSub";
}
button.addEventListener("click", (event) => {
event.target.style.pointerEvents = "none";
event.target.style.opacity = "0.6";
event.target.style.color = "#aaa";
const coordinator = new EpubBuilderCoordinator(isEditMode, isDownloadAll);
coordinator.start(event.target);
});
return button;
}
function loopDownloadSub() {
const elements = document.querySelectorAll("a.ePubSub");
const linksArray = Array.from(elements);
const delayBetweenClicks = 5e3;
const checkInterval = 5e3;
const constructionTimeout = 6e4;
UILogger.logInfo("循环下载分卷ePub(全本)...");
function processElement(index) {
if (index >= linksArray.length) {
UILogger.logInfo("所有分卷下载任务处理完毕。");
return;
}
const currentLink = linksArray[index];
UILogger.logInfo(`开始处理链接: ${currentLink.href} (索引: ${index})`);
checkConstructionStatus(index, Date.now());
}
function checkConstructionStatus(index, startTime) {
const currentTime = Date.now();
const elapsedTime = currentTime - startTime;
const isUnderConstruction = typeof _unsafeWindow !== "undefined" && _unsafeWindow._isUnderConstruction;
if (isUnderConstruction) {
if (elapsedTime > constructionTimeout) {
const errorMessage = `处理链接超时(${constructionTimeout / 1e3}s): ${linksArray[index].href} (索引: ${index}),跳过此元素。`;
UILogger.logError(errorMessage);
processElement(index + 1);
} else {
UILogger.logInfo(`检测到正在构建状态,已等待 ${elapsedTime / 1e3}s,${checkInterval / 1e3}秒后将再次检查...`);
setTimeout(() => checkConstructionStatus(index, startTime), checkInterval);
}
} else {
UILogger.logInfo("未处于构建状态,点击当前链接。");
linksArray[index].click();
UILogger.logInfo(`Clicked link: ${linksArray[index].href}`);
setTimeout(() => processElement(index + 1), delayBetweenClicks);
}
}
processElement(0);
}
function handleContentPage() {
const contentMain = document.getElementById("contentmain");
const contentDiv = document.getElementById("content");
if (!contentMain || !contentDiv)
return;
const isCopyrightRestricted = contentMain.firstElementChild && contentMain.firstElementChild.tagName === "SPAN" && contentMain.firstElementChild.textContent.trim().toLowerCase() === "null";
if (isCopyrightRestricted && typeof _unsafeWindow.chapter_id !== "undefined" && _unsafeWindow.chapter_id !== null && _unsafeWindow.chapter_id !== "0") {
UILogger.logInfo("检测到版权限制页面,尝试通过App接口加载内容...");
const bookInfoForReading = {
aid: _unsafeWindow.article_id,
targetEncoding: _unsafeWindow.targetEncoding,
// 传递页面当前目标编码
logger: UILogger,
// 传递日志记录器
refreshProgress: (info, msg) => UILogger.updateProgress(info, msg)
// 适配旧的refreshProgress
};
AppApiService.fetchChapterForReading(bookInfoForReading, _unsafeWindow.chapter_id, contentDiv, _unsafeWindow.translateBody);
}
}
function initializeReviewPageFeatures() {
if (!CURRENT_URL.pathname.includes("/modules/article/"))
return;
document.querySelectorAll(".jieqiCode").forEach((codeElement) => {
var _a, _b;
const yidAnchor = (_a = codeElement.closest("table")) == null ? void 0 : _a.querySelector('a[name^="y"]');
const yid = yidAnchor == null ? void 0 : yidAnchor.getAttribute("name");
const reviewId = CURRENT_URL.searchParams.get("rid");
const pageNum = CURRENT_URL.searchParams.get("page") || "1";
if (reviewId && yid) {
try {
const configText = codeElement.textContent;
const parsedConfig = JSON.parse(configText.replace(/\s/g, "").replace(/^\uFEFF/, ""));
if (parsedConfig && parsedConfig.UID === EPUB_EDITOR_CONFIG_UID && parsedConfig.aid && parsedConfig.pathname && (parsedConfig.ImgLocationBase64 || Array.isArray(parsedConfig.ImgLocation) && parsedConfig.ImgLocation.length > 0)) {
const titleDiv = (_b = yidAnchor.closest("tr")) == null ? void 0 : _b.querySelector("td > div");
if (titleDiv) {
const useConfigLink = document.createElement("a");
useConfigLink.textContent = "[使用此插图配置生成ePub]";
useConfigLink.style.color = "fuchsia";
useConfigLink.style.marginRight = "10px";
useConfigLink.href = `${CURRENT_URL.origin}${parsedConfig.pathname}?rid=${reviewId}&page=${pageNum}&yid=${yid}&CfgRef=1#title`;
titleDiv.insertBefore(useConfigLink, titleDiv.firstChild);
}
}
} catch (e) {
}
}
});
}
_unsafeWindow.ImgLocationCfgRef = _unsafeWindow.ImgLocationCfgRef || [];
async function loadConfigFromUrlIfPresent() {
var _a;
const urlParams = CURRENT_URL.searchParams;
if (urlParams.get("CfgRef") !== "1")
return;
const rid = urlParams.get("rid");
const page = urlParams.get("page");
const yidToLoad = urlParams.get("yid");
if (!rid || !yidToLoad)
return;
const reviewPageUrl = `${CURRENT_URL.origin}/modules/article/reviewshow.php?rid=${rid}&page=${page || 1}`;
UILogger.init();
UILogger.logInfo(`尝试从书评页加载插图配置...`);
try {
const response = await gmXmlHttpRequestAsync({ method: "GET", url: reviewPageUrl, timeout: XHR_TIMEOUT_MS });
if (response.status === 200) {
const parser = new DOMParser();
const doc = parser.parseFromString(cleanXmlIllegalChars(response.responseText), "text/html");
const targetAnchor = doc.querySelector(`a[name="${yidToLoad}"]`);
const codeElement = (_a = targetAnchor == null ? void 0 : targetAnchor.closest("table")) == null ? void 0 : _a.querySelector(".jieqiCode");
if (codeElement) {
const configText = codeElement.textContent;
const parsedConfig = JSON.parse(configText.replace(/\s/g, "").replace(/^\uFEFF/, ""));
if (parsedConfig.ImgLocationBase64) {
try {
const zip = new JSZip();
await zip.load(parsedConfig.ImgLocationBase64, { base64: true });
const imgLocFile = zip.file(IMG_LOCATION_FILENAME);
if (imgLocFile) {
const imgLocJson = await imgLocFile.async("string");
parsedConfig.ImgLocation = JSON.parse(imgLocJson);
delete parsedConfig.ImgLocationBase64;
} else {
throw new Error(`压缩包中未找到 ${IMG_LOCATION_FILENAME} 文件。`);
}
} catch (zipErr) {
throw new Error(`解压或解析配置压缩包失败: ${zipErr.message}`);
}
}
if (parsedConfig && parsedConfig.UID === EPUB_EDITOR_CONFIG_UID && parsedConfig.aid && Array.isArray(parsedConfig.ImgLocation) && parsedConfig.ImgLocation.length > 0) {
_unsafeWindow.ImgLocationCfgRef.push(parsedConfig);
UILogger.logInfo(`成功加载来自书评的 ${parsedConfig.ImgLocation.length} 条插图位置配置。现在可以点击下载按钮了。`);
const titleElem = document.getElementById("title");
if (titleElem) {
const notice = document.createElement("p");
notice.style.color = "green";
notice.textContent = `提示:来自书评的插图配置已加载,请点击相应的“ePub下载(调整插图)”按钮开始。`;
titleElem.parentNode.insertBefore(notice, titleElem.nextSibling);
}
} else {
UILogger.logError(`书评中的配置无效或不完整。`);
}
} else {
UILogger.logError(`在书评页未能找到对应的配置代码块。`);
}
} else {
UILogger.logError(`下载书评配置失败,状态码: ${response.status}`);
}
} catch (error) {
UILogger.logError(`加载或解析书评配置时出错: ${error.message}`);
console.error("加载书评配置错误:", error);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeUserScript);
} else {
initializeUserScript();
}
})(FileSaver, JSZip);