您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
下载CSDN文章为Markdown格式,支持专栏批量下载。CSDN排版经过精心调教,最大程度支持CSDN的全部Markdown语法:KaTeX内联公式、KaTeX公式块、图片、内联代码、代码块、Bilibili视频控件、有序/无序/任务/自定义列表、目录、注脚、加粗斜体删除线下滑线高亮、内容居左/中/右、引用块、链接、快捷键(kbd)、表格、上下标、甘特图、UML图、FlowChart流程图
// ==UserScript== // @name csdn2md - 批量下载CSDN文章为Markdown // @namespace http://tampermonkey.net/ // @version 3.3.1 // @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-idle // @license PolyForm Strict License 1.0.0 https://polyformproject.org/licenses/strict/1.0.0/ // @supportURL https://github.com/Qalxry/csdn2md // @require https://cdn.jsdmirror.com/gh/Qalxry/csdn2md/plugins/jszip.min.js#sha256-yeSlK6wYruTz+Q0F+8pgP1sPW/HOjEXmC7TtOiyy7YY= // @require https://cdn.jsdmirror.com/gh/Qalxry/csdn2md/plugins/fflate.min.js#sha256-w7NPLp9edNTX1k4BysegwBlUxsQGQU1CGFx7U9aHXd8= // @require https://cdn.jsdmirror.com/gh/Qalxry/csdn2md/plugins/streamSaver.min.js#sha256-VxQm++CYEdHipBjKWh4QQHHOYZmyo8F/7dJQxG11xFM= // ==/UserScript== (function () { "use strict"; // 需要加载的库及其备用源 const libsToLoad = { JSZip: { isLoaded: () => typeof JSZip !== "undefined", urls: [ "https://cdn.jsdelivr.net/gh/Qalxry/csdn2md/plugins/jszip.min.js#sha256-yeSlK6wYruTz+Q0F+8pgP1sPW/HOjEXmC7TtOiyy7YY=", "https://cdn.jsdmirror.com/gh/Qalxry/csdn2md/plugins/jszip.min.js#sha256-yeSlK6wYruTz+Q0F+8pgP1sPW/HOjEXmC7TtOiyy7YY=", "https://cdnjs.webstatic.cn/ajax/libs/jszip/3.7.1/jszip.min.js#sha256-yeSlK6wYruTz+Q0F+8pgP1sPW/HOjEXmC7TtOiyy7YY=", "https://mirrors.sustech.edu.cn/cdnjs/ajax/libs/jszip/3.7.1/jszip.min.js#sha256-yeSlK6wYruTz+Q0F+8pgP1sPW/HOjEXmC7TtOiyy7YY=", "https://use.sevencdn.com/ajax/libs/jszip/3.7.1/jszip.min.js#sha256-yeSlK6wYruTz+Q0F+8pgP1sPW/HOjEXmC7TtOiyy7YY=", "https://cdn.jsdmirror.com/ajax/libs/jszip/3.7.1/jszip.min.js#sha256-yeSlK6wYruTz+Q0F+8pgP1sPW/HOjEXmC7TtOiyy7YY=", "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js#sha256-yeSlK6wYruTz+Q0F+8pgP1sPW/HOjEXmC7TtOiyy7YY=", ], }, fflate: { isLoaded: () => typeof fflate !== "undefined", urls: [ "https://cdn.jsdelivr.net/gh/Qalxry/csdn2md/plugins/fflate.min.js#sha256-w7NPLp9edNTX1k4BysegwBlUxsQGQU1CGFx7U9aHXd8=", "https://cdn.jsdmirror.com/gh/Qalxry/csdn2md/plugins/fflate.min.js#sha256-w7NPLp9edNTX1k4BysegwBlUxsQGQU1CGFx7U9aHXd8=", "https://npm.webcache.cn/[email protected]/umd/index.js#sha256-w7NPLp9edNTX1k4BysegwBlUxsQGQU1CGFx7U9aHXd8=", "https://use.sevencdn.com/npm/[email protected]/umd/index.js#sha256-w7NPLp9edNTX1k4BysegwBlUxsQGQU1CGFx7U9aHXd8=", "https://cdn.jsdmirror.com/npm/[email protected]/umd/index.js#sha256-w7NPLp9edNTX1k4BysegwBlUxsQGQU1CGFx7U9aHXd8=", "https://unpkg.com/[email protected]/umd/index.js#sha256-w7NPLp9edNTX1k4BysegwBlUxsQGQU1CGFx7U9aHXd8=", "https://cdn.jsdelivr.net/npm/[email protected]/umd/index.js#sha256-w7NPLp9edNTX1k4BysegwBlUxsQGQU1CGFx7U9aHXd8=", ], }, streamSaver: { isLoaded: () => typeof streamSaver !== "undefined", urls: [ "https://cdn.jsdelivr.net/gh/Qalxry/csdn2md/plugins/streamSaver.min.js#sha256-VxQm++CYEdHipBjKWh4QQHHOYZmyo8F/7dJQxG11xFM=", "https://cdn.jsdmirror.com/gh/Qalxry/csdn2md/plugins/streamSaver.min.js#sha256-VxQm++CYEdHipBjKWh4QQHHOYZmyo8F/7dJQxG11xFM=", "https://use.sevencdn.com/npm/[email protected]/StreamSaver.min.js", "https://cdn.jsdelivr.net/npm/[email protected]/StreamSaver.min.js", "https://cdn.jsdmirror.com/npm/[email protected]/StreamSaver.min.js", ], }, }; // 动态插入脚本 function loadScript(src) { return new Promise((resolve, reject) => { const s = document.createElement("script"); let hash = src.match(/#(.*)$/)?.[1]; s.src = src; if (hash) { s.setAttribute("integrity", hash); s.setAttribute("crossorigin", "anonymous"); } s.onload = () => { resolve(); }; s.onerror = () => reject(new Error(`Failed to load ${src.slice(0, 100)}`)); document.head.appendChild(s); }); } // 如果全局对象不存在,就按顺序尝试加载备用源 (async () => { for (const [libName, libData] of Object.entries(libsToLoad)) { if (!libData.isLoaded()) { console.warn(`${libName} not found, loading from additional sources...`); for (const url of libData.urls) { try { await loadScript(url); // 检查是否加载成功 if (!libData.isLoaded()) { throw new Error(`not loaded after script injection`); } console.info(`${libName} loaded successfully from ${url}`); break; } catch (e) { console.error(`Failed to load ${libName} from ${url}:`, e); } } } else { console.info(`${libName} is already loaded.`); } } })(); /** * 模块: 工具函数 * 提供各种辅助功能的工具函数集合 */ const Utils = { /** * 清除字符串中的特殊字符 * @param {string} str - 输入字符串 * @returns {string} 清理后的字符串 */ clearSpecialChars(str) { return str .replaceAll(/[\s]{2,}/g, "") .replaceAll( /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF\u00AD\u034F\u061C\u180E\u2800\u3164\uFFA0\uFFF9-\uFFFB]/g, "" ) .replaceAll("⎧", "") .replaceAll("⎨", "{") .replaceAll("⎩", "") .replaceAll("⎫", "") .replaceAll("⎬", "}") .replaceAll("⎭", "") .replaceAll("⎡", "[") .replaceAll("⎢", "") .replaceAll("⎣", "") .replaceAll("⎤", "]") .replaceAll("⎥", "") .replaceAll("⎦", ""); }, /** * 根据长度特征清除字符串中开头的杂乱字符 * @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.replaceAll(/[?#@!$&'()*+,;=].*$/g, ""); }, /** * 将文件名转换为安全的文件名 * @param {string} filename - 原始文件名 * @returns {string} 安全的文件名 */ safeFilename(filename) { return filename.replaceAll(/[\\/:*?"<>|]/g, "_"); }, /** * 压缩HTML内容,移除多余的空白和换行符 * @param {string} html - 输入的HTML字符串 * @returns {string} 压缩后的HTML字符串 */ shrinkHtml(html) { return html .replaceAll(/>\s+</g, "><") // 去除标签之间的空白 .replaceAll(/\s{2,}/g, " ") // 多个空格压缩成一个 .replaceAll(/^\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); }, formatSeconds(seconds) { const hrs = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const pad = (num) => num.toString().padStart(2, "0"); return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; }, async parallelPool(array, iteratorFn, poolLimit = 10) { const ret = []; // 存储所有任务 const executing = []; // 存储正在执行的任务 let index = 0; for (const item of array) { const currentIndex = index++; const p = new Promise(async (resolve, reject) => { try { await iteratorFn(item, currentIndex); resolve(); } catch (error) { reject(error); } }); ret.push(p); if (poolLimit <= array.length) { const e = p.finally(() => executing.splice(executing.indexOf(e), 1)); executing.push(e); if (executing.length >= poolLimit) { await Promise.race(executing); } } } return Promise.all(ret); }, /** * 计算字符串的简单哈希值 * @param {string} str - 输入字符串 * @param {number} length - 返回的16进制字符串长度,默认为8 * @returns {string} 指定长度的16进制哈希字符串 */ simpleHash(str, length = 8) { let hash = 0; if (str.length === 0) return "0".repeat(length); for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // 转换为32位整数 } // 转换为16进制并确保为正数 let hexHash = Math.abs(hash).toString(16); // 如果长度不够,重复哈希直到达到要求长度 while (hexHash.length < length) { hash = (hash << 5) - hash + hash; hash = hash & hash; hexHash += Math.abs(hash).toString(16); } // 截取到指定长度 return hexHash.substring(0, length); }, formatFileSize(bytes) { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }, }; /** * 模块: 锁管理 * 处理异步操作锁 */ class ReentrantAsyncLock { /** * 创建一个可重入异步锁 * @param {boolean} enableReentrant - 是否启用重入功能 */ constructor(enableReentrant = true) { this.queue = []; this.locked = false; this.owner = null; // 记录锁的持有者,用于重入 this.enableReentrant = enableReentrant; } /** * 获取锁 * @param {any} ownerId - 锁持有者的标识 * @returns {Promise<void>} */ 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 { /** * 创建UI管理器 **/ constructor() { /** @type {ConfigManager} */ this.configManager = new ConfigManager(); // 配置管理 /** @type {ArticleDownloader} */ this.downloadManager = new ArticleDownloader(); // 下载管理器 this.downloadManager.setUIManager(this); // 设置UI与下载管理器的双向引用 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.isOpen = false; this.repo_url = "https://github.com/Qalxry/csdn2md"; // 初始化 this.initStyles(); this.initUI(); this.setupEventListeners(); this.dialogQueue = []; this.isDialogActive = false; this.updateAllOptions(); } /** * 初始化UI样式 */ initStyles() { GM_addStyle(` :root { --tm_ui-linear-gradient: linear-gradient(135deg, #12c2e9 0%, #c471ed 50%, #f64f59 100%); } .tm_floating-container { position: fixed; bottom: 20px; right: 20px; z-index: 9999; transform-origin: bottom right; font-size: 13px; } .tm_main-button { width: 50px; height: 50px; border-radius: 50%; background: var(--tm_ui-linear-gradient); box-shadow: 0 0 20px rgba(0,0,0,0.2); border: none; color: white; font-size: 20px; 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: 13px; padding: 13px; width: 360px; box-shadow: 0 7px 20px rgba(0,0,0,0.15); margin-bottom: 13px; 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: 10px; } #myFloatWindow { width: 100%; position: relative; } .tm_ui-options-container { max-height: 480px; overflow-y: auto; padding-right: 5px; margin: 10px 0; scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.3) transparent; position: relative; } .tm_ui-options-disabled-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(240,240,240,0.7); z-index: 10; display: none; border-radius: 8px; } .tm_ui-options-container::-webkit-scrollbar { width: 4px; } .tm_ui-options-container::-webkit-scrollbar-thumb { background-color: rgba(0,0,0,0.3); border-radius: 2px; } .tm_ui-option-group { border: 1px solid rgba(0,0,0,0.08); border-radius: 6px; padding: 0; margin-bottom: 7px; background: #ffffff; box-shadow: 0 1px 2px rgba(0,0,0,0.05); overflow: hidden; transition: all 0.3s ease; } .tm_ui-option-group-header { padding: 7px 8px; cursor: pointer; background: rgba(0,0,0,0.02); border-bottom: 1px solid rgba(0,0,0,0.08); display: flex; align-items: center; justify-content: space-between; user-select: none; transition: background 0.2s ease; } .tm_ui-option-group-header:hover { background: rgba(0, 123, 255, 0.05); } .tm_ui-option-group-title { font-weight: 800; color: #444; flex: 1; font-size: 12px; } .tm_ui-option-group-content { padding: 8px; overflow: hidden; max-height: 0px; /* 设置足够大的展开高度 */ transition: max-height 0.3s cubic-bezier(0.33, 1, 0.68, 1), padding 0.3s ease, opacity 0.3s ease; opacity: 1; } .tm_ui-option-group-collapsed .tm_ui-option-group-content { max-height: 0px; padding-top: 0; padding-bottom: 0; opacity: 0; } .tm_ui-option-item { margin-bottom: 8px; display: flex; align-items: center; flex-wrap: wrap; } .tm_ui-option-item:last-child { margin-bottom: 0; } .tm_ui-option-label { margin-left: 5px; flex: 1; min-width: 80px; font-size: 12px; } .tm_ui-input-container { display: flex; align-items: center; flex: 1; max-width: 100px; } .tm_ui-tooltip { position: relative; display: inline-flex; margin-left: 6px; cursor: help; } .tm_ui-tooltip-icon { display: inline-flex; align-items: center; justify-content: center; width: 12px; height: 12px; border-radius: 50%; background-color: rgba(0,0,0,0.2); color: white; font-size: 9px; font-weight: bold; user-select: none; /* 设置不可选择文本 */ } .tm_ui-tooltip-text { visibility: hidden; background-color: rgba(0,0,0,0.7); color: #fff; text-align: left; border-radius: 4px; padding: 5px 7px; position: absolute; z-index: 10000; font-size: 11px; opacity: 0; transition: opacity 0.3s; line-height: 1.4; pointer-events: none; box-shadow: 0 2px 8px rgba(0,0,0,0.2); top: -5px; right: 110%; white-space: nowrap; overflow-wrap: break-word; } .tm_ui-tooltip:hover .tm_ui-tooltip-text { visibility: visible; opacity: 1; } .tm_ui-switch { --tm_ui-width: 28px; --tm_ui-height: 14px; --tm_ui-padding: 2px; --tm_ui-duration: 0.2s; --tm_ui-color-on:rgb(76, 97, 175); --tm_ui-color-off: #e0e0e0; --tm_ui-color-knob: #ffffff; --tm_ui-shadow: 0 2px 5px rgba(0,0,0,0.2); --tm_ui-knob-size: calc(var(--tm_ui-height) - var(--tm_ui-padding) * 2); display: inline-block; position: relative; width: var(--tm_ui-width); height: var(--tm_ui-height); } .tm_ui-switch input { opacity: 0; width: 0; height: 0; } .tm_ui-switch-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--tm_ui-color-off); transition: background-color var(--tm_ui-duration) ease; border-radius: var(--tm_ui-height); box-shadow: var(--tm_ui-shadow) inset; } .tm_ui-switch-slider:before { position: absolute; content: ""; height: var(--tm_ui-knob-size); width: var(--tm_ui-knob-size); left: var(--tm_ui-padding); bottom: var(--tm_ui-padding); background-color: var(--tm_ui-color-knob); transition: transform var(--tm_ui-duration) ease; border-radius: 50%; box-shadow: var(--tm_ui-shadow); } .tm_ui-switch input:checked + .tm_ui-switch-slider { background-color: var(--tm_ui-color-on); } .tm_ui-switch input:checked + .tm_ui-switch-slider:before { transform: translateX(calc(var(--tm_ui-width) - var(--tm_ui-height))); } .tm_ui-switch input:disabled + .tm_ui-switch-slider { opacity: 0.6; cursor: not-allowed; } .tm_ui-range-container { display: flex; align-items: center; width: 100%; max-width: 120px; gap: 5px; } .tm_ui-range-input { flex: 1; width: 90px; } .tm_ui-range-value { width: 25px; text-align: center; border: 1px solid #ccc; border-radius: 2px; padding: 1px; font-size: 10px; } .tm_ui-select { padding: 3px; border-radius: 3px; border: 1px solid #ccc; background-color: white; width: 100%; font-size: 11px; } .tm_ui-input-number, .tm_ui-input-text { padding: 3px; border-radius: 3px; border: 1px solid #ccc; width: 100%; font-size: 11px; } .tm_ui-buttons-container { display: flex; gap: 7px; justify-content: center; margin-top: 3px; } #myDownloadButton, #myResetButton { text-align: center; padding: 5px 8px; background: var(--tm_ui-linear-gradient); color: white; cursor: pointer; transition: all 0.3s ease; border-radius: 3px; border: none; font-size: 11px; } #myDownloadButton { width: 100%; margin-bottom: 3px; padding: 8px; } #myResetButton { flex: 1; } #myGotoRepoButton { flex: 3; } .collapse-icon { width: 12px; height: 12px; transition: transform 0.3s ease; display: flex; align-items: center; justify-content: center; } .tm_ui-option-group-collapsed .collapse-icon { transform: rotate(180deg); } #myDownloadButton:hover, #myResetButton:hover, #myGotoRepoButton:hover { transform: scale(1.02); box-shadow: 0 1px 5px rgba(0,0,0,0.15); } #myDownloadButton:disabled, #myResetButton:disabled { background: gray; color: #aaa; cursor: not-allowed; transform: none; box-shadow: none; } #myGotoRepoButton { background: #000000; color: #ffffff; text-align: center; padding: 5px 8px; cursor: pointer; transition: all 0.3s ease; border-radius: 3px; border: none; font-size: 11px; } #myGotoRepoButton:active { transform: scale(1); } #myGotoRepoButton:hover .goto-repo-btn-icon { fill: #ffff00; transform: scale(1.02); rotate: 360deg; filter: drop-shadow(0 0 5px rgba(255, 208, 0, 0.8)) drop-shadow(0 0 10px rgba(255, 208, 0, 0.6)); } #myGotoRepoButton:hover .goto-repo-btn-text { filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.2)) drop-shadow(0 0 10px rgba(255, 255, 255, 0.4)); } #myGotoRepoButton .goto-repo-btn-text { transition: all 1s ease; } #myGotoRepoButton .goto-repo-btn-icon { display: inline-block; width: 12px; height: 12px; transition: all 1s ease; } `); } /** * 初始化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 = `<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#FFFFFF"><path d="M480-337q-8 0-15-2.5t-13-8.5L308-492q-12-12-11.5-28t11.5-28q12-12 28.5-12.5T365-549l75 75v-286q0-17 11.5-28.5T480-800q17 0 28.5 11.5T520-760v286l75-75q12-12 28.5-11.5T652-548q11 12 11.5 28T652-492L508-348q-6 6-13 8.5t-15 2.5ZM240-160q-33 0-56.5-23.5T160-240v-80q0-17 11.5-28.5T200-360q17 0 28.5 11.5T240-320v80h480v-80q0-17 11.5-28.5T760-360q17 0 28.5 11.5T800-320v80q0 33-23.5 56.5T720-160H240Z"/></svg>`; // 创建内容区域 this.contentBox = document.createElement("div"); this.contentBox.className = "tm_content-box"; // 创建复杂内容 this.contentBox.innerHTML = ` <div class="tm_complex-content" id="tmComplexContent"></div> `; // 组装元素 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 - 50, parseInt(savedTop)) + "px"; } // 创建浮动窗口 this.createFloatWindow(); } /** * 创建浮动窗口和选项 */ createFloatWindow() { // 创建悬浮窗 this.floatWindow = document.createElement("div"); this.floatWindow.style.display = "flex"; this.floatWindow.style.flexDirection = "column"; this.floatWindow.id = "myFloatWindow"; // 创建下载按钮 this.downloadButton = document.createElement("button"); this.downloadButton.innerHTML = "点击下载Markdown<br>(支持文章、专栏、用户全部文章页面)"; this.downloadButton.id = "myDownloadButton"; this.floatWindow.appendChild(this.downloadButton); // 创建选项容器,设置为可滚动 const optionContainer = document.createElement("div"); optionContainer.className = "tm_ui-options-container"; this.floatWindow.appendChild(optionContainer); // 创建选项容器禁用遮罩 const overlay = document.createElement("div"); overlay.className = "tm_ui-options-disabled-overlay"; optionContainer.appendChild(overlay); // 添加选项分组 this.createOptionGroups(optionContainer); // 创建底部按钮容器 const buttonsContainer = document.createElement("div"); buttonsContainer.className = "tm_ui-buttons-container"; this.floatWindow.appendChild(buttonsContainer); // 创建恢复默认设置按钮 this.resetButton = document.createElement("button"); this.resetButton.innerHTML = "恢复默认"; this.resetButton.id = "myResetButton"; buttonsContainer.appendChild(this.resetButton); // 创建去GitHub按钮 this.gotoRepoButton = document.createElement("button"); // this.gotoRepoButton.innerHTML = "前往 GitHub 给作者点个 Star ⭐"; this.gotoRepoButton.innerHTML = ` <span class="goto-repo-btn-text">前往 GitHub 给作者点个 Star</span> <svg aria-hidden="true" fill="currentColor" viewBox="0 0 47.94 47.94" xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" class="goto-repo-btn-icon"> <path d="M26.285,2.486l5.407,10.956c0.376,0.762,1.103,1.29,1.944,1.412l12.091,1.757 c2.118,0.308,2.963,2.91,1.431,4.403l-8.749,8.528c-0.608,0.593-0.886,1.448-0.742,2.285l2.065,12.042 c0.362,2.109-1.852,3.717-3.746,2.722l-10.814-5.685c-0.752-0.395-1.651-0.395-2.403,0l-10.814,5.685 c-1.894,0.996-4.108-0.613-3.746-2.722l2.065-12.042c0.144-0.837-0.134-1.692-0.742-2.285l-8.749-8.528 c-1.532-1.494-0.687-4.096,1.431-4.403l12.091-1.757c0.841-0.122,1.568-0.65,1.944-1.412l5.407-10.956 C22.602,0.567,25.338,0.567,26.285,2.486z" ></path> </svg> `; this.gotoRepoButton.id = "myGotoRepoButton"; buttonsContainer.appendChild(this.gotoRepoButton); // 将浮窗添加到内容区 document.getElementById("tmComplexContent").appendChild(this.floatWindow); } /** * 创建选项分组 * @param {HTMLElement} container - 父容器 */ createOptionGroups(container) { // 下载设置组 const downloadGroup = this.createOptionGroup(container, "基础下载设置", true); this.addBoolOption({ id: "parallelDownload", label: "批量并行下载模式", defaultValue: true, container: downloadGroup, tooltip: "使用Iframe,能够获取JS动态的内容", }); this.addBoolOption({ id: "fastDownload", label: "快速模式(内存占用低,建议文章较多时启用)", defaultValue: false, container: downloadGroup, tooltip: `使用Fetch API,速度快,但<span style="color:red">无法获取JS动态加载内容</span>。<br>如果不启用,则使用iframe下载,容易内存溢出崩溃。<br><center>不启用时占用内存 ≈ 40MB * 文章数量</center><center>启用后占用内存 ≈ 40MB</center>文章较多时建议启用快速模式。下载后需要<span style="color:red">仔细检查内容<br>是否完整</span>,一般不会有问题。<br>另外,单篇文章直接读取当前页面,不受此选项影响。`, }); this.addBoolOption({ id: "zipCategories", label: "下载为压缩包", defaultValue: true, container: downloadGroup, constraints: { false: [{ id: "saveWebImages", value: false }], }, }); this.addBoolOption({ id: "saveWebImages", label: "将图片保存至本地", defaultValue: true, container: downloadGroup, tooltip: "默认保存到和MD文件同名的文件夹中", constraints: { true: [{ id: "zipCategories", value: true }], false: [{ id: "saveAllImagesToAssets", value: false }], }, }); this.addBoolOption({ id: "saveAllImagesToAssets", label: "图片保存至assets文件夹", defaultValue: true, container: downloadGroup, tooltip: "如不启用,则保存到和MD文件同名的文件夹中", constraints: { true: [ { id: "zipCategories", value: true }, { id: "saveWebImages", value: true }, ], }, }); this.addBoolOption({ id: "enableCustomFileName", label: "启用批量下载文件名模板", defaultValue: true, container: downloadGroup, tooltip: "启用后,批量下载的文件名将根据下方模板生成", }); this.addStringOption({ id: "customFileNamePattern", label: "批量下载文件名模板", defaultValue: "{no}_{title}", container: downloadGroup, tooltip: "可用变量:{title}、{author}、{index}、{no}(有前导0)", }); const advancedDownloadGroup = this.createOptionGroup(container, "高级下载设置"); this.addIntOption({ id: "maxConcurrentDownloads", label: "最大并行解析数", defaultValue: 4, container: advancedDownloadGroup, min: 1, max: 128, step: 1, tooltip: "越小越稳定,过大容易风控、内存溢出", }); this.addIntOption({ id: "delayBetweenDownloads", label: "下载间隔(毫秒)", defaultValue: 100, container: advancedDownloadGroup, min: 0, max: 60000, step: 1, tooltip: "每次下载文章之间的延时,单位毫秒。<br>在并行时每个worker的间隔是独立的。<br>用于进一步减慢串行下载避免风控(放到最慢)", }); this.addIntOption({ id: "downloadAssetRetryCount", label: "下载资源失败重试次数", defaultValue: 3, container: advancedDownloadGroup, min: 0, max: 32, step: 1, tooltip: "下载网页、图片等失败时重试次数,0表示不重试", }); this.addIntOption({ id: "downloadAssetRetryDelay", label: "下载资源失败重试延时(毫秒)", defaultValue: 1000, container: advancedDownloadGroup, min: 0, max: 60000, step: 1, tooltip: "下载网页、图片等失败时重试前的延时,单位毫秒。<br>避免过快重试导致服务器风控", }); this.addIntOption({ id: "startArticleIndex", label: "从第几篇文章开始下载", defaultValue: 1, container: advancedDownloadGroup, min: 1, max: 10000, step: 1, tooltip: "从第几篇文章开始下载,1表示第一篇<br>避免一次下载多篇文章时风控,用于分批下载", }); this.addIntOption({ id: "endArticleIndex", label: "下载到第几篇文章", defaultValue: 10000, container: advancedDownloadGroup, min: 1, max: 10000, step: 1, tooltip: "下载到第几篇文章,超出范围则下载到最后一篇<br>避免一次下载多篇文章时风控,用于分批下载", }); this.addSelectOption({ id: "zipLibrary", label: "压缩文件时使用的库", defaultValue: "fflate", container: advancedDownloadGroup, options: [ { value: "fflate", label: "fflate(默认,更快)" }, { value: "jszip", label: "JSZip(备选,较慢)" }, ], tooltip: "如果提示没有找到fflate,请尝试切换到jszip。<br>流式压缩下载时只能使用fflate。", }); this.addBoolOption({ id: "enableStreaming", label: "启用流式压缩下载(节省内存,实验性功能)", defaultValue: false, container: advancedDownloadGroup, tooltip: "稍微减少内存占用。如下载失败,请关闭此选项。", constraints: { true: [{ id: "zipCategories", value: true }], }, }); // 文章内容组 const contentGroup = this.createOptionGroup(container, "文章内容设置"); this.addBoolOption({ id: "addArticleInfoInYaml", label: "添加文章元信息", defaultValue: false, container: contentGroup, tooltip: "以YAML格式添加,对于转Hexo博客比较有用", constraints: { true: [{ id: "mergeArticleContent", value: false }], }, }); this.addBoolOption({ id: "addArticleTitleToMarkdown", label: "添加文章标题", defaultValue: true, container: contentGroup, tooltip: "以一级标题形式添加", }); this.addBoolOption({ id: "addArticleInfoInBlockquote", label: "添加文章阅读量、点赞等信息", defaultValue: true, container: contentGroup, tooltip: "以引用块形式添加", }); this.addBoolOption({ id: "removeCSDNSearchLink", label: "移除CSDN搜索链接", defaultValue: true, container: contentGroup, }); this.addBoolOption({ id: "enableColorText", label: "启用彩色文字", defaultValue: true, container: contentGroup, tooltip: "使用<span>格式实现彩色文字", }); this.addBoolOption({ id: "enableImageSize", label: "启用图片宽高属性", defaultValue: true, container: contentGroup, tooltip: "仅当网页提供宽高属性时生效。<br>如果启用,则具有宽高的图片会以<img>标签<br>插入文本中。如果不启用,则会以![]()格式插入。", }); this.addBoolOption({ id: "forceImageCentering", label: "全部图片居中", defaultValue: false, container: contentGroup, tooltip: "忽略网页原有的图片对齐方式,全部居中。<br>图片靠左对齐是通过在图片前添加空格实现的,<br>不加空格则Typora显示为居中。", }); this.addBoolOption({ id: "enableMarkdownEscape", label: "启用Markdown特殊字符转义", defaultValue: false, container: contentGroup, tooltip: "把会影响Markdown解析的字符用\\转义", }); this.addStringOption({ id: "markdownEscapePattern", label: "需要转义的Markdown字符", defaultValue: "`*_[]{}()#+-.!", container: contentGroup, tooltip: "填入字符即可,这不是正则表达式,注意不要空格", }); // 批量文章处理组 const batchGroup = this.createOptionGroup(container, "合并文章设置"); this.addBoolOption({ id: "mergeArticleContent", label: "启用合并文章", defaultValue: false, container: batchGroup, tooltip: "将多篇文章保存为单个MD文件", constraints: { true: [{ id: "addArticleInfoInYaml", value: false }], }, }); this.addBoolOption({ id: "addSerialNumberToTitle", label: "添加序号到文章标题前", defaultValue: false, container: batchGroup, tooltip: "在合并文章时可能有用", }); this.addBoolOption({ id: "addArticleInfoInBlockquote_batch", label: "合并文章时添加栏目总信息", defaultValue: true, container: batchGroup, tooltip: "以引用块形式添加栏目总阅读量、点赞等", }); } /** * 创建选项分组 * @param {HTMLElement} container - 父容器 * @param {string} title - 分组标题 * @param {boolean} expanded - 是否展开(默认折叠) * @returns {HTMLElement} - 创建的分组元素内容容器 */ createOptionGroup(container, title, expanded = false) { const group = document.createElement("div"); group.className = "tm_ui-option-group" + (expanded ? "" : " tm_ui-option-group-collapsed"); // 创建分组头部(可点击折叠/展开) const header = document.createElement("div"); header.className = "tm_ui-option-group-header"; const titleElem = document.createElement("div"); titleElem.className = "tm_ui-option-group-title"; titleElem.textContent = title; // 创建折叠图标 const icon = document.createElement("span"); icon.className = "collapse-icon"; icon.innerHTML = `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M4 6L8 10L12 6" stroke="#555555" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg>`; header.appendChild(titleElem); header.appendChild(icon); group.appendChild(header); // 创建内容区域 const content = document.createElement("div"); content.className = "tm_ui-option-group-content"; group.appendChild(content); // 添加点击事件,实现折叠/展开功能 header.addEventListener("click", () => { // 展开,则设置max-height为scrollHeight,折叠则为0 if (group.classList.contains("tm_ui-option-group-collapsed")) { group.classList.toggle("tm_ui-option-group-collapsed"); content.style.maxHeight = content.scrollHeight + 16 + "px"; } else { group.classList.toggle("tm_ui-option-group-collapsed"); content.style.maxHeight = "0px"; } }); if (expanded) { setTimeout(() => { content.style.maxHeight = content.scrollHeight + 16 + "px"; // 16是padding的总和 }, 100); // 确保在DOM渲染后执行 } container.appendChild(group); return content; } /** * 添加布尔选项(复选框) * @param {Object} option - 选项对象 * @param {string} option.id - 选项ID * @param {string} option.label - 选项标签 * @param {boolean} option.defaultValue - 默认值 * @param {HTMLElement} option.container - 父容器 * @param {string} [option.tooltip=""] - 提示信息(可选) * @param {Object} [option.constraints={}] - 约束条件(可选) */ addBoolOption(option) { const { id, label, defaultValue, container, tooltip = "", constraints = {} } = option; // 注册(不可用)到配置管理器 this.configManager.register(id, defaultValue); const optionItem = document.createElement("div"); optionItem.className = "tm_ui-option-item"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.id = id; checkbox.checked = this.configManager.get(id); const checkboxWrapper = document.createElement("label"); checkboxWrapper.className = "tm_ui-switch"; checkboxWrapper.appendChild(checkbox); const slider = document.createElement("span"); slider.className = "tm_ui-switch-slider"; checkboxWrapper.appendChild(slider); const labelElem = document.createElement("label"); labelElem.htmlFor = id; labelElem.className = "tm_ui-option-label"; labelElem.textContent = label; optionItem.appendChild(labelElem); optionItem.appendChild(checkboxWrapper); // 添加提示 if (tooltip) { optionItem.appendChild(this.createTooltip(tooltip)); } else { const tooltipElem = this.createTooltip(""); tooltipElem.style.visibility = "hidden"; // 默认隐藏 tooltipElem.style.opacity = "0"; // 默认透明 optionItem.appendChild(tooltipElem); } container.appendChild(optionItem); // 事件监听 checkbox.addEventListener("change", () => { this.configManager.set(id, checkbox.checked); // 处理约束 if (checkbox.checked && constraints.true) { for (const constraint of constraints.true) { if (constraint.id !== undefined && constraint.value !== undefined) { this.configManager.set(constraint.id, constraint.value); this.updateOption(constraint.id); } } } else if (!checkbox.checked && constraints.false) { for (const constraint of constraints.false) { if (constraint.id !== undefined && constraint.value !== undefined) { this.configManager.set(constraint.id, constraint.value); this.updateOption(constraint.id); } } } }); return optionItem; } /** * 添加整数选项(数字输入框) * @param {Object} option - 选项对象 * @param {string} option.id - 选项ID * @param {string} option.label - 选项标签 * @param {number} option.defaultValue - 默认值 * @param {HTMLElement} option.container - 父容器 * @param {number} option.min - 最小值 * @param {number} option.max - 最大值 * @param {number} option.step - 步长 * @param {string} [option.tooltip=""] - 提示信息 */ addIntOption(option) { const { id, label, defaultValue, container, min, max, step, tooltip = "" } = option; // 注册(不可用)到配置管理器 this.configManager.register(id, defaultValue); const optionItem = document.createElement("div"); optionItem.className = "tm_ui-option-item"; const labelElem = document.createElement("label"); labelElem.className = "tm_ui-option-label"; labelElem.textContent = label; const inputContainer = document.createElement("div"); inputContainer.className = "tm_ui-input-container"; const input = document.createElement("input"); input.type = "number"; input.className = "tm_ui-input-number"; input.id = id; input.min = min; input.max = max; input.step = step; input.value = this.configManager.get(id); inputContainer.appendChild(input); optionItem.appendChild(labelElem); optionItem.appendChild(inputContainer); // 添加提示 if (tooltip) { optionItem.appendChild(this.createTooltip(tooltip)); } container.appendChild(optionItem); // 事件监听 input.addEventListener("change", () => { let value = parseInt(input.value); if (isNaN(value)) value = defaultValue; if (value < min) value = min; if (value > max) value = max; input.value = value; this.configManager.set(id, value); }); return optionItem; } /** * 添加浮点数选项(滑块) * @param {Object} option - 选项对象 * @param {string} option.id - 选项ID * @param {string} option.label - 选项标签 * @param {number} option.defaultValue - 默认值 * @param {HTMLElement} option.container - 父容器 * @param {number} option.min - 最小值 * @param {number} option.max - 最大值 * @param {number} option.step - 步长 * @param {string} [option.tooltip=""] - 提示信息 */ addFloatOption(option) { const { id, label, defaultValue, container, min, max, step, tooltip = "" } = option; // 注册(不可用)到配置管理器 this.configManager.register(id, defaultValue); const optionItem = document.createElement("div"); optionItem.className = "tm_ui-option-item"; const labelElem = document.createElement("label"); labelElem.className = "tm_ui-option-label"; labelElem.textContent = label; // 创建滑块容器 const inputContainer = document.createElement("div"); inputContainer.className = "tm_ui-input-container"; const rangeContainer = document.createElement("div"); rangeContainer.className = "tm_ui-range-container"; const slider = document.createElement("input"); slider.type = "range"; slider.className = "tm_ui-range-input"; slider.id = id; slider.min = min; slider.max = max; slider.step = step; slider.value = this.configManager.get(id); const valueDisplay = document.createElement("span"); valueDisplay.className = "tm_ui-range-value"; valueDisplay.textContent = slider.value; rangeContainer.appendChild(slider); rangeContainer.appendChild(valueDisplay); inputContainer.appendChild(rangeContainer); optionItem.appendChild(labelElem); optionItem.appendChild(inputContainer); // 添加提示 if (tooltip) { optionItem.appendChild(this.createTooltip(tooltip)); } container.appendChild(optionItem); // 事件监听 slider.addEventListener("input", () => { valueDisplay.textContent = slider.value; this.configManager.set(id, parseFloat(slider.value)); }); return optionItem; } /** * 添加字符串选项(文本框) * @param {Object} option - 选项对象 * @param {string} option.id - 选项ID * @param {string} option.label - 选项标签 * @param {string} option.defaultValue - 默认值 * @param {HTMLElement} option.container - 父容器 * @param {string} [option.tooltip=""] - 提示信息 */ addStringOption(option) { const { id, label, defaultValue, container, tooltip = "" } = option; // 注册(不可用)到配置管理器 this.configManager.register(id, defaultValue); const optionItem = document.createElement("div"); optionItem.className = "tm_ui-option-item"; const labelElem = document.createElement("label"); labelElem.className = "tm_ui-option-label"; labelElem.textContent = label; const inputContainer = document.createElement("div"); inputContainer.className = "tm_ui-input-container"; const input = document.createElement("input"); input.type = "text"; input.className = "tm_ui-input-text"; input.id = id; input.value = this.configManager.get(id); inputContainer.appendChild(input); optionItem.appendChild(labelElem); optionItem.appendChild(inputContainer); // 添加提示 if (tooltip) { optionItem.appendChild(this.createTooltip(tooltip)); } container.appendChild(optionItem); // 事件监听 input.addEventListener("change", () => { this.configManager.set(id, input.value); }); return optionItem; } /** * 添加下拉选择选项 * @param {Object} option - 选项对象 * @param {string} option.id - 选项ID * @param {string} option.label - 选项标签 * @param {string} option.defaultValue - 默认值 * @param {HTMLElement} option.container - 父容器 * @param {Array} option.options - 选项数组,格式为[{value: '', label: ''}] * @param {string} [option.tooltip=""] - 提示信息 */ addSelectOption(option) { const { id, label, defaultValue, container, options, tooltip = "" } = option; // 注册(不可用)到配置管理器 this.configManager.register(id, defaultValue); const optionItem = document.createElement("div"); optionItem.className = "tm_ui-option-item"; const labelElem = document.createElement("label"); labelElem.className = "tm_ui-option-label"; labelElem.textContent = label; const inputContainer = document.createElement("div"); inputContainer.className = "tm_ui-input-container"; const select = document.createElement("select"); select.className = "tm_ui-select"; select.id = id; // 添加选项 options.forEach((option) => { const optElem = document.createElement("option"); optElem.value = option.value; optElem.textContent = option.label; select.appendChild(optElem); }); // 设置当前值 select.value = this.configManager.get(id); inputContainer.appendChild(select); optionItem.appendChild(labelElem); optionItem.appendChild(inputContainer); // 添加提示 if (tooltip) { optionItem.appendChild(this.createTooltip(tooltip)); } container.appendChild(optionItem); // 事件监听 select.addEventListener("change", () => { this.configManager.set(id, select.value); }); return optionItem; } /** * 创建提示工具 * @param {string} text - 提示文本 * @returns {HTMLElement} 提示元素 */ createTooltip(text) { const tooltip = document.createElement("div"); tooltip.className = "tm_ui-tooltip"; const icon = document.createElement("div"); icon.className = "tm_ui-tooltip-icon"; icon.textContent = "?"; const tooltipText = document.createElement("div"); tooltipText.className = "tm_ui-tooltip-text"; tooltipText.innerHTML = text; tooltip.appendChild(icon); tooltip.appendChild(tooltipText); return tooltip; } /** * 更新指定选项的UI状态 * @param {string} id - 选项ID */ updateOption(id) { const element = document.getElementById(id); if (!element) return; const value = this.configManager.get(id); switch (element.type) { case "checkbox": element.checked = value; break; case "range": case "number": case "text": element.value = value; break; case "select-one": element.value = value; break; } } /** * 更新所有选项的状态 */ updateAllOptions() { for (const id of this.configManager.getAllKeys()) { this.updateOption(id); } } /** * 设置事件监听器 */ 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.runMain(); }); // 默认设置按钮点击事件 this.resetButton.addEventListener("click", () => { this.configManager.resetToDefaults(); this.updateAllOptions(); this.showFloatTip("已恢复默认设置", 1500); }); // GitHub按钮点击事件 this.gotoRepoButton.addEventListener("click", () => { window.open(this.repo_url, "_blank"); }); // 拖拽功能 const draggable = document.getElementById("draggable"); draggable.addEventListener("mousedown", (e) => { if (e.target === this.mainButton || this.mainButton.contains(e.target)) { this.isDragging = true; this.offsetX = e.clientX - draggable.offsetLeft; this.offsetY = e.clientY - draggable.offsetTop; } }); document.addEventListener("mousemove", (e) => { if (this.isDragging) { draggable.style.top = Math.min(window.innerHeight - 100, Math.max(0, e.clientY - this.offsetY)) + "px"; } }); document.addEventListener("mouseup", () => { if (this.isDragging) { this.isDragging = false; GM_setValue("draggableTop", draggable.style.top); } }); // 监视页面缩放事件 window.addEventListener("resize", () => { const savedTop = GM_getValue("draggableTop"); if (savedTop) { this.container.style.top = Math.min(window.innerHeight - 100, parseInt(savedTop)) + "px"; } }); // 监听窗口聚焦事件 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)"; } /** * 启用悬浮窗 */ enableFloatWindow() { this.downloadButton.disabled = false; this.downloadButton.innerHTML = "下载CSDN文章为Markdown<br>(支持专栏、文章、用户全部文章页面)"; this.resetButton.disabled = false; // 启用所有输入元素 const inputs = this.floatWindow.querySelectorAll("input, select"); inputs.forEach((input) => { input.disabled = false; }); // 隐藏遮罩层 const overlay = this.floatWindow.querySelector(".tm_ui-options-disabled-overlay"); function preventScroll(e) { e.preventDefault(); // 关键:阻止默认滚动 e.stopPropagation(); // 可选:阻止事件冒泡 } overlay.removeEventListener("wheel", preventScroll); overlay.removeEventListener("touchmove", preventScroll); if (overlay) overlay.style.display = "none"; } /** * 禁用悬浮窗 */ disableFloatWindow() { this.downloadButton.innerHTML = "正在下载,请稍候..."; this.downloadButton.disabled = true; this.resetButton.disabled = true; // 禁用所有输入元素 const inputs = this.floatWindow.querySelectorAll("input, select"); inputs.forEach((input) => { input.disabled = true; }); // 显示遮罩层 const overlay = this.floatWindow.querySelector(".tm_ui-options-disabled-overlay"); function preventScroll(e) { e.preventDefault(); // 关键:阻止默认滚动 e.stopPropagation(); // 可选:阻止事件冒泡 } overlay.addEventListener("wheel", preventScroll, { passive: false }); overlay.addEventListener("touchmove", preventScroll, { passive: false }); if (overlay) overlay.style.display = "block"; } /** * 显示悬浮提示 * @param {string} text - 提示内容 * @param {number} timeout - 自动关闭时间(毫秒) */ showFloatTip(text, timeout = 0) { if (document.getElementById("myInfoFloatTip")) { document.getElementById("myInfoFloatTip").innerHTML = text; } else { 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 = "7px 10px"; floatTip.style.backgroundColor = "rgba(0, 0, 0, 0.8)"; floatTip.style.color = "#fff"; floatTip.style.borderRadius = "3px"; floatTip.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.2)"; floatTip.style.zIndex = "10000"; floatTip.style.fontSize = "12px"; floatTip.innerHTML = text; floatTip.id = "myInfoFloatTip"; document.body.appendChild(floatTip); } if (timeout > 0) { setTimeout(() => { this.hideFloatTip(); }, timeout); } } /** * 显示一个高度可定制的对话框。 * 这是核心方法,返回一个Promise。 * @param {object} options - 对话框的配置选项。 * @param {string} options.title - (可选) 对话框的标题。 * @param {string} options.message - 对话框的主体信息。 * @param {Array<object>} options.buttons - 按钮配置数组。 * @param {string} options.buttons[].text - 按钮上显示的文本。 * @param {any} options.buttons[].value - 点击按钮后Promise resolve的值。 * @param {'primary'|'default'|'danger'} [options.buttons[].type='default'] - 按钮类型,用于应用不同样式。 * @returns {Promise<any>} 当用户点击按钮时,Promise会resolve,并返回对应按钮的value。 */ _createDialog(options) { return new Promise((resolve) => { // 将请求加入队列 const dialogRequest = { options, resolve }; if (this.isDialogActive) { this.dialogQueue.push(dialogRequest); return; } this._displayDialog(dialogRequest); }); } /** * 显示一个预设的确认对话框(兼容旧功能)。 * @param {string|object} options - 对话框配置或消息字符串。 * @param {string} [options.message] - 对话框的主体信息。 * @param {string} [options.title] - (可选) 对话框的标题。 * @param {function} [onConfirm] - (可选) 确认回调。 * @param {function} [onCancel] - (可选) 取消回调。 * @returns {Promise<'confirm'|'cancel'>} 返回一个Promise,方便链式调用。 */ async showConfirmDialog(options, onConfirm, onCancel) { if (typeof options === "string") { options = { message: options }; } const result = await this._createDialog({ ...options, buttons: [ { text: "取消", value: "cancel", type: "default" }, { text: "确定", value: "confirm", type: "primary" }, ], }); if (result === "confirm" && typeof onConfirm === "function") { onConfirm(); } else if (result === "cancel" && typeof onCancel === "function") { onCancel(); } return result; } /** * 显示一个自定义对话框。 * @param {object|string} options - 对话框配置或消息字符串。 * @param {...object} items - 按钮配置数组或单个按钮对象 * @param {string} items[].text - 按钮文本。 * @param {any} items[].value - 按钮点击后返回的值 * @param {'primary'|'default'|'danger'} [items[].type='default'] - 按钮类型。 * @param {function} [items[].callback] - 按钮点击时的回调函数。 * @returns {Promise<any>} 返回一个Promise,resolve为点击的按钮的value */ async showDialog(options, ...items) { if (typeof options === "string") { options = { message: options }; } else if (Array.isArray(options)) { items = options; options = { message: "请选择操作" }; } else if (typeof options !== "object") { throw new Error("Expected options to be a string, array, or object"); } const result = await this._createDialog({ ...options, buttons: items.map((item, index) => ({ text: item.text || `按钮${index + 1}`, type: item.type || "default", value: index, })), }); if (items[result].callback && typeof items[result].callback === "function") { items[result].callback(); } return items[result].value || items[result].text || result; } /** * @private * 内部方法,用于实际创建和显示对话框。 * @param {object} dialogRequest - 包含options和resolve函数的请求对象。 */ _displayDialog({ options, resolve }) { this.isDialogActive = true; // 默认配置 const config = { title: "", ...options, }; // 创建遮罩层 const overlay = this._createOverlay(); // 创建对话框容器 const dialog = this._createDialogContainer(); // 添加标题 (如果提供) if (config.title) { const titleEl = this._createTitle(config.title); dialog.appendChild(titleEl); } // 添加消息 const messageEl = this._createMessage(config.message); dialog.appendChild(messageEl); // 创建并添加按钮 const btnBox = this._createButtonContainer(); config.buttons.forEach((btnConfig) => { const button = this._createButton(btnConfig, (value) => { // 关闭对话框的逻辑 document.body.removeChild(overlay); this._processNextDialog(); resolve(value); }); btnBox.appendChild(button); }); dialog.appendChild(btnBox); overlay.appendChild(dialog); document.body.appendChild(overlay); } /** * @private * 处理队列中的下一个对话框。 */ _processNextDialog() { this.isDialogActive = false; if (this.dialogQueue.length > 0) { const nextRequest = this.dialogQueue.shift(); // 加一个短暂的延迟,避免视觉上两个弹窗无缝衔接 setTimeout(() => this._displayDialog(nextRequest), 100); } } // --- DOM元素创建的辅助方法 --- _createOverlay() { const overlay = document.createElement("div"); Object.assign(overlay.style, { position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh", background: "rgba(0,0,0,0.5)", zIndex: "10000", backdropFilter: "blur(10px)", display: "flex", alignItems: "center", justifyContent: "center", }); return overlay; } _createDialogContainer() { const dialog = document.createElement("div"); Object.assign(dialog.style, { background: "#fff", padding: "20px 24px", borderRadius: "12px", boxShadow: "0 5px 20px rgba(0,0,0,0.2)", minWidth: "300px", maxWidth: "calc(100vw - 40px)", wordBreak: "break-word", fontSize: "14px", display: "flex", flexDirection: "column", gap: "16px", // 统一内容间距 }); return dialog; } _createTitle(titleText) { const title = document.createElement("h3"); title.textContent = titleText; Object.assign(title.style, { margin: "0", fontSize: "18px", fontWeight: "600", color: "#111", textAlign: "center", }); return title; } _createMessage(messageText) { const msg = document.createElement("div"); msg.innerHTML = messageText.replace(/\n/g, "<br>"); Object.assign(msg.style, { textAlign: "left", lineHeight: "1.6", color: "#333", maxHeight: "60vh", overflowY: "auto", }); return msg; } _createButtonContainer() { const btnBox = document.createElement("div"); Object.assign(btnBox.style, { display: "flex", justifyContent: "flex-end", // 按钮靠右更常见 gap: "12px", marginTop: "8px", }); return btnBox; } _getButtonStyles(type = "default") { const baseStyle = { padding: "6px 18px", border: "none", borderRadius: "5px", cursor: "pointer", transition: "all 0.2s ease", fontWeight: "500", fontSize: "13px", }; const typeStyles = { primary: { background: "linear-gradient(135deg, #12c2e9 0%, #c471ed 50%, #f64f59 100%)", color: "#fff", }, danger: { background: "#e74c3c", color: "#fff", }, default: { background: "#f0f0f0", color: "#333", border: "1px solid #ddd", }, }; return { ...baseStyle, ...(typeStyles[type] || typeStyles["default"]) }; } _createButton(btnConfig, closeCallback) { const button = document.createElement("button"); button.textContent = btnConfig.text; Object.assign(button.style, this._getButtonStyles(btnConfig.type)); button.onclick = () => closeCallback(btnConfig.value); // 添加悬停效果 button.onmouseover = () => (button.style.opacity = "0.85"); button.onmouseout = () => (button.style.opacity = "1"); return button; } /** * 跳转到 GitHub issue 页面,并将信息参数化到 URL 中 * @param {string} title - 标题 * @param {string} info - 要传递的信息 */ gotoGithubIssue(title, info) { const url = `${this.repo_url}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent( info )}`; window.open(url, "_blank"); } /** * 隐藏悬浮提示 */ hideFloatTip() { if (document.getElementById("myInfoFloatTip")) { document.getElementById("myInfoFloatTip").remove(); } } async mainErrorHandler(error) { // 使用对话框 const now = new Date(); const timeStr = now .toISOString() .replace("T", " ") .replace(/\.\d+Z$/, ""); const script_config = this.configManager.exportAll(); // More detailed error capturing with formatted stack trace let errorDetails = ""; if (error instanceof Error) { errorDetails += `name: ${error.name}\n`; errorDetails += `message: ${error.message}\n`; // Format stack trace to be more readable if (error.stack) { errorDetails += "stack trace:\n"; const stackLines = error.stack.split("\n"); // Process each line of the stack trace stackLines.forEach((line) => { line = decodeURIComponent(line.trim()); // Extract the relevant parts from each stack line const match = line.match(/([^@\s]+)@(.*?):(\d+):(\d+)/); if (match) { const [_, functionName, filePath, lineNum, colNum] = match; // Get just the filename from the path const fileName = filePath.split("/").pop().split("?")[0]; // filename 里被编码为url的特殊字符需要解码,以便查看 const decodedFileName = decodeURIComponent(fileName); // Add formatted line to error details errorDetails += ` → func:${functionName} (file:${decodedFileName}@line:${lineNum}@col:${colNum})\n`; } else { // For lines that don't match the pattern, include them as is errorDetails += ` ${line.trim()}\n`; } }); } // Capture custom properties for (const key in error) { if ( Object.prototype.hasOwnProperty.call(error, key) && key !== "stack" && key !== "message" && key !== "name" ) { errorDetails += `${key}: ${JSON.stringify(error[key])}\n`; } } } else if (typeof error === "object" && error !== null) { errorDetails = JSON.stringify(error, null, 2); } else { errorDetails = String(error); } errorDetails = errorDetails.trim(); await this.showConfirmDialog( { title: "⚠️ 警告", message: `下载文章时出错!是否前往Github提交Issue以告知开发者进行修复?(您需要拥有Github账号)\n错误详情:\n${errorDetails}`, }, () => this.gotoGithubIssue( `[BUG] 下载失败 (${getCurrentPageType()}页面)`, `#### 时间\n\n${timeStr}\n\n#### 错误内容\n\n\`\`\`\n${errorDetails}\n\`\`\`\n\n#### 其他信息\n\n- URL:\`${ window.location.href }\`\n- 脚本版本:\`${GM_info.script.version}\`\n- 脚本配置:\n\`\`\`json\n${JSON.stringify( script_config, null, 4 )}\n\`\`\`\n` ), this.showFloatTip("感谢您的反馈!", 2000), () => { this.showFloatTip("已取消。", 2000); console.error("下载文章时出错:", error); } ); } /** * 主函数 - 下载文章入口 */ async runMain() { this.disableFloatWindow(); const nowConfig = this.configManager.exportAll(); try { switch (getCurrentPageType()) { case "unknown": alert("无法识别的页面。请确保在CSDN文章页面、专栏文章列表页面或用户全部文章列表页面。"); break; case "article": await this.downloadManager.downloadSingleArticle(nowConfig); break; case "category": await this.downloadManager.downloadCategory(nowConfig); break; case "user_all_articles": await this.downloadManager.downloadUserAllArticles(nowConfig); break; } } catch (error) { this.mainErrorHandler(error); } finally { this.enableFloatWindow(); this.downloadManager.reset(); // 重置FileManager } } } /** * 配置管理器类 * 用于管理应用配置 */ class ConfigManager { constructor() { this.configs = new Map(); // 仅用于存储key,实际值从GM_getValue获取 this.defaults = new Map(); } /** * 注册(不可用)配置项 * @param {string} key - 配置键 * @param {any} defaultValue - 默认值 */ register(key, defaultValue) { this.defaults.set(key, defaultValue); // 如果未设置,则使用默认值 if (GM_getValue(key) === undefined) { GM_setValue(key, defaultValue); } // 加入配置映射 this.configs.set(key, GM_getValue(key)); } /** * 获取配置项值 * @param {string} key - 配置键 * @returns {any} 配置值 */ get(key) { // 直接从GM_getValue获取最新值 return GM_getValue(key); } /** * 设置配置项值 * @param {string} key - 配置键 * @param {any} value - 配置值 */ set(key, value) { GM_setValue(key, value); this.configs.set(key, value); } /** * 重置所有配置到默认值 */ resetToDefaults() { for (const [key, defaultValue] of this.defaults.entries()) { this.set(key, defaultValue); } } /** * 获取所有配置键 * @returns {Array} 配置键数组 */ getAllKeys() { return Array.from(this.configs.keys()); } /** * 导出所有配置 * @returns {Object} 配置对象 */ exportAll() { const result = {}; for (const key of this.getAllKeys()) { result[key] = this.get(key); } return result; } /** * 导入配置 * @param {Object} configs - 配置对象 */ importAll(configs) { for (const [key, value] of Object.entries(configs)) { if (this.configs.has(key)) { this.set(key, value); } } } } /** * 模块: 文件管理 * 处理文件相关的操作 */ class FileManager { constructor() { this.fileQueue = []; this.imageCount = {}; this.imageSet = {}; this.zipStream = null; this.zipStreamName = ""; this.zipStreamSize = 0; this.zipStreamPendingFiles = new Map(); // 用于存储待处理的Blob对象 this.zipStreamFileCount = 0; this.zipStreamProgressCallback = null; this.zipStreamErrorCallback = null; this.zipStreamEndFlag = null; } /** * 将文本保存为文件 * @param {string} content - 文件内容 * @param {string} filename - 文件名 * @param {number} index - 文件索引(用于排序) */ async addTextFile(content, filename, index = 0, streaming = false) { filename = Utils.safeFilename(filename); if (streaming) { // 如果是流式处理,直接添加到zip流 this.addFileToZipStream(filename, content); } else { // 保存到队列中,等待打包 this.fileQueue.push({ filename, type: "text/plain", content, index }); } } /** * 将SVG内容保存到本地,添加到fileQueue,并返回本地路径 * @param {string} svgText - SVG内容 * @param {string} assetDirName - 资源文件夹名 * @param {string} imgPrefix - 图片前缀 * @returns {Promise<string>} 本地SVG路径 */ async addSvgFile(svgText, assetDirName, imgPrefix = "", streaming = false) { // 检查参数是否合法 if (typeof svgText !== "string") { throw new Error("[saveSvgToLocal] Invalid argument: svgText must be a string."); } const imgOwner = imgPrefix + assetDirName; // 初始化 if (!this.imageCount[imgOwner]) { this.imageSet[imgOwner] = {}; this.imageCount[imgOwner] = 0; } // 检查是否已保存过该SVG(通过内容哈希) const svgHash = Utils.simpleHash(svgText, 16); // 使用16位哈希 if (this.imageSet[imgOwner][svgHash]) { return this.imageSet[imgOwner][svgHash]; } // 记录图片数量 this.imageCount[imgOwner]++; const index = this.imageCount[imgOwner]; const filename = `${assetDirName}/${imgPrefix}${index}.svg`; // 记录已保存的SVG this.imageSet[imgOwner][svgHash] = `./${filename}`; // 创建SVG的Blob对象 const blob = new Blob([svgText], { type: "image/svg+xml" }); if (streaming) { // 如果是流式处理,直接添加到zip流 this.addFileToZipStream(filename, blob); } else { // 添加到文件队列 this.fileQueue.push({ filename, content: blob, type: "image/svg+xml", index }); } // 返回本地路径 return `./${filename}`; } /** * 将网络图片保存到本地,添加到fileQueue,并返回本地路径 * @param {string} imgUrl - 图片URL * @param {string} assetDirName - 资源文件夹名 * @param {string} imgPrefix - 图片前缀 * @returns {Promise<string>} 本地图片路径 */ async addWebImageFile( imgUrl, assetDirName, imgPrefix = "", streaming = false, retryCount = 3, retryDelay = 1000 ) { // 检查参数是否合法 if (typeof imgUrl !== "string") { throw new Error("[saveWebImageToLocal] Invalid argument: imgUrl must be a string."); } // 清理URL imgUrl = Utils.clearUrl(imgUrl); const imgOwner = imgPrefix + assetDirName; // 初始化 if (!this.imageCount[imgOwner]) { this.imageSet[imgOwner] = {}; this.imageCount[imgOwner] = 0; } // 检查是否已保存过该图片 if (this.imageSet[imgOwner][imgUrl]) { return this.imageSet[imgOwner][imgUrl]; } // 记录图片数量 this.imageCount[imgOwner]++; const index = this.imageCount[imgOwner]; 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 = `${assetDirName}/${imgPrefix}${index}${ext}`; // 记录已保存的图片 this.imageSet[imgOwner][imgUrl] = `./${filename}`; // 获取图片的Blob对象 // 返回的Promise Blob对象,需要等到打包时进行等待 const blob = this.fetchResource(imgUrl, "blob", retryCount, retryDelay, "GM_xmlhttpRequest"); if (streaming) { // 存储 Promise 对象 this.zipStreamPendingFiles.set( filename, new Promise(async (resolve) => { // 等待Blob解析完成 try { const blobData = await blob; await this.addFileToZipStream(filename, blobData); } catch (error) { console.dir(`Error fetching image ${imgUrl}:`, error); if (this.zipStreamErrorCallback) { this.zipStreamErrorCallback(error); } } resolve(); this.zipStreamPendingFiles.delete(filename); // 完成后从待处理列表中删除 }) ); } else { // 添加到文件队列 this.fileQueue.push({ filename, content: blob, type: blob.type, index }); } // 返回本地路径 return `./${filename}`; } /** * 获取网络资源 * @param {string} url - 资源URL * @param {string} [responseType='blob'] - 响应类型,默认为'blob',可选值为'text'、'json'等 * @param {number} retryCount - 重试次数,默认值为3 * @returns {Promise<Blob|string|Object>} - 返回资源的Blob对象或文本内容 * @throws {Error} - 如果获取资源失败,抛出错误 */ async fetchResource(url, responseType = "blob", retryCount = 3, retryDelay = 1000, api = "GM_xmlhttpRequest") { return new Promise((resolve, reject) => { function attemptFetch(remaining) { if (api === "GM_xmlhttpRequest") { GM_xmlhttpRequest({ method: "GET", url: url, responseType: responseType, onload: function (response) { if (response.status === 200) { resolve(response.response); } else { if (remaining > 0) { console.warn(`Retrying fetch for ${url}, attempts left: ${remaining - 1}`); setTimeout(() => { attemptFetch(remaining - 1); }, retryDelay); } else { reject( `Failed to fetch resource: ${url}\nStatus: ${response.status} ${response.statusText}` ); } } }, onerror: function () { if (remaining > 0) { console.warn(`Retrying fetch for ${url}, attempts left: ${remaining - 1}`); setTimeout(() => { attemptFetch(remaining - 1); }, retryDelay); } else { reject(`Error fetching resource: ${url}`); } }, }); } else if (api === "fetch") { fetch(url) .then((response) => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } if (responseType === "json") return response.json(); if (responseType.startsWith("text")) return response.text(); if (responseType === "blob") return response.blob(); throw new Error(`Unsupported response type: ${responseType}`); }) .then((data) => { resolve(data); }) .catch((error) => { if (remaining > 0) { console.warn(`Retrying fetch for ${url}, attempts left: ${remaining - 1}`); setTimeout(() => { attemptFetch(remaining - 1); }, retryDelay); } else { reject(`Error fetching resource: ${url}\n${error.message}`); } }); } else { reject(new Error(`Unsupported API: ${api}. Use 'GM_xmlhttpRequest' or 'fetch'.`)); } } attemptFetch(retryCount); }); } /** * 合并文章内容 * @param {string} outputFileName - 合并后的文件名 * @param {string} extraTopContent - 额外的顶部内容 */ mergeTextFile(outputFileName, extraTopContent = "") { // 检查队列是否只有一个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: `${outputFileName}.md`, type: "text/plain", content: `${extraTopContent}${mergedContent}`, }); this.fileQueue = newFileQueue; } /** * 创建ZIP流,使用 fflate 库和 streamSaver * @param {Array<{name: string, data: Uint8Array|string}>} files - 文件对象数组 * @param {string} zipName - ZIP文件名 * @param {function(string, number):void} [onProgress] - 可选的进度回调,接收文件名和索引 * @param {function(string, number, number):void} [onFinished] - 可选的完成回调,接收当前zip文件名、文件总数、zip大小 * @param {function(Error):void} [onError] - 可选的错误回调 **/ async initializeZipStream(zipName, onProgress = null, onFinished = null, onError = null) { if (!zipName.endsWith(".zip")) { zipName += ".zip"; // 确保ZIP文件名以.zip结尾 } const downloadStream = streamSaver.createWriteStream(zipName); let writer = downloadStream.getWriter(); // 定义一个在页面卸-载前中止写入的函数 const abortStream = () => { if (writer) { console.dir("页面即将卸载,中止写入流..."); writer.abort("用户中断了下载").catch((e) => console.error("中止流时出错:", e)); writer = null; // 清理writer引用 } }; // 注册(不可用)事件监听器 window.addEventListener("beforeunload", abortStream); // 如果已经有zipStream存在,先结束之前的流 if (this.zipStream) { console.dir("Ending previous ZIP stream before creating a new one."); await this.endZipStream(); } this.zipStreamName = zipName; this.zipStreamSize = 0; this.zipStreamFileCount = 0; if (onProgress && typeof onProgress === "function") this.zipStreamProgressCallback = onProgress; else this.zipStreamProgressCallback = null; if (onError && typeof onError === "function") this.zipStreamErrorCallback = onError; else this.zipStreamErrorCallback = null; let zipFinalResolve = () => { console.dir("ZIP stream resolved."); this.zipStream = null; // 清理zipStream引用 }; let zipFinalReject = () => {}; this.zipStreamEndFlag = new Promise((resolve, reject) => { zipFinalResolve = resolve; zipFinalReject = reject; }); this.zipStream = new fflate.Zip((err, chunk, final) => { try { if (err) { writer.abort(err); // 如果出错,中止写入 console.dir(`ZIP stream error: ${err}`); if (onError && typeof onError === "function") onError(err); zipFinalReject(err); return; } if (chunk) { this.zipStreamSize += chunk.length; // 累计ZIP数据大小,单位为字节 writer.write(chunk); } if (final) { writer.close(); writer = null; // 清理writer引用 if (onFinished && typeof onFinished === "function") { onFinished(this.zipStreamName, this.zipStreamFileCount, this.zipStreamSize); } zipFinalResolve(); } } catch (error) { writer.abort(error); // 如果处理过程中出错,中止写入 console.dir(`ZIP processing error: ${error}`); if (onError && typeof onError === "function") onError(error); zipFinalReject(error); } }); } /** * 向ZIP流中添加文件 * @param {string} filename - 文件名 * @param {string|Uint8Array|Blob|Promise} content - 文件内容,可以是字符串、Uint8Array或Blob * @throws {Error} 如果zipStream未初始化或内容类型不支持 * @return {Promise<void>} 返回一个Promise,表示文件已添加到ZIP流中 **/ async addFileToZipStream(filename, content) { if (!this.zipStream) throw new Error("ZIP stream is not initialized. Call createZipStream first."); let data = null; if (typeof content === "string") data = new TextEncoder().encode(content); else if (content instanceof Uint8Array) data = content; else if (content instanceof Blob) data = new Uint8Array(await content.arrayBuffer()); else throw new Error("Unsupported content type. Must be string, Uint8Array, or Blob."); // 增加文件计数 this.zipStreamFileCount++; console.dir( `Add file to stream (No: ${this.zipStreamFileCount}, Now Size: ${Utils.formatFileSize( this.zipStreamSize )}): ${filename}` ); const fileStream = new fflate.ZipPassThrough(filename); this.zipStream.add(fileStream); fileStream.push(data, true); if (this.zipStreamProgressCallback && typeof this.zipStreamProgressCallback === "function") { this.zipStreamProgressCallback(this.zipStreamFileCount, filename); } } /** * 将所有文件添加到ZIP流中 * @param {boolean} [endStream=true] - 是否在添加完所有文件结束ZIP流 * @param {function(string, number, number):void} [addFilesFinalCallback=null] - 可选的最终回调,接收当前zip文件名、文件总数、zip大小 * @throws {Error} 如果zipStream未初始化 * @return {Promise<void>} 返回一个Promise,表示所有文件已添加到ZIP流中 **/ async addAllFilesInQueueToZipStream(endStream = false, addFilesFinalCallback = null) { if (!this.zipStream) throw new Error("ZIP stream is not initialized. Call createZipStream first."); if (this.fileQueue.length === 0) { console.dir("没有文件需要打包到ZIP中"); } else { // 使用 for...of 循环替代 forEach,以便正确处理 async/await for (let idx = 0; idx < this.fileQueue.length; idx++) { let status = true; const file = this.fileQueue[idx]; // content 可能是 promise(Blob对象),需要等待 if (file.content instanceof Promise) { try { file.content = await file.content; // 等待Blob对象 } catch (err) { console.dir(`Error resolving content for file ${file.filename}: ${err}`); if (this.zipStreamErrorCallback && typeof this.zipStreamErrorCallback === "function") { this.zipStreamErrorCallback(err); } status = false; } } if (!status) { console.dir(`Skipping file ${file.filename} due to download failure.`); continue; // 如果下载失败,跳过当前文件 } // 将文件添加到ZIP中 await this.addFileToZipStream(file.filename, file.content); } } // 确保ZIP流已结束 if (endStream) { await this.endZipStream(); console.dir("Ending ZIP stream after adding all files."); } // 调用最终回调 if (addFilesFinalCallback && typeof addFilesFinalCallback === "function") { addFilesFinalCallback(this.zipStreamName, this.zipStreamFileCount, this.zipStreamSize); } // 清空文件队列 this.reset(); } /** * 结束ZIP流,完成写入 * @throws {Error} 如果zipStream未初始化 * @return {Promise<void>} 返回一个Promise,表示ZIP流已结束 **/ async endZipStream() { if (!this.zipStream) throw new Error("ZIP stream is not initialized. Call createZipStream first."); // 如果有待处理的Blob对象,等待它们完成 for (const [filename, blobProcessPromise] of this.zipStreamPendingFiles.entries()) { if (blobProcessPromise instanceof Promise) { try { await blobProcessPromise; // 等待Blob对象解析 } catch (error) { console.dir(`Error processing pending file ${filename}: ${error}`); if (this.zipStreamErrorCallback && typeof this.zipStreamErrorCallback === "function") { this.zipStreamErrorCallback(error); } } } } // 清空待处理的Blob对象 this.zipStreamPendingFiles.clear(); this.zipStream.end(); try { await this.zipStreamEndFlag; // 返回结束的Promise } catch (error) { console.dir(`Error ending ZIP stream: ${error}`); if (this.zipStreamErrorCallback && typeof this.zipStreamErrorCallback === "function") { this.zipStreamErrorCallback(error); } } this.zipStream = null; // 清理zipStream引用 } /** * 使用 fflate 将文件打包成 ZIP,支持进度回调 * @param {Array<{name: string, data: Uint8Array|string}>} files - 文件对象数组 * @param {function(number, string):void} [onProgress] - 可选的进度回调,接收百分比和文件名 * @param {function(Error):void} [onError] - 可选的错误回调 * @return {Promise<Uint8Array>} 返回包含 ZIP 数据的 Promise **/ async createZipWithProgress(files, onProgress = null, onError = null) { return new Promise((resolve, reject) => { const encoder = new TextEncoder(); const chunks = []; let totalFiles = files.length; let processedFiles = 0; const zip = new fflate.Zip((err, chunk, final) => { if (err) { // Logger.error("ZIP creation failed:", err); console.dir(`ZIP creation failed: ${err}`); if (onError && typeof onError === "function") { onError(err); } return reject(err); } if (chunk) chunks.push(chunk); if (final) { const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } resolve(result); } }); if (totalFiles === 0) { zip.end(); return; } files.forEach((file, index) => { const data = typeof file.data === "string" ? encoder.encode(file.data) : file.data; const fileStream = new fflate.ZipPassThrough(file.name); zip.add(fileStream); fileStream.push(data, true); processedFiles++; const percentage = Math.round((processedFiles / totalFiles) * 100); if (onProgress && typeof onProgress === "function") { try { onProgress(percentage, file.name); } catch (e) { // Logger.error("Progress callback error:", e); console.dir(`Progress callback error: ${e}`); if (onError && typeof onError === "function") { onError(e); } return reject(e); } } if (processedFiles === totalFiles) zip.end(); }); }); } /** * 将文件队列打包为ZIP下载(fflate) * @param {string} zipName - ZIP文件名 */ async zipAllFilesInQueue(zipName, progressCallback = null, finalCallback = null, api = "fflate") { // 检查是否有 fflate 库 if (typeof fflate === "undefined" || api !== "fflate") { // 如果没有 fflate 库,使用 jszip 作为备选方案 console.warn("使用 jszip 作为备选方案"); return this.zipAllFilesInQueue_jszip(zipName, progressCallback, finalCallback); } if (this.fileQueue.length === 0) { console.dir("没有文件需要保存"); return; } if (!zipName.endsWith(".zip")) { zipName = zipName + ".zip"; // 确保ZIP文件名以.zip结尾 } zipName = Utils.safeFilename(zipName); const zipFiles = []; // 使用 for...of 循环替代 forEach,以便正确处理 async/await for (let idx = 0; idx < this.fileQueue.length; idx++) { let status = true; const file = this.fileQueue[idx]; // content 可能是 promise(Blob对象),需要等待 if (file.content instanceof Promise) { if (progressCallback && typeof progressCallback === "function") { progressCallback(`正在下载资源:${file.filename} (${idx + 1}/${this.fileQueue.length})`); } try { file.content = await file.content; // 等待Blob对象 } catch (err) { if (progressCallback && typeof progressCallback === "function") { progressCallback(`下载资源失败:${err}`); } status = false; } } if (!status) { continue; // 如果下载失败,跳过当前文件 } // 将文件添加到ZIP中 zipFiles.push({ name: file.filename, data: file.content instanceof Blob ? new Uint8Array(await file.content.arrayBuffer()) : file.content instanceof Uint8Array ? file.content : new TextEncoder().encode(file.content), }); } // 获取当前时间,以便计算剩余时间 const startTime = Date.now(); // 使用 fflate 创建 ZIP 文件 const zipContent = await this.createZipWithProgress( zipFiles, (percent, currentFile) => { // 进度回调 if (progressCallback) { // percent: 当前进度百分比 // currentFile: 当前正在处理的文件名 progressCallback( `正在打包:${currentFile} (${percent}%)(剩余时间:${Utils.formatSeconds( ((Date.now() - startTime) / 1000 / percent) * (100 - percent) )})` ); } }, async (error) => { console.error("Error generating ZIP file:", error); if (finalCallback && typeof finalCallback === "function") { finalCallback(`下载失败:${zipName},错误信息:${error}`); this.reset(); // 清空文件队列 throw new Error(`下载失败:${zipName},错误信息:${error}`); } } ); const zipBlob = new Blob([zipContent], { type: "application/octet-stream" }); // 调用最终回调 if (finalCallback && typeof finalCallback === "function") { finalCallback( `打包完成:${zipName},文件大小:${Utils.formatFileSize(zipBlob.size)}\n请等待下载完成。` ); } this.reset(); // 清空文件队列 this.fileQueue.push({ filename: zipName, type: "application/zip", content: zipBlob, }); } /** * 将文件队列打包为ZIP下载 * @param {string} zipName - ZIP文件名 */ async zipAllFilesInQueue_jszip(zipName, progressCallback = null, finalCallback = null) { if (this.fileQueue.length === 0) { console.error("没有文件需要保存"); return; } if (!zipName.endsWith(".zip")) { zipName = zipName + ".zip"; // 确保ZIP文件名以.zip结尾 } zipName = Utils.safeFilename(zipName); // 创建JSZip实例 const zip = new JSZip(); // 使用 for...of 循环替代 forEach,以便正确处理 async/await for (let idx = 0; idx < this.fileQueue.length; idx++) { let status = true; const file = this.fileQueue[idx]; // content 可能是 promise(Blob对象),需要等待 if (file.content instanceof Promise) { if (progressCallback) { progressCallback(`正在下载资源:${file.filename} (${idx + 1}/${this.fileQueue.length})`); } try { file.content = await file.content; // 等待Blob对象 } catch (err) { if (progressCallback) { progressCallback(`下载资源失败:${err}`); } status = false; } } if (!status) { continue; // 如果下载失败,跳过当前文件 } // 将文件添加到ZIP中 zip.file(file.filename, file.content); } // 获取当前时间,以便计算剩余时间 const startTime = Date.now(); return new Promise((resolve, reject) => { // 生成ZIP文件 zip.generateAsync({ type: "blob" }, (metadata) => { // 进度回调 if (progressCallback) { // metadata.percent: 当前进度百分比 // metadata.currentFile: 当前正在处理的文件名 progressCallback( `正在打包:${metadata.currentFile} (${Math.round( metadata.percent )}%)(剩余时间:${Utils.formatSeconds( ((Date.now() - startTime) / 1000 / metadata.percent) * (100 - metadata.percent) )})` ); } }) .then((blob) => { // 调用最终回调 if (finalCallback) { finalCallback( `打包完成:${zipName},文件大小:${Utils.formatFileSize(blob.size)}\n请等待下载完成。` ); } this.reset(); // 清空文件队列 this.fileQueue.push({ filename: zipName, type: "application/zip", content: blob, }); resolve(); }) .catch((error) => { // 处理错误 this.reset(); // 清空文件队列 console.error("Error generating ZIP file:", error); if (finalCallback) { finalCallback(`下载失败:${zipName},错误信息:${error}`); throw new Error(`下载失败:${zipName},错误信息:${error}`); } reject(error); }); }); } /** * 下载队列里的全部文件 */ async downloadAllFilesInQueue() { if (this.fileQueue.length === 0) { console.dir("没有文件需要下载"); return; } for (let i = 0; i < this.fileQueue.length; i++) { const file = this.fileQueue[i]; let content = file.content; // 如果content是Promise,等待其完成 if (content instanceof Promise) { try { content = await content; } catch (error) { console.error(`下载文件 ${file.filename} 失败:`, error); continue; } } // 创建Blob对象 const blob = content instanceof Blob ? content : new Blob([content], { type: file.type }); // 创建下载链接 const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = file.filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // 添加小延迟避免浏览器阻止多文件下载 await new Promise((resolve) => setTimeout(resolve, 100)); } this.reset(); // 清空文件队列 } /** * 重置图片计数器和缓存 */ clearImageCache() { this.imageCount = {}; this.imageSet = {}; } /** * 清空文件队列 */ clearFileQueue() { this.fileQueue = []; } /** * 重置FileManager */ reset() { this.clearFileQueue(); this.clearImageCache(); } } /** * 模块: Markdown转换 * 将HTML转换为Markdown */ class MarkdownConverter { /** * 创建HTML标签到处理函数的映射表 * @returns {Object} 标签名称到处理方法的映射 */ static createTagHandlers() { return { h1: this.prototype.handleHeading, h2: this.prototype.handleHeading, h3: this.prototype.handleHeading, h4: this.prototype.handleHeading, h5: this.prototype.handleHeading, h6: this.prototype.handleHeading, p: this.prototype.handleParagraph, strong: this.prototype.handleStrong, b: this.prototype.handleStrong, em: this.prototype.handleEmphasis, i: this.prototype.handleEmphasis, u: this.prototype.handleUnderline, s: this.prototype.handleStrikethrough, strike: this.prototype.handleStrikethrough, a: this.prototype.handleAnchor, img: this.prototype.handleImage, ul: this.prototype.handleList, ol: this.prototype.handleList, blockquote: this.prototype.handleBlockquote, pre: this.prototype.handlePreformatted, code: this.prototype.handleCode, hr: this.prototype.handleHorizontalRule, br: this.prototype.handleLineBreak, table: this.prototype.handleTable, div: this.prototype.handleDiv, span: this.prototype.handleSpan, kbd: this.prototype.handleKeyboard, mark: this.prototype.handleMark, sub: this.prototype.handleSubscript, sup: this.prototype.handleSuperscript, svg: this.prototype.handleSvg, section: this.prototype.handleSection, input: this.prototype.handleInput, dl: this.prototype.handleDefinitionList, abbr: this.prototype.handleAbbreviation, font: this.prototype.handleFont, td: this.prototype.handleTableCell, th: this.prototype.handleTableCell, center: this.prototype.handleCenter, }; } /** * @param {FileManager} fileManager - 文件管理实例 * @constructor */ constructor(fileManager) { this.fileManager = fileManager; this.tagHandlers = MarkdownConverter.createTagHandlers(); // 预定义的特殊字段 // 内容之间保持两个换行符 this.CONSTANT_DOUBLE_NEW_LINE = "<|CSDN2MD@CONSTANT_DOUBLE_NEW_LINE@23hy7b|>"; // 分隔符用于美化,比如公式和文本之间加上空格会更美观 this.SEPARATION_BEAUTIFICATION = "<|CSDN2MD@SEPARATION_BEAUTIFICATION@2caev2|>"; this.DDNL = this.escapeRegExp(this.CONSTANT_DOUBLE_NEW_LINE); this.SEPB = this.escapeRegExp(this.SEPARATION_BEAUTIFICATION); // 1. 连续的 "\n" 与 CONSTANT_DOUBLE_NEW_LINE 替换为 "\n\n" this.RE_DOUBLE_NL = new RegExp(`(?:\\n|${this.DDNL})*${this.DDNL}(?:\\n|${this.DDNL})*`, "g"); // 2. 连续的 SEPARATION_BEAUTIFICATION 替换为 " ",但如果前面是换行符,替换为 "" this.RE_SEP_NOLINE = new RegExp(`(?<!\\n)(?:${this.SEPB})+`, "g"); this.RE_SEP_WITHNL = new RegExp(`(\\n)(?:${this.SEPB})+`, "g"); // 节点类型常量 this.ELEMENT_NODE = 1; this.TEXT_NODE = 3; this.COMMENT_NODE = 8; } /** * 将HTML内容转换为Markdown格式 * @param {Element} articleElement - 文章DOM元素 * @param {Object} config - 配置选项 * @param {string} [config.assetDirName=""] - 资源文件夹名 * @param {boolean} [config.enableTOC=true] - 是否启用目录 * @param {string} [config.imgPrefix=""] - 图片文件前缀 * @param {boolean} [config.saveWebImages=false] - 是否将网络图片保存到本地 * @param {number} [config.downloadAssetRetryCount=3] - 下载资源失败时的重试次数 * @param {boolean} [config.enableStreaming=false] - 是否启用流式处理 * @param {boolean} [config.forceImageCentering=false] - 是否强制所有图片居中 * @param {boolean} [config.enableImageSize=false] - 是否保留图片尺寸 * @param {boolean} [config.enableColorText=false] - 是否保留彩色文本 * @param {boolean} [config.removeCSDNSearchLink=true] - 是否移除CSDN搜索链接 * @returns {Promise<string>} Markdown内容 */ async htmlToMarkdown(articleElement, config = {}) { // 设置默认配置 const defaultConfig = { assetDirName: "", enableTOC: true, imgPrefix: "", saveWebImages: false, downloadAssetRetryCount: 3, downloadAssetRetryDelay: 1000, enableStreaming: false, forceImageCentering: false, enableImageSize: false, enableColorText: false, removeCSDNSearchLink: true, enableMarkdownEscape: true, markdownEscapePattern: "`*_[]{}()#+-.!", }; // 合并用户配置和默认配置,并添加上下文信息 const context = { ...defaultConfig, ...config, listLevel: 0, }; // 处理文章元素的子节点 const markdown = await this.processChildren(articleElement, context); // 后处理Markdown内容,美化输出 return this.postProcessMarkdown(markdown.trim()); } /** * 处理单个DOM节点 * @param {Node} node - 当前DOM节点 * @param {Object} context - 处理上下文 * @returns {Promise<string>} 节点的Markdown字符串 */ async processNode(node, context) { switch (node.nodeType) { case this.ELEMENT_NODE: const tagName = node.tagName.toLowerCase(); const handler = this.tagHandlers[tagName]; return handler ? await handler.call(this, node, context) : await this.handleDefaultElement(node, context); case this.TEXT_NODE: // 处理文本节点(即没有被单独的标签包裹的文本) if (context.enableMarkdownEscape) { // 如果启用了Markdown转义,则转义文本内容 return this.escapeMarkdown(node.textContent, context.markdownEscapePattern); } else { return node.textContent.trim(); // 如果没有启用转义,则直接返回文本内容 } case this.COMMENT_NODE: // 忽略注释 return ""; default: return ""; } } /** * 处理元素的子节点 * @param {Node} node - 父节点 * @param {Object} context - 处理上下文 * @returns {Promise<string>} 子节点拼接后的Markdown字符串 */ async processChildren(node, context) { let result = ""; for (const child of node.childNodes) { result += await this.processNode(child, context); } return result; } /** * 转义特殊的Markdown字符 * @param {string} text - 需要转义的文本 * @returns {string} 转义后的文本 */ escapeMarkdown(text, escapePattern = "`*_[]{}()#+-.!") { // 注:原代码中有一个被注释掉的转义逻辑,这里只保留了trim操作 // return text.replace(/([\\`*_\{\}\[\]()#+\-.!])/g, "\\$1").trim(); // return text.trim(); // 不转义特殊字符 // 使用正则表达式转义特殊字符 escapePattern = this.escapeRegExp(escapePattern); const escapeRegex = new RegExp(`([${escapePattern}])`, "g"); return text.replace(escapeRegex, "\\$1").trim(); } /** * 对常量做正则转义 * @param {string} s - 需要转义的字符串 * @returns {string} 转义后的字符串 */ escapeRegExp(s) { return s.replaceAll(/[\-\.\*\+\?\^\$\{\}\(\)\|\[\]\\]/g, "\\$&"); } /** * 特殊字符串修剪函数:移除字符串开头和结尾的分隔符和空白字符 * @param {string} text - 需要修剪的字符串 * @returns {string} 修剪后的字符串 */ specialTrim(text = "") { return text .replace(new RegExp(`^(?:${this.SEPB}|\\s)+`), "") .replace(new RegExp(`(?:${this.SEPB}|\\s)+$`), ""); } /** * 后处理Markdown内容 * @param {string} markdown - 原始Markdown内容 * @returns {string} 处理后的Markdown内容 */ postProcessMarkdown(markdown) { return markdown .replaceAll(this.RE_DOUBLE_NL, "\n\n") // 吃掉前后重复换行和标记,统一为两个换行 .replaceAll(this.RE_SEP_NOLINE, " ") // 非换行前的标记串 → 空格 .replaceAll(this.RE_SEP_WITHNL, "$1"); // 换行后的标记串 → 保留换行 } /**************************************** * 标签处理函数 ****************************************/ /** * 处理标题元素(h1-h6) */ async handleHeading(node, context) { const level = parseInt(node.tagName[1]); // 移除节点内部开头的空 <a> 标签 node.querySelectorAll("a").forEach((aTag) => { if (aTag && aTag.textContent.trim() === "") { aTag.remove(); } }); let content = await this.processChildren(node, context); // 按行分割分别处理 // 如果该行内容不为空且不包含图片,则添加标题前缀 content = content .split("\n") .map((line) => { if (line.trim() !== "") { // 如果该行内容是 <img /> 标签,则不添加前缀 if (line.trim().search("<img") !== -1 && line.trim().search("/>") !== -1) { return line; } return `${"#".repeat(level)} ${line}`; } return line; }) .join("\n"); return `${content}${this.CONSTANT_DOUBLE_NEW_LINE}`; } /** * 处理段落元素 */ async handleParagraph(node, context) { const cls = node.getAttribute("class"); const style = node.getAttribute("style"); if (cls && cls.includes("img-center")) { // 处理图片居中,类似 <center> 标签 this.addPicCenterToImages(node); return (await this.processChildren(node, context)) + this.CONSTANT_DOUBLE_NEW_LINE; } // 处理目录 if (node.getAttribute("id") === "main-toc") { if (context.enableTOC) { return `**目录**\n\n[TOC]\n\n`; } return ""; } let text = await this.processChildren(node, context); // 处理带样式的段落 if (style) { if (style.includes("padding-left")) { return ""; } if (style.includes("text-align:center")) { return `<div style="text-align:center;">${Utils.shrinkHtml(node.innerHTML)}</div>\n\n`; } else if (style.includes("text-align:right")) { return `<div style="text-align:right;">${Utils.shrinkHtml(node.innerHTML)}</div>\n\n`; } } return `${text}\n\n`; } /** * 处理加粗元素 */ async handleStrong(node, context) { const content = this.specialTrim(await this.processChildren(node, context)); if (content === "") return ""; return `${this.SEPARATION_BEAUTIFICATION}**${content}**${this.SEPARATION_BEAUTIFICATION}`; } /** * 处理斜体元素 */ async handleEmphasis(node, context) { const content = this.specialTrim(await this.processChildren(node, context)); if (content === "") return ""; return `${this.SEPARATION_BEAUTIFICATION}*${content}*${this.SEPARATION_BEAUTIFICATION}`; } /** * 处理下划线元素 */ async handleUnderline(node, context) { const content = this.specialTrim(await this.processChildren(node, context)); if (content === "") return ""; return `${this.SEPARATION_BEAUTIFICATION}<u>${content}</u>${this.SEPARATION_BEAUTIFICATION}`; } /** * 处理删除线元素 */ async handleStrikethrough(node, context) { const content = this.specialTrim(await this.processChildren(node, context)); if (content === "") return ""; return `${this.SEPARATION_BEAUTIFICATION}~~${content}~~${this.SEPARATION_BEAUTIFICATION}`; } /** * 处理链接元素 */ async handleAnchor(node, context) { const nodeClass = node.getAttribute("class"); // 忽略脚注返回链接 if (nodeClass && nodeClass.includes("footnote-backref")) { return ""; } const href = node.getAttribute("href") || ""; // 处理卡片链接 if (nodeClass && nodeClass.includes("has-card")) { const desc = node.title || ""; return `[${desc}](${href}) `; } let text = await this.processChildren(node, context); // 处理CSDN搜索链接 if (href.includes("https://so.csdn.net/so/search") && context.removeCSDNSearchLink) { return text; } // 适配旧版CSDN的 "OLE_LINK{xxx}" 链接 const name = node.getAttribute("name") || ""; if (name.startsWith("OLE_LINK")) { text = text.replaceAll("\n", ""); } // 如果链接和文本都为空,则返回空字符串 if (text === "" && href === "") return ""; return `${this.SEPARATION_BEAUTIFICATION}[${text}](${href})${this.SEPARATION_BEAUTIFICATION}`; } /** * 处理图片元素 */ async handleImage(node, context) { 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") || ""; let result = ""; // 处理数学代码图片 if (cls.includes("mathcode")) { return `${this.SEPARATION_BEAUTIFICATION}\$\$\n${alt}\n\$\$`; } else { // 根据图片是否居中添加空格 if (src.includes("#pic_center") || context.forceImageCentering) { result = this.CONSTANT_DOUBLE_NEW_LINE; } else { result = " "; } // 保存网络图片到本地(如果配置启用) if (context.saveWebImages) { src = await this.fileManager.addWebImageFile( src, context.assetDirName, context.imgPrefix, context.enableStreaming, context.downloadAssetRetryCount, context.downloadAssetRetryDelay ); } // 处理图片尺寸 if (height && context.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 += `<img src="${src}" alt="${alt}" style="${heightStyle} box-sizing:content-box;" />`; } else if (width && context.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 += `<img src="${src}" alt="${alt}" style="${widthStyle} box-sizing:content-box;" />`; } else { result += ``; } return result + this.CONSTANT_DOUBLE_NEW_LINE; } } /** * 处理列表元素(ul/ol) */ async handleList(node, context) { const ordered = node.tagName.toLowerCase() === "ol"; // 创建新的上下文,增加列表嵌套级别 const newContext = { ...context, listLevel: context.listLevel + 1 }; let result = this.CONSTANT_DOUBLE_NEW_LINE; // 筛选出所有li元素 const children = Array.from(node.children).filter((child) => child.tagName.toLowerCase() === "li"); for (let index = 0; index < children.length; index++) { const child = children[index]; // 根据列表类型选择前缀和缩进 const prefix = ordered ? `${index + 1}. ` : `- `; const indent = ordered ? " " : " "; let childText = await this.processChildren(child, newContext); // 处理嵌套列表的换行和缩进 childText = childText.replaceAll(this.RE_DOUBLE_NL, "\n\n"); // 对除第一行外的所有行添加缩进 childText = childText .split("\n") .map((line, i) => { // 如果是空行或首行,则不添加缩进 if (line.trim() === "" || i === 0) { return line; } return `${indent}${line}`; }) .join("\n"); result += `${prefix}${childText}${this.CONSTANT_DOUBLE_NEW_LINE}`; } return result; } /** * 处理引用块元素 */ async handleBlockquote(node, context) { // 处理每一行,添加引用标记 > const text = (await this.processChildren(node, context)) .trim() .split("\n") .map((line) => (line ? `> ${line}` : "> ")) .join("\n"); return `${text}\n\n`; } /** * 处理预格式化代码块 */ async handlePreformatted(node, context) { const codeNode = node.querySelector("code"); if (codeNode) { const className = codeNode.className || ""; let language = ""; // 提取语言信息 // 新版本的代码块,class含有language-xxx if (className.includes("language-")) { for (const item of className.split(" ")) { if (item.startsWith("language-")) { language = item.replace("language-", ""); break; } } } // 老版本的代码块 else if (className.startsWith("hljs")) { const languageMatch = className.split(" "); language = languageMatch.length > 1 ? languageMatch[1] : ""; } return `\`\`\`${language}\n${await this.processCodeBlock(codeNode)}\`\`\`\n\n`; } else { const codeText = node.textContent.replace(/^\s+|\s+$/g, ""); return `\`\`\`\n${codeText}\n\`\`\`\n\n`; } } /** * 处理行内代码元素 */ async handleCode(node, context) { const codeText = node.textContent; return `${this.SEPARATION_BEAUTIFICATION}\`${codeText}\`${this.SEPARATION_BEAUTIFICATION}`; } /** * 处理水平分割线元素 */ async handleHorizontalRule(node, context) { if (node.getAttribute("id") !== "hr-toc") { return `---\n\n`; } return ""; } /** * 处理换行元素 */ async handleLineBreak(node, context) { return `\n`; } /** * 处理表格元素 */ async handleTable(node, context) { 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) => { const content = await this.processNode(cell, context); return content.trim().replaceAll(this.RE_DOUBLE_NL, "<br />"); }) ); table += `| ${headers.join(" | ")} |\n`; // 处理分隔符行(对齐方式) const alignments = headerCells.map((cell) => { const align = cell.getAttribute("align"); if (align === "center") return ":---:"; if (align === "right") return "---:"; if (align === "left") return ":---"; return ":---:"; // 默认居中 }); table += `|${alignments.join("|")}|\n`; // 处理表格内容行 for (let i = 1; i < rows.length; i++) { const cells = Array.from(rows[i].querySelectorAll("td")); const rowContent = await Promise.all( cells.map(async (cell) => { const content = await this.processNode(cell, context); return content.trim().replaceAll(this.RE_DOUBLE_NL, "<br />"); }) ); table += `| ${rowContent.join(" | ")} |`; if (i < rows.length - 1) { table += "\n"; } } return table + "\n\n"; } /** * 处理div元素 */ async handleDiv(node, context) { const className = node.getAttribute("class") || ""; // 处理视频盒子 if (className.includes("csdn-video-box")) { const iframe = node.querySelector("iframe"); if (iframe) { const src = iframe.getAttribute("src") || ""; const titleElem = node.querySelector("p"); const title = titleElem ? titleElem.textContent || "" : ""; const iframeHTML = iframe.outerHTML.replace( "></iframe>", ' style="width: 100%; aspect-ratio: 2;"></iframe>' ); return `<div align="center" style="border: 3px solid gray;border-radius: 27px;overflow: hidden;"> <a class="link-info" href="${src}" rel="nofollow" title="${title}">${title}</a>${iframeHTML}</div>\n\n`; } } // 处理目录 else if (className.includes("toc")) { if (context.enableTOC) { const titleElem = node.querySelector("h4"); const customTitle = titleElem ? titleElem.textContent || "" : ""; return `**${customTitle}**\n\n[TOC]\n\n`; } } return `${await this.processChildren(node, context)}\n`; } /** * 处理span元素 */ async handleSpan(node, context) { const nodeClass = node.getAttribute("class"); // 处理KaTeX数学公式 if (nodeClass) { if (nodeClass.includes("katex--inline") || nodeClass.includes("katex--display")) { return this.handleKatexElement(node, nodeClass); } } // 处理带颜色的文本 const style = node.getAttribute("style") || ""; if ((style.includes("background-color") || style.includes("color")) && context.enableColorText) { if (node.childNodes.length === 1 && node.childNodes[0].nodeType === this.TEXT_NODE) { return `<span style="${style}">${await this.processChildren(node, context)}</span>`; } } return await this.processChildren(node, context); } /** * 处理KaTeX数学公式元素 */ handleKatexElement(node, nodeClass) { const katexMathmlElem = node.querySelector(".katex-mathml"); const katexHtmlElem = node.querySelector(".katex-html"); if (!katexMathmlElem || !katexHtmlElem) return ""; // 清理KaTeX元素 this.cleanKatexElements(katexMathmlElem); const mathml = Utils.clearSpecialChars(katexMathmlElem.textContent); const katexHtml = Utils.clearSpecialChars(katexHtmlElem.textContent); // 处理行内公式和行间公式 if (nodeClass.includes("katex--inline")) { // 行内公式 if (mathml.startsWith(katexHtml)) { return `${this.SEPARATION_BEAUTIFICATION}\$${mathml.replace(katexHtml, "")}\$${ this.SEPARATION_BEAUTIFICATION }`; } else { return `${this.SEPARATION_BEAUTIFICATION}\$${Utils.clearKatexMathML( katexMathmlElem.textContent )}\$${this.SEPARATION_BEAUTIFICATION}`; } } else { // 行间公式 if (mathml.startsWith(katexHtml)) { return `${this.CONSTANT_DOUBLE_NEW_LINE}\$\$\n${mathml.replace(katexHtml, "")}\n\$\$${ this.CONSTANT_DOUBLE_NEW_LINE }`; } else { return `${this.CONSTANT_DOUBLE_NEW_LINE}\$\$\n${Utils.clearKatexMathML( katexMathmlElem.textContent )}\n\$\$${this.CONSTANT_DOUBLE_NEW_LINE}`; } } } /** * 清理KaTeX元素 * 移除可能导致公式显示错乱的元素 */ cleanKatexElements(katexMathmlElem) { const elementsToRemove = [".MathJax_Display", ".MathJax_Preview", ".MathJax_Error"]; elementsToRemove.forEach((selector) => { if (katexMathmlElem.querySelector(selector) && katexMathmlElem.querySelector("script")) { katexMathmlElem.querySelectorAll(selector).forEach((elem) => elem.remove()); } }); } /** * 处理键盘按键元素 */ async handleKeyboard(node, context) { return `${this.SEPARATION_BEAUTIFICATION}<kbd>${node.textContent}</kbd>${this.SEPARATION_BEAUTIFICATION}`; } /** * 处理标记(高亮)元素 */ async handleMark(node, context) { return `${this.SEPARATION_BEAUTIFICATION}<mark>${await this.processChildren(node, context)}</mark>${ this.SEPARATION_BEAUTIFICATION }`; } /** * 处理下标元素 */ async handleSubscript(node, context) { return `<sub>${await this.processChildren(node, context)}</sub>`; } /** * 处理上标元素 */ async handleSuperscript(node, context) { const nodeClass = node.getAttribute("class"); // 处理脚注引用 if (nodeClass && nodeClass.includes("footnote-ref")) { return `[^${node.textContent}]`; } else { return `<sup>${await this.processChildren(node, context)}</sup>`; } } /** * 处理SVG元素 */ async handleSvg(node, context) { const style = node.getAttribute("style"); if (style && style.includes("display: none")) { return ""; } // 为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"); }); } // 保存SVG图像 if (context.saveWebImages) { const svgSavePath = await this.fileManager.addSvgFile( node.outerHTML, context.assetDirName, context.imgPrefix, context.enableStreaming ); return `${this.CONSTANT_DOUBLE_NEW_LINE}`; } else { // 检查是否有style标签存在于svg元素内,如果有则转换为base64形式 if (node.querySelector("style")) { const base64 = Utils.svgToBase64(node.outerHTML); return `${this.CONSTANT_DOUBLE_NEW_LINE}`; } else { return `<div align="center">${node.outerHTML}</div>${this.CONSTANT_DOUBLE_NEW_LINE}`; } } } /** * 处理section元素 */ async handleSection(node, context) { const nodeClass = node.getAttribute("class"); // 处理脚注内容 if (nodeClass && nodeClass.includes("footnotes")) { return await this.processFootnotes(node); } return await this.processChildren(node, context); } /** * 处理input元素 */ async handleInput(node, context) { // 仅处理checkbox类型的input元素 if (node.getAttribute("type") === "checkbox") { return `[${node.checked ? "x" : " "}] `; } return ""; } /** * 处理定义列表元素 */ async handleDefinitionList(node, context) { // 自定义列表,直接用HTML return `${Utils.shrinkHtml(node.outerHTML)}\n\n`; } /** * 处理缩写元素 */ async handleAbbreviation(node, context) { return Utils.shrinkHtml(node.outerHTML); } /** * 处理字体元素 */ async handleFont(node, context) { // 避免进入 default,直接处理子元素 return await this.processChildren(node, context); } /** * 处理表格单元格元素 */ async handleTableCell(node, context) { return await this.processChildren(node, context); } /** * 处理居中元素 */ async handleCenter(node, context) { if (node.childNodes.length === 1 && node.childNodes[0].nodeType === this.TEXT_NODE) { // 只有一个文本子节点时,使用center标签 return `<center>${node.textContent.trim().replaceAll("\n", "<br>")}</center>\n\n`; } else { // 处理含有图片的居中标签,为图片添加#pic_center后缀 this.addPicCenterToImages(node); return (await this.processChildren(node, context)) + this.CONSTANT_DOUBLE_NEW_LINE; } } /** * 默认元素处理器,用于没有特定处理器的元素 */ async handleDefaultElement(node, context) { return (await this.processChildren(node, context)) + this.CONSTANT_DOUBLE_NEW_LINE; } /**************************************** * 辅助方法 ****************************************/ /** * 为图片添加#pic_center后缀以实现居中效果 */ addPicCenterToImages(node) { node.querySelectorAll("img").forEach((img) => { const src = img.getAttribute("src"); if (src && !src.includes("#pic_center")) { img.setAttribute("src", src + "#pic_center"); } }); } /** * 处理代码块内容 * 支持新旧两种代码块格式 */ async processCodeBlock(codeNode) { // 查找code内部是否有ol元素(新版代码块格式) const olNode = codeNode.querySelector("ol"); if (!olNode || olNode.tagName.toLowerCase() !== "ol") { // 老版本的代码块,直接返回文本内容 return codeNode.textContent.replace(/\n$/, "") + "\n"; } // 新版本的代码块,处理每行代码 const listItems = olNode.querySelectorAll("li"); let result = ""; // 遍历每个<li>元素(每行代码) listItems.forEach((li) => { result += li.textContent + "\n"; }); return result; } /** * 处理脚注 * 将脚注列表转换为Markdown格式 */ async processFootnotes(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 this.processNode(li, {})).replaceAll("\n", " ").replaceAll("↩︎", "").trim(); result += `[^${index + 1}]: ${text}\n`; } return result; } } /** * 模块: 文章下载管理 * 协调各模块完成文章下载功能 */ class ArticleDownloader { /** * @param {UIManager} [uiManager=null] - UI管理器实例 */ constructor(uiManager = null) { /** @type {FileManager} */ this.fileManager = new FileManager(); /** @type {MarkdownConverter} */ this.markdownConverter = new MarkdownConverter(this.fileManager); /** @type {UIManager} */ this.uiManager = uiManager; } /** * 设置UI管理器 * @param {UIManager} uiManager - UI管理器实例 **/ setUIManager(uiManager) { this.uiManager = uiManager; } reset() { this.fileManager.reset(); } async unfoldHideArticleBox(document_body) { // 展开隐藏的文章内容 const hideArticleBox = document_body.querySelector(".hide-article-box"); if (!hideArticleBox) return; const readAllContentBtn = hideArticleBox.querySelector(".read-all-content-btn"); if (!readAllContentBtn) return; readAllContentBtn.click(); console.dir("已展开隐藏的文章内容。"); // 动态等待 #article_content 加载完成 const articleContent = document_body.querySelector("#article_content"); if (!articleContent) { throw new Error("未找到文章内容元素 #article_content"); } // 创建动态等待函数 const waitForContentStable = (element, timeout = 30000, stabilityDelay = 1000) => { return new Promise((resolve, reject) => { let stabilityTimer = null; let timeoutTimer = null; const observer = new MutationObserver(() => { if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; // 清除超时计时器 } if (stabilityTimer) clearTimeout(stabilityTimer); stabilityTimer = setTimeout(resolve, stabilityDelay); // 重置稳定倒计时 }); observer.observe(element, { childList: true, // 监听子元素变化 subtree: true, // 监听所有后代 attributes: true, // 监听属性变化 }); // 设置超时强制返回 setTimeout(() => { observer.disconnect(); reject(new Error(`等待加载超时 (${timeout}ms)`)); }, timeout); }); }; await waitForContentStable(articleContent); console.dir("内容展开完成"); } /** * 解析网页并转换为Markdown格式 * @param {Document} doc_body - 文章的body元素 * @param {Object} config - 配置选项 * @param {string} config.articleUrl - 文章链接 * @param {number} config.fileIndex - 文件索引(用于批量下载) * @param {number} config.fileTotal - 文件总数(用于批量下载) * @param {boolean} config.enableStreaming - 是否启用流式处理 * @param {boolean} config.mergeArticleContent - 是否合并文章内容 * @param {boolean} config.saveAllImagesToAssets - 是否将所有图片保存 * @param {boolean} config.addSerialNumberToTitle - 是否在标题前添加序号 * @param {boolean} config.addArticleInfoInBlockquote - 是否在引用块中添加文章信息 * @param {boolean} config.addArticleTitleToMarkdown - 是否在Markdown中添加文章标题 * @param {boolean} config.addArticleInfoInYaml - 是否在YAML中添加文章信息 * @param {boolean} config.saveWebImages - 是否保存网络图片 * @param {number} config.downloadAssetRetryCount - 下载网络图片的重试次数 * @param {boolean} config.forceImageCentering - 是否强制图片居中 * @param {boolean} config.enableImageSize - 是否启用图片尺寸 * @param {boolean} config.enableColorText - 是否启用彩色文本 * @param {boolean} config.removeCSDNSearchLink - 是否移除CSDN搜索链接 * @param {string} config.customFileNamePattern - 是否使用自定义文件名模式 * @param {boolean} config.enableCustomFileName - 是否启用自定义文件名 * @returns {Promise<string>} 解析后的文章标题 * @throws {Error} 如果未找到文章内容 */ async parseArticle(doc_body, config = {}) { const { articleUrl = "", fileIndex = 0, fileTotal = 1 } = config; await this.unfoldHideArticleBox(doc_body); const articleTitle = doc_body.querySelector("#articleContentId")?.textContent.trim() || "未命名文章"; const articleAuthor = doc_body.querySelector("#uid")?.textContent.trim() || ""; const articleInfo = doc_body .querySelector(".bar-content") ?.textContent.replace(/\s{2,}/g, " ") .trim() || ""; const htmlInput = doc_body.querySelector("#content_views"); if (!htmlInput) throw new Error("未找到文章内容。请检查网页结构是否发生变化。"); const padNo = `${String(fileIndex).padStart(fileTotal.toString().length, "0")}`; let fileName = articleTitle; if (fileIndex > 0 && config.enableCustomFileName) { fileName = config.customFileNamePattern .replaceAll("{no}", padNo) .replaceAll("{title}", articleTitle) .replaceAll("{author}", articleAuthor) .replaceAll("{index}", fileIndex.toString()); } this.uiManager.showFloatTip( `正在解析文章:(${fileTotal - Math.max(1, fileIndex) + 1}/${fileTotal}) ` + articleTitle ); let markdown = await this.markdownConverter.htmlToMarkdown(htmlInput, { assetDirName: config.mergeArticleContent || config.saveAllImagesToAssets ? "assets" : fileName, enableTOC: !config.mergeArticleContent, imgPrefix: `${padNo}_`, ...config, // 传入其他配置选项 }); if (config.addArticleInfoInBlockquote) { markdown = `> ${articleInfo}\n> 文章链接:${Utils.clearUrl(articleUrl)}\n\n${markdown}`; } if (config.addArticleTitleToMarkdown) { if (config.addSerialNumberToTitle) { markdown = `# ${padNo} ${articleTitle}\n\n${markdown}`; } else { markdown = `# ${articleTitle}\n\n${markdown}`; } } if (config.addArticleInfoInYaml) { const article_info_box = doc_body.querySelector(".article-info-box"); // 文章标题 const meta_title = config.addSerialNumberToTitle ? `${padNo} ${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`; // 文章分类和标签 if (article_info_box) { 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}`; } // 这里不启用流式压缩,在解析结束时会统一压缩,文本占用内存比较小 await this.fileManager.addTextFile(markdown, `${fileName}.md`, fileIndex, false); return articleTitle; } /** * 在iframe中下载文章 * @param {string} url - 文章URL * @param {object} config - 配置选项 * @returns {Promise<void>} */ async downloadArticleInIframe(url, config = {}) { return new Promise((resolve, reject) => { const originalUrl = url; // 保存原始URL let isRedirected = false; // 重置重定向标志 const hasCaptcha = (doc) => { return doc.body.querySelector(".text-wrap")?.textContent.includes("安全验证"); }; const onCheckPassed = () => { // 创建一个隐藏的iframe const iframe = document.createElement("iframe"); const showIframe = (iframe_element) => { iframe_element.style.display = "block"; // 显示iframe iframe_element.style.position = "fixed"; iframe_element.style.top = "50%"; iframe_element.style.left = "50%"; iframe_element.style.transform = "translate(-50%, -50%)"; iframe_element.style.width = "80vw"; iframe_element.style.height = "80vh"; iframe_element.style.zIndex = "99999"; iframe_element.style.background = "#fff"; iframe_element.style.boxShadow = "0 4px 24px rgba(0,0,0,0.18)"; iframe_element.style.border = "2px solid #12c2e9"; iframe_element.style.borderRadius = "12px"; }; const hideIframe = (iframe_element) => { iframe_element.style.display = "none"; // 隐藏iframe }; hideIframe(iframe); // 初始隐藏iframe document.body.appendChild(iframe); iframe.src = url; // 监听iframe加载完成事件 iframe.onload = async () => { console.dir(`iframe加载完成,开始下载文章: Url: ${url}`); try { const doc = iframe.contentDocument || iframe.contentWindow.document; // 检查是否有验证码 if (hasCaptcha(doc)) { console.dir(`检测到验证码: Url: ${url}`); await this.uiManager.showConfirmDialog( { title: "ℹ️ 提示", message: `检测到验证码,您需要手动验证通过后,再刷新页面重新进行下载。\n点击确认将显示该验证页面,若取消则无法下载。\nUrl: ${url}`, }, async () => { // 用户点击确认后,重新加载iframe console.dir(`用户确认验证码处理: Url: ${url}`); showIframe(iframe); }, () => { // 用户点击取消后,移除iframe并拒绝Promise console.dir(`用户取消验证码处理: Url: ${url}`); document.body.removeChild(iframe); } ); return; } // 调用解析函数 await this.parseArticle(doc.body, { ...config, // 传入其他配置选项 articleUrl: url, }); // 移除iframe document.body.removeChild(iframe); resolve(); } catch (error) { // 在发生错误时移除iframe并拒绝Promise document.body.removeChild(iframe); console.dir( `解析文章时出错: Url: ${url} OriginalUrl: ${originalUrl} Redirected: ${isRedirected}. Original error: ${ error.message || error }` ); const newError = new Error( `解析文章时出错:Url: ${url} OriginalUrl: ${originalUrl} Redirected: ${isRedirected}. Original error: ${ error.message || error }` ); newError.stack = error.stack; reject(newError); } }; // 监听iframe加载错误事件 iframe.onerror = (error) => { document.body.removeChild(iframe); console.dir( `Iframe加载失败: Url: ${url} OriginalUrl: ${originalUrl} Redirected: ${isRedirected}. Original error: ${ error.message || error }` ); const newError = new Error( `Iframe加载失败:Url: ${url} OriginalUrl: ${originalUrl} Redirected: ${isRedirected}. Original error: ${ error.message || error }` ); newError.stack = error.stack || new Error().stack; reject(error); }; }; const uiManager = this.uiManager; // FIX: 使用 GM_xmlhttpRequest 检测是否存在重定向 // https://github.com/Qalxry/csdn2md/issues/6 // https://github.com/Qalxry/csdn2md/issues/7 GM_xmlhttpRequest({ method: "HEAD", url: url, redirect: "manual", // 禁止自动重定向 onload: async function (response) { if (response.status === 301 || response.status === 302) { const redirectUrl = response.responseHeaders.match(/Location:\s*(.+)/i)?.[1]; console.dir(`检测到重定向: ${url} -> ${redirectUrl}`); isRedirected = true; // 设置重定向标志 // 将 http 替换为 https url = redirectUrl.replace(/^http:\/\//, "https://"); } else if (response.status !== 200) { console.dir(`文章页面状态码异常:Url: ${url} Response Status: ${response.status}`); // 这里的检测无意义,反而导致用户体验变差 // 因为有些文章页面会返回521状态码,但实际可以下载 // if (response.status === 521) { // console.dir( // `检查文章 ${url} 时状态码异常:${response.status},有风控的可能性。` // ); // } else { // await uiManager.showConfirmDialog( // `检查文章 ${url} 时状态码异常:${response.status},是否继续下载?\n(这里只是用HEAD方法预先检查了一下,也许下载时会成功)`, // () => { // // 用户点击确认后,继续下载 // console.dir(`用户确认继续下载: Url: ${url}`); // }, // () => { // const newError = new Error( // `文章页面状态码异常:Url: ${url} Response Status: ${response.status}` // ); // reject(newError); // } // ); // } } else { console.dir(`文章页面检查成功:${url}`); } onCheckPassed(); // 检测通过,开始下载 }, onerror: function (error) { console.dir(`检查文章页面失败: Url: ${url}. Original error: ${error.message || error}`); const newError = new Error( `检查文章页面失败:Url: ${url}. Original error: ${error.message || error}` ); newError.stack = error.stack; reject(error); }, }); }); } /** * 从URL批量下载文章 * @param {string} url - 文章URL * @param {number} index - 文件前缀 * @param {Object} config - 配置选项 * @param {boolean} config.fastDownload - 是否快速下载 * @return {Promise<void>} * @throws {Error} 如果下载失败或解析文章时出错 */ async downloadOneArticleFromBatch(url, index, total, config = {}) { return new Promise(async (resolve, reject) => { try { const newConfig = { ...config, // 传入其他配置选项 articleUrl: url, fileIndex: index, fileTotal: total, }; if (config.fastDownload) { const response = await (await fetch(url)).text(); // const response = await this.fileManager.fetchResource( // url, // "text/html", // config.downloadAssetRetryCount || 3, // config.downloadAssetRetryDelay || 1000, // "fetch" // ); const parser = new DOMParser(); const doc = parser.parseFromString(response, "text/html"); // 调用解析函数 await this.parseArticle(doc.body, newConfig); } else { await this.downloadArticleInIframe(url, newConfig); } } catch (error) { reject(error); } if (config.delayBetweenDownloads && config.delayBetweenDownloads > 0) { setTimeout(() => resolve(), config.delayBetweenDownloads); } else { resolve(); } }); } /** * 下载专栏的全部文章为Markdown格式 * @param {Object} config - 配置选项 * @param {boolean} config.zipCategories - 是否将文章打包成zip * @param {string} config.filePrefix - 文件名前缀 * @param {string} config.articleUrl - 文章链接 * @param {number} config.maxConcurrentDownloads - 最大并发下载数 * @param {boolean} config.parallelDownload - 是否并行下载 * @param {boolean} config.fastDownload - 是否快速下载 * @param {boolean} config.mergeArticleContent - 是否合并文章内容 * @param {boolean} config.saveAllImagesToAssets - 是否将所有图片保存 * @param {boolean} config.addSerialNumber - 是否添加序号 * @param {boolean} config.addSerialNumberToTitle - 是否在标题前添加序号 * @param {boolean} config.addArticleInfoInBlockquote - 是否在引用块中添加文章信息 * @param {boolean} config.addArticleTitleToMarkdown - 是否在Markdown中添加文章标题 * @param {boolean} config.addArticleInfoInYaml - 是否在YAML中添加文章信息 * @param {boolean} config.saveWebImages - 是否保存网络图片 * @param {boolean} config.forceImageCentering - 是否强制图片居中 * @param {boolean} config.enableImageSize - 是否启用图片尺寸 * @param {boolean} config.enableColorText - 是否启用彩色文本 * @param {boolean} config.removeCSDNSearchLink - 是否移除CSDN搜索链接 * @param {boolean} config.addArticleInfoInBlockquote_batch - 是否在批量下载时在引用块中添加文章信息 **/ async downloadCategory(config = {}) { // 获取专栏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} 篇文章。开始解析...`); } // FIX: 解决自定义域名在Chrome里下载专栏时,专栏和文章hostname不一致导致跨域问题 // https://github.com/Qalxry/csdn2md/issues/7 // 专栏的 url 为 https://blog.csdn.net/{user_id}/category_{category_id}.html // 文章的 url 为 https://{custom_domain}.blog.csdn.net/article/details/{article_id} // // > 方案1:将文章的 url 替换为 https://blog.csdn.net/{user_id}/article/details/{article_id} // 会引发 CSDN 的安全策略问题,废弃 // if (base_url.startsWith("https://blog.csdn.net/")) { // const user_id = base_url.match(/blog\.csdn\.net\/([^\/]+)/)[1]; // for (let i = 0; i < url_list.length; i++) { // if (!url_list[i].startsWith("https://blog.csdn.net/")) { // const article_id = url_list[i].match(/\/article\/details\/([^\/]+)/)[1]; // url_list[i] = `https://blog.csdn.net/${user_id}/article/details/${article_id}`; // } // } // } // > 方案2:将专栏的 url 替换为 https://{custom_domain}.blog.csdn.net/category_{category_id}.html // 虽然有效,但不确定是否稳定,目前来看可以 let isAllArticlesCustomDomain = true; let isAllArticlesDefaultDomain = true; for (let i = 0; i < url_list.length; i++) { if (url_list[i].startsWith("https://blog.csdn.net/")) { isAllArticlesCustomDomain = false; break; } } for (let i = 0; i < url_list.length; i++) { if (!url_list[i].startsWith("https://blog.csdn.net/")) { isAllArticlesDefaultDomain = false; break; } } if (isAllArticlesCustomDomain) { // 如果全部文章都是自定义域名,则将专栏的 url 替换为 https://{custom_domain}.blog.csdn.net/category_{category_id}.html if (base_url.startsWith("https://blog.csdn.net/")) { console.dir( `Warning: 文章与专栏的域名不一致,正在将专栏的URL替换为自定义域名。当前专栏URL: ${base_url} 文章URL: ${url_list[0]}` ); const custom_domain = url_list[0].match(/https:\/\/([^\/]+)\.blog\.csdn\.net/)[1]; GM_setValue("status", { timestamp: Date.now(), action: "downloadCategory", targetUrl: `https://${custom_domain}.blog.csdn.net/category_${category_id}.html`, }); window.location.href = `https://${custom_domain}.blog.csdn.net/category_${category_id}.html`; } } else if (isAllArticlesDefaultDomain) { // 如果全部文章都是默认域名,则将专栏的 url 替换为 https://blog.csdn.net/category_{category_id}.html if (!base_url.startsWith("https://blog.csdn.net/")) { console.dir( `Warning: 文章与专栏的域名不一致,正在将专栏的URL替换为默认域名。当前专栏URL: ${base_url} 文章URL: ${url_list[0]}` ); const user_id = url_list[0].match(/blog\.csdn\.net\/([^\/]+)/)[1]; GM_setValue("status", { timestamp: Date.now(), action: "downloadCategory", targetUrl: `https://blog.csdn.net/${user_id}/category_${category_id}.html`, }); window.location.href = `https://blog.csdn.net/${user_id}/category_${category_id}.html`; } } else { // 如果文章的域名不一致,则回退为方案1,至少可能可以下载 console.dir( `Warning: 文章与专栏的域名不一致,可能无法下载。请检查是否有自定义域名。当前专栏URL: ${base_url}` ); if (base_url.startsWith("https://blog.csdn.net/")) { const user_id = base_url.match(/blog\.csdn\.net\/([^\/]+)/)[1]; for (let i = 0; i < url_list.length; i++) { if (!url_list[i].startsWith("https://blog.csdn.net/")) { const article_id = url_list[i].match(/\/article\/details\/([^\/]+)/)[1]; url_list[i] = `https://blog.csdn.net/${user_id}/article/details/${article_id}`; } } } } if (config.enableStreaming) { this.uiManager.showFloatTip("正在初始化流式下载..."); await this.fileManager.initializeZipStream( `${document.title}`, (fileName, fileIndex) => {}, (zipStreamName, zipStreamFileCount, zipStreamSize) => { this.uiManager.showFloatTip( `打包完成:${zipStreamName}。文件数量:${zipStreamFileCount},文件大小:${Utils.formatFileSize( zipStreamSize )}\n请等待下载完成。`, 3000 ); }, async (error) => { const newError = new Error(`流式下载中出现错误:${error.message || error}`); newError.stack = error.stack || new Error().stack; this.uiManager.showFloatTip(`流式下载中出现错误:${newError.message}`); await this.uiManager.showConfirmDialog( `出现错误:${newError.message}。是否忽略错误继续下载?`, () => { this.uiManager.showFloatTip("已忽略错误,继续下载。"); }, () => { throw newError; } ); } ); } // 下载每篇文章 const totalArticleCount = url_list.length; if (config.endArticleIndex < 1) { this.uiManager.showFloatTip(`结束文章索引 ${config.endArticleIndex} 小于1,将不下载任何文章。`, 3000); return; } else if (config.startArticleIndex > totalArticleCount) { this.uiManager.showFloatTip( `开始文章索引 ${config.startArticleIndex} 超过总文章数 ${totalArticleCount},将不下载任何文章。`, 3000 ); return; } else if (config.startArticleIndex > config.endArticleIndex) { this.uiManager.showFloatTip( `开始文章索引 ${config.startArticleIndex} 大于结束文章索引 ${config.endArticleIndex},将不下载任何文章。`, 3000 ); return; } else { this.uiManager.showFloatTip( `开始下载文章:从第 ${Math.max(1, config.startArticleIndex)} 篇到第 ${Math.min( totalArticleCount, config.endArticleIndex )} 篇,共 ${url_list.length} 篇。(总文章数:${totalArticleCount})` ); } const taskCount = Math.min(url_list.length, config.endArticleIndex) - Math.max(1, config.startArticleIndex) + 1; if (taskCount >= 100 && config.parallelDownload && !config.fastDownload) { let continueDownload = true; await this.uiManager.showDialog( { title: "ℹ️ 提示", message: `检测到文章数量超过100篇(将要下载${taskCount}篇,总${url_list.length}篇),\n使用并行下载可能会导致CSDN风控或者内存溢出。\n建议改用串行(慢些)或者启用快速模式(避免内存溢出崩溃)。\n请注意,继续当前模式预计需要${Utils.formatFileSize( taskCount * 30 * 1024 * 1024 )}内存。`, }, { text: "取消下载", type: "default", callback: () => { this.uiManager.showFloatTip("已取消下载。", 3000, 3000); continueDownload = false; // 取消下载 }, }, { text: "取消并行,使用串行", type: "primary", callback: () => { config.parallelDownload = false; // 串行下载 this.uiManager.showFloatTip("已切换为串行下载。"); }, }, { text: "启用快速模式", type: "primary", callback: () => { config.fastDownload = true; // 启用快速下载 this.uiManager.showFloatTip("已启用快速下载模式。"); }, }, { text: "继续使用并行下载", type: "danger", callback: () => { this.uiManager.showFloatTip("继续使用并行下载。"); }, } ); if (!continueDownload) { return; // 如果用户取消下载,则退出 } } await Utils.parallelPool( url_list, async (url, index) => { const articleIndex = totalArticleCount - index; // 反向 if (articleIndex >= config.startArticleIndex && articleIndex <= config.endArticleIndex) { await this.downloadOneArticleFromBatch(url, articleIndex, totalArticleCount, config); } }, config.parallelDownload ? config.maxConcurrentDownloads : 1 ); if (config.mergeArticleContent) { let extraTopContent = ""; if (config.addArticleTitleToMarkdown) { extraTopContent += `# ${document.title}\n\n`; } if (config.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); extraTopContent += `> ${batchDesc}\n> ${batchAuthor} ${batchColumnData}\n${batchUrl}\n\n`; } this.fileManager.mergeTextFile(`${document.title}`, extraTopContent); } if (config.enableStreaming) { this.uiManager.showFloatTip("正在等待流式压缩完成,请稍候..."); // endStream参数为true,表示结束流式压缩,直接下载 await this.fileManager.addAllFilesInQueueToZipStream(true); } else { if (config.zipCategories) { await this.fileManager.zipAllFilesInQueue( `${document.title}`, (info_string) => { this.uiManager.showFloatTip(info_string); }, (info_string) => { this.uiManager.showFloatTip(info_string, 3000); }, // zip 库选用:fflate / jszip config.zipLibrary || "fflate" ); } await this.fileManager.downloadAllFilesInQueue(); } this.uiManager.showFloatTip("专栏文章全部处理完毕,请等待下载结束。", 3000); } /** * 下载用户的全部文章为Markdown格式 * @param {Object} config - 配置选项 * @param {boolean} config.zipCategories - 是否将文章打包成zip * @param {string} config.filePrefix - 文件名前缀 * @param {string} config.articleUrl - 文章链接 * @param {number} config.maxConcurrentDownloads - 最大并发下载数 * @param {boolean} config.parallelDownload - 是否并行下载 * @param {boolean} config.fastDownload - 是否快速下载 * @param {boolean} config.mergeArticleContent - 是否合并文章内容 * @param {boolean} config.saveAllImagesToAssets - 是否将所有图片保存 * @param {boolean} config.addSerialNumber - 是否添加序号 * @param {boolean} config.addSerialNumberToTitle - 是否在标题前添加序号 * @param {boolean} config.addArticleInfoInBlockquote - 是否在引用块中添加文章信息 * @param {boolean} config.addArticleTitleToMarkdown - 是否在Markdown中添加文章标题 * @param {boolean} config.addArticleInfoInYaml - 是否在YAML中添加文章信息 * @param {boolean} config.saveWebImages - 是否保存网络图片 * @param {boolean} config.forceImageCentering - 是否强制图片居中 * @param {boolean} config.enableImageSize - 是否启用图片尺寸 * @param {boolean} config.enableColorText - 是否启用彩色文本 * @param {boolean} config.removeCSDNSearchLink - 是否移除CSDN搜索链接 * @param {boolean} config.addArticleInfoInBlockquote_batch - 是否在批量下载时在引用块中添加文章信息 **/ async downloadUserAllArticles(config = {}) { const mainContent = document.body.querySelector(".mainContent"); const url_list = []; // 获取用户原始ID // <link rel="canonical" href="https://blog.csdn.net/yanglfree"> const getUrlListFromAPI = async () => { let user_id = document.querySelector("link[rel='canonical']")?.href.match(/\/([^\/]+)$/)?.[1]; if (!user_id) { console.dir(`Warning: 无法从canonical链接中获取用户ID。`); user_id = document.querySelector(".blog-second-rss-btn a")?.href.match(/\/([^\/]+)\/rss/)?.[1]; if (!user_id) { console.dir(`Warning: 无法从RSS链接中获取用户ID。`); throw new Error("无法获取用户ID,请检查页面是否正确。"); } } // 使用 API 获取文章列表 // https://blog.csdn.net/community/home-api/v1/get-business-list?page=1&size=20&businessType=blog&orderby=&noMore=false&year=&month=&username=yanglfree const temp_url_list = []; let total_articles = 0; let page = 1; do { console.dir( `正在获取第 ${page} 页文章链接: https://blog.csdn.net/community/home-api/v1/get-business-list?page=${page}&size=100&businessType=blog&orderby=&noMore=false&year=&month=&username=${user_id}` ); // const response = await ( // await fetch( // `https://blog.csdn.net/community/home-api/v1/get-business-list?page=${page}&size=100&businessType=blog&orderby=&noMore=false&year=&month=&username=${user_id}` // ) // ).json(); const response = await this.fileManager.fetchResource( `https://blog.csdn.net/community/home-api/v1/get-business-list?page=${page}&size=100&businessType=blog&orderby=&noMore=false&year=&month=&username=${user_id}`, "json", config.downloadAssetRetryCount || 3, config.downloadAssetRetryDelay || 1000, "fetch" ); if (total_articles === 0) total_articles = response.data.total; if (response.data.list.length === 0) break; temp_url_list.push(...response.data.list.map((item) => item.url)); console.dir( `获取到第 ${page} 页 ${response.data.list.length} 篇文章链接 (${temp_url_list.length} / ${total_articles}):` ); this.uiManager.showFloatTip( `获取到第 ${page} 页 ${response.data.list.length} 篇文章链接 (${temp_url_list.length} / ${total_articles}):` ); page++; } while (temp_url_list.length < total_articles); return temp_url_list; }; try { const res = await getUrlListFromAPI(); if (res.length === 0) { console.dir(`从API获取文章列表失败,尝试从页面获取文章链接。`); } else { url_list.push(...res); console.dir(`从API获取到 ${url_list.length} 篇文章链接。`); } } catch (error) { console.dir(`从API获取文章列表失败,尝试从页面获取文章链接。${error.message || error}`); } // 如果API获取失败,则从页面获取文章链接 if (url_list.length === 0) { // 滚回顶部 window.scrollTo({ top: 0, behavior: "smooth", }); this.uiManager.showFloatTip("正在获取用户全部文章链接。可能需要进行多次页面滚动,请耐心等待。"); 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} 篇文章。开始解析...`); } // FIX: 解决自定义域名在Chrome里下载用户主页时,用户主页和文章hostname不一致导致跨域问题 // https://github.com/Qalxry/csdn2md/issues/7 // 用户主页的 url 为 https://blog.csdn.net/{user_id} + 可能存在的 ?type=xxx // 文章的 url 为 https://{custom_domain}.blog.csdn.net/article/details/{article_id} // // > 方案1:将文章的 url 替换为 https://blog.csdn.net/{user_id}/article/details/{article_id} // 会引发 CSDN 的安全策略问题,废弃 // if (base_url.startsWith("https://blog.csdn.net/")) { // const user_id = base_url.match(/blog\.csdn\.net\/([^\/]+)/)[1]; // for (let i = 0; i < url_list.length; i++) { // if (!url_list[i].startsWith("https://blog.csdn.net/")) { // const article_id = url_list[i].match(/\/article\/details\/([^\/]+)/)[1]; // url_list[i] = `https://blog.csdn.net/${user_id}/article/details/${article_id}`; // } // } // } // > 方案2:将用户主页的 url 替换为 https://{custom_domain}.blog.csdn.net + /?type=xxx // 虽然有效,但不确定是否稳定,目前来看可以 const base_url = window.location.href; let isAllArticlesCustomDomain = true; let isAllArticlesDefaultDomain = true; for (let i = 0; i < url_list.length; i++) { if (url_list[i].startsWith("https://blog.csdn.net/")) { isAllArticlesCustomDomain = false; break; } } for (let i = 0; i < url_list.length; i++) { if (!url_list[i].startsWith("https://blog.csdn.net/")) { isAllArticlesDefaultDomain = false; break; } } if (isAllArticlesCustomDomain) { // 如果全部文章都是自定义域名,则将用户主页的 url 替换为 https://{custom_domain}.blog.csdn.net/category_{category_id}.html if (base_url.startsWith("https://blog.csdn.net/")) { console.dir( `Warning: 文章与用户主页的域名不一致,正在将用户主页的URL替换为自定义域名。当前用户主页URL: ${base_url} 文章URL: ${url_list[0]}` ); const custom_domain = url_list[0].match(/https:\/\/([^\/]+)\.blog\.csdn\.net/)[1]; GM_setValue("status", { timestamp: Date.now(), action: "downloadUserAllArticles", targetUrl: `https://${custom_domain}.blog.csdn.net/?type=blog`, }); window.location.href = `https://${custom_domain}.blog.csdn.net/?type=blog`; } } else if (isAllArticlesDefaultDomain) { // 如果全部文章都是默认域名,则将用户主页的 url 替换为 https://blog.csdn.net/category_{category_id}.html if (!base_url.startsWith("https://blog.csdn.net/")) { console.dir( `Warning: 文章与用户主页的域名不一致,正在将用户主页的URL替换为默认域名。当前用户主页URL: ${base_url} 文章URL: ${url_list[0]}` ); const user_id = url_list[0].match(/blog\.csdn\.net\/([^\/]+)/)[1]; GM_setValue("status", { timestamp: Date.now(), action: "downloadUserAllArticles", targetUrl: `https://blog.csdn.net/${user_id}?type=blog`, }); window.location.href = `https://blog.csdn.net/${user_id}?type=blog`; } } else { // 如果文章的域名不一致,则回退为方案1,至少可能可以下载 console.dir( `Warning: 文章与用户主页的域名不一致,可能无法下载。请检查是否有自定义域名。当前用户主页URL: ${base_url}` ); if (base_url.startsWith("https://blog.csdn.net/")) { const user_id = base_url.match(/blog\.csdn\.net\/([^\/]+)/)[1]; for (let i = 0; i < url_list.length; i++) { if (!url_list[i].startsWith("https://blog.csdn.net/")) { const article_id = url_list[i].match(/\/article\/details\/([^\/]+)/)[1]; url_list[i] = `https://blog.csdn.net/${user_id}/article/details/${article_id}`; } } } } if (config.enableStreaming) { this.uiManager.showFloatTip("正在初始化流式下载..."); await this.fileManager.initializeZipStream( `${document.title}`, (fileName, fileIndex) => {}, (zipStreamName, zipStreamFileCount, zipStreamSize) => { this.uiManager.showFloatTip( `打包完成:${zipStreamName}。文件数量:${zipStreamFileCount},文件大小:${Utils.formatFileSize( zipStreamSize )}\n请等待下载完成。`, 3000 ); }, async (error) => { const newError = new Error(`流式下载中出现错误:${error.message || error}`); newError.stack = error.stack || new Error().stack; this.uiManager.showFloatTip(`流式下载中出现错误:${newError.message}`); await this.uiManager.showConfirmDialog( `出现错误:${newError.message}。是否忽略错误继续下载?`, () => { this.uiManager.showFloatTip("已忽略错误,继续下载。"); }, () => { throw newError; } ); } ); } // 下载每篇文章 const totalArticleCount = url_list.length; if (config.endArticleIndex < 1) { this.uiManager.showFloatTip(`结束文章索引 ${config.endArticleIndex} 小于1,将不下载任何文章。`, 3000); return; } else if (config.startArticleIndex > totalArticleCount) { this.uiManager.showFloatTip( `开始文章索引 ${config.startArticleIndex} 超过总文章数 ${totalArticleCount},将不下载任何文章。`, 3000 ); return; } else if (config.startArticleIndex > config.endArticleIndex) { this.uiManager.showFloatTip( `开始文章索引 ${config.startArticleIndex} 大于结束文章索引 ${config.endArticleIndex},将不下载任何文章。`, 3000 ); return; } else { this.uiManager.showFloatTip( `开始下载文章:从第 ${Math.max(1, config.startArticleIndex)} 篇到第 ${Math.min( totalArticleCount, config.endArticleIndex )} 篇,共 ${url_list.length} 篇。(总文章数:${totalArticleCount})` ); } const taskCount = Math.min(url_list.length, config.endArticleIndex) - Math.max(1, config.startArticleIndex) + 1; if (taskCount >= 100 && config.parallelDownload && !config.fastDownload) { let continueDownload = true; await this.uiManager.showDialog( { title: "ℹ️ 提示", message: `检测到文章数量超过100篇(将要下载${taskCount}篇,总${url_list.length}篇),\n使用并行下载可能会导致CSDN风控或者内存溢出。\n建议改用串行(慢些)或者启用快速模式(避免内存溢出崩溃)。\n请注意,继续当前模式预计需要${Utils.formatFileSize( taskCount * 30 * 1024 * 1024 )}内存。`, }, { text: "取消下载", type: "default", callback: () => { this.uiManager.showFloatTip("已取消下载。", 3000); continueDownload = false; // 取消下载 }, }, { text: "取消并行,使用串行", type: "primary", callback: () => { config.parallelDownload = false; // 串行下载 this.uiManager.showFloatTip("已切换为串行下载。"); }, }, { text: "启用快速模式", type: "primary", callback: () => { config.fastDownload = true; // 启用快速下载 this.uiManager.showFloatTip("已启用快速下载模式。"); }, }, { text: "继续使用并行下载", type: "danger", callback: () => { this.uiManager.showFloatTip("继续使用并行下载。"); }, } ); if (!continueDownload) { return; // 如果用户取消下载,则退出 } } await Utils.parallelPool( url_list, async (url, index) => { const articleIndex = totalArticleCount - index; // 反向 if (articleIndex >= config.startArticleIndex && articleIndex <= config.endArticleIndex) { await this.downloadOneArticleFromBatch(url, articleIndex, totalArticleCount, config); } }, config.parallelDownload ? config.maxConcurrentDownloads : 1 ); if (config.mergeArticleContent) { let extraTopContent = ""; if (config.addArticleTitleToMarkdown) { extraTopContent += `# ${document.title}\n\n`; } if (config.addArticleInfoInBlockquote_batch) { extraTopContent += `> ${Utils.clearUrl(window.location.href)}\n\n`; } // 下载每篇文章 this.fileManager.mergeTextFile(`${document.title}`, extraTopContent); } if (config.enableStreaming) { this.uiManager.showFloatTip("正在等待流式压缩完成,请稍候..."); // endStream参数为true,表示结束流式压缩,直接下载 await this.fileManager.addAllFilesInQueueToZipStream(true); } else { if (config.zipCategories) { await this.fileManager.zipAllFilesInQueue( `${document.title}`, (info_string) => { this.uiManager.showFloatTip(info_string); }, (info_string) => { this.uiManager.showFloatTip(info_string, 3000); }, // zip 库选用:fflate / jszip config.zipLibrary || "fflate" ); } await this.fileManager.downloadAllFilesInQueue(); } this.uiManager.showFloatTip("用户全部文章处理完毕,请等待下载结束。", 3000); } /** * 下载单篇文章 * @param {Object} config - 配置选项 * @param {boolean} config.zipCategories - 是否将文章打包成zip * @param {string} config.enableStreaming - 是否启用流式下载 * @param {string} config.filePrefix - 文件名前缀 * @param {string} config.articleUrl - 文章链接 * @param {boolean} config.parallelDownload - 是否并行下载 * @param {boolean} config.fastDownload - 是否快速下载 * @param {boolean} config.mergeArticleContent - 是否合并文章内容 * @param {boolean} config.saveAllImagesToAssets - 是否将所有图片保存 * @param {boolean} config.addSerialNumber - 是否添加序号 * @param {boolean} config.addSerialNumberToTitle - 是否在标题前添加序号 * @param {boolean} config.addArticleInfoInBlockquote - 是否在引用块中添加文章信息 * @param {boolean} config.addArticleTitleToMarkdown - 是否在Markdown中添加文章标题 * @param {boolean} config.addArticleInfoInYaml - 是否在YAML中添加文章信息 * @param {boolean} config.saveWebImages - 是否保存网络图片 * @param {number} config.downloadAssetRetryCount - 下载网络图片的重试次数 * @param {boolean} config.forceImageCentering - 是否强制图片居中 * @param {boolean} config.enableImageSize - 是否启用图片尺寸 * @param {boolean} config.enableColorText - 是否启用彩色文本 * @param {boolean} config.removeCSDNSearchLink - 是否移除CSDN搜索链接 **/ async downloadSingleArticle(config = {}) { const articleTitle = await this.parseArticle(document.body, { articleUrl: window.location.href, ...config, mergeArticleContent: false, enableStreaming: false, // 单篇文章下载不支持流式下载 }); if (config.zipCategories) { await this.fileManager.zipAllFilesInQueue( `${articleTitle}`, (info_string) => { this.uiManager.showFloatTip(info_string); }, (info_string) => { this.uiManager.showFloatTip(info_string, 3000); }, // zip 库选用:fflate / jszip config.zipLibrary || "fflate" ); } await this.fileManager.downloadAllFilesInQueue(); this.uiManager.showFloatTip("文章下载完毕!", 4000); } } /** * 判断当前页面类型 * @returns {"category"|"article"|"user_all_articles"|"unknown"} */ function getCurrentPageType() { const url = window.location.href; if (url.includes("category")) { return "category"; } else if (url.includes("article/details")) { return "article"; } else if ( url.includes("type=blog") || url.includes("type=lately") || url.match(/^https:\/\/[^.]+\.blog\.csdn\.net\/$/) || url.match(/^https:\/\/blog\.csdn\.net\/[^\/]+\/?$/) ) { return "user_all_articles"; } else { return "unknown"; } } // 初始化应用 function initApp() { // 确保在目标页面 if (getCurrentPageType() === "unknown") { console.dir({ message: "当前页面不是CSDN文章页面、专栏文章列表页面或用户全部文章列表页面,脚本不会执行。", url: window.location.href, }); return; } // 初始化App const uiManager = new UIManager(); // 检查是否有下载任务 const status = GM_getValue("status"); if ( status && status.timestamp && status.action && status.targetUrl && Date.now() - status.timestamp < 5 * 60 * 1000 // 检查下载任务是否在5分钟内 ) { GM_setValue("status", null); // 清除下载任务状态 if ( status.action === "downloadCategory" && Utils.clearUrl(status.targetUrl) === Utils.clearUrl(window.location.href) ) { // 如果有下载任务,直接跳转到下载页面 console.dir(`检测到下载任务,开始自动下载专栏文章: ${status.targetUrl}`); uiManager.showFloatTip(`检测到下载任务,开始自动下载专栏文章: ${status.targetUrl}`); uiManager.downloadButton.click(); } else if ( status.action === "downloadUserAllArticles" && Utils.clearUrl(status.targetUrl) === Utils.clearUrl(window.location.href) ) { // 如果有下载任务,直接跳转到下载页面 console.dir(`检测到下载任务,开始自动下载用户全部文章: ${status.targetUrl}`); uiManager.showFloatTip(`检测到下载任务,开始自动下载用户全部文章: ${status.targetUrl}`); uiManager.downloadButton.click(); } else { console.dir( `检测到下载任务,但当前页面与任务目标页面不一致,跳过自动下载。当前页面:${window.location.href} 任务目标页面:${status.targetUrl}` ); uiManager.showFloatTip( `检测到下载任务,但当前页面与任务目标页面不一致,跳过自动下载。当前页面:${window.location.href} 任务目标页面:${status.targetUrl}`, 5000 ); } } } // 启动应用 initApp(); })();