// ==UserScript== // @name csdn2md - 批量下载CSDN文章为Markdown // @namespace http://tampermonkey.net/ // @version 2.1.3 // @description 下载CSDN文章为Markdown格式,支持专栏批量下载。CSDN排版经过精心调教,最大程度支持CSDN的全部Markdown语法:KaTeX内联公式、KaTeX公式块、图片、内联代码、代码块、Bilibili视频控件、有序/无序/任务/自定义列表、目录、注脚、加粗斜体删除线下滑线高亮、内容居左/中/右、引用块、链接、快捷键(kbd)、表格、上下标、甘特图、UML图、FlowChart流程图 // @author ShizuriYuki // @match https://*.csdn.net/* // @icon https://g.csdnimg.cn/static/logo/favicon32.ico // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @run-at document-end // @license PolyForm Strict License 1.0.0 https://polyformproject.org/licenses/strict/1.0.0/ // @supportURL https://github.com/Qalxry/csdn2md // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js // @downloadURL https://update.greasyfork.icu/scripts/523540/csdn2md%20-%20%E6%89%B9%E9%87%8F%E4%B8%8B%E8%BD%BDCSDN%E6%96%87%E7%AB%A0%E4%B8%BAMarkdown.user.js // @updateURL https://update.greasyfork.icu/scripts/523540/csdn2md%20-%20%E6%89%B9%E9%87%8F%E4%B8%8B%E8%BD%BDCSDN%E6%96%87%E7%AB%A0%E4%B8%BAMarkdown.meta.js // ==/UserScript== (function () { "use strict"; /** * 模块: 工具函数 * 提供各种辅助功能的工具函数集合 */ const Utils = { /** * 清除字符串中的特殊字符 * @param {string} str - 输入字符串 * @returns {string} 清理后的字符串 */ clearSpecialChars(str) { return str .replace(/[\s]{2,}/g, "") .replace( /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF\u00AD\u034F\u061C\u180E\u2800\u3164\uFFA0\uFFF9-\uFFFB]/g, "" ) .replace("⎧", "") .replace("⎨", "{") .replace("⎩", "") .replace("⎫", "") .replace("⎬", "}") .replace("⎭", "") .replace("⎡", "[") .replace("⎢", "") .replace("⎣", "") .replace("⎤", "]") .replace("⎥", "") .replace("⎦", ""); }, /** * 根据长度特征清除字符串中开头的杂乱字符 * @param {string} str - 输入字符串 * @returns {string} 清理后的字符串 */ clearKatexMathML(str) { const strSplit = str.split(/(?=.*\n)(?=.* )[\s\n]{10,}/); let maxLen = 0; let maxStr = ""; for (const item of strSplit) { if (item.length > maxLen) { maxLen = item.length; maxStr = item; } } return maxStr; }, /** * 清理URL中的参数和锚点 * @param {string} url - 输入URL * @returns {string} 清理后的URL */ clearUrl(url) { return url.replace(/[?#@!$&'()*+,;=].*$/, ""); }, /** * 将文件名转换为安全的文件名 * @param {string} filename - 原始文件名 * @returns {string} 安全的文件名 */ safeFilename(filename) { return filename.replace(/[\\/:*?"<>|]/g, "_"); }, /** * 压缩HTML内容,移除多余的空白和换行符 * @param {string} html - 输入的HTML字符串 * @returns {string} 压缩后的HTML字符串 */ shrinkHtml(html) { return html .replace(/>\s+<") // 去除标签之间的空白 .replace(/\s{2,}/g, " ") // 多个空格压缩成一个 .replace(/^\s+|\s+$/g, ""); // 去除首尾空白 }, /** * 将SVG图片转换为Base64编码的字符串 * @param {string} svgText - SVG图片的文本内容 * @returns {string} Base64编码的字符串 */ svgToBase64(svgText) { const uint8Array = new TextEncoder().encode(svgText); const binaryString = uint8Array.reduce((data, byte) => data + String.fromCharCode(byte), ""); return btoa(binaryString); }, }; /** * 模块: 锁管理 * 处理异步操作锁 */ class ReentrantAsyncLock { /** * 创建一个可重入异步锁 * @param {boolean} enableReentrant - 是否启用重入功能 */ constructor(enableReentrant = true) { this.queue = []; this.locked = false; this.owner = null; // 记录锁的持有者,用于重入 this.enableReentrant = enableReentrant; } /** * 获取锁 * @param {any} ownerId - 锁持有者的标识 * @returns {Promise} */ async acquire(ownerId = null) { if (this.locked) { // 如果允许重入,且当前持有者是ownerId,则直接返回 if (this.enableReentrant && this.owner === ownerId) { return; } // 否则加入队列等待 await new Promise((resolve) => this.queue.push(resolve)); } this.locked = true; this.owner = ownerId; } /** * 释放锁 * @param {any} ownerId - 锁持有者的标识 */ release(ownerId) { if (this.enableReentrant && this.owner !== ownerId) { throw new Error("Cannot release a lock you do not own"); } this.locked = false; this.owner = null; if (this.queue.length > 0) { const resolve = this.queue.shift(); resolve(); this.locked = true; this.owner = ownerId; // 继续持有锁 } } } /** * 模块: UI管理 * 处理界面相关的功能 */ class UIManager { constructor(fileManager, downloadManager) { this.fileManager = fileManager; this.downloadManager = downloadManager; this.isDragging = 0; this.offsetX = 0; this.offsetY = 0; this.container = null; this.contentBox = null; this.mainButton = null; this.floatWindow = null; this.downloadButton = null; this.gotoRepoButton = null; this.defaultOptions = {}; this.optionDivList = []; this.optionCheckBoxList = []; this.isOpen = false; this.initStyles(); this.initUI(); this.setupEventListeners(); } /** * 初始化UI样式 */ initStyles() { GM_addStyle(` .tm_floating-container { position: fixed; bottom: 30px; right: 30px; z-index: 9999; transform-origin: bottom right; } .tm_main-button { width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #12c2e9 0%, #c471ed 50%, #f64f59 100%); box-shadow: 0 0 20px rgba(0,0,0,0.2); border: none; color: white; font-size: 30px; cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; } .tm_content-box { background: linear-gradient(45deg, #ffffff, #f8f9fa); border-radius: 20px; padding: 20px; min-width: 480px !important; box-shadow: 0 10px 30px rgba(0,0,0,0.15); margin-bottom: 20px; opacity: 0; transform: scale(0); transform-origin: bottom right; transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); position: absolute; bottom: 100%; right: 0; } .tm_content-box.open { opacity: 1; transform: scale(1); } .tm_complex-content { display: flex; flex-direction: column; gap: 15px; } .tm_content-item { padding: 15px; background: rgba(255,255,255,0.9); border-radius: 10px; border: 1px solid rgba(0,0,0,0.1); transition: transform 0.2s ease; } .tm_content-item:hover { transform: scale(1.1); } #myFloatWindow label { white-space: nowrap; /* 防止文字换行 */ user-select: none; /* 优化用户体验 */ } #myDownloadButton { text-align: center; padding: 5px 10px; background: linear-gradient(135deg, #12c2e9 0%, #c471ed 50%, #f64f59 100%); color: white; cursor: pointer; transition: all 0.3s ease; border: none; border-radius: 5px; margin-bottom: 5px; } #myDownloadButton:hover { transform: scale(1.1); } #myGotoRepoButton, #myResetButton { text-align: center; padding: 5px 10px; background: linear-gradient(135deg, #12c2e9 0%, #c471ed 50%, #f64f59 100%); color: white; cursor: pointer; transition: all 0.3s ease; border-radius: 5px; border: none; margin-top: 12px; } #myGotoRepoButton:hover, #myResetButton:hover { transform: scale(1.1); } `); } /** * 初始化UI元素 */ initUI() { // 创建悬浮容器 this.container = document.createElement("div"); this.container.className = "tm_floating-container"; this.container.id = "draggable"; // 创建主按钮 this.mainButton = document.createElement("button"); this.mainButton.className = "tm_main-button"; this.mainButton.innerHTML = ``; // 创建内容区域 this.contentBox = document.createElement("div"); this.contentBox.className = "tm_content-box"; // 创建复杂内容 this.contentBox.innerHTML = `
`; // 组装元素 this.container.appendChild(this.contentBox); this.container.appendChild(this.mainButton); document.body.appendChild(this.container); // 还原之前保存的位置 const savedTop = GM_getValue("draggableTop"); if (savedTop) { this.container.style.top = Math.min(window.innerHeight - 100, parseInt(savedTop)) + "px"; } // 创建浮动窗口 this.createFloatWindow(); } /** * 创建浮动窗口和选项 */ createFloatWindow() { // 创建悬浮窗 this.floatWindow = document.createElement("div"); this.floatWindow.style.alignItems = "center"; this.floatWindow.style.display = "flex"; this.floatWindow.style.flexDirection = "column"; // 里面的元素每个占一行 this.floatWindow.id = "myFloatWindow"; // 创建下载按钮 this.downloadButton = document.createElement("button"); this.downloadButton.innerHTML = "下载CSDN文章为Markdown
(支持专栏、文章、用户全部文章页面)
(推荐使用typora打开下载的Markdown)"; this.downloadButton.id = "myDownloadButton"; this.floatWindow.appendChild(this.downloadButton); // 创建选项容器 const optionContainer = document.createElement("div"); optionContainer.style.display = "flex"; optionContainer.style.flexDirection = "column"; optionContainer.style.alignItems = "left"; optionContainer.style.marginTop = "10px"; this.floatWindow.appendChild(optionContainer); // 添加选项 this.addOption( "parallelDownload", "批量并行下载模式(下载乱序,但可以添加前缀弥补)", false, optionContainer ); this.addOption( "fastDownload", "批量高速下载模式(有代码块语言无法识别等问题,能接受就开)", false, optionContainer ); this.addOption("addSerialNumber", "批量文章文件加入序号前缀", false, optionContainer); this.addOption("zipCategories", "下载为压缩包", true, optionContainer, { false: [{ id: "saveWebImages", value: false }], }); this.addOption("addArticleInfoInYaml", "添加文章元信息(以YAML元信息格式)", false, optionContainer); this.addOption("addArticleTitleToMarkdown", "添加文章标题(以一级标题形式)", true, optionContainer); this.addOption( "addArticleInfoInBlockquote", "添加阅读量、点赞等信息(以引用块形式)", true, optionContainer ); this.addOption( "saveWebImages", "将图片保存到与MD文件同名的文件夹内,以相对路径使用", true, optionContainer, { true: [{ id: "zipCategories", value: true }], } ); this.addOption("forceImageCentering", "全部图片居中排版", false, optionContainer); this.addOption("enableImageSize", "启用图片宽高属性(如果网页中的图片具有宽高)", true, optionContainer); this.addOption("removeCSDNSearchLink", "移除CSDN搜索链接", true, optionContainer); this.addOption("enableColorText", "启用彩色文字(以span形式保存)", true, optionContainer); this.addOption("mergeArticleContent", "合并批量文章内容(以一篇文章的形式保存)", false, optionContainer, { true: [ { id: "zipCategories", value: true }, { id: "addArticleInfoInYaml", value: false }, ], }); this.addOption( "addSerialNumberToTitle", "添加序号到标题前缀(建议在合并文章时开启)", false, optionContainer ); this.addOption( "addArticleInfoInBlockquote_batch", "合并文章时添加阅读量、点赞等信息(以引用块形式)", true, optionContainer ); // 创建恢复默认设置按钮 this.resetButton = document.createElement("button"); this.resetButton.innerHTML = "恢复默认设置"; this.resetButton.id = "myResetButton"; this.floatWindow.appendChild(this.resetButton); // 创建去GitHub按钮 this.gotoRepoButton = document.createElement("button"); this.gotoRepoButton.innerHTML = "前往 GitHub 给作者点个 Star ⭐ ➡️"; this.gotoRepoButton.id = "myGotoRepoButton"; this.floatWindow.appendChild(this.gotoRepoButton); // 将浮窗添加到内容区 document.getElementById("tmComplexContent").appendChild(this.floatWindow); } /** * 设置事件监听器 */ setupEventListeners() { // 主按钮点击事件 this.mainButton.addEventListener("click", (e) => { e.stopPropagation(); this.toggleContent(); }); // 点击外部区域关闭 document.addEventListener("click", (e) => { if (!this.container.contains(e.target)) { this.closeContent(); } }); // 阻止内容区域点击关闭 this.contentBox.addEventListener("click", (e) => { e.stopPropagation(); }); // 下载按钮点击事件 this.downloadButton.addEventListener("click", async () => { await this.downloadManager.runMain(); }); // 默认设置按钮点击事件 this.resetButton.addEventListener("click", () => { Object.entries(this.defaultOptions).forEach(([id, value]) => GM_setValue(id, value)); this.updateAllOptions(); this.showFloatTip("已恢复默认设置", 1000); }); // GitHub按钮点击事件 this.gotoRepoButton.addEventListener("click", () => { window.open("https://github.com/Qalxry/csdn2md"); }); // 拖拽功能 const draggable = document.getElementById("draggable"); draggable.addEventListener("mousedown", (e) => { this.isDragging = true; this.offsetX = e.clientX - draggable.offsetLeft; this.offsetY = e.clientY - draggable.offsetTop; }); document.addEventListener("mousemove", (e) => { if (this.isDragging) { // draggable.style.left = `${e.clientX - offsetX}px`; // 左侧拖拽 // draggable.style.top = `${e.clientY - this.offsetY}px`; draggable.style.top = Math.min(window.innerHeight - 100, Math.max(0, e.clientY - this.offsetY)) + "px"; // 限制在窗口内 } }); // 监视页面缩放事件 window.addEventListener("resize", () => { const savedTop = GM_getValue("draggableTop"); if (savedTop) { this.container.style.top = Math.min(window.innerHeight - 100, parseInt(savedTop)) + "px"; } }); document.addEventListener("mouseup", () => { this.isDragging = false; GM_setValue("draggableTop", draggable.style.top); }); // 监听窗口聚焦事件 window.addEventListener("focus", () => { this.updateAllOptions(); }); } /** * 切换内容区域显示状态 */ toggleContent() { this.isOpen = !this.isOpen; this.contentBox.classList.toggle("open", this.isOpen); this.mainButton.style.transform = this.isOpen ? "scale(1.1) rotate(360deg)" : "scale(1) rotate(0deg)"; } /** * 关闭内容区域 */ closeContent() { this.isOpen = false; this.contentBox.classList.remove("open"); this.mainButton.style.transform = "scale(1) rotate(0deg)"; } /** * 添加选项 * @param {string} id - 选项ID * @param {string} innerHTML - 选项文本 * @param {boolean} defaultValue - 默认值 * @param {HTMLElement} container - 父容器 * @param {Object} constraints - 约束条件 */ addOption(id, innerHTML, defaultValue, container, constraints = {}) { this.defaultOptions[id] = defaultValue; if (GM_getValue(id) === undefined) { GM_setValue(id, defaultValue); } const checked = GM_getValue(id); const optionDiv = document.createElement("div"); optionDiv.style.display = "flex"; optionDiv.style.alignItems = "left"; const optionCheckbox = document.createElement("input"); optionCheckbox.type = "checkbox"; optionCheckbox.checked = checked; optionCheckbox.id = id + "Checkbox"; optionCheckbox.style.marginRight = "5px"; const optionLabel = document.createElement("label"); optionLabel.htmlFor = optionCheckbox.id; optionLabel.textContent = innerHTML; optionLabel.style.marginRight = "10px"; optionDiv.appendChild(optionCheckbox); optionDiv.appendChild(optionLabel); this.optionDivList.push(optionDiv); this.optionCheckBoxList.push(optionCheckbox); container.appendChild(optionDiv); optionCheckbox.addEventListener("change", () => { GM_setValue(id, optionCheckbox.checked); if (optionCheckbox.checked) { if (constraints.true) { for (const constraint of constraints.true) { if (constraint.id !== undefined && constraint.value !== undefined) { GM_setValue(constraint.id, constraint.value); } } this.updateAllOptions(); } } else { if (constraints.false) { for (const constraint of constraints.false) { if (constraint.id !== undefined && constraint.value !== undefined) { GM_setValue(constraint.id, constraint.value); } } this.updateAllOptions(); } } }); } /** * 更新所有选项的状态 */ updateAllOptions() { this.optionCheckBoxList.forEach((optionElem) => { optionElem.checked = GM_getValue(optionElem.id.replace("Checkbox", "")); }); } /** * 启用悬浮窗 */ enableFloatWindow() { this.downloadButton.disabled = false; this.downloadButton.innerHTML = "下载CSDN文章为Markdown
(支持专栏、文章、用户全部文章页面)
(推荐使用typora打开下载的Markdown)"; this.optionCheckBoxList.forEach((optionElem) => { optionElem.disabled = false; }); } /** * 禁用悬浮窗 */ disableFloatWindow() { this.downloadButton.disabled = true; this.downloadButton.innerHTML = "正在下载,请稍候..."; this.optionCheckBoxList.forEach((optionElem) => { optionElem.disabled = true; }); } /** * 显示悬浮提示 * @param {string} text - 提示内容 * @param {number} timeout - 自动关闭时间(毫秒) */ showFloatTip(text, timeout = 0) { if (document.getElementById("myInfoFloatTip")) { document.getElementById("myInfoFloatTip").remove(); } const floatTip = document.createElement("div"); floatTip.style.position = "fixed"; floatTip.style.top = "40%"; floatTip.style.left = "50%"; floatTip.style.transform = "translateX(-50%)"; floatTip.style.padding = "10px"; floatTip.style.backgroundColor = "rgba(0, 0, 0, 0.7)"; floatTip.style.color = "#fff"; floatTip.style.borderRadius = "5px"; floatTip.style.boxShadow = "0 2px 5px rgba(0, 0, 0, 0.5)"; floatTip.style.zIndex = "9999"; floatTip.innerHTML = text; floatTip.id = "myInfoFloatTip"; document.body.appendChild(floatTip); if (timeout > 0) { setTimeout(() => { this.hideFloatTip(); }, timeout); } } /** * 隐藏悬浮提示 */ hideFloatTip() { if (document.getElementById("myInfoFloatTip")) { document.getElementById("myInfoFloatTip").remove(); } } } /** * 模块: 文件管理 * 处理文件相关的操作 */ class FileManager { constructor() { this.fileQueue = []; this.imageCount = {}; this.imageSet = {}; this.saveWebImageToLocal_lock = new ReentrantAsyncLock(false); } /** * 将文本保存为文件 * @param {string} content - 文件内容 * @param {string} filename - 文件名 * @param {number} index - 文件索引(用于排序) */ async saveTextAsFile(content, filename, index = 0) { filename = Utils.safeFilename(filename); if (GM_getValue("zipCategories") || GM_getValue("mergeArticleContent")) { // 保存到队列中,等待打包 this.fileQueue.push({ filename, type: "text/plain", content, index }); return; } const blob = new Blob([content], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } /** * 将网络图片保存到本地,添加到fileQueue,并返回本地路径 * @param {string} imgUrl - 图片URL * @param {string} mdAssetDirName - 资源文件夹名 * @param {boolean} reset - 是否重置计数器 * @returns {Promise} 本地图片路径 */ async saveWebImageToLocal(imgUrl, mdAssetDirName) { if (GM_getValue("mergeArticleContent")) { // 避免多篇文章合并时,异步操作导致索引错乱,所以加锁 await this.saveWebImageToLocal_lock.acquire(); } // 检查参数是否合法 if (typeof imgUrl !== "string") { throw new Error("[saveWebImageToLocal] Invalid argument: imgUrl must be a string."); } // 清理URL imgUrl = Utils.clearUrl(imgUrl); // 初始化 if (!this.imageCount[mdAssetDirName]) { this.imageSet[mdAssetDirName] = {}; this.imageCount[mdAssetDirName] = 0; } // 检查是否已保存过该图片 if (this.imageSet[mdAssetDirName][imgUrl]) { return this.imageSet[mdAssetDirName][imgUrl]; } // 记录图片数量 this.imageCount[mdAssetDirName]++; const index = this.imageCount[mdAssetDirName]; let ext = imgUrl.split(".").pop(); const allowedExt = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif"]; if (!allowedExt.includes(ext)) { console.warn(`[saveWebImageToLocal] Unsupported image format: ${ext}`); ext = ""; } else { ext = `.${ext}`; } const filename = `${mdAssetDirName}/${index}${ext}`; // 记录已保存的图片 this.imageSet[mdAssetDirName][imgUrl] = `./${filename}`; // 释放锁 if (GM_getValue("mergeArticleContent")) { this.saveWebImageToLocal_lock.release(); } // 获取图片的Blob对象 const blob = await this.fetchImageAsBlob(imgUrl); // 添加到文件队列 this.fileQueue.push({ filename, content: blob, type: blob.type, index }); // 返回本地路径 return `./${filename}`; } /** * 获取网络资源 * @param {string} url - 资源URL * @returns {Promise} 资源Blob对象 */ async fetchImageAsBlob(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, responseType: "blob", onload: function (response) { if (response.status === 200) { resolve(response.response); } else { reject(`Failed to fetch resource: ${url}`); } }, onerror: function () { reject(`Error fetching resource: ${url}`); }, }); }); } /** * 合并文章内容 * @param {string} mergeName - 合并后的文件名 * @param {string} extraPrefix - 额外前缀 */ mergeArticleContent(mergeName, extraPrefix = "") { // 检查队列是否只有一个md文件 let mdCount = 0; this.fileQueue.forEach((file) => { if (file.type === "text/plain") { mdCount++; } }); if (mdCount <= 1) { return; } // 合并文章内容 const textArray = []; const newFileQueue = []; this.fileQueue.forEach((file) => { if (file.type === "text/plain") { textArray.push({ content: file.content, index: file.index }); } else { newFileQueue.push(file); } }); // 按照index排序 textArray.sort((a, b) => a.index - b.index); const mergedContent = textArray.map((item) => item.content).join("\n\n\n\n"); newFileQueue.push({ filename: `${mergeName}.md`, type: "text/plain", content: `${extraPrefix}${mergedContent}`, }); this.fileQueue = newFileQueue; } /** * 下载合并后的文章 */ downloadMergedArticle() { const content = this.fileQueue.pop(); const blob = new Blob([content.content], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = content.filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } /** * 将文件队列打包为ZIP下载 * @param {string} zipName - ZIP文件名 */ async saveAllFileToZip(zipName) { if (this.fileQueue.length === 0) { console.error("没有文件需要保存"); return; } zipName = Utils.safeFilename(zipName); // 创建JSZip实例 const zip = new JSZip(); this.fileQueue.forEach((file) => { // 将文件添加到ZIP中 zip.file(file.filename, file.content); }); // 生成ZIP文件 zip.generateAsync({ type: "blob" }) .then((blob) => { // 创建下载链接 const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${zipName}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.fileQueue = []; }) .catch((error) => { console.error("Error generating ZIP file:", error); }); } /** * 重置图片计数器和缓存 */ clearImageCache() { this.imageCount = {}; this.imageSet = {}; } /** * 清空文件队列 */ clearFileQueue() { this.fileQueue = []; } /** * 重置FileManager */ async reset() { await this.saveWebImageToLocal_lock.acquire(); this.clearFileQueue(); this.clearImageCache(); this.saveWebImageToLocal_lock.release(); } } /** * 模块: Markdown转换 * 将HTML转换为Markdown */ class MarkdownConverter { constructor(fileManager) { this.fileManager = fileManager; } /** * 将HTML内容转换为Markdown格式 * @param {Element} articleElement - 文章DOM元素 * @param {string} mdAssetDirName - 资源文件夹名 * @param {boolean} enableTOC - 是否启用目录 * @returns {Promise} Markdown内容 */ async htmlToMarkdown(articleElement, mdAssetDirName = "", enableTOC = true) { // 预定义的特殊字段 const CONSTANT_DOUBLE_NEW_LINE = "<|CSDN2MD@CONSTANT_DOUBLE_NEW_LINE@23hy7b|>"; const SEPARATION_BEAUTIFICATION = "<|CSDN2MD@SEPARATION_BEAUTIFICATION@2caev2|>"; // 处理预定义的特殊字段 const DDNL = escapeRegExp(CONSTANT_DOUBLE_NEW_LINE); const SEPB = escapeRegExp(SEPARATION_BEAUTIFICATION); function SpecialTrim(text = "") { return text.replace(new RegExp(`^(?:${SEPB}|\\s)+`), "").replace(new RegExp(`(?:${SEPB}|\\s)+$`), ""); } // 1. 连续的 "\n" 与 CONSTANT_DOUBLE_NEW_LINE 替换为 "\n\n" const RE_DOUBLE_NL = new RegExp(`(?:\\n|${DDNL})*${DDNL}(?:\\n|${DDNL})*`, "g"); // 2. 连续的 SEPARATION_BEAUTIFICATION 替换为 " ",但如果前面是换行符,替换为 "" const RE_SEP_NOLINE = new RegExp(`(? { // return text.replace(/([\\`*_\{\}\[\]()#+\-.!])/g, "\\$1").trim(); return text.trim(); // 不转义特殊字符 }; /** * 递归处理DOM节点并将其转换为Markdown * @param {Node} node - 当前DOM节点 * @param {number} listLevel - 当前列表嵌套级别 * @returns {Promise} 节点的Markdown字符串 */ const processNode = async (node, listLevel = 0) => { let result = ""; const ELEMENT_NODE = 1; const TEXT_NODE = 3; const COMMENT_NODE = 8; switch (node.nodeType) { case ELEMENT_NODE: // 处理元素节点 switch (node.tagName.toLowerCase()) { case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": { const htype = Number(node.tagName[1]); // FIX: 修复该页面中,hx标签的内容中有其他标签,而这里直接使用了textContent,造成内容丢失的BUG // URL: https://blog.csdn.net/naozibuok/article/details/142671763 // <<< FIX BEGIN >>> // 不再直接使用textContent // result += `${"#".repeat(htype)} ${node.textContent.trim()}\n\n`; // 移除节点内部开头的 标签 node.querySelectorAll("a").forEach((aTag) => { if (aTag && aTag.textContent.trim() === "") { aTag.remove(); } }); // // 创建一个浮动的div元素,作为打印hx标签的内容,因为console.log被重写了 // let hxContent = document.getElementById("hxContent"); // if (!hxContent) { // hxContent = document.createElement("div"); // hxContent.id = "hxContent"; // hxContent.style.position = "absolute"; // hxContent.style.left = "0"; // hxContent.style.top = "0"; // hxContent.style.zIndex = "9999"; // hxContent.style.backgroundColor = "lightgray"; // hxContent.style.border = "1px solid black"; // hxContent.style.padding = "10px"; // hxContent.style.width = "auto"; // hxContent.style.height = "auto"; // hxContent.style.whiteSpace = "pre-wrap"; // hxContent.style.fontSize = "16px"; // document.body.appendChild(hxContent); // } // hxContent.innerHTML += node.nodeType + " " + node.tagName + "
"; // hxContent.innerHTML += `Original:

`; // 处理节点内部元素,包括hx标签的文本 let childContent = await processChildren(node, listLevel); // hxContent.innerHTML += `Processed:

`; const hPrefix = "#".repeat(htype); // 按行分割分别处理。 // 如果该行内容不为空,则添加前缀。 childContent = childContent .split("\n") .map((line) => { if (line.trim() !== "") { // 如果该行内容是 标签,则不添加前缀 if ( line.trim().search("") !== -1 ) { return line; } return `${hPrefix} ${line}`; } else { return line; } }) .join("\n"); // hxContent.innerHTML += `Markdown:


`; result += `${childContent}${CONSTANT_DOUBLE_NEW_LINE}`; // <<< FIX END >>> } break; case "p": { const cls = node.getAttribute("class"); const style = node.getAttribute("style"); if (cls && cls.includes("img-center")) { // Same as
tag node.childNodes.forEach((child) => { if ( child.nodeType === ELEMENT_NODE && child.tagName.toLowerCase() === "img" ) { if (!child.getAttribute("src").includes("#pic_center")) { child.setAttribute( "src", child.getAttribute("src") + "#pic_center" ); } } }); result += await processChildren(node, listLevel); result += CONSTANT_DOUBLE_NEW_LINE; break; } if (node.getAttribute("id") === "main-toc") { if (enableTOC) { result += `**目录**\n\n[TOC]\n\n`; } break; } let text = await processChildren(node, listLevel); if (style) { if (style.includes("padding-left")) { break; } if (style.includes("text-align:center")) { text = `
${Utils.shrinkHtml( node.innerHTML )}
\n\n`; } else if (style.includes("text-align:right")) { text = `
${Utils.shrinkHtml( node.innerHTML )}
\n\n`; } else if (style.includes("text-align:justify")) { text += "\n\n"; } else { text += "\n\n"; } } else { text += "\n\n"; } result += text; } break; case "strong": case "b": result += `${SEPARATION_BEAUTIFICATION}**${SpecialTrim( await processChildren(node, listLevel) )}**${SEPARATION_BEAUTIFICATION}`; break; case "em": case "i": result += `${SEPARATION_BEAUTIFICATION}*${SpecialTrim( await processChildren(node, listLevel) )}*${SEPARATION_BEAUTIFICATION}`; break; case "u": result += `${SEPARATION_BEAUTIFICATION}${SpecialTrim( await processChildren(node, listLevel) )}${SEPARATION_BEAUTIFICATION}`; break; case "s": case "strike": result += `${SEPARATION_BEAUTIFICATION}~~${SpecialTrim( await processChildren(node, listLevel) )}~~${SEPARATION_BEAUTIFICATION}`; break; case "a": { const node_class = node.getAttribute("class"); if (node_class && node_class.includes("footnote-backref")) { break; } const href = node.getAttribute("href") || ""; if (node_class && node_class.includes("has-card")) { const desc = node.title || ""; result += `[${desc}](${href}) `; break; } const text = await processChildren(node, listLevel); if ( href.includes("https://so.csdn.net/so/search") && GM_getValue("removeCSDNSearchLink") ) { result += `${text}`; break; } result += `${SEPARATION_BEAUTIFICATION}[${text}](${href})${SEPARATION_BEAUTIFICATION}`; } break; case "img": { let src = node.getAttribute("src") || ""; const alt = node.getAttribute("alt") || ""; const cls = node.getAttribute("class") || ""; const width = node.getAttribute("width") || ""; const height = node.getAttribute("height") || ""; if (cls.includes("mathcode")) { result += `${SEPARATION_BEAUTIFICATION}\$\$\n${alt}\n\$\$`; } else { if (src.includes("#pic_center") || GM_getValue("forceImageCentering")) { result += CONSTANT_DOUBLE_NEW_LINE; } else { result += " "; } if (GM_getValue("saveWebImages")) { src = await this.fileManager.saveWebImageToLocal(src, mdAssetDirName); } if (height && GM_getValue("enableImageSize")) { // 如果 height 是数字,则添加 px // 如果带有单位,则直接使用 const heightValue = height.replace(/[^0-9]/g, ""); const heightUnit = height.replace(/[0-9]/g, "") || "px"; const heightStyle = heightValue ? `max-height:${heightValue}${heightUnit};` : ""; result += `${alt}`; } else if (width && GM_getValue("enableImageSize")) { // 如果 width 是数字,则添加 px // 如果带有单位,则直接使用 const widthValue = width.replace(/[^0-9]/g, ""); const widthUnit = width.replace(/[0-9]/g, "") || "px"; const widthStyle = widthValue ? `max-width:${widthValue}${widthUnit};` : ""; result += `${alt}`; } else { result += `![${alt}](${src})`; } result += CONSTANT_DOUBLE_NEW_LINE; } } break; case "ul": result += await processList(node, listLevel, false); break; case "ol": result += await processList(node, listLevel, true); break; case "blockquote": { const text = (await processChildren(node, listLevel)) .trim() .split("\n") .map((line) => (line ? `> ${line}` : "> ")) .join("\n"); result += `${text}\n\n`; } break; case "pre": { const codeNode = node.querySelector("code"); if (codeNode) { const className = codeNode.className || ""; let language = ""; // 新版本的代码块,class含有language-xxx if (className.includes("language-")) { const languageMatch = className.split(" "); // 找到第一个language-开头的字符串 for (const item of languageMatch) { if (item.startsWith("language-")) { language = item; break; } } language = language.replace("language-", ""); } // 老版本的代码块 else if (className.startsWith("hljs")) { const languageMatch = className.split(" "); language = languageMatch ? languageMatch[1] : ""; } result += `\`\`\`${language}\n${await processCodeBlock(codeNode)}\`\`\`\n\n`; } else { console.warn("Code block without element:", node.outerHTML); const codeText = node.textContent.replace(/^\s+|\s+$/g, ""); result += `\`\`\`\n${codeText}\n\`\`\`\n\n`; } } break; case "code": { const codeText = node.textContent; result += `${SEPARATION_BEAUTIFICATION}\`${codeText}\`${SEPARATION_BEAUTIFICATION}`; } break; case "hr": if (node.getAttribute("id") !== "hr-toc") { result += `---\n\n`; } break; case "br": result += ` \n`; break; case "table": result += (await processTable(node)) + "\n\n"; break; case "div": { const className = node.getAttribute("class") || ""; if (className.includes("csdn-video-box")) { const iframe = node.querySelector("iframe"); const src = iframe.getAttribute("src") || ""; const title = node.querySelector("p").textContent || ""; const iframeHTML = iframe.outerHTML.replace( ">", ' style="width: 100%; aspect-ratio: 2;">' ); result += `
${title}${iframeHTML}
\n\n`; } else if (className.includes("toc")) { const customTitle = node.querySelector("h4")?.textContent || ""; if (enableTOC) { result += `**${customTitle}**\n\n[TOC]\n\n`; } } else { result += `${await processChildren(node, listLevel)}\n`; } } break; case "span": { const node_class = node.getAttribute("class"); if (node_class) { if ( node_class.includes("katex--inline") || node_class.includes("katex--display") ) { const katex_mathml_elem = node.querySelector(".katex-mathml"); const katex_html_elem = node.querySelector(".katex-html"); if (katex_mathml_elem !== null && katex_html_elem !== null) { // 移除.katex-mathml里的.MathJax_Display类,否则会造成错乱 if ( katex_mathml_elem.querySelector(".MathJax_Display") && katex_mathml_elem.querySelector("script") ) { katex_mathml_elem .querySelectorAll(".MathJax_Display") .forEach((elem) => elem.remove()); } if ( katex_mathml_elem.querySelector(".MathJax_Preview") && katex_mathml_elem.querySelector("script") ) { katex_mathml_elem .querySelectorAll(".MathJax_Preview") .forEach((elem) => elem.remove()); } if ( katex_mathml_elem.querySelector(".MathJax_Error") && katex_mathml_elem.querySelector("script") ) { katex_mathml_elem .querySelectorAll(".MathJax_Error") .forEach((elem) => elem.remove()); } const mathml = Utils.clearSpecialChars(katex_mathml_elem.textContent); const katex_html = Utils.clearSpecialChars(katex_html_elem.textContent); if (node_class.includes("katex--inline")) { if (mathml.startsWith(katex_html)) { result += `${SEPARATION_BEAUTIFICATION}\$${mathml.replace( katex_html, "" )}\$${SEPARATION_BEAUTIFICATION}`; } else { result += `${SEPARATION_BEAUTIFICATION}\$${Utils.clearKatexMathML( katex_mathml_elem.textContent )}\$${SEPARATION_BEAUTIFICATION}`; } } else { if (mathml.startsWith(katex_html)) { result += `${CONSTANT_DOUBLE_NEW_LINE}\$\$\n${mathml.replace( katex_html, "" )}\n\$\$${CONSTANT_DOUBLE_NEW_LINE}`; } else { result += `${CONSTANT_DOUBLE_NEW_LINE}\$\$\n${Utils.clearKatexMathML( katex_mathml_elem.textContent )}\n\$\$${CONSTANT_DOUBLE_NEW_LINE}`; } } } break; } } const style = node.getAttribute("style") || ""; if ( (style.includes("background-color") || style.includes("color")) && GM_getValue("enableColorText") ) { result += `${await processChildren( node, listLevel )}`; } else { result += await processChildren(node, listLevel); } } break; case "kbd": result += `${SEPARATION_BEAUTIFICATION}${node.textContent}${SEPARATION_BEAUTIFICATION}`; break; case "mark": result += `${SEPARATION_BEAUTIFICATION}${await processChildren( node, listLevel )}${SEPARATION_BEAUTIFICATION}`; break; case "sub": result += `${await processChildren(node, listLevel)}`; break; case "sup": { const node_class = node.getAttribute("class"); if (node_class && node_class.includes("footnote-ref")) { result += `[^${node.textContent}]`; } else { result += `${await processChildren(node, listLevel)}`; } } break; case "svg": { const style = node.getAttribute("style"); if (style && style.includes("display: none")) { break; } // 为foreignObject里的div添加属性xmlns="http://www.w3.org/1999/xhtml",否则typora无法识别 const foreignObjects = node.querySelectorAll("foreignObject"); for (const foreignObject of foreignObjects) { const divs = foreignObject.querySelectorAll("div"); divs.forEach((div) => { div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); }); } // 检查是否有style标签存在于svg元素内,如果有则转换为base64形式 if (node.querySelector("style")) { const base64 = Utils.svgToBase64(node.outerHTML); result += `![SVG Image](data:image/svg+xml;base64,${base64})${CONSTANT_DOUBLE_NEW_LINE}`; } else { result += `
${node.outerHTML}
${CONSTANT_DOUBLE_NEW_LINE}`; } } break; case "section": // 这个是注脚的内容 { const node_class = node.getAttribute("class"); if (node_class && node_class.includes("footnotes")) { result += await processFootnotes(node); } } break; case "input": // 仅处理checkbox类型的input元素 if (node.getAttribute("type") === "checkbox") { result += `[${node.checked ? "x" : " "}] `; } break; case "dl": // 自定义列表,直接用html result += `${Utils.shrinkHtml(node.outerHTML)}\n\n`; break; case "abbr": result += `${Utils.shrinkHtml(node.outerHTML)}`; break; case "font": // 避免进入 default : https://blog.csdn.net/azhengye/article/details/8481846 result += await processChildren(node, listLevel); break; case "td": case "th": // 处理表格单元格 result += await processChildren(node, listLevel); break; case "center": // 处理居中标签 if (node.childNodes.length === 1 && node.childNodes[0].nodeType === TEXT_NODE) { result += `
${node.textContent.trim().replace("\n", "
")}
\n\n`; } else { node.childNodes.forEach((child) => { if (child.nodeType === ELEMENT_NODE && child.tagName.toLowerCase() === "img") { if (!child.getAttribute("src").includes("#pic_center")) { child.setAttribute("src", child.getAttribute("src") + "#pic_center"); } } }); result += await processChildren(node, listLevel); result += CONSTANT_DOUBLE_NEW_LINE; } break; default: result += await processChildren(node, listLevel); result += CONSTANT_DOUBLE_NEW_LINE; break; } break; case TEXT_NODE: // 处理文本节点(即没有被单独的标签包裹的文本) result += escapeMarkdown(node.textContent); break; case COMMENT_NODE: // 忽略注释 break; default: break; } return result; }; /** * 处理给定节点的子节点 * @param {Node} node - 父节点 * @param {number} listLevel - 当前列表嵌套级别 * @returns {Promise} 子节点拼接后的Markdown字符串 */ const processChildren = async (node, listLevel) => { let text = ""; for (const child of node.childNodes) { text += await processNode(child, listLevel); } return text; }; /** * 处理列表元素 * @param {Element} node - 列表元素 * @param {number} listLevel - 当前列表嵌套级别 * @param {boolean} ordered - 列表是否有序 * @returns {Promise} 列表的Markdown字符串 */ const processList = async (node, listLevel, ordered) => { let text = CONSTANT_DOUBLE_NEW_LINE; const children = Array.from(node.children).filter((child) => child.tagName.toLowerCase() === "li"); for (let index = 0; index < children.length; index++) { const child = children[index]; let prefix = ordered ? `${index + 1}. ` : `- `; let indent = ordered ? " " : " "; let childText = `${await processChildren(child, listLevel + 1)}`; // 由于缩进,这里必须先替换掉 CONSTANT_DOUBLE_NEW_LINE childText = childText.replace(RE_DOUBLE_NL, "\n\n"); childText = childText .split("\n") .map((line, index) => { // 如果是空行,则不添加缩进 if (line.trim() === "" || index === 0) { return line; } // 否则添加缩进 return `${indent}${line}`; }) .join("\n"); text += `${prefix}${childText}${CONSTANT_DOUBLE_NEW_LINE}`; } // text += `\n`; return text; }; /** * 处理表格 * @param {Element} node - 包含表格的元素 * @returns {Promise} 表格的Markdown字符串 */ const processTable = async (node) => { const rows = Array.from(node.querySelectorAll("tr")); if (rows.length === 0) return ""; let table = ""; // 处理表头 const headerCells = Array.from(rows[0].querySelectorAll("th, td")); const headers = await Promise.all( headerCells.map(async (cell) => (await processNode(cell)).trim().replaceAll(RE_DOUBLE_NL, "
")) ); table += `| ${headers.join(" | ")} |\n`; // 处理分隔符 const alignments = headerCells.map((cell) => { const align = cell.getAttribute("align"); if (align === "center") { return ":---:"; } else if (align === "right") { return "---:"; } else if (align === "left") { return ":---"; } else { return ":---:"; } }); table += `|${alignments.join("|")}|\n`; // 处理表格内容 for (let i = 1; i < rows.length; i++) { const cells = Array.from(rows[i].querySelectorAll("td")); const row = await Promise.all( cells.map(async (cell) => (await processNode(cell)).trim().replaceAll(RE_DOUBLE_NL, "
")) ); table += `| ${row.join(" | ")} |`; if (i < rows.length - 1) { table += "\n"; } } return table; }; /** * 处理代码块 * @param {Element} codeNode - 包含代码的元素 * @returns {Promise} 代码块的Markdown字符串 */ const processCodeBlock = async (codeNode) => { // 查找code内部是否有ol元素 const node = codeNode.querySelector("ol"); // 确保传入的节点是一个
    元素 if (!node || node.tagName.toLowerCase() !== "ol") { // 如果没有ol元素,则说明是老版本,直接返回codeNode的textContent return codeNode.textContent.replace(/\n$/, "") + "\n"; } // 获取所有
  1. 子元素 const listItems = node.querySelectorAll("li"); let result = ""; // 遍历每个
  2. 元素 listItems.forEach((li) => { result += li.textContent; result += "\n"; }); return result; }; /** * 处理脚注 * @param {Element} node - 包含脚注的元素 * @returns {Promise} 脚注的Markdown字符串 */ const processFootnotes = async (node) => { const footnotes = Array.from(node.querySelectorAll("li")); let result = ""; for (let index = 0; index < footnotes.length; index++) { const li = footnotes[index]; const text = (await processNode(li)).replaceAll("\n", " ").replaceAll("↩︎", "").trim(); result += `[^${index + 1}]: ${text}\n`; } return result; }; let markdown = ""; for (const child of articleElement.childNodes) { markdown += await processNode(child); } markdown = markdown.trim(); markdown = markdown .replaceAll(RE_DOUBLE_NL, "\n\n") // 1. 吃掉前后重复换行和标记,统一为两个换行 .replaceAll(RE_SEP_NOLINE, " ") // 2.a 非换行前的标记串 → 空格 .replaceAll(RE_SEP_WITHNL, "$1"); // 2.b 换行后的标记串 → 保留换行 return markdown; } } /** * 模块: 文章下载管理 * 协调各模块完成文章下载功能 */ class ArticleDownloader { /** * @param {FileManager} fileManager * @param {MarkdownConverter} markdownConverter * @param {UIManager} uiManager */ constructor(fileManager, markdownConverter, uiManager) { /** @type {FileManager} */ this.fileManager = fileManager; /** @type {MarkdownConverter} */ this.markdownConverter = markdownConverter; /** @type {UIManager} */ this.uiManager = uiManager; } /** * 主函数 - 下载文章入口 */ async runMain() { this.uiManager.disableFloatWindow(); const url = window.location.href; try { if (url.includes("category")) { // 专栏 await this.downloadCSDNCategoryToMarkdown(); } else if (url.includes("article/details")) { // 文章 if (GM_getValue("mergeArticleContent")) { GM_setValue("mergeArticleContent", false); await this.downloadCSDNArticleToMarkdown( document.body, GM_getValue("zipCategories"), window.location.href, "" ); GM_setValue("mergeArticleContent", true); } else { await this.downloadCSDNArticleToMarkdown( document.body, GM_getValue("zipCategories"), window.location.href, "" ); } this.uiManager.showFloatTip("文章下载完成。", 3000); } else if (url.includes("type=blog")) { // 用户全部文章 await this.downloadAllArticlesOfUserToMarkdown(); } else { alert("无法识别的页面。"); } } catch (error) { this.uiManager.showFloatTip(`下载文章时出错,错误信息:\n${error.message}`); } finally { this.uiManager.enableFloatWindow(); this.fileManager.reset(); // 重置FileManager } } /** * 下载文章内容并转换为Markdown格式 * @param {Document} doc_body - 文章的body元素 * @param {boolean} getZip - 是否下载为ZIP * @param {string} url - 文章URL * @param {string} prefix - 文件前缀 */ async downloadCSDNArticleToMarkdown(doc_body, getZip = false, url = "", prefix = "") { const articleTitle = doc_body.querySelector("#articleContentId")?.textContent.trim() || "未命名文章"; const articleInfo = doc_body .querySelector(".bar-content") ?.textContent.replace(/\s{2,}/g, " ") .trim() || ""; const htmlInput = doc_body.querySelector("#content_views"); if (!htmlInput) { alert("未找到文章内容。"); return; } let mode = GM_getValue("parallelDownload") ? "并行" : "串行"; mode += GM_getValue("fastDownload") ? "快速" : "完整"; this.uiManager.showFloatTip(`正在以${mode}模式下载文章:` + articleTitle); if (url === "") { url = window.location.href; } url = Utils.clearUrl(url); let markdown = await this.markdownConverter.htmlToMarkdown( htmlInput, GM_getValue("mergeArticleContent") ? "assets" : `${prefix}${articleTitle}`, !GM_getValue("mergeArticleContent") ); if (GM_getValue("addArticleInfoInBlockquote")) { markdown = `> ${articleInfo}\n> 文章链接:${url}\n\n${markdown}`; } if (GM_getValue("addArticleTitleToMarkdown")) { if (GM_getValue("addSerialNumberToTitle")) { markdown = `# ${prefix}${articleTitle}\n\n${markdown}`; } else { markdown = `# ${articleTitle}\n\n${markdown}`; } } if (GM_getValue("addArticleInfoInYaml")) { const article_info_box = doc_body.querySelector(".article-info-box"); // 文章标题 const meta_title = GM_getValue("addSerialNumberToTitle") ? `${prefix}${articleTitle}` : articleTitle; // 文章日期 YYYY-MM-DD HH:MM:SS const meta_date = article_info_box .querySelector(".time") ?.textContent.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)[0] || ""; let articleMeta = `title: ${meta_title}\ndate: ${meta_date}\n`; // 文章分类和标签 const meta_category_and_tags = Array.from(article_info_box.querySelectorAll(".tag-link")) || []; if (meta_category_and_tags.length > 0 && article_info_box.textContent.includes("分类专栏")) { articleMeta += `categories:\n- ${meta_category_and_tags[0].textContent}\n`; meta_category_and_tags.shift(); } if (meta_category_and_tags.length > 0 && article_info_box.textContent.includes("文章标签")) { articleMeta += `tags:\n${Array.from(meta_category_and_tags) .map((tag) => `- ${tag.textContent}`) .join("\n")}\n`; } markdown = `---\n${articleMeta}---\n\n${markdown}`; } // 从prefix中获取序号 let index = 0; if (prefix !== "" && prefix.endsWith("_")) { index = Number(prefix.slice(0, -1)); } // 如果是批量下载,则需要添加序号 const saveFileName = GM_getValue("addSerialNumber") ? `${prefix}${articleTitle}.md` : `${articleTitle}.md`; await this.fileManager.saveTextAsFile(markdown, saveFileName, index); if (getZip) { await this.fileManager.saveAllFileToZip(`${prefix}${articleTitle}`); } } /** * 在iframe中下载文章 * @param {string} url - 文章URL * @param {string} prefix - 文件前缀 * @returns {Promise} */ async downloadArticleInIframe(url, prefix = "") { return new Promise((resolve, reject) => { // 创建一个隐藏的iframe const iframe = document.createElement("iframe"); iframe.style.display = "none"; iframe.src = url; document.body.appendChild(iframe); // 监听iframe加载完成事件 iframe.onload = async () => { try { const doc = iframe.contentDocument || iframe.contentWindow.document; // 调用下载函数 await this.downloadCSDNArticleToMarkdown(doc.body, false, url, prefix); // 移除iframe document.body.removeChild(iframe); resolve(); } catch (error) { // 在发生错误时移除iframe并拒绝Promise document.body.removeChild(iframe); console.error("下载文章时出错:", error); reject(error); } }; // 监听iframe加载错误事件 iframe.onerror = () => { document.body.removeChild(iframe); console.error("无法加载文章页面:", url); reject(new Error("无法加载文章页面")); }; }); } /** * 从URL批量下载文章 * @param {string} url - 文章URL * @param {string} prefix - 文件前缀 */ async downloadArticleFromBatchURL(url, prefix = "") { // if (!(GM_getValue("addSerialNumber") || GM_getValue("addSerialNumberToTitle"))) { // prefix = ""; // } if (GM_getValue("fastDownload")) { const response = await fetch(url); const text = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(text, "text/html"); // 调用下载函数 await this.downloadCSDNArticleToMarkdown(doc.body, false, url, prefix); } else { await this.downloadArticleInIframe(url, prefix); } } /** * 下载专栏的全部文章为Markdown格式 */ async downloadCSDNCategoryToMarkdown() { // 获取专栏id,注意url可能是/category_数字.html或/category_数字_数字.html,需要第一个数字 this.uiManager.showFloatTip("正在获取专栏的全部文章链接..."); const base_url = window.location.href; const category_id = base_url.match(/category_(\d+)(?:_\d+)?\.html/)[1]; const url_list = []; let page = 1; let doc_body = document.body; while (true) { let hasNextArticle = false; // 获取当前页面的文章列表 doc_body .querySelector(".column_article_list") .querySelectorAll("a") .forEach((item) => { url_list.push(item.href); hasNextArticle = true; }); if (!hasNextArticle) break; // 下一页 page++; const next_url = base_url.replace( /category_\d+(?:_\d+)?\.html/, `category_${category_id}_${page}.html` ); const response = await fetch(next_url); const text = await response.text(); const parser = new DOMParser(); doc_body = parser.parseFromString(text, "text/html").body; } if (url_list.length === 0) { this.uiManager.showFloatTip("没有找到文章。"); return; } else { this.uiManager.showFloatTip( `找到 ${url_list.length} 篇文章。开始下载...(预计时间:${Math.round(url_list.length * 0.6)} 秒)` ); } // 下载每篇文章 const prefixMaxLength = url_list.length.toString().length; if (GM_getValue("parallelDownload")) { await Promise.all( url_list.map((url, index) => this.downloadArticleFromBatchURL( url, `${String(url_list.length - index).padStart(prefixMaxLength, "0")}_` ) ) ); } else { for (let i = 0; i < url_list.length; i++) { await this.downloadArticleFromBatchURL( url_list[i], `${String(url_list.length - i).padStart(prefixMaxLength, "0")}_` ); } } let extraPrefix = ""; if (GM_getValue("addArticleTitleToMarkdown")) { extraPrefix += `# ${document.title}\n\n`; } if (GM_getValue("addArticleInfoInBlockquote_batch")) { const batchTitle = document.body.querySelector(".column_title")?.textContent.trim() || ""; const batchDesc = document.body.querySelector(".column_text_desc")?.textContent.trim() || ""; const batchColumnData = document.body .querySelector(".column_data") ?.textContent.replace(/\s{2,}/g, " ") .trim() || ""; const batchAuthor = document.body .querySelector(".column_person_tit") ?.textContent.replace(/\s{2,}/g, " ") .trim() || ""; const batchUrl = Utils.clearUrl(base_url); extraPrefix += `> ${batchDesc}\n> ${batchAuthor} ${batchColumnData}\n${batchUrl}\n\n`; } if (GM_getValue("mergeArticleContent")) { this.fileManager.mergeArticleContent(`${document.title}`, extraPrefix); } if (GM_getValue("zipCategories")) { await this.fileManager.saveAllFileToZip(`${document.title}`); this.uiManager.showFloatTip( `专栏文章全部处理完毕,请等待打包。(预计时间: ${Math.round(url_list.length * 0.25)} 秒)`, url_list.length * 250 ); } else { if (GM_getValue("mergeArticleContent")) { this.fileManager.downloadMergedArticle(); } this.uiManager.showFloatTip("专栏文章全部处理完毕,请等待下载结束。", 3000); } } /** * 下载用户的全部文章为Markdown格式 */ async downloadAllArticlesOfUserToMarkdown() { this.uiManager.showFloatTip("正在获取用户全部文章链接。可能需要进行多次页面滚动,请耐心等待。"); const mainContent = document.body.querySelector(".mainContent"); const url_list = []; const url_set = new Set(); while (true) { // 等待2秒,等待页面加载完成 await new Promise((resolve) => setTimeout(resolve, 2000)); window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth", }); let end = true; mainContent.querySelectorAll("article").forEach((item) => { const url = item.querySelector("a").href; if (!url_set.has(url)) { url_list.push(url); url_set.add(url); end = false; } }); if (end) break; } // 滚回顶部 window.scrollTo({ top: 0, behavior: "smooth", }); if (url_list.length === 0) { this.uiManager.showFloatTip("没有找到文章。"); } else { this.uiManager.showFloatTip( `找到 ${url_list.length} 篇文章。开始下载...(预计时间:${Math.round(url_list.length * 0.6)} 秒)` ); } // 下载每篇文章 const prefixMaxLength = url_list.length.toString().length; if (GM_getValue("parallelDownload")) { await Promise.all( url_list.map((url, index) => this.downloadArticleFromBatchURL( url, `${String(url_list.length - index).padStart(prefixMaxLength, "0")}_` ) ) ); } else { for (let i = 0; i < url_list.length; i++) { await this.downloadArticleFromBatchURL( url_list[i], `${String(url_list.length - i).padStart(prefixMaxLength, "0")}_` ); } } let extraPrefix = ""; if (GM_getValue("addArticleTitleToMarkdown")) { extraPrefix += `# ${document.title}\n\n`; } if (GM_getValue("addArticleInfoInBlockquote_batch")) { const batchUrl = Utils.clearUrl(window.location.href); extraPrefix += `> ${batchUrl}\n\n`; } if (GM_getValue("mergeArticleContent")) { this.fileManager.mergeArticleContent(`${document.title}`, extraPrefix); } if (GM_getValue("zipCategories")) { await this.fileManager.saveAllFileToZip(`${document.title}`); this.uiManager.showFloatTip( `用户全部文章处理完毕,请等待打包。(预计时间: ${Math.round(url_list.length * 0.5)} 秒)`, url_list.length * 250 ); } else { if (GM_getValue("mergeArticleContent")) { this.fileManager.downloadMergedArticle(); } this.uiManager.showFloatTip("用户全部文章处理完毕,请等待下载结束。", 3000); } } } // 初始化应用 function initApp() { const fileManager = new FileManager(); const markdownConverter = new MarkdownConverter(fileManager); const uiManager = new UIManager(fileManager); const downloadManager = new ArticleDownloader(fileManager, markdownConverter, uiManager); // 设置UI与下载管理器的双向引用 uiManager.downloadManager = downloadManager; // 更新选项状态 uiManager.updateAllOptions(); } // 启动应用 initApp(); })();