news 2026/10/20 17:26:10

博客搬家工具:HTML解析实现全量下载与图片本地化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
博客搬家工具:HTML解析实现全量下载与图片本地化

简介:这是一款基于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.txt

requirements.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_text

body_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"![{match.group(1)}]({relative_path})"

逻辑说明:正则匹配 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 转换中的细微格式问题。这套流程让我帮身边不少开发者安全搬过家,也希望帮到你。

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

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

企业级日志系统架构实战:Filebeat+Kafka+ELK完整链路解析

做了几年日志系统的开发和维护&#xff0c;我特别想聊聊 ELK 这套组合在“企业级”场景下的真实玩法。这里说的 ELK&#xff0c;已经不只是简单的 Elasticsearch、Logstash、Kibana 三件套&#xff0c;而是带上了 Filebeat 和 Kafka 的完整数据管道。很多团队一开始只是搭了个单…

作者头像 李华
网站建设 2026/10/20 17:23:58

Flink实时热榜TopN:窗口选型、两阶段聚合与乱序处理实践

很多团队做热榜的第一版&#xff0c;逻辑都很直白&#xff1a;定时任务跑一遍 SQL&#xff0c;按某个维度 group by 后 order by desc 取前 100 条&#xff0c;写回缓存。这套方案在上线初期确实够用&#xff0c;但流量涨到一定程度后&#xff0c;三个问题会非常扎眼&#xff1…

作者头像 李华
网站建设 2026/10/20 17:22:40

从标红到精准捕捉:AI降重中的相似片段识别与逻辑重组

1. 从“标红一片”到“精准捕捉”&#xff1a;AI降重解决的是哪一步 毕业季一到&#xff0c;论文查重软件的标红截图就开始在群里刷屏。有些段落红得几乎只剩句号&#xff0c;任谁看到都不想碰。而比标红更让人头疼的是&#xff0c;用传统方法把句子改了一遍之后&#xff0c;查…

作者头像 李华
网站建设 2026/10/20 17:21:54

移相全桥离散化Simulink仿真:3kW到3.6W极限切载动态响应分析

最近在搭一个移相全桥的离散化Simulink仿真&#xff0c;输入400V&#xff0c;输出60V&#xff0c;满载3kW。最折磨人的不是满载稳态——只要参数不离谱&#xff0c;稳态基本都能稳住。真正暴露问题的反而是把负载从3kW瞬间切到3.6W的那一刻&#xff1a;输出电压直接冲高十几伏&…

作者头像 李华
网站建设 2026/10/20 17:21:22

GitHub代码著作权与开源许可证:从版权纠纷到合规防护

1. 版权战争在GitHub上是怎么打起来的先聊一个我最近遇到的真实场景。有天早上打开邮箱&#xff0c;看到一封标题很冷静的邮件&#xff1a;"Notice of Copyright Infringement"&#xff08;版权侵权通知&#xff09;。发件人是一家做SDK的创业公司&#xff0c;说我某…

作者头像 李华
网站建设 2026/10/20 17:21:20

虚拟机部署openGauss完整指南:openEuler环境搭建与踩坑排查

很多第一次接触国产数据库的同学&#xff0c;看到“虚拟机 openEuler openGauss”这套组合时&#xff0c;第一反应往往是“步骤多、怕踩坑、装不上”。我最初做这个数据库实验指导时也是这种心情——光虚拟机就重装了三次&#xff0c;openGauss初始化失败过五次&#xff0c;中…

作者头像 李华