🎉 欢迎访问GreasyFork.Org 镜像站!本镜像站由公众号【爱吃馍】搭建,用于分享脚本。联系邮箱📮

Greasy fork 爱吃馍镜像

csdn2md - 批量下载CSDN文章为Markdown

下载CSDN文章为Markdown格式,支持专栏批量下载。CSDN排版经过精心调教,最大程度支持CSDN的全部Markdown语法:KaTeX内联公式、KaTeX公式块、图片、内联代码、代码块、Bilibili视频控件、有序/无序/任务/自定义列表、目录、注脚、加粗斜体删除线下滑线高亮、内容居左/中/右、引用块、链接、快捷键(kbd)、表格、上下标、甘特图、UML图、FlowChart流程图

Versión del día 13/1/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

// ==UserScript==
// @name         csdn2md - 批量下载CSDN文章为Markdown
// @namespace    http://tampermonkey.net/
// @version      1.1.6
// @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_xmlhttpRequest
// @run-at       document-end
// @license      PolyForm Strict License 1.0.0  https://polyformproject.org/licenses/strict/1.0.0/
// @supportURL   https://github.com/Qalxry/csdn2md
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// ==/UserScript==

(function () {
    "use strict";

    /**
     * 显示悬浮提示框。
     * @param {string} text - 提示框的文本内容。
     */
    function showFloatTip(text, timeout = 0) {
        if (document.getElementById("myInfoFloatTip")) {
            document.getElementById("myInfoFloatTip").remove();
        }
        const floatTip = document.createElement("div");
        floatTip.style.position = "fixed";
        floatTip.style.top = "40%";
        floatTip.style.left = "50%";
        floatTip.style.transform = "translateX(-50%)";
        floatTip.style.padding = "10px";
        floatTip.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
        floatTip.style.color = "#fff";
        floatTip.style.borderRadius = "5px";
        floatTip.style.boxShadow = "0 2px 5px rgba(0, 0, 0, 0.5)";
        floatTip.style.zIndex = "9999";
        floatTip.innerHTML = text;
        floatTip.id = "myInfoFloatTip";
        document.body.appendChild(floatTip);

        if (timeout > 0) {
            setTimeout(() => {
                hideFloatTip();
            }, timeout);
        }
    }

    /**
     * 隐藏悬浮提示框。
     */
    function hideFloatTip() {
        if (document.getElementById("myInfoFloatTip")) {
            document.getElementById("myInfoFloatTip").remove();
        }
    }

    // 创建悬浮窗
    const floatWindow = document.createElement("div");
    floatWindow.style.position = "fixed";
    floatWindow.style.bottom = "20px";
    floatWindow.style.right = "20px";
    floatWindow.style.padding = "10px";
    floatWindow.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
    floatWindow.style.color = "#fff";
    floatWindow.style.borderRadius = "5px";
    floatWindow.style.boxShadow = "0 2px 5px rgba(0, 0, 0, 0.5)";
    floatWindow.style.zIndex = "9999";
    floatWindow.style.display = "flex";
    floatWindow.style.flexDirection = "column"; // 里面的元素每个占一行
    floatWindow.id = "myFloatWindow";

    // 创建下载按钮
    const downloadButton = document.createElement("button");
    downloadButton.innerHTML = "下载CSDN文章为Markdown<br>(支持专栏、文章、用户全部文章页面)<br>(推荐使用typora打开下载的Markdown)";
    downloadButton.style.textAlign = "center";
    downloadButton.style.padding = "5px 10px";
    downloadButton.style.border = "none";
    downloadButton.style.backgroundColor = "#4CAF50";
    downloadButton.style.color = "white";
    downloadButton.style.borderRadius = "3px";
    downloadButton.style.cursor = "pointer";
    downloadButton.id = "myDownloadButton";
    // 为下载按钮添加hover效果
    downloadButton.addEventListener("mouseover", function () {
        downloadButton.style.backgroundColor = "#45a049";
    });
    downloadButton.addEventListener("mouseout", function () {
        downloadButton.style.backgroundColor = "#4CAF50";
    });
    // 将按钮添加到悬浮窗
    floatWindow.appendChild(downloadButton);

    // 创建选项 checkbox
    // 从油猴脚本中获取选项的值
    // - 专栏高速下载模式(会导致乱序,但可通过下面的加入序号排序)
    // - 专栏文章文件加入序号前缀
    // - 将专栏文章整合为压缩包
    const optionDivList = [];
    const optionCheckBoxList = [];

    function updateAllOptions() {
        optionCheckBoxList.forEach((optionElem) => {
            optionElem.checked = GM_getValue(optionElem.id.replace("Checkbox", ""));
        });
    }

    function addOption(id, innerHTML, defaultValue = false, constraints = {true: null, false: null}) {
        if (GM_getValue(id) === undefined) {
            GM_setValue(id, defaultValue);
        }
        const checked = GM_getValue(id);
        const optionDiv = document.createElement("div");
        optionDiv.style.display = "flex";
        optionDiv.style.alignItems = "left";
        const optionCheckbox = document.createElement("input");
        optionCheckbox.type = "checkbox";
        optionCheckbox.checked = checked;
        optionCheckbox.id = id + "Checkbox";
        optionCheckbox.style.marginRight = "5px";
        const optionLabel = document.createElement("label");
        optionLabel.htmlFor = optionCheckbox.id;
        optionLabel.textContent = innerHTML;
        optionLabel.style.marginRight = "10px";
        optionDiv.appendChild(optionCheckbox);
        optionDiv.appendChild(optionLabel);
        optionDivList.push(optionDiv);
        optionCheckBoxList.push(optionCheckbox);
        floatWindow.appendChild(optionDiv);
        optionCheckbox.addEventListener("change", function () {
            GM_setValue(id, optionCheckbox.checked);
            if (optionCheckbox.checked) {
                if (constraints.true !== null) {
                    for (const constraint of constraints.true) {
                        if (constraint.id !== undefined && constraint.value !== undefined) {
                            GM_setValue(constraint.id, constraint.value);
                        }
                    }
                    updateAllOptions();
                }
            } else {
                if (constraints.false !== null) {
                    for (const constraint of constraints.false) {
                        if (constraint.id !== undefined && constraint.value !== undefined) {
                            GM_setValue(constraint.id, constraint.value);
                        }
                    }
                    updateAllOptions();
                }
            }
        });
    }

    addOption("parallelDownload", "批量并行下载模式(下载乱序,但可以添加前缀弥补)", false);
    addOption("fastDownload", "批量高速下载模式(有代码块语言无法识别等问题,能接受就开)", false);
    addOption("addSerialNumber", "批量文章文件加入序号前缀", false);
    addOption("zipCategories", "下载为压缩包", true, {false: [{id: "saveWebImages", value: false}]});
    addOption("addArticleInfoInYaml", "添加文章元信息(以YAML元信息格式)", false);
    addOption("addArticleTitleToMarkdown", "添加文章标题(以一级标题形式)", true);
    addOption("addArticleInfoInBlockquote", "添加阅读量、点赞等信息(以引用块形式)", true);
    addOption("saveWebImages", "将图片保存到与MD文件同名的文件夹内,以相对路径使用", true, {true: [{id: "zipCategories", value: true}]});
    addOption("forceImageCentering", "全部图片居中排版", false);
    addOption("enableImageSize", "启用图片宽高属性(如果网页中的图片具有宽高)", true);
    addOption("removeCSDNSearchLink", "移除CSDN搜索链接", true);
    addOption("enableColorText", "启用彩色文字(以span形式保存)", true);

    function enableFloatWindow() {
        downloadButton.disabled = false;
        downloadButton.innerHTML = "下载CSDN文章为Markdown<br>(支持专栏、文章、用户全部文章页面)<br>(推荐使用typora打开下载的Markdown)";
        optionCheckBoxList.forEach((optionElem) => {optionElem.disabled = false;});
    }

    function disableFloatWindow() {
        downloadButton.disabled = true;
        downloadButton.innerHTML = "正在下载,请稍候...";
        optionCheckBoxList.forEach((optionElem) => {optionElem.disabled = true;});
    }

    async function testMain() {
        // 1s
        await new Promise((resolve) => setTimeout(resolve, 1000));
        console.log("1s");
    }

    // 按钮点击事件
    downloadButton.addEventListener("click", async function () {
        await runMain();
        // await testMain();
    });

    document.body.appendChild(floatWindow);

    // 监听窗口的 focus 事件
    window.addEventListener('focus', function() {
        // 脚本选项可能在其他窗口中被修改,所以每次窗口获得焦点时都要重新加载
        updateAllOptions();
    });

    // 全局变量
    let fileQueue = [];

    /**
     * 将 SVG 图片转换为 Base64 编码的字符串。
     * @param {string} text - SVG 图片的文本内容。
     * @returns {string} - Base64 编码的字符串。
     */
    function svgToBase64(svgText) {
        const uint8Array = new TextEncoder().encode(svgText);
        const binaryString = uint8Array.reduce((data, byte) => data + String.fromCharCode(byte), "");
        return btoa(binaryString);
    }

    /**
     * 压缩HTML内容,移除多余的空白和换行符。
     * @param {string} html - 输入的HTML字符串。
     * @returns {string} - 压缩后的HTML字符串。
     */
    function shrinkHtml(html) {
        return html
            .replace(/>\s+</g, "><") // 去除标签之间的空白
            .replace(/\s{2,}/g, " ") // 多个空格压缩成一个
            .replace(/^\s+|\s+$/g, ""); // 去除首尾空白
    }

    /**
     * 清除字符串中的特殊字符。
     * @param {string} str - 输入的字符串。
     * @returns {string} - 清除特殊字符后的字符串。
     */
    function clearSpecialChars(str) {
        return str
            .replace(/[\s]{2,}/g, "")
            .replace(
                /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF\u00AD\u034F\u061C\u180E\u2800\u3164\uFFA0\uFFF9-\uFFFB]/g,
                ""
            );
    }
    
    /**
     * 依靠油猴脚本的 GM_xmlhttpRequest 方法获取网络资源。
     * @param {string} url - 网络资源的 URL。
     * @returns {Promise<Blob>} - 网络资源的 Blob 对象。
     */
    async function fetchImageAsBlob(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'blob',
                onload: function(response) {
                    if (response.status === 200) {
                        resolve(response.response);
                    } else {
                        reject(`Failed to fetch resource: ${url}`);
                    }
                },
                onerror: function() {
                    reject(`Error fetching resource: ${url}`);
                }
            });
        });
    }

    /**
     * 将网络图片添加到 fileQueue { filename, content, type },并返回本地路径。
     * 会将图片名称改为 articleTitle/count.后缀 的形式,这样在添加图片到 zip 的时候就会自动创建文件夹。
     * @param {string} imgUrl - 图片的网络路径。
     * @param {string} articleTitle - 文章标题。
     * @returns {Promise<string>} - 本地路径,格式为 ./articleTitle/图片名 。
     */
    async function saveWebImageToLocal(imgUrl, articleTitle, reset = false) {
        if (reset) {
            window.imageCount = {};
            window.imageSet = {};
            return;
        }

        // 检查参数是否合法
        if (typeof imgUrl !== "string") {
            showFloatTip("【ERROR】Invalid argument: imgUrl must be a string.");
            throw new Error("[saveWebImageToLocal] Invalid argument: imgUrl must be a string.");
        }

        // 去除 #pic_center
        imgUrl = imgUrl.replace("#pic_center", "");

        // 用于记录当前文章中的图片数量
        if (!window.imageCount) {
            window.imageCount = {};
            window.imageSet = {};
        }
        if (!window.imageCount[articleTitle]) {
            window.imageSet[articleTitle] = {};
            window.imageCount[articleTitle] = 0;
        }
        
        // 检查是否已保存过该图片
        if (window.imageSet[articleTitle][imgUrl]) {
            return window.imageSet[articleTitle][imgUrl];
        }

        // 记录图片数量
        window.imageCount[articleTitle]++;

        // 获取图片的 Blob 对象
        const blob = await fetchImageAsBlob(imgUrl);

        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 = `${articleTitle}/${window.imageCount[articleTitle]}${ext}`;
        fileQueue.push({ filename, content: blob, type: blob.type });

        // 记录已保存的图片
        window.imageSet[articleTitle][imgUrl] = `./${filename}`;

        // 返回本地路径
        return `./${filename}`;
    }


    /**
     * 将文件名转换为安全的文件名。(路径名中不允许的字符都替换为其对应的全角字符)
     * @param {string} filename - 原始文件名。
     * @returns {string} - 安全的文件名。
     */
    function safeFilename(filename) {
        return filename.replace(/[\\/:*?"<>|]/g, "_");
        // return filename
        //     .replace(/\//g, "/")
        //     .replace(/\\/g, "\")
        //     .replace(/:/g, ":")
        //     .replace(/\*/g, "*")
        //     .replace(/\?/g, "?")
        //     .replace(/"/g, """)
        //     .replace(/</g, "<")
        //     .replace(/>/g, ">")
        //     .replace(/\|/g, "|");
    }

    /**
     * 将文本保存为文件。但也支持先缓存到队列中,给后续打包为 zip 文件使用。
     * @param {string} content
     * @param {string} filename
     */
    async function saveTextAsFile(content, filename, setCount = -1) {
        if (setCount !== -1) {
            saveTextAsFile.count = setCount;
            return;
        }
        filename = safeFilename(filename);
        if (GM_getValue("addSerialNumber")) {
            if (saveTextAsFile.count !== undefined) {
                // filename = `${saveTextAsFile.count.toString().padStart(3, "0")}_${filename}`;
                filename = `${saveTextAsFile.count}_${filename}`;
                saveTextAsFile.count--;
            }
        }
        if (GM_getValue("zipCategories")) {
            // if (saveTextAsFile.queue === undefined) {
            //     saveTextAsFile.queue = [];
            // }
            // saveTextAsFile.queue.push({ text, filename });
            // 保存到队列中,等待打包
            fileQueue.push({ filename, type: "text/plain", content });
            return;
        }
        const blob = new Blob([content], { type: "text/plain" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    /**
     * 获取当前文章的序号。
     * @returns {number} - 当前文章的序号。
     */
    function getCurrentArticleIndex() {
        return saveTextAsFile.count;
    }

    /**
     * 从 queue 中,将所有 text 转换为 md 文件,并放入文件夹中,然后将文件夹打包为 zip 文件,最后下载 zip 文件。
     * @param {string} zipName - zip 文件名。
     * @returns {Promise<void>}
     */
    async function saveAllFileToZip(zipName) {
        if (fileQueue.length === 0) {
            showFloatTip("【ERROR】没有文件需要保存。");
            return;
        }
        zipName = safeFilename(zipName);
        // 创建 JSZip 实例
        const zip = new JSZip();

        fileQueue.forEach(file => {
            // 将文件添加到 ZIP 中
            zip.file(file.filename, file.content);
        })

        // 生成 ZIP 文件
        zip.generateAsync({ type: "blob" })
            .then(blob => {
                // 创建下载链接
                const url = URL.createObjectURL(blob);
                const a = document.createElement("a");
                a.href = url;
                a.download = `${zipName}.zip`;
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
    
                fileQueue = [];
            })
            .catch(error => {
                console.error("Error generating ZIP file:", error);
            });
    }

    /**
     * 将 HTML 内容转换为 Markdown 格式。
     * @param {Element} articleElement - 文章的 DOM 元素。
     * @param {string} markdownFileName - Markdown 文件名。
     * @returns {Promise<string>} - 转换后的 Markdown 字符串。
     */
    async function htmlToMarkdown(articleElement, markdownFileName = "") {
        // 辅助函数,用于转义特殊的 Markdown 字符
        const escapeMarkdown = (text) => {
            // return text.replace(/([\\`*_\{\}\[\]()#+\-.!])/g, "\\$1").trim();
            return text.trim();
        };

        /**
         * 递归处理 DOM 节点并将其转换为 Markdown。
         * @param {Node} node - 当前的 DOM 节点。
         * @param {number} listLevel - 当前列表嵌套级别。
         * @returns {Promise<string>} - 节点的 Markdown 字符串。
         */
        async function processNode(node, listLevel = 0) {
            let result = "";
            const ELEMENT_NODE = 1;
            const TEXT_NODE = 3;
            const COMMENT_NODE = 8;
            switch (node.nodeType) {
                case ELEMENT_NODE:
                    switch (node.tagName.toLowerCase()) {
                        case "h1":
                        case "h2":
                        case "h3":
                        case "h4":
                        case "h5":
                        case "h6":
                            {
                                const htype = Number(node.tagName[1]);
                                result += `${"#".repeat(htype)} ${node.textContent.trim()}\n\n`;
                            }
                            break;
                        case "p":
                            {
                                const style = node.getAttribute("style");
                                if (node.getAttribute("id") === "main-toc") {
                                    result += `**目录**\n\n[TOC]\n\n`;
                                    break;
                                }
                                let text = await processChildren(node, listLevel);
                                if (style) {
                                    if (style.includes("padding-left")) {
                                        break;
                                    }
                                    if (style.includes("text-align:center")) {
                                        // text = `<div style="text-align:center;">${text}</div>\n\n`;
                                        text = `<div style="text-align:center;">${shrinkHtml(node.innerHTML)}</div>\n\n`;
                                    } else if (style.includes("text-align:right")) {
                                        // text = `<div style="text-align:right;">${text}</div>\n\n`;
                                        text = `<div style="text-align:right;">${shrinkHtml(node.innerHTML)}</div>\n\n`;
                                    } else if (style.includes("text-align:justify")) {
                                        // text = `<div style="text-align:justify;">${text}</div>\n\n`;
                                        text += "\n\n";
                                    } else {
                                        text += "\n\n";
                                    }
                                } else {
                                    text += "\n\n";
                                }
                                result += text;
                            }
                            break;
                        case "strong":
                        case "b":
                            result += ` **${(await processChildren(node, listLevel)).trim()}** `;
                            break;
                        case "em":
                        case "i":
                            result += ` *${(await processChildren(node, listLevel)).trim()}* `;
                            break;
                        case "u":
                            result += ` <u>${(await processChildren(node, listLevel)).trim()}</u> `;
                            break;
                        case "s":
                        case "strike":
                            result += ` ~~${(await processChildren(node, listLevel)).trim()}~~ `;
                            break;
                        case "a":
                            {
                                const node_class = node.getAttribute("class");
                                if (node_class && node_class.includes("footnote-backref")) {
                                    break;
                                }
                                const href = node.getAttribute("href") || "";
                                if (node_class && node_class.includes("has-card")) {
                                    const desc = node.title || "";
                                    result += `[${desc}](${href}) `;
                                    break;
                                }
                                const text = await processChildren(node, listLevel);
                                if (href.includes("https://so.csdn.net/so/search") && GM_getValue("removeCSDNSearchLink")) {
                                    result += `${text}`;
                                    break;
                                }
                                result += ` [${text}](${href}) `;
                            }
                            break;
                        case "img":
                            {
                                let src = node.getAttribute("src") || "";
                                const alt = node.getAttribute("alt") || "";
                                const cls = node.getAttribute("class") || "";
                                const width = node.getAttribute("width") || "";
                                const height = node.getAttribute("height") || "";

                                if (cls.includes("mathcode")) {
                                    result += `$$\n${alt}\n$$`;
                                } else {
                                    if (src.includes("#pic_center") || GM_getValue("forceImageCentering")) {
                                        result += "\n\n";
                                    } else {
                                        result += " ";
                                    }
                                    if (GM_getValue("saveWebImages")) {
                                        src = await saveWebImageToLocal(src, markdownFileName);
                                    }
                                    if (width && height && GM_getValue("enableImageSize")) {
                                        // result += `<img src="${src}" alt="${alt}" width="${width}" height="${height}" />`;
                                        // result += `<img src="${src}" alt="${alt}" style="max-width:${width}px; max-height:${height}px; box-sizing:content-box;" />`;
                                        result += `<img src="${src}" alt="${alt}" style="max-height:${height}px; box-sizing:content-box;" />`;
                                    } else {
                                        result += `![${alt}](${src})`;
                                    }
                                }
                            }
                            break;
                        case "ul":
                            result += await processList(node, listLevel, false);
                            break;
                        case "ol":
                            result += await processList(node, listLevel, true);
                            break;
                        case "blockquote":
                            {
                                const text = (await processChildren(node, listLevel))
                                    .trim()
                                    .split("\n")
                                    .map((line) => (line ? `> ${line}` : "> "))
                                    .join("\n");
                                result += `${text}\n\n`;
                            }
                            break;
                        case "pre":
                            {
                                const codeNode = node.querySelector("code");
                                if (codeNode) {
                                    const className = codeNode.className || "";
                                    let language = "";
                                    // 新版本的代码块,class 含有 language-xxx
                                    if (className.includes("language-")) {
                                        // const languageMatch = className.match(/language-(\w+)/);
                                        // language = languageMatch ? languageMatch[0] : "";
                                        const languageMatch = className.split(" ");
                                        // 找到第一个 language- 开头的字符串
                                        for (const item of languageMatch) {
                                            if (item.startsWith("language-")) {
                                                language = item;
                                                break;
                                            }
                                        }
                                        language = language.replace("language-", "");
                                    }
                                    // 老版本的代码块
                                    else if (className.startsWith("hljs")) {
                                        const languageMatch = className.split(" ");
                                        language = languageMatch ? languageMatch[1] : "";
                                    }
                                    result += `\`\`\`${language}\n${await processCodeBlock(codeNode)}\`\`\`\n\n`;
                                } else {
                                    console.warn("Code block without <code> element:", node.outerHTML);
                                    const codeText = node.textContent.replace(/^\s+|\s+$/g, "");
                                    result += `\`\`\`\n${codeText}\n\`\`\`\n\n`;
                                }
                            }
                            break;
                        case "code":
                            {
                                const codeText = node.textContent;
                                result += ` \`${codeText}\` `;
                            }
                            break;
                        case "hr":
                            if (node.getAttribute("id") !== "hr-toc") {
                                result += `---\n\n`;
                            }
                            break;
                        case "br":
                            result += `  \n`;
                            break;
                        case "table":
                            result += await processTable(node) + "\n\n";
                            break;
                        // case 'iframe':
                        //     {
                        //         const src = node.getAttribute('src') || '';
                        //         const iframeHTML = node.outerHTML.replace('></iframe>', ' style="width: 100%; aspect-ratio: 2;"></iframe>'); // Ensure proper closing
                        //         result += `${iframeHTML}\n\n`;
                        //     }
                        //     break;
                        case "div":
                            {
                                const className = node.getAttribute("class") || "";
                                if (className.includes("csdn-video-box")) {
                                    // Handle video boxes or other specific divs
                                    // result += `<div>${processChildren(node, listLevel)}</div>\n\n`;

                                    // 不递归处理了,直接在这里进行解析
                                    const iframe = node.querySelector("iframe");
                                    const src = iframe.getAttribute("src") || "";
                                    const title = node.querySelector("p").textContent || "";
                                    const iframeHTML = iframe.outerHTML.replace(
                                        "></iframe>",
                                        ' style="width: 100%; aspect-ratio: 2;"></iframe>'
                                    ); // Ensure video box is full width
                                    result += `<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")) {
                                    const customTitle = node.querySelector("h4").textContent || "";
                                    result += `**${customTitle}**\n\n[TOC]\n\n`;
                                } else {
                                    result += await processChildren(node, listLevel);
                                }
                            }
                            break;
                        case "span":
                            {
                                const node_class = node.getAttribute("class");
                                if (node_class) {
                                    if (node_class.includes("katex--inline")) {
                                        // class="katex-mathml"
                                        const mathml = clearSpecialChars(
                                            node.querySelector(".katex-mathml").textContent
                                        );
                                        const katex_html = clearSpecialChars(
                                            node.querySelector(".katex-html").textContent
                                        );
                                        // result += ` $${mathml.replace(katex_html, "")}$ `;

                                        if (mathml.startsWith(katex_html)) {
                                            result += ` $${mathml.replace(katex_html, "")}$ `;
                                        } else {
                                            // 字符串切片,去掉 mathml 开头等同长度的 katex_html,注意不能用 replace,因为 katex_html 里的字符顺序可能会变
                                            result += ` $${mathml.slice(katex_html.length)}$ `;
                                        }
                                        break;
                                    } else if (node_class.includes("katex--display")) {
                                        const mathml = clearSpecialChars(
                                            node.querySelector(".katex-mathml").textContent
                                        );
                                        const katex_html = clearSpecialChars(
                                            node.querySelector(".katex-html").textContent
                                        );
                                        // result += `$$\n${mathml.replace(katex_html, "")}\n$$\n\n`;

                                        if (mathml.startsWith(katex_html)) {
                                            result += `$$\n${mathml.replace(katex_html, "")}\n$$\n\n`;
                                        } else {
                                            // 字符串切片,去掉 mathml 开头等同长度的 katex_html,注意不能用 replace,因为 katex_html 里的字符顺序可能会变
                                            result += `$$\n${mathml.slice(katex_html.length)}\n$$\n\n`;
                                        }
                                        break;
                                    }
                                }
                                const style = node.getAttribute("style") || "";
                                if ((style.includes("background-color") || style.includes("color")) && GM_getValue("enableColorText")) {
                                    result += `<span style="${style}">${await processChildren(node, listLevel)}</span>`;
                                } else {
                                    result += await processChildren(node, listLevel);
                                }
                            }
                            break;
                        case "kbd":
                            result += ` <kbd>${node.textContent}</kbd> `;
                            break;
                        case "mark":
                            result += ` <mark>${await processChildren(node, listLevel)}</mark> `;
                            break;
                        case "sub":
                            result += `<sub>${await processChildren(node, listLevel)}</sub>`;
                            break;
                        case "sup":
                            {
                                const node_class = node.getAttribute("class");
                                if (node_class && node_class.includes("footnote-ref")) {
                                    result += `[^${node.textContent}]`;
                                } else {
                                    result += `<sup>${await processChildren(node, listLevel)}</sup>`;
                                }
                            }
                            break;
                        case "svg":
                            {
                                const style = node.getAttribute("style");
                                if (style && style.includes("display: none")) {
                                    break;
                                }
                                // 必须为 foreignObject 里的 div 添加属性 xmlns="http://www.w3.org/1999/xhtml" ,否则 typora 无法识别
                                const foreignObjects = node.querySelectorAll("foreignObject");
                                for (const foreignObject of foreignObjects) {
                                    const divs = foreignObject.querySelectorAll("div");
                                    divs.forEach((div) => {
                                        div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
                                    });
                                }
                                // 检查是否有 style 标签存在于 svg 元素内,如果有,则需要将 svg 元素转换为 img 元素,用 Base64 编码的方式显示。否则直接返回 svg 元素
                                if (node.querySelector("style")) {
                                    const base64 = svgToBase64(node.outerHTML);
                                    // result += `<img src="data:image/svg;base64,${base64}" alt="SVG Image" />`;
                                    result += `![SVG Image](data:image/svg+xml;base64,${base64})\n\n`;
                                } else {
                                    result += `<div align="center">${node.outerHTML}</div>\n\n`;
                                }
                            }
                            break;
                        case "section": // 这个是注脚的内容
                            {
                                const node_class = node.getAttribute("class");
                                if (node_class && node_class.includes("footnotes")) {
                                    result += await processFootnotes(node);
                                }
                            }
                            break;
                        case "input":
                            // 仅处理 checkbox 类型的 input 元素
                            if (node.getAttribute("type") === "checkbox") {
                                result += `[${node.checked ? "x" : " "}] `;
                            }
                            break;
                        case "dl":
                            // 自定义列表,懒得解析了,直接用 html 吧
                            result += `${shrinkHtml(node.outerHTML)}\n\n`;
                            break;
                        case 'abbr':
                            result += `${shrinkHtml(node.outerHTML)}`;
                            break;
                        default:
                            result += await processChildren(node, listLevel);
                            result += "\n\n";
                            break;
                    }
                    break;
                case TEXT_NODE:
                    result += escapeMarkdown(node.textContent);
                    break;
                case COMMENT_NODE:
                    // Ignore comments
                    break;
                default:
                    break;
            }
            return result;
        }

        /**
         * 处理给定节点的子节点。
         * @param {Node} node - 父节点。
         * @param {number} listLevel - 当前列表嵌套级别。
         * @returns {Promise<string>} - 子节点拼接后的 Markdown 字符串。
         */
        async function processChildren(node, listLevel) {
            let text = "";
            for (const child of node.childNodes) {
                text += await processNode(child, listLevel);
            }
            return text;
        }

        /**
         * 处理列表元素 (<ul> 或 <ol>)。
         * @param {Element} node - 列表元素。
         * @param {number} listLevel - 当前列表嵌套级别。
         * @param {boolean} ordered - 列表是否有序。
         * @returns {Promise<string>} - 列表的 Markdown 字符串。
         */
        async function processList(node, listLevel, ordered) {
            let text = "";
            const children = Array.from(node.children).filter((child) => child.tagName.toLowerCase() === "li");
            text += "\n";
            for (let index = 0; index < children.length; index++) {
                const child = children[index];
                let prefix = ordered ? `${"   ".repeat(listLevel)}${index + 1}. ` : `${"  ".repeat(listLevel)}- `;
                const childText = (await processChildren(child, listLevel + 1)).trim();
                text += `${prefix}${childText}\n`;
            }
            text += `\n`;
            return text;
        }

        /**
         * 处理表格。
         * @param {Element} node - 包含表格的元素。
         * @returns {Promise<string>} - 表格的 Markdown 字符串。
         */
        async function processTable(node) {
            const rows = Array.from(node.querySelectorAll("tr"));
            if (rows.length === 0) return "";

            let table = "";

            // Process header
            const headerCells = Array.from(rows[0].querySelectorAll("th, td"));
            const headers = await Promise.all(headerCells.map(async (cell) => (await processNode(cell)).trim()));
            table += `| ${headers.join(" | ")} |\n`;

            // Process separator
            const alignments = headerCells.map((cell) => {
                const align = cell.getAttribute("align");
                if (align === "center") {
                    return ":---:";
                } else if (align === "right") {
                    return "---:";
                } else if (align === "left") {
                    return ":---";
                } else {
                    return ":---:";
                }
            });
            table += `|${alignments.join("|")}|\n`;

            // Process body
            for (let i = 1; i < rows.length; i++) {
                const cells = Array.from(rows[i].querySelectorAll("td"));
                const row = await Promise.all(cells.map(async (cell) => (await processNode(cell)).trim()));
                table += `| ${row.join(" | ")} |\n`;
            }

            return table;
        }

        /**
         * 处理代码块。有两种代码块,一种是老版本的代码块,一种是新版本的代码块。
         * @param {Element} node - 包含代码块的元素。一般是 <pre> 元素。
         * @returns {Promise<string>} - 代码块的 Markdown 字符串。
         */
        async function processCodeBlock(codeNode) {
            // 查找 code 内部是否有 ol 元素,这两个是老/新版本的代码块,需要分开处理
            const node = codeNode.querySelector("ol");

            // 确保传入的节点是一个 <ol> 元素
            if (!node || node.tagName.toLowerCase() !== "ol") {
                // console.error('Invalid node: Expected an <ol> element.');
                // return '';
                // 如果没有 ol 元素,则说明是老版本,直接返回 codeNode 的 textContent
                // return codeNode.textContent + '\n';

                // 如果尾部有换行符,则去掉
                return codeNode.textContent.replace(/\n$/, "") + "\n";
            }

            // 获取所有 <li> 子元素
            const listItems = node.querySelectorAll("li");
            let result = "";

            // 遍历每个 <li> 元素
            listItems.forEach((li, index) => {
                // 将 <li> 的 textContent 添加到结果中
                result += li.textContent;
                result += "\n";
            });

            return result;
        }

        /**
         * 处理脚注。
         * @param {Element} node - 包含脚注的元素。
         * @returns {Promise<string>} - 脚注的 Markdown 字符串。
         */
        async function 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 processNode(li)).replaceAll("\n", " ").replaceAll("↩︎", "").trim();
                result += `[^${index + 1}]: ${text}\n`;
            }

            return result;
        }

        let markdown = "";
        for (const child of articleElement.childNodes) {
            markdown += await processNode(child);
        }
        // markdown = markdown.replace(/[\n]{3,}/g, '\n\n');
        return markdown.trim();
    }

    /**
     * 下载文章内容并转换为 Markdown 格式。并保存为文件。这里会额外获取文章标题和文章信息并添加到 Markdown 文件的开头。
     * @param {Document} doc_body - 文章的 body 元素。
     * @returns {Promise<void>} - 下载完成后的 Promise 对象。
     */
    async function downloadCSDNArticleToMarkdown(doc_body, getZip = false, url = "") {
        const articleTitle = doc_body.querySelector("#articleContentId")?.textContent.trim() || "未命名文章";
        const articleInfo = doc_body.querySelector(".bar-content")?.textContent.replace(/\s{2,}/g, " ").trim() || "";
        const htmlInput = doc_body.querySelector("#content_views");
        if (!htmlInput) {
            alert("未找到文章内容。");
            return;
        }
        let mode = GM_getValue("parallelDownload") ? "并行" : "串行";
        mode += GM_getValue("fastDownload") ? "快速" : "完整";
        showFloatTip(`正在以${mode}模式下载文章:` + articleTitle);

        if (url === "") {
            url = window.location.href;
        }

        let serialNumber = "";
        if (GM_getValue("addSerialNumber")) {
            serialNumber = `${getCurrentArticleIndex()}_`;
        }
        let markdown = await htmlToMarkdown(htmlInput, serialNumber + articleTitle);

        if (GM_getValue("addArticleInfoInBlockquote")) {
            markdown = `> ${articleInfo}\n> 文章链接:${url}\n\n${markdown}`;
        }

        if (GM_getValue("addArticleTitleToMarkdown")) {
            markdown = `# ${articleTitle}\n\n${markdown}`;
        }
        
        if (GM_getValue("addArticleInfoInYaml")) {
            const article_info_box = doc_body.querySelector(".article-info-box");
            const meta_title = articleTitle;
            // 文字文字 YYYY-MM-DD HH:MM:SS 文字文字
            const meta_date = article_info_box.querySelector(".time")?.textContent.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)[0] || "";
            let articleMeta = `title: ${meta_title}\ndate: ${meta_date}\n`;

            // 文章分类
            const meta_category_and_tags = Array.from(article_info_box.querySelectorAll(".tag-link")) || [];
            if (meta_category_and_tags.length > 0 && article_info_box.textContent.includes("分类专栏")) {
                articleMeta += `categories:\n- ${meta_category_and_tags[0].textContent}\n`;
                meta_category_and_tags.shift();
            }
            if (meta_category_and_tags.length > 0 && article_info_box.textContent.includes("文章标签")) {
                articleMeta += `tags:\n${Array.from(meta_category_and_tags).map((tag) => `- ${tag.textContent}`).join("\n")}\n`;
            }
            markdown = `---\n${articleMeta}---\n\n${markdown}`;
        }

        // markdown = `# ${articleTitle}\n\n> ${articleInfo}\n\n${markdown}`;
        
        await saveTextAsFile(markdown, `${articleTitle}.md`);
        if (getZip) {
            await saveAllFileToZip(`${articleTitle}`);
        }
    }

    /**
     * 创建一个隐藏的 iframe 并下载指定 URL 的文章。
     * @param {string} url - 文章的 URL。
     * @returns {Promise<void>} - 下载完成后的 Promise 对象。
     */
    async function downloadArticleInIframe(url) {
        return new Promise((resolve, reject) => {
            // 创建一个隐藏的 iframe
            const iframe = document.createElement("iframe");
            iframe.style.display = "none";
            iframe.src = url;
            document.body.appendChild(iframe);

            // 监听 iframe 加载完成事件
            iframe.onload = async () => {
                try {
                    const doc = iframe.contentDocument || iframe.contentWindow.document;

                    // 调用下载函数
                    await downloadCSDNArticleToMarkdown(doc.body, false, url);

                    // 移除 iframe
                    document.body.removeChild(iframe);

                    resolve();
                } catch (error) {
                    // 在发生错误时移除 iframe 并拒绝 Promise
                    document.body.removeChild(iframe);
                    console.error("下载文章时出错:", error);
                    reject(error);
                }
            };

            // 监听 iframe 加载错误事件
            iframe.onerror = async () => {
                document.body.removeChild(iframe);
                console.error("无法加载文章页面:", url);
                reject(new Error("无法加载文章页面"));
            };
        });
    }

    async function downloadArticleFromCategory(url) {
        if (GM_getValue("fastDownload")) {
            const response = await fetch(url);
            const text = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(text, "text/html");
            // 调用下载函数
            await downloadCSDNArticleToMarkdown(doc.body, false, url);
        } else {
            await downloadArticleInIframe(url);
        }
    }

    /**
     * 下载专栏的全部文章为 Markdown 格式。
     * @returns {Promise<void>} - 下载完成后的 Promise 对象。
     */
    async function downloadCSDNCategoryToMarkdown() {
        // 获取专栏 id,注意 url 可能是 /category_数字.html 或 /category_数字_数字.html,需要第一个数字
        showFloatTip("开始下载专栏文章...");
        const base_url = window.location.href;
        const category_id = base_url.match(/category_(\d+)(?:_\d+)?\.html/)[1];
        let page = 1;
        const original_html = document.body.innerHTML;

        if (GM_getValue("addSerialNumber")) {
            const column_data = document.querySelector(".column_data").textContent;
            // 匹配:文章数:count\n
            const count = column_data.match(/文章数:(\d+)/)[1];
            await saveTextAsFile(count, "count.txt", count);
        }
        let doc_body = document.body;
        while (true) {
            // 获取当前页面的文章列表
            const url_list = [];
            doc_body.querySelector(".column_article_list").querySelectorAll("a").forEach((item) => {
                url_list.push(item.href);
            });

            if (url_list.length === 0) {
                break;
            }

            // 下载每篇文章
            if (GM_getValue("parallelDownload")) {
                await Promise.all(url_list.map((url) => downloadArticleFromCategory(url)));
            } else {
                for (const url of url_list) {
                    await downloadArticleFromCategory(url);
                }
            }

            // 下一页
            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();
            // document.body.innerHTML = text; // 更新页面内容
            const parser = new DOMParser();
            doc_body = parser.parseFromString(text, "text/html").body;
        }
        // document.body.innerHTML = original_html; // 恢复原始页面内容

        if (GM_getValue("zipCategories")) {
            await saveAllFileToZip(`${document.title}`);
        }

        showFloatTip("专栏文章全部下载完成。", 3000);
    }

    /**
     * 下载用户的全部文章为 Markdown 格式。
     * @returns {Promise<void>} - 下载完成后的 Promise 对象。
     */
    async function downloadAllArticlesOfUserToMarkdown() {
        showFloatTip("开始下载用户全部文章。可能需要进行多次页面滚动以获取全部文章链接,请耐心等待。");

        const mainContent = document.body.querySelector(".mainContent")
        const navListData = document.body.querySelector(".navList").textContent;
        const articleCount = navListData.match(/文章(\d+)/)[1];

        if (GM_getValue("addSerialNumber")) {
            await saveTextAsFile(null, null, articleCount);
        }
        
        const url_list = [];
        const url_set = new Set();

        while (true) {
            // 等待 2 秒,等待页面加载完成
            await new Promise((resolve) => setTimeout(resolve, 2000));
            window.scrollTo({
                top: document.body.scrollHeight,
                behavior: 'smooth' // 可选,使滚动平滑
            });
            let end = true;
            mainContent.querySelectorAll("article").forEach((item) => {
                const url = item.querySelector("a").href;
                if (!url_set.has(url)) {
                    url_list.push(url);
                    url_set.add(url);
                    end = false;
                }
            });
            if (end) break;
        }

        // 滚回顶部
        window.scrollTo({
            top: 0,
            behavior: 'smooth' // 可选,使滚动平滑
        });

        if (url_list.length === 0) {
            showFloatTip("没有找到文章。");
        }

        // 下载每篇文章
        if (GM_getValue("parallelDownload")) {
            await Promise.all(url_list.map((url) => downloadArticleFromCategory(url)));
        } else {
            for (const url of url_list) {
                await downloadArticleFromCategory(url);
            }
        }

        if (GM_getValue("zipCategories")) {
            await saveAllFileToZip(`${document.title}`);
        }

        showFloatTip("用户全部文章下载完成。", 3000);
    }

    /**
     * 主函数。点击下载按钮后执行。
     * @returns {Promise<void>} - 运行完成后的 Promise
     */
    async function runMain() {
        // 检查是专栏还是文章
        // 专栏的 url 里有 category
        // 文章的 url 里有 article/details
        disableFloatWindow();
        const url = window.location.href;
        if (url.includes("category")) {
            // 专栏
            await downloadCSDNCategoryToMarkdown();
        } else if (url.includes("article/details")) {
            // 文章
            await downloadCSDNArticleToMarkdown(document.body, GM_getValue("zipCategories"), window.location.href);
            showFloatTip("文章下载完成。", 3000);
        } else if (url.includes("type=blog")) {
            await downloadAllArticlesOfUserToMarkdown();
            showFloatTip("用户全部文章下载完成。", 3000);
        } else {
            alert("无法识别的页面。");
        }
        enableFloatWindow();
        saveWebImageToLocal(null, null, true);
    }
})();