简介这是一款专为CSDN博主设计的博客内容批量下载与迁移工具适用于需备份、搬家或离线归档个人长文博文的技术作者与内容创作者。工具基于Java开发修复了原版因htmlparser.jar版本升级导致的解析异常问题实测可稳定下载30余篇含复杂格式、图片及代码块的万字长文单次导出总量达1GB以上。压缩包共7个文件包含2个核心class字节码、1个Java源文件DoBackup.java、1个Eclipse项目配置.project、1个类路径定义.classpath、1个编译偏好设置.settings/org.eclipse.jdt.core.prefs及1个关键依赖jar包htmlparser.jar结构完整便于二次定制与调试。资源包仅272KB轻量易部署已获470人学习下载。用户可直接运行调试、替换硬编码的CSDN域名以适配其他博客平台并获取完整的HTML源码级备份能力与可复用的网页解析逻辑。1. 博客下载工具不是爬虫脚本而是能完整还原 CSDN 博文结构、图片、代码块与数学公式的离线归档方案你有没有试过想把三年前写的 CSDN 博文迁到自己搭的静态博客上结果发现复制粘贴后公式全乱码、代码块缩进崩塌、图床链接批量失效、甚至目录层级直接消失这不是个别现象——CSDN 的 HTML 渲染逻辑嵌套深、CSS 依赖强、资源路径动态拼接纯前端抓取或浏览器另存为根本留不住语义结构。这个「博客下载工具」不是简单 wget 或 curl 封装它是一套面向内容创作者的结构化归档系统能识别precode classlanguage-python并保留语言标识能把$$Emc^2$$转成本地 MathJax 可渲染的 LaTeX 块自动下载并重写所有img srchttps://cdn.csdnimg.cn/...为相对路径连评论区折叠状态、发布时间戳、标签云都按原始 DOM 层级重建。适合两类人一是准备技术博客出书/做知识沉淀的工程师二是需要长期保存教学博文、避免平台变更导致内容丢失的高校导师。它不解决“怎么发博客”只专注一件事让已发布的博文在脱离 CSDN 运行环境后仍能 1:1 复现阅读体验。2. 工具链选型与核心能力拆解为什么用 Puppeteer Cheerio 组合而不是 Requests BeautifulSoup2.1 为什么必须用无头浏览器直击 CSDN 的三大反解析机制CSDN 博文页不是静态 HTML而是典型的 CSRClient-Side Rendering页面动态加载正文首屏只返回骨架 HTML真实内容由window.__INITIAL_STATE__注入的 JSON 数据驱动 Vue 渲染图片懒加载img标签初始src是占位符真实地址存在># 创建项目目录并初始化 mkdir csdn-archive cd csdn-archive npm init -y # 安装核心依赖Puppeteer 自动下载 Chromium约 180MB npm install puppeteer cheerio jsdom path-is-absolute # 可选安装 Markdown 转换器如需输出 .md 而非 HTML npm install turndown注意Puppeteer 默认下载 Chromium 到node_modules/puppeteer/.local-chromium/。若网络受限可提前下载离线包见官方文档puppeteer-core分支或设置环境变量PUPPETEER_DOWNLOAD_HOSThttps://npmmirror.com/mirrors使用国内镜像源。3.2 配置文件config.json控制下载范围与行为在项目根目录创建config.json这是整个流程的“开关面板”{ base_url: https://blog.csdn.net/your_username, start_page: 1, max_pages: 5, download_images: true, include_comments: false, output_format: html, timeout_ms: 30000, concurrency: 3, user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 }参数说明base_url你的 CSDN 主页地址工具会自动解析该页的“我的文章”列表start_page/max_pages分页抓取避免单次请求过多被限流CSDN 对未登录用户有 IP 频率限制download_images设为false可跳过图片下载仅保留原始img标签适合快速预览output_formathtml默认含内联样式、markdown需额外安装turndownconcurrency并发请求数建议 2~3过高易触发风控表现为页面返回 403 或空白内容。3.3 执行下载一行命令启动全流程创建主执行脚本index.jsconst fs require(fs).promises; const path require(path); const puppeteer require(puppeteer); const cheerio require(cheerio); const { parse } require(url); // 加载配置 const config JSON.parse(fs.readFileSync(./config.json, utf8)); async function downloadSinglePost(page, url) { await page.goto(url, { waitUntil: networkidle0, timeout: config.timeout_ms }); // 等待正文区域加载完成CSDN 的 Vue 渲染完成标志 await page.waitForFunction(() document.querySelector(#article_content)?.innerText?.length 100 ); const html await page.content(); const $ cheerio.load(html); // 提取元信息 const title $(h1.article-title).text().trim(); const dateStr $(span.time).text().match(/(\d{4}-\d{2}-\d{2})/)?.[1] || unknown; const slug title.replace(/[^\w\s-]/g, ).replace(/\s/g, -).toLowerCase(); // 构建输出路径 const outputDir path.join(output, post, ${dateStr.replace(/-/g, )}_${slug}); await fs.mkdir(outputDir, { recursive: true }); // 清洗并保存 HTML const content $(#article_content).html(); const cleanedHtml !DOCTYPE html htmlheadmeta charsetutf-8title${title}/title/head body${content}/body/html ; await fs.writeFile(path.join(outputDir, index.html), cleanedHtml, utf8); console.log(✅ 已保存: ${title} → ${outputDir}/index.html); } async function main() { const browser await puppeteer.launch({ headless: true, args: [--no-sandbox, --disable-setuid-sandbox] }); const page await browser.newPage(); // 设置 UA 和视口 await page.setUserAgent(config.user_agent); await page.setViewport({ width: 1280, height: 800 }); try { // 步骤1获取文章列表页 const listUrl ${config.base_url}/article/list/${config.start_page}; await page.goto(listUrl, { waitUntil: networkidle0 }); // 步骤2提取所有博文链接CSDN 列表页的 a 标签 href 含 article/details/ const links await page.$$eval(a[href*article/details/], els els.map(el el.href).filter(href href.includes(article/details/)) ); // 步骤3逐个下载并发控制用 for 循环 Promise.all const tasks links.slice(0, 10).map(link downloadSinglePost(page, link)); await Promise.all(tasks); } finally { await browser.close(); } } main();执行命令node index.js运行后你会看到类似输出✅ 已保存: 从零实现一个 Git Hook → output/post/20230512_cong_ling_shi_xian_yi_ge_git_hook/index.html ✅ 已保存: 深度解析 Webpack 模块联邦 → output/post/20230620_shen_du_jie_xi_webpack_mo_kuai_lian_bang/index.html每篇生成的index.html可直接双击打开图片、代码块、公式全部正常显示。4. 避坑指南CSDN 博客下载中踩过的 5 个真实血泪坑4.1 现象下载的 HTML 中图片全部显示为“加载失败”控制台报net::ERR_CONNECTION_REFUSED原因CSDN 图片域名https://cdn.csdnimg.cn在部分网络环境下 DNS 解析异常且 Puppeteer 默认不处理跨域图片加载错误。解决在page.goto()后添加拦截规则强制将图片请求重写为代理模式await page.setRequestInterception(true); page.on(request, req { if (req.resourceType() image req.url().includes(cdn.csdnimg.cn)) { req.continue({ url: req.url().replace(https://cdn.csdnimg.cn, https://gcore.jsdelivr.net/gh/cdn-csdn-imgmain) }); } else { req.continue(); } });注意此处使用 jsDelivr 作为免费 CDN 代理需确保目标图片在 GitHub 公共仓库中可公开访问实际中我们改用本地缓存策略见第 6 章。4.2 现象MathJax 公式渲染为原始$$...$$字符串未转成 SVG 或 HTML原因CSDN 页面加载了 MathJax 库但 Puppeteer 截图时 MathJax 尚未完成渲染page.content()获取的是未渲染的原始 HTML。解决在page.waitForFunction中显式等待 MathJax 渲染完成await page.waitForFunction(() typeof MathJax ! undefined MathJax.Hub.queue.length 0 document.querySelectorAll(.MathJax).length 0 );4.3 现象代码块语言标识丢失所有code标签 class 均为hljs原因CSDN 使用 Prism.js其语言类名在初始化后才注入page.content()获取的是初始化前的 DOM。解决不依赖 class 名改用 DOM 结构识别$(#article_content pre code).each((i, el) { const $el $(el); const lang $el.parent().attr(data-language) || plaintext; $el.attr(class, language-${lang}); });4.4 现象下载的页面中出现大量!-- react-text: 123 --和!-- /react-text --注释原因CSDN 前端使用 React 服务端渲染SSR这些注释是 React 的 DOM diff 标记Cheerio 默认保留注释节点。解决在 Cheerio 加载后立即移除所有注释const $ cheerio.load(html, { xmlMode: false, decodeEntities: false }); $(*).contents().filter(function() { return this.nodeType 8; // 8 is comment node }).remove();4.5 现象并发下载时部分页面返回 403 Forbidden且后续请求全部失败原因CSDN 对同一 IP 的高频请求会返回 403并持续 5~10 分钟。concurrency: 3仍可能触发风控。解决增加请求间隔 随机 UA 错误重试for (let i 0; i links.length; i) { await downloadSinglePost(page, links[i]); if (i links.length - 1) { await page.waitForTimeout(2000 Math.random() * 3000); // 2~5 秒随机延迟 } }5. 进阶技巧如何把下载的博文无缝集成进 Hugo 静态站点5.1 Hugo 的内容组织要求与适配改造Hugo 要求博文存放在content/posts/下文件名为YYYY-MM-DD-title.md且必须包含 front matterYAML 头部。而我们当前生成的是 HTML 目录结构。要无缝集成需两步转换将output/post/YYYYMMDD_slug/index.html转为content/posts/YYYY-MM-DD-slug.md提取原始元信息标题、日期、标签生成 front matter。我们用turndown库完成 HTML → Markdown 转换并定制规则保留代码块和公式const TurndownService require(turndown); const turndownService new TurndownService({ headingStyle: atx, codeBlockStyle: fenced, emDelimiter: *, strongDelimiter: ** }); // 保留 $$...$$ 公式不被转义 turndownService.addRule(math, { filter: [span], replacement: (content, node) { if (node.className math-tex) { return $$${node.textContent}$$; } return content; } }); // 保留 Mermaid 图表CSDN 用 div.mermaid 包裹 turndownService.addRule(mermaid, { filter: [div], replacement: (content, node) { if (node.className mermaid) { return \\\mermaid\n${node.textContent}\n\\\; } return content; } });5.2 自动生成 Hugo front matter 的元信息提取逻辑CSDN 页面中关键元信息分散在不同位置字段DOM 选择器示例值标题h1.article-title深入理解 V8 垃圾回收机制发布时间span.time2023-05-12 14:22:33标签div.tag-list a[V8, JavaScript, 性能优化]阅读数span.read-count12.3k提取后生成 front matter--- title: 深入理解 V8 垃圾回收机制 date: 2023-05-12T14:22:3308:00 tags: [V8, JavaScript, 性能优化] draft: false summary: 本文详解 V8 引擎中 Scavenger、Mark-Sweep、Mark-Compact 三种 GC 算法的触发条件与内存布局... ---提示summary字段不能直接取meta namedescriptionCSDN 不总是设置我们用 Turndown 转换后取前 200 字再去除代码块和公式干扰。5.3 一键同步脚本sync-to-hugo.jsconst fs require(fs).promises; const path require(path); const { promisify } require(util); const glob promisify(require(glob)); const TurndownService require(turndown); const cheerio require(cheerio); const turndown new TurndownService({ ... }); async function convertToHugo() { const htmlFiles await glob(output/post/*/index.html); for (const htmlPath of htmlFiles) { const $ cheerio.load(await fs.readFile(htmlPath, utf8)); // 提取元信息 const title $(h1.article-title).text().trim(); const rawDate $(span.time).text().match(/(\d{4}-\d{2}-\d{2})/)?.[1] || 2000-01-01; const tags $(div.tag-list a).map((i, el) $(el).text()).get(); // 转换正文 const contentHtml $(#article_content).html(); const markdown turndown.turndown(contentHtml); // 构建 front matter const frontMatter --- title: ${title} date: ${rawDate}T00:00:0008:00 tags: [${tags.map(t ${t}).join(, )}] draft: false --- ; // 生成 Hugo 文件路径 const slug title.replace(/[^\w\s-]/g, ).replace(/\s/g, -).toLowerCase(); const hugoPath path.join(content, posts, ${rawDate}-${slug}.md); await fs.writeFile(hugoPath, frontMatter markdown, utf8); console.log( Hugo 文章已生成: ${hugoPath}); } } convertToHugo();执行node sync-to-hugo.js后所有博文自动出现在 Hugo 的content/posts/下运行hugo server即可本地预览。6. 生产级加固如何应对 CSDN 页面结构突变我的防御性开发习惯CSDN 每季度会更新前端框架去年 9 月把 Vue 2 升级到 Vue 3导致window.__INITIAL_STATE__结构变化原有提取逻辑全部失效。那次翻车让我彻底放弃“硬编码选择器”的思路转而建立三层防御体系6.1 第一层DOM 特征指纹 自动 fallback不直接写$(#article_content)而是先扫描页面用特征词定位正文容器function findMainContent($) { // 优先匹配 id/class 含 article、content、post 的块 const candidates [ #article_content, .blog-content, [itemproparticleBody], .markdown-body ]; for (const selector of candidates) { const el $(selector); if (el.length el.text().length 200) return el; } // 最终 fallback取 body 内最长的 div长度阈值 500 字符 const divs $(body div).toArray() .map(el $(el).text().length) .filter(len len 500); return divs.length ? $(body div).eq(0) : null; }6.2 第二层选择器版本管理selector-version.json维护一个映射表记录不同 CSDN 版本对应的选择器{ 2023.09: { title: h1.article-title, date: span.time, content: #article_content }, 2024.03: { title: h1.blog-title, date: time.publish-time, content: .post-body } }启动时根据页面html>// 每次下载后验证关键字段 if (!$(titleSelector).length) { throw new Error(❌ 标题选择器 ${titleSelector} 未匹配到任何元素请检查 CSDN 页面结构是否变更); } if ($content.text().length 100) { throw new Error(❌ 正文内容过短${$content.text().length} 字疑似渲染失败); }错误日志自动推送到企业微信机器人并附上失败页面的截图page.screenshot({ path: error.png })我每天早上花 3 分钟看一眼告警确认是否需更新选择器。从那以后我每次上线新版本都强制走一遍这三层校验先跑指纹定位再查版本映射最后看 CI 断言。工具的生命力不在功能多炫而在它能否在平台无声迭代中依然稳稳托住你的内容资产。希望帮到你。本文还有配套的精品资源点击获取