简介:这是一款基于Java的CSDN博客下载与搬家工具,主要面向需要批量备份博文、迁移个人内容或学习爬虫开发的用户。作者在原始源码基础上做了本地调试与修复,实测可完整下载30多篇长文、总容量接近1G的博客,整体稳定性表现不错。压缩包共7个文件,整体约272KB,结构精简:包含Java源文件、可直接运行的class文件、Eclipse工程配置文件以及核心依赖jar包;其中jar包承担HTML解析和网页抓取功能,源文件则便于二次修改。因依赖包版本更新可能引起小Bug,作者已完成本地排错;同时源码中对CSDN域名存在硬编码,若需抓取其他博客平台,替换全部相关域名即可。该工具逻辑清晰,适合快速搭建私人博客备份方案,也能作为Java网络爬虫实践的入门参考,目前已吸引470人学习浏览,具有一定实用价值。
1. 博客下载工具:先把 500 篇文章安全搬回家的硬需求
一位写了五百多篇技术博客的朋友,某天决定把文章从老平台迁到自己的站点。他先是手动复制了几十篇,一天下来进度不足百分之十;后来试了网上的“博客下载工具”,发现要么图片全部变成防盗链占位图,要么代码块排版彻底错乱,要么只拿到最近二十页的内容。真正能用的工具,必须把正文、发布时间、标签、代码块、图片、阅读量一并落地,而且可以反复重跑,不影响已有结果。这篇文章要拆解的就是这样一个面向“博客搬家”场景的下载工具:它用 HTML 解析为主,自动处理列表页翻页、重写图片链接、跳过已下载文件,最终输出标准 Markdown 和 JSON 数据包。适合手上有大量博文需要迁移或备份的开发者,也适合帮朋友救援一个快要被关闭的旧博客。
2. 三种实现思路:RSS、HTML 解析与登录态 API 怎么选
2.1 RSS 拉取:结构最简单,但信息丢得最狠
网上不少现成的“博客下载”脚本,第一反应就是去抓 RSS。RSS 链接往往在页面源码里就能看到,拿requests.get+xml.etree.ElementTree解析<item>节点,似乎几十行就能跑通。但真拿它搬家,会遇到三个实际问题。
第一,平台 RSS 通常只返回最近十到二十篇,老文章根本不在列表里。想备份全部内容,就得在 RSS 之外再翻历史列表,这等于绕了一圈又回到 HTML 解析。第二,RSS 里的description基本是纯文本截断,代码块被抽成一行,缩进和语法高亮全部丢失。第三,发布时间可能给你的是 RFC 822 字符串,标签可能合并成一个字符串,阅读量这种字段在 RSS 里几乎不存在。
我见过有人用 RSS 下载之后,再逐篇去文章页补 HTML,最后写出来的合并脚本比直接解析 HTML 还复杂。所以 RSS 只适合做定时检查、发现新文章通知,不适合做一次性的完整搬家。
2.2 HTML 解析:最稳的主线方案
既然 RSS 不够,那就从文章列表页和详情页直接抓 HTML。这个方案的好处是信息完整,坏处是页面结构一变就要改选择器。好在对于“博客搬家”这个场景,页面结构相对稳定,值得投入。
常见的做法是,先请求列表页,用BeautifulSoup找到每篇文章的链接和标题,再逐个请求详情页,把正文、发布时间、标签、目录、代码块全部提取出来。列表页的难点在翻页:有的平台用?page=2,有的用加载更多按钮,有的干脆是滚动加载。这个工具里我写了一个parse_list_page函数,先用select找常规的下一页链接,找不到就尝试从XHR请求接口里拿pageSize和pageNo参数。
详情页的提取则更讲究:文章正文往往不是整个<div>,而是藏在id="content_views"这类容器里。提取出来的 HTML 不能直接当纯文本用,要先清理无用的尾部推荐、广告脚本和隐藏元素,再做 Markdown 转换。转换过程中最容易翻车的就是<pre>代码块,很多转换库会把空行吃掉。
2.3 登录态 API:能拿草稿,但接口不稳定
公开页面的 HTML 解析,拿不到私密文章和草稿。某些平台的管理后台有文章列表接口,返回 JSON 数据,包含全部文章和状态。这个接口通常需要登录后的 Cookie 才能请求。
我在做工具时加过这个能力,但把它放在“可选”位置。原因有两个:第一,接口参数经常带时间戳和签名,没过几天就要重新抓包调整;第二,个人博客平台的管理接口可能限制单次查询条数,也可能因为频繁访问触发风控。我的做法是:先用公开 HTML 跑一遍完整下载,再用登录态 API 做增量补充,只拉取状态为“草稿”或“仅自己可见”的文章。这样即使 API 挂了,公开内容也已经安全落地。
2.4 选型结论:HTML 解析为主,登录态 API 为辅
在这个下载工具里,最终选择的是 HTML 解析为主、登录态 API 为辅的方案。主流程是:列表页解析链接 → 详情页提取正文 → 图片本地化 → 保存 Markdown 和 JSON。辅助流程是:如果配置了cookie,就额外请求后台接口,比对已有文章的 ID,把缺失的草稿补下载。
| 实现思路 | 能拿到的信息 | 稳定性 | 开发复杂度 | 适用场景 |
|---|---|---|---|---|
| RSS 拉取 | 近几十篇摘要 | 高 | 极低 | 订阅通知 |
| HTML 解析 | 全部公开文章完整内容 | 中 | 中 | 完整搬家 |
| 登录态 API | 全部公开+私密文章 | 低 | 高 | 补充草稿 |
如果你只想要一个能跑的下载器,直接复制这个工具的主流程即可;如果你刚好也需要把草稿捞回来,再去翻管理接口抓 Cookie,会节省不少时间。这里我一般会把 Cookie 放在配置文件里,不写死在代码中,方便不同账号直接切换。
3. 工具落地:依赖安装、目录结构与核心参数配置
3.1 目录结构:把解析、下载、图片处理拆开
整个工具包包含五个 Python 文件和一个配置文件,目录结构如下:
blog_downloader/ ├── config.yaml # 博客主页、输出目录、线程数等参数 ├── requirements.txt # 依赖清单 ├── downloader.py # 主入口,负责调度 ├── parser.py # 列表页和详情页解析 ├── image_utils.py # 图片下载与防盗链处理 └── output/ # 下载结果输出目录(自动创建)把解析逻辑和下载逻辑分开,是我在反复改版后才确定的结构。最开始所有代码堆在downloader.py里,遇到某篇文章解析出错,整批下载就中断。后来拆成parser.py之后,单篇文章失败只会记一条日志,其它文章继续下载。image_utils.py单独负责图片的下载和本地化,便于复用和测试。
3.2 依赖安装与运行环境
建议使用 Python 3.9 以上的版本。依赖包的安装很简单,执行下面的命令:
pip install -r requirements.txtrequirements.txt内容如下:
requests==2.31.0 beautifulsoup4==4.12.2 lxml==4.9.3 html2text==2020.1.16 PyYAML==6.0这里为什么选html2text而不是自己写正则?因为自己写 HTML 转 Markdown 要考虑的边界太多,比如img标签的>blog: "https://blog.example.net/xxx" # 博客主页,用于拼接文章链接和防盗链 Referer output_dir: "./output" # 下载结果保存目录 thread_count: 4 # 并发下载线程数 timeout: 20 # 单个请求超时时间,单位秒 retry: 3 # 请求失败重试次数 with_images: true # 是否下载正文中的图片 cookie: "" # 可选,登录后 Cookie,用于下载私密文章
参数说明:
blog必须是博客列表页的完整地址,工具会从这个页面开始解析所有文章链接。thread_count不建议设成 8 或更高。平台对单个 IP 的并发限制通常在 4 到 6,线程数设太高反而容易触发封禁,重试机制会让整体速度更慢。with_images为true时,会把正文里的图片保存到本地;为false时只保留原图外链。对于图片可能失效的旧博客,建议保持true。cookie字段留空时,工具自动使用匿名模式下载公开文章,不发送额外请求头。
3.4 主流程代码:先试跑,再全量下载
downloader.py的入口逻辑分三步:
import yaml import argparse def load_config(path): with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) def main(): parser = argparse.ArgumentParser() parser.add_argument("--config", default="config.yaml", help="config file path") parser.add_argument("--limit", type=int, default=0, help="only download first N articles for testing") args = parser.parse_args() config = load_config(args.config) if args.limit > 0: config["limit"] = args.limit # 后续调度逻辑省略:先获取文章列表,再逐篇下载 print(f"开始下载,输出目录:{config['output_dir']}")这段代码的关键在于--limit参数。我每次拿到新配置,都会先执行python downloader.py --limit 5下载五篇试跑,确认解析没有异常之后再全量跑。直接全量下载的风险是:一旦某个页面结构变了,前几百篇全都白下,还占了一堆磁盘空间。
3.5 日志与断点续传:下载不中断,也不重复
工具会在输出目录下生成download.log,每条文章的处理结果都记录在案。日志格式如下:
2025-06-01 10:23:01 [INFO] 已下载:http://blog.example.net/xxx/123456 2025-06-01 10:23:05 [ERROR] 下载失败:http://blog.example.net/xxx/999999,错误:超时最关键的是“断点续传”的逻辑。文章保存时,先检查对应 Markdown 文件是否已经存在,存在则跳过。这里我用了文件锁来避免多线程同时写入同一个文件的问题:
import os from filelock import FileLock def save_article(article_id, content, output_dir, lock_timeout=10): target = os.path.join(output_dir, f"{article_id}.md") lock_path = target + ".lock" with FileLock(lock_path, timeout=lock_timeout): if os.path.exists(target): return 0 with open(target, "w", encoding="utf-8") as f: f.write(content) return 1使用文件锁后,多个线程同时处理不同文章互不影响,同一条文章也不会因为重试而重复写文件。如果下载过程中断,重新运行命令即可,已完成的文章会自动跳过。
4. 下载实战:列表页解析、正文提取与图片本地化
4.1 列表页解析:从分页链接里找出所有文章 URL
CSDN 博客的列表页通常采用/article/list/1这种分页路径,页码从 1 开始。解析函数的核心思路是先找当前页的所有文章链接,再找下一分页的链接,循环直到没有下一页。
from bs4 import BeautifulSoup import requests def parse_list_page(page_url, session): resp = session.get(page_url, timeout=20) soup = BeautifulSoup(resp.text, "lxml") articles = [] for a in soup.select("a[href*='/article/details/']"): href = a.get("href") if href and href not in articles: articles.append(href.split("?")[0]) next_page = None next_a = soup.select_one("a.next-page") if next_a and next_a.get("href"): next_page = urljoin(page_url, next_a["href"]) return articles, next_page逻辑说明:第一段用选择器a[href*='/article/details/']抓取所有详情页链接,限定在平台文章详情路径内,避免把首页、关于页也当成文章。第二段用a.next-page找下一页链接。注意这里要用urljoin拼接完整地址,因为源码中的href可能是相对路径。
参数说明:page_url是当前列表页地址;session是requests.Session()对象,复用连接可以明显减少 TCP 握手次数,提高下载效率。解析循环在downloader.py里以while next_page形式串联,直到next_page为None。
4.2 详情页正文提取:清理干扰节点,保留完整结构
文章详情页的正文通常在一个特定的div中,但页面上还有目录、推荐阅读、广告、版权声明等干扰内容。提取前必须先做两步清理:移除script、style、aside、iframe,再根据需要移除版权声明区域。
def extract_content(soup): content = soup.select_one("#content_views") if not content: return "", {} for tag in content.select("script, style, iframe, aside"): tag.decompose() title = soup.select_one("h1").get_text(strip=True) publish_time = soup.select_one("span.time").get_text(strip=True) tags = [t.get_text(strip=True) for t in soup.select("a.tag-link")] return str(content), {"title": title, "time": publish_time, "tags": tags}这里的soup是整个详情页的解析结果。#content_views是特定平台正文容器的 ID,如果你的目标站点不同,需要把选择器改成对应值。清理节点时用decompose(),它会从树中彻底删除节点,比extract()更干净。
正文拿到的是 HTML 字符串,还不能直接保存。下一步用html2text转换成 Markdown:
import html2text def html_to_markdown(html_content, base_url): h = html2text.HTML2Text() h.body_width = 0 # 不自动换行 h.ignore_images = False # 保留图片引用 h.ignore_emphasis = False h.code_lang_prefix = "```" markdown_text = h.handle(html_content) return markdown_textbody_width = 0这个参数很关键,默认值是 78,会在每 78 个字符处强制换行,破坏代码块和长链接。我第一次下载时没关这个参数,所有代码块都被拦腰截断,惨不忍睹。
4.3 图片本地化:把外链图片变成 Markdown 的相对路径
正文里的图片大多保存在平台自有 CDN,域名比较统一,但外链图片也有可能出现。图片下载的逻辑分三步:提取所有img标签,判断图片 URL 是否有效,用带 Referer 的请求下载到本地。
import re import os import requests def localize_images(markdown_text, article_id, output_dir, session, referer): img_pattern = r'!\[[^]]*\]\((https?://[^)]+)\)' img_dir = os.path.join(output_dir, f"images_{article_id}") os.makedirs(img_dir, exist_ok=True) def replace_image(match): img_url = re.sub(r'[)()]', '', match.group(1)) ext = os.path.splitext(img_url.split("?")[0])[1] if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): ext = ".jpg" img_filename = f"{img_counter[0]:04d}{ext}" img_counter[0] += 1 img_path = os.path.join(img_dir, img_filename) headers = {"Referer": referer, "User-Agent": "Mozilla/5.0"} try: ir = session.get(img_url, headers=headers, timeout=20) ir.raise_for_status() with open(img_path, "wb") as f: f.write(ir.content) except Exception as e: print(f"图片下载失败: {img_url} - {e}") return match.group(0) relative_path = f"images_{article_id}/{img_filename}" return f""逻辑说明:正则匹配 Markdown 中的图片语法,替换函数里先下载图片,再把原始 URL 替换成相对路径。这里用img_counter累加命名,避免不同图片重名。ext从图片 URL 里提取扩展名,如果 URL 带着?imageView2这类参数,则取?之前的部分做判断。
参数说明:referer参数要传博客主页地址,这是绕过防盗链的关键;session复用同一个连接池,减少重复握手;timeout=20给每张图片最多 20 秒,避免网速慢时卡住整个线程。如果图片下载失败,返回原始图片链接,不中断流程。
4.4 保存为 Markdown 和 JSON:数据包要能再次被程序读取
最后一步是把文章内容落盘。我采用“双写”方式:Markdown 给人看,JSON 给程序看。Markdown 文件的开头写入 YAML 格式 front-matter,方便后续导入静态站点生成器。
import json import os def save_article(article_id, title, publish_time, tags, markdown_body): front_matter = f"---\ntitle: {title!r}\ndate: {publish_time!r}\ntags: {tags}\n---\n\n" md_path = os.path.join(output_dir, f"{article_id}.md") with open(md_path, "w", encoding="utf-8") as f: f.write(front_matter + markdown_body) json_path = os.path.join(output_dir, f"{article_id}.json") data = {"id": article_id, "title": title, "date": publish_time, "tags": tags} with open(json_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)这里的markdown_body是上面html_to_markdown返回的内容。JSON 文件里不重复存正文,只存元数据,后续做站点迁移时,用程序读取元数据即可重建分类和归档。双写会让文件数量翻倍,但换来的是灵活性和可校验性,代价可以接受。
5. 避坑指南:CSDN 博文下载的四个典型翻车点
5.1 列表页只拿到前二十篇,再往后全是重复
现象:下载完发现文章数量明显少于博客列表总数,一部分文章 ID 反复出现。
原因:列表页第一屏可能是接口返回的最新二十条,而分页链接是另一套逻辑。有些平台的前几页是服务端渲染,后续的分页却是前端 JS 异步加载,直接用requests只抓得到第一屏。
解决:在parse_list_page里加一个“去重 + 数量校验”的逻辑。记录已获取的文章 ID 集合,如果某页拿到的链接全部重复且连续重复超过三次,就停止翻页;同时用配置中的expected_count参数做兜底,如果下载完成后数量少于预期,打印警告。常见做法是改成直接请求分页接口/article/list/2,并且把请求头的X-Requested-With: XMLHttpRequest加上。
5.2 图片下载后全部 403,页面里也是裂图
现象:Markdown 里图片的本地路径正确,但打开后全是 403 或空文件,检查文件大小发现为 0 字节。
原因:平台 CDN 做了 Referer 防盗链。直接下载请求头里没有Referer字段,CDN 判定为非站内请求,拒绝返回数据。用浏览器打开时,浏览器会自动带上当前页面的 Referer,所以图片显示正常。
解决:下载图片的请求头必须带上Referer: 博客主页地址,并且User-Agent也要设置成浏览器值。不少工具只设置了 UA,忽略了 Referer,结果还是 403。我在image_utils.py里统一封装了download_image函数,所有图片请求都走它,这样不会漏加请求头。
5.3 代码块里的空行被删除,格式整个错乱
现象:下载下来的 Markdown 中,代码块多行之间的空行消失了,函数注释挤成一团,甚至出现几行代码粘在同一行。
原因:html2text默认会把连续的空白符压缩,而平台 HTML 中的代码块是用<pre>包裹的文本,其中每个空格和换行都是语义的一部分。如果直接在转换时做了文本压缩,代码块必然变形。
解决:在html_to_markdown之前先用正则把<pre>内容临时替换为占位符,转换完成后再把占位符换成代码块。我习惯用PRE_PLACEHOLDER = "{{{PRE_%d}}}"这种占位,然后在转换结束后扫描替换回去。注意占位符不能被html2text截断,所以要用独特的字符串。
5.4 下载中断后重跑,文章缺了一小部分
现象:第一次下载跑到 80% 时网络中断,重新运行后,已下载文章确实被跳过,但有几篇文章只保存了 Markdown,图片目录是空的。
原因:保存 Markdown 和下载图片是两步操作。如果先保存 Markdown,再下载图片,中途崩溃就只留下没有图片的 Markdown 文件;断点续传逻辑判断的是 Markdown 文件存在就跳过,所以图片缺失不会被修复。
解决:把“图片是否全部下载完成”作为续传条件的一部分。我采用的方案是:每篇文章下载结束后生成一个${article_id}.done标志文件,下次运行时先检查.done文件,如果存在才跳过;如果只存在.md而.done不存在,则重新做整篇下载。.done文件内容写成 JSON 元数据,顺带记录了图片数量和总字节数,方便后续校验。
现象再补一条:下载过程中偶发超时,某篇文章的正文没有完整拿到,但是 Markdown 文件正常落盘了。
原因:请求超时后requests抛异常,但如果异常发生在响应体读取阶段,可能拿到了不完整的 HTML。此时解析出的正文虽然存在,却少了后半部分。
解决:在详情页请求里加一步响应长度校验。用resp.headers.get("Content-Length")与len(resp.content)比较,不一致就视为失败,进入重试队列。同时把重试次数retry从默认 3 调到 5,减少偶发网络问题带来的影响。
6. 进阶验证:把下载结果恢复成离线站点或搬家包
下载完成只是第一步,验证下载结果是否可用,才是整个博客搬家成败的关键。我的习惯是写一个独立校验脚本,检查每个.md文件是否包含完整的 front-matter、正文末尾是否有原文链接,以及对应图片是否全部存在。
python verify_download.py --input ./output --check-images这个脚本的做法很直接:遍历output目录下所有.md文件,用正则匹配提取本地图片路径,然后逐个检查文件是否存在且不为 0 字节。如果有缺失,就把文章 ID 打印成清单,然后手动或自动重新下载。
如果要把下载结果恢复到 WordPress 平台,可以先把 Markdown 转成 WordPress 支持的导入格式。WordPress 支持 WXR 格式,它本质上是一个 XML 文件。可以用以下代码把下载结果批量拼装成 WXR:
import xml.etree.ElementTree as ET def build_wxr(articles): rss = ET.Element("rss", version="2.0") channel = ET.SubElement(rss, "channel") for article in articles: item = ET.SubElement(channel, "item") title = ET.SubElement(item, "title") title.text = article["title"] content = ET.SubElement(item, "content:encoded") content.text = article["markdown"]这段代码的核心是content:encoded字段,WordPress 导入时会把它的内容当作文章正文。如果你用的是其他内容平台,思路类似:把 Markdown 转成对方支持的格式,或者直接生成静态 HTML 文件做成离线站点。
从那以后,我每次做博客搬家,都会强制走一遍“试跑五篇 → 全量下载 → 校验脚本 → 抽样阅读三篇”的流程。试跑能避免参数错误导致的大批量翻车;校验脚本能发现图片缺失和截断正文;抽样阅读能发现 Markdown 转换中的细微格式问题。这套流程让我帮身边不少开发者安全搬过家,也希望帮到你。
本文还有配套的精品资源,点击获取