简介一款用于CSDN博客批量下载与备份的Java工具面向需要将博客文章迁移到本地、做内容留存的博主以及想了解爬虫解析原理的Java开发者。工具基于htmlparser.jar实现页面抓取与解析作者在本地调试后已成功完成30多篇长文、总容量约1G的完整下载原始工程源码对CSDN域名存在写死逻辑如需备份其他博客平台统一替换域名即可复用同时作者也给出了htmlparser.jar升级后可能引发兼容性问题的排查思路。压缩包共7个文件以Java源码、编译后的class、Eclipse工程配置及htmlparser.jar核心库为主整体仅272KB体积小巧、结构精简便于快速定位关键代码。该资源已有470人学习/下载适合有一定Java基础、希望动手改造或学习博客抓取工具实现细节的读者研究参考。1. 博客下载工具把 CSDN 博文批量搬成 Markdown 的落地姿势CSDN博客搬家听着只是“把文章下载下来”真动手的人知道坑全在细节里一篇篇复制粘贴会丢掉代码块浏览器右键另存为会带回一屏推荐位和脚本图片散落在一堆同名文件夹里。正经做法是把这个过程脚本化——先拉列表拿到全部文章 ID再逐个抓详情、抽正文、下图片、生成 Markdown。这份博客下载工具就是按这个思路做的适合要从 CSDN 迁到本地笔记、Hexo 或别的平台的人。它解决的不只是“能不能抓”而是“抓下来之后能不能直接用”目录结构不乱图片在本地能打开代码块不裂断点能续跑。后面几章按抓取、解析、落地、避坑的顺序把完整流程拆开新手按步骤抄熟手可以直接看参数和边界。2. 先拆 CSDN 的数据源列表分页、详情正文与元数据边界任何博客下载器都跑不脱一个前提先搞清楚对方页面把内容放在哪。我拿到这套工具的第一件事不是写代码而是打开浏览器开发者工具Network 面板里完整走一遍“打开博主主页 → 滚动到底 → 点进一篇详情”。这步不是为了看热闹而是确定三件事列表用什么接口、正文藏在哪个容器、哪些区块无论如何都要扔掉。CSDN 页面结构改过很多版与其背选择器不如把抓包习惯固定下来。2.1 列表分页URL 参数、JSON 响应与退出条件博主主页的文章列表有两个世代。老版本是服务器渲染的 HTML分页地址形如/article/list/{page}直接可以在响应的article-list区块里抓文章的链接。新版本改成滚动加载首屏之后通过接口拿 JSON。两种我都见过脚本里不建议只认一种先请求一次页面看返回的是 HTML 还是 JSON再做分支。以这套资源里抓下来的接口为例新版列表接口长这样import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://blog.csdn.net/, } def fetch_page(username, page, session): # 优先用新版列表接口如果 404 或返回 HTML再退回 article/list 页面 api https://blog.csdn.net/community/home-api/v1/get-business-list params { page: page, size: 20, businessType: blog, username: username, } resp session.get(api, paramsparams, headersHEADERS, timeout10) if resp.headers.get(Content-Type, ).startswith(text/html): return parse_html_list(resp.text) return resp.json()这段代码里有几个参数要盯住。page从 1 开始还是 0 开始不同接口不一样先打印一页响应确认别想当然size决定单页数量CSDN 这边常见是 20调太大会触发风控username要用博主主页路径里那个英文 ID不是昵称否则接口返回空数组。返回的 JSON 通常是包了一层data.list里面每个元素至少包含articleId、title、publishTime、nickname有的版本还带url。退出条件也不能用“请求到某一个固定页就停”因为博主文章数量会变。更稳的做法是每次循环里检查返回列表是否为空或者 JSON 里是否带total字段用总数除以单页大小算出最大页两者取较小值。我一般会这么写def iter_all_articles(username, session, max_pagesNone): page 1 while True: data fetch_page(username, page, session) items data[data][list] if isinstance(data, dict) else [] if not items: break for item in items: yield item total data.get(data, {}).get(total, 0) if max_pages is None and total: max_pages (total 19) // 20 if max_pages and page max_pages: break page 1逻辑说明这个生成器把“分页”抹掉了外层调用者只需要for item in iter_all_articles(...)不用关心第几页。max_pages是保险丝脚本被风控挡住时接口可能返回空列表这时靠total判断会导致提前退出所以在配置文件里允许手动指定“这个博主差不多有 80 篇最多给我翻 10 页”。注意不要依赖articleId递增来判断终点CSDN 的老文章 ID 不一定按发布时间单调排列。2.2 详情正文从正文容器里抽内容而不是整页另存列表拿到的是“目录”接下来要对付详情页。详情页的 HTML 很大里面包含博主信息、右侧推荐、底部相关推荐、评论区脚本。直接存整页等于把一堆噪音搬回家后续做全文检索时全是无意义命中。正文主体一般落在#article_content或.markdown_views#content_views里不同模板换过几次名字脚本里要做多重 fallback。from bs4 import BeautifulSoup def extract_article(content_html): soup BeautifulSoup(content_html, html.parser) node ( soup.select_one(#content_views) or soup.select_one(.markdown_views) or soup.select_one(#article_content) ) if node is None: return None # 去掉正文里由页面注入的推荐位、版权声明、工具栏 for selector in (.recommend-box, .article-copyright, .csdn-side-toolbar, .more-toolbox, .person-info-bottom): for tag in node.select(selector): tag.decompose() return node参数说明select_one按顺序返回第一个命中的容器三个选择器是不同时期 CSDN 正文容器的常见 id/class新号博主和老号博主的页面模板可能不同所以 fallback 必须保留。.decompose()是从 BeautifulSoup 树里移除节点比clear()更彻底连子节点一起删除。这里删掉的区块按实际抓包结果调整比如版权声明你想保留就从删除列表里拿掉。拿到容器之后不要急着转 Markdown还有两个前置动作把所有img的>import requests import time import random session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/json;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.6, })参数说明Accept不要漏掉application/json因为列表接口返回 JSONAccept不对时服务端可能返回 HTML 错误页Accept-Language保持中文优先级CSDN 有根据语言返回不同模板的行为。如果你要抓付费专栏或者需要登录可见的文章才需要往 Session 里塞登录 Cookie只搬公开博文的话匿名加 UA 就够了。重试策略我建议放到 Session 层面统一管理。不要在每处请求写三层 try/except用requests.adapters.HTTPAdapter设置连接重试但对 HTTP 403/404 不重试。这样代码干净而且不会在风控时无限撞墙。from requests.adapters import HTTPAdapter adapter HTTPAdapter(max_retries2, pool_connections10, pool_maxsize10) session.mount(https://, adapter)pool_maxsize10配合后面的线程池连接复用不会成为瓶颈。max_retries2只在ConnectionError这类网络层错误时触发HTTP 状态码是 4xx/5xx 时它不重试避免放大风控。实际跑大批量时我倾向于把重试次数降到 1宁可漏一篇下次补也不要在 403 上反复等超时。3.2 列表抓取与去重先拿全部文章 ID 再干活常见误区是边遍历列表边下载详情。看起来省一个循环但中途断网时你不知道哪些已经写盘哪些还在内存里。正确的做法的确不是这样第一趟只把文章 ID、标题、URL 存进一个待办文件pending.jsonl第二趟再逐个下载详情。两趟分离之后断点续跑会变得非常简单。import json def build_pending(username, session, out_pathpending.jsonl): pending [] for article in iter_all_articles(username, session): pending.append({ articleId: article.get(articleId), title: article.get(title), url: fhttps://blog.csdn.net/{username}/article/details/{article.get(articleId)}, time: article.get(publishTime, ), }) # 按 articleId 去重同一个 ID 只保留一次 seen set() unique [] for item in pending: if item[articleId] in seen: continue seen.add(item[articleId]) unique.append(item) with open(out_path, w, encodingutf-8) as f: for item in unique: f.write(json.dumps(item, ensure_asciiFalse) \n) print(fpending 列表共 {len(unique)} 篇)逻辑说明pending.jsonl每行是一个 JSON 对象后面任何一步挂了只要这个文件在就能重新读取所有文章 URL 继续跑。去重逻辑用seen集合比if x not in list的写法快得多当博主文章上千时差别非常明显。URL 结构里的articleId是数字 ID部分老文章 URL 还带排序参数拼接时不要带参用详情页接口里的 ID 作为唯一键。3.3 正文清洗与代码块保护转换前先做占位html2text是常用的 HTML 转 Markdown 库但它对代码块的处理不够可靠代码块里的缩进可能被当成段落缩进丢掉lt;gt;这些实体可能保留在输出里。这里有个实用技巧转 Markdown 之前先把所有pre代码块抽出来替换成占位符转换完成后再把真实代码塞回去。import html2text import html def code_block_protect(node): placeholders {} for idx, pre in enumerate(node.select(pre)): code pre.find(code) raw code.get_text() if code else pre.get_text() raw html.unescape(raw) key fCODE_BLOCK_{idx} placeholders[key] raw pre.replace_with(key) return node, placeholders def restore_code_blocks(markdown_text, placeholders): for key, code in placeholders.items(): markdown_text markdown_text.replace(key, f\n\n{code}\n\n) return markdown_text参数说明pre.replace_with(...)把整个pre标签从解析树里替换成一个普通文本CODE_BLOCK_0这样 html2text 就不会再碰它。恢复时用三个反引号包起来语言标识可以根据code的class里的language-xxx提取。注意html.unescape要在抽取时做因为后面替换回 Markdown 时不会再走 HTML 解码。转换器实例要设置body_width0否则 html2text 会在固定宽度处插入换行破坏中文排版和代码。converter html2text.HTML2Text() converter.body_width 0 converter.ignore_links False converter.protect_links True md_body converter.handle(str(node)) md_body restore_code_blocks(md_body, placeholders)ignore_linksFalse是为了保留文章里的外链博客搬家时链接经常被丢掉很可惜。protect_linksTrue会让 html2text 用 Markdown 的尖括号包住链接避免超长 URL 在转换过程中被截断。如果你确定不要链接改ignore_linksTrue也行但线上文章里引用的参考链接很有价值搬完本地做知识库时顺着链接还能找回原文。3.4 图片下载与路径重写防盗链和相对路径一起处理图片是整个下载流程里最脆弱的一环。CSDN 的图片域名通常是img-blog.csdnimg.cn它在服务端校验Referer直接请求不带 Referer 会拿到 403。下载图片时要把Referer设为对应文章页并且用streamTrue边下边写避免大图占满内存。from pathlib import Path def download_image(session, url, save_path, referer): headers {Referer: referer} with session.get(url, headersheaders, streamTrue, timeout15) as resp: if resp.status_code ! 200: return False save_path.parent.mkdir(parentsTrue, exist_okTrue) with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size64 * 1024): f.write(chunk) return True参数说明chunk_size64 * 1024是 64KB太大会占内存太小会频繁 IO写文件时用with open确保异常时文件句柄释放。timeout15是单张图片的等待上限CSDN 图片服务器偶尔抽风超时后记日志跳过即可不要让整个线程卡死。图片落地之后要重写 Markdown 里的图片地址。原始 HTML 里图片形如img src...html2text 输出的是 Markdown 图片语法。要把本地能访问的相对路径替换进去import re def rewrite_image_paths(md_text, article_id, asset_root, session): def repl(match): alt match.group(1) url match.group(2) if csdnimg.cn not in url: return match.group(0) # 外站图片不动 ext Path(url.split(?)[0]).suffix or .jpg filename f{article_id}_{hash(url) 0x7fffffff:08x}{ext} save_path asset_root / str(article_id) / filename if not save_path.exists(): download_image(session, url, save_path, refererARTICLE_URL) return f}) return re.sub(r!\[([^\]]*)\]\((https?://[^)])\), repl, md_text)参数说明hash(url)取低 32 位做文件名后缀防止两篇文章里同名图片互相覆盖ext从 URL 里提取拿不到扩展名时默认.jpg。as_posix()把 Windows 路径反斜杠转成正斜杠保证 Markdown 在本地和线上渲染都不会断。外站图片不下载因为防盗链你不一定能绕过去而且外站图片可能随时失效本地化只保证 CSDN 这边的内容自洽。3.5 断点与索引跑一半断了也能接着来大博主上千篇文章一次抓完是不可能的。断点续跑靠的是记录状态文件。每下载完一篇文章就往done.json里写一条下次启动时先加载done把pending.jsonl里已经存在的 articleId 过滤掉剩下的继续。这样批量跑到第 800 篇时被限流睡一觉第二天接着跑不用从头再来。def load_done(pathdone.json): try: with open(path, encodingutf-8) as f: return set(json.load(f)) except FileNotFoundError: return set() def mark_done(article_id, done_filedone.json): done load_done(done_file) done.add(article_id) with open(done_file, w, encodingutf-8) as f: json.dump(sorted(done), f, ensure_asciiFalse)逻辑说明done.json只存 articleId 列表不存文章内容避免文件越来越大。sorted()写入是为了让 diff 工具查看时更直观并发场景下标记 done 的写操作要加锁否则两个线程同时写会把文件写坏。如果只用单线程下载这步可以不加锁。下载主流程其实很短把上面几块串起来def download_one(item, session): article_id item[articleId] html fetch_article_html(item[url], session) node extract_article(html) md convert_to_markdown(node) write_md_file(article_id, md, item) mark_done(article_id)最后把所有.md文件的路径、标题、时间汇总成一个index.json。这个索引后面做全文检索、增量更新都靠它比每次重新扫描目录快得多。生成时机放在批量下载结束后不要边下边写否则文件列表不完整。4. 避坑指南CSDN 博客搬运中的五个翻车现场下面五条是 CSDN 博客搬家最常踩的坑每一条我都在实际项目里见过翻车。按现象、原因、解决三个角度写能帮你省下至少一个通宵。4.1 只拿到前两页列表分页被风控拦了现象脚本日志显示page1抓到 20 条page2又抓到 20 条page3返回空列表退出循环。文件里最终只有 40 篇和博主主页显示的文章数对不上。原因有两个最常见。第一个是接口返回的 JSON 里没有total字段或total是已显示数量导致代码用total当终点第二个是未登录状态下服务端对列表接口做了风控连续请求几页后直接返回空数组但 HTTP 状态码仍是 200脚本误以为到底了。解决先抓包确认接口真实响应结构。尤其是看total是全量文章数还是当前页数量。代码里不要只信空列表还要监听连续空页次数连续两页为空才退出。另一个做法是先请求主页 HTML里面的 JSON 埋点会暴露文章总数articleCount用这个数字除以 size 得到预期页数实际抓取页数少于预期时打印告警。empty_page 0 while True: items fetch_page(...) if not items: empty_page 1 if empty_page 2: break continue empty_page 0 handle(items)这个代码的关键是不要一遇到空列表就 break先记连续次数。有时候接口抽风只有一页返空不立即判死。4.2 图片全部 403防盗链 Referer 没补现象文章正文下完Markdown 里图片地址看起来正常但双击本地文件打开图片全部裂图。单独 curl 图片 URL返回 403 Forbidden。原因CSDN 的图片 CDN 配置了防盗链只允许带 CSDN 站点 Referer 的请求通过。requests直接 GET 图片Referer 通常是https://blog.csdn.net/或缺失被 CDN 拒绝。这里有个迷惑点文章详情页能加载图片但脚本下载时 403因为浏览器里 Referer 是当前文章页而脚本带的是主页或没带。解决下载图片时把Referer设置为该文章页完整 URL或至少设置为https://blog.csdn.net/{username}这个栏目页。两者都能过校验但完整文章页更稳。另外要处理懒加载很多图片在 HTML 里的src是占位图真实地址在>def fix_lazy_load(soup): for img in soup.select(img[data-src]): img[src] img[data-src]这段直接放在正文抽取之后、图片下载之前。注意>rg -l 线程池 content/ rg CSDN博客搬家 content/ --type md -lrg -l只打印文件名内容里的代码块、中文、英文都可以命中比资源管理器的搜索快一个量级。配合index.json还能按标签筛选想找所有带“爬虫”标签的文章用 Python 读 JSON 过滤即可。5.2 增量更新与内容校验博主每隔几天发一篇新文章没必要每次全量重抓。增量逻辑很直接拉最新列表和done.json比对只下载新增的 articleId。已下载文章如果正文只有几百字可能是抓取异常单独打印出来人工复核。这个校验动作成本极低却能避免“搬完了才发现某篇文章正文是空白”。5.3 离线 HTML/PDF 打包想离线阅读或存档可以把 Markdown 转成 PDF。pandoc是常见选择注意中文要用 xelatex 引擎否则中文全是方块pandoc article.md -o article.pdf --pdf-enginexelatex -V mainfontNoto Serif CJK SC如果只是临时看看我更推荐转 HTML浏览器直接打印成 PDF排版和代码高亮都比命令行渲染更接近原站效果。转 HTML 时记得把图片相对路径和 Markdown 放在同一级目录别单独拷贝文件过去。从那以后我每次跑博客搬家都强制自己走一遍“pending → done → index → 抽查三篇”的流程。第一次犯懒跳过了抽查结果有一半文章正文里混进了页面底部的相关推荐后来花了两小时清理。现在宁可慢一点也要在批量结束后随机打开三篇检查代码块缩进和图片路径。这套流程看着简单能帮你避开我曾经翻过的车。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?