news 2026/10/29 1:01:15

CSDN博客批量下载工具:从网页抓取到Markdown导出的完整实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CSDN博客批量下载工具:从网页抓取到Markdown导出的完整实践

简介:一款用于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, params=params, headers=HEADERS, timeout=10) 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_pages=None): 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/xhtml+xml,application/json;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.6", })

参数说明:Accept不要漏掉application/json,因为列表接口返回 JSON,Accept不对时服务端可能返回 HTML 错误页;Accept-Language保持中文优先级,CSDN 有根据语言返回不同模板的行为。如果你要抓付费专栏或者需要登录可见的文章,才需要往 Session 里塞登录 Cookie;只搬公开博文的话,匿名加 UA 就够了。

重试策略我建议放到 Session 层面统一管理。不要在每处请求写三层 try/except,用requests.adapters.HTTPAdapter设置连接重试,但对 HTTP 403/404 不重试。这样代码干净,而且不会在风控时无限撞墙。

from requests.adapters import HTTPAdapter adapter = HTTPAdapter(max_retries=2, pool_connections=10, pool_maxsize=10) session.mount("https://", adapter)

pool_maxsize=10配合后面的线程池,连接复用不会成为瓶颈。max_retries=2只在ConnectionError这类网络层错误时触发,HTTP 状态码是 4xx/5xx 时它不重试,避免放大风控。实际跑大批量时,我倾向于把重试次数降到 1,宁可漏一篇下次补,也不要在 403 上反复等超时。

3.2 列表抓取与去重:先拿全部文章 ID 再干活

常见误区是边遍历列表边下载详情。看起来省一个循环,但中途断网时你不知道哪些已经写盘,哪些还在内存里。正确的做法的确不是这样:第一趟只把文章 ID、标题、URL 存进一个待办文件pending.jsonl,第二趟再逐个下载详情。两趟分离之后,断点续跑会变得非常简单。

import json def build_pending(username, session, out_path="pending.jsonl"): pending = [] for article in iter_all_articles(username, session): pending.append({ "articleId": article.get("articleId"), "title": article.get("title"), "url": f"https://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", encoding="utf-8") as f: for item in unique: f.write(json.dumps(item, ensure_ascii=False) + "\n") print(f"pending 列表共 {len(unique)} 篇")

逻辑说明:pending.jsonl每行是一个 JSON 对象,后面任何一步挂了,只要这个文件在,就能重新读取所有文章 URL 继续跑。去重逻辑用seen集合,比if x not in list的写法快得多;当博主文章上千时差别非常明显。URL 结构里的articleId是数字 ID,部分老文章 URL 还带排序参数,拼接时不要带参,用详情页接口里的 ID 作为唯一键。

3.3 正文清洗与代码块保护:转换前先做占位

html2text是常用的 HTML 转 Markdown 库,但它对代码块的处理不够可靠:代码块里的缩进可能被当成段落缩进丢掉,<>这些实体可能保留在输出里。这里有个实用技巧:转 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 = f"@@CODE_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_width=0,否则 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_links=False是为了保留文章里的外链,博客搬家时链接经常被丢掉,很可惜。protect_links=True会让 html2text 用 Markdown 的尖括号包住链接,避免超长 URL 在转换过程中被截断。如果你确定不要链接,改ignore_links=True也行,但线上文章里引用的参考链接很有价值,搬完本地做知识库时顺着链接还能找回原文。

3.4 图片下载与路径重写:防盗链和相对路径一起处理

图片是整个下载流程里最脆弱的一环。CSDN 的图片域名通常是img-blog.csdnimg.cn,它在服务端校验Referer,直接请求不带 Referer 会拿到 403。下载图片时要把Referer设为对应文章页,并且用stream=True边下边写,避免大图占满内存。

from pathlib import Path def download_image(session, url, save_path, referer): headers = {"Referer": referer} with session.get(url, headers=headers, stream=True, timeout=15) as resp: if resp.status_code != 200: return False save_path.parent.mkdir(parents=True, exist_ok=True) with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=64 * 1024): f.write(chunk) return True

参数说明:chunk_size=64 * 1024是 64KB,太大会占内存,太小会频繁 IO;写文件时用with open确保异常时文件句柄释放。timeout=15是单张图片的等待上限,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, referer=ARTICLE_URL) return f"![{alt}]({save_path.as_posix()})" 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(path="done.json"): try: with open(path, encoding="utf-8") as f: return set(json.load(f)) except FileNotFoundError: return set() def mark_done(article_id, done_file="done.json"): done = load_done(done_file) done.add(article_id) with open(done_file, "w", encoding="utf-8") as f: json.dump(sorted(done), f, ensure_ascii=False)

逻辑说明: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 只拿到前两页列表:分页被风控拦了

现象:脚本日志显示page=1抓到 20 条,page=2又抓到 20 条,page=3返回空列表,退出循环。文件里最终只有 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 -l

rg -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-engine=xelatex -V mainfont="Noto Serif CJK SC"

如果只是临时看看,我更推荐转 HTML,浏览器直接打印成 PDF,排版和代码高亮都比命令行渲染更接近原站效果。转 HTML 时记得把图片相对路径和 Markdown 放在同一级目录,别单独拷贝文件过去。

从那以后我每次跑博客搬家,都强制自己走一遍“pending → done → index → 抽查三篇”的流程。第一次犯懒跳过了抽查,结果有一半文章正文里混进了页面底部的相关推荐,后来花了两小时清理。现在宁可慢一点,也要在批量结束后随机打开三篇,检查代码块缩进和图片路径。这套流程看着简单,能帮你避开我曾经翻过的车。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 1:00:03

K8s可观测性实践:kube-prometheus-stack部署与告警配置全指南

简介&#xff1a;在云原生场景中&#xff0c;Kubernetes集群的监控与告警是保障稳定性的关键。这套资源合集面向运维工程师与云原生开发者&#xff0c;围绕kube-prometheus-stack提供了一套可直接参考的部署配置与说明。包体共299个文件&#xff0c;以272个yaml配置为主&#x…

作者头像 李华
网站建设 2026/10/29 0:59:07

穿搭系统工程:从个人形象诊断到公式化穿搭的完整方法论

前两天整理一位老客户三年前的诊断记录&#xff0c;她很直白地说&#xff1a;"我就是不会穿。"我问她衣柜里有多少衣服&#xff0c;她想了想&#xff1a;四十多件上衣、三十来条裤子&#xff0c;但每天早上还是觉得没衣服能穿。三年后&#xff0c;她的衣柜只剩不到三…

作者头像 李华
网站建设 2026/10/29 0:56:13

具身智能入门到产业应用:从ROS2到机械臂自主抓取全解析

聊具身智能之前&#xff0c;先直面一个尴尬的问题&#xff1a;大模型能写诗、能编程、能画图&#xff0c;却让一台机器把桌上的杯子稳稳端起来都费劲。差别就在“具身”这两个字——语言模型活在文本空间里&#xff0c;而机器人必须把感知、决策、运动控制全部塞进一个物理实体…

作者头像 李华
网站建设 2026/10/29 0:36:49

蒙泰图像输出方案:从色差到精准输出的全链路解析

1. 从一张打样图说起&#xff1a;这套方案到底在解决什么问题做图像输出这行的人都有一个共同的痛&#xff1a;屏幕上看着完美的一张图&#xff0c;送到设备上打出来&#xff0c;颜色偏了、尺寸歪了、边缘糊了&#xff0c;客户站在旁边皱着眉&#xff0c;你只能一边赔笑一边重新…

作者头像 李华
网站建设 2026/10/29 0:35:43

VMware精简磁盘空间回收:VMFS5下Thin盘瘦身实操

简介&#xff1a;面向 VMware 虚拟化运维人员与存储管理初学者&#xff0c;这份 408KB 的 docx 文档聚焦 Thin 精简磁盘空间回收难题&#xff0c;围绕 VMFS5 不支持自动回收空间的现实限制&#xff0c;梳理 SDelete 清理与 Storage VMotion 迁移两条解决路径&#xff0c;并补充…

作者头像 李华