简介:这是一份CSDN博客下载/搬家工具,主要面向需要批量备份与迁移博文的博主或爬虫学习者。作者在本地调试后解决了部分长文及大容量下载问题,但原始代码存在与新版htmlparser.jar相关的兼容性缺陷,源码中部分域名写死为CSDN,如需抓取其他博客需批量替换域名。压缩包共7个文件,包含Java源码、编译后的class、Eclipse工程文件(project/classpath/prefs)及htmlparser.jar爬虫库,整体约272KB。已有470人学习下载,适合有一定Java基础、希望二次开发或了解博客备份实现思路的读者。通过阅读源码可掌握基本的HTML解析调用方式、文件备份流程及常见爬虫版本兼容问题,动手替换域名后即可扩展至其他站点。
1. 博客下载工具:把别人的主页变成你的本地备份,到底值不值得装?
你可能遇到过这种场景:某位博主写了五六年的技术笔记,某天域名到期忘了续费,你收藏夹里几十个链接一夜之间全部变成 404。或者你正在跟一个开源项目,作者的博客里刚好有一篇关键的设计思路讲解,结果文章被删了。这时候才想起来“当初要是存下来就好了”——博客下载工具就是干这个的:把整个博客站点的公开页面、图片、样式、附件批量抓下来,生成一份可以在本地浏览器里离线浏览的完整副本。
它解决的痛点是“内容所有权不在你手里,你只有浏览权”。适合谁用?三类人:一是做技术调研时需要长期保存参考资料的研究者;二是域名、托管隔几年就要折腾换一换,想把旧站完整迁移到新环境的站长;三是内容安全要求高的团队,要把外部公开技术资料镜像到内部知识库。它不是你想象中的“爬虫入门第一课”,实际上手时你会发现编码、链接重写、频率控制、增量更新才是真功夫。这篇文章直接聊我常用的落地方案、参数设计,以及那些翻车之后才知道的坑。
2. 搞清楚抓什么、存什么:目录结构、静态化格式与依赖文件处理
2.1 博文页面、图片和附件:抓取对象的边界要先定死
博客下载工具和通用爬虫最大的区别在于边界:不是见链接就抓,而是只抓“这个博客自己的内容”。判断一条链接是否需要抓取,常用做法是看域名和路径前缀。比如目标博客的根地址是https://blog.example.com,那么页面里指向blog.example.com的资源要抓,指向github.com、twitter.com的外部链接则只保留 URL,不发起请求。这里面最容易忽略的是 CDN 域名:很多博客的图片放在cdn.example.com甚至img.example.net上,如果你严格按主域名过滤,离线后所有图都会裂。我一般会把“页面起始域名 + 页面里的图片、CSS、JS 资源域名”一起列入允许抓取名单,文本链接仍然保持严格同源。
另一个边界是动态参数。同一个文章页可能带?replytocom=123、?utm_source=feed这类跟踪参数,如果它们和基本路径指向同一篇正文,应该做归一化处理,去掉跟踪参数后再判断是否已经抓过。否则会出现同一篇文章被存储七八遍的情况,浪费磁盘空间是小事,循环抓取导致目录越来越乱才是大事。
存储格式上,如果你只是个人备份,保持站点原有目录结构是最省心的。也就是https://blog.example.com/posts/2024/03/post-name.html对应本地posts/2024/03/post-name.html文件,本地根目录对应站点根路径。原因是:离线浏览时浏览器会自动用相对路径解析同目录下的图片和样式资源,你不用改存量 HTML 里的绝对链接;将来要重新上线,目录结构也和原站保持一致,迁移成本低。如果你要把抓下来的内容导入某个内容管理系统做二次编辑,那就要转换成 Markdown,同时把图片单独导出成一个文件夹。
2.2 为什么 HTML 离线副本比 Markdown 转换更适合做备份
很多新手上来就想把别人的博客转成 Markdown,理由是“干净、好编辑”。但我的观点是:首轮备份一律用 HTML 原稿,Markdown 是后续按需再生成的产物。原因有三。其一,HTML 是原站的真实呈现,涵盖排版、代码高亮、表格、数学公式,转换成 Markdown 必然有信息损耗,公式和复杂的表格很容易乱掉。其二,HTML 可以增量更新,你第二次抓取只需比对文件长度、修改时间或内容哈希,Markdown 转换则必须全文重新解析,大站点的时间成本差好几倍。其三,HTML 离线副本能被搜索引擎工具、浏览器的原生打开方式直接识别,不需要额外渲染环境。
当然有一个例外:目标博客本身就是纯 Markdown 源文件驱动的,比如不少静态站点生成器,页面上能直接看到.md原始文件的链接。这时直接抓 Markdown 源文件反而最合适,因为你能连带拿到 front-matter 元数据。判断方法是打开网页源码,看<article>标签里是已经渲染完成的 HTML,还是包含{% raw %}之类的模板语法残留——如果是后者,那它本身就不是静态渲染站点,上面的讨论可以直接跳过。
2.3 链接重写策略:本地文件路径和 URL 的关系不能糊弄
这是整个下载工具里最容易埋雷的一环。站点里的链接大致有三类:站内相对链接(../2022/foo.html)、站内绝对链接(/posts/2022/foo.html)、完整 URL(https://blog.example.com/posts/2022/foo.html)。离线化之后,它们都必须能指到本地文件。我通常的规则是:
- 站内相对链接:保留原样。因为目录结构不变,相对路径在本地依然成立。
- 站内绝对链接:改成相对链接。在原站里
/posts/foo.html指的是站点根下的文件,在本地如果直接保留/posts/foo.html,浏览器会把它解析成本地磁盘根路径,结果打不开。做法是计算当前 HTML 文件在本地目录树里的深度,用../补齐到本地根目录,再拼接目标路径。 - 完整 URL:如果目标域名在允许名单里,按同源规则改成相对链接;否则保持原样作为外部引用。
这个步骤必须在存储之后、结束本轮抓取之前统一跑一遍。我见过有人用 BeautifulSoup 逐条原地替换,跑完才发现有些链接匹配的是页面里的>wget \ --recursive \ --level=inf \ --no-host-directories \ --cut-dirs=0 \ --page-requisites \ --adjust-extension \ --convert-links \ --restrict-file-names=windows \ --domains=blog.example.com,cdn.example.com \ --exclude-domains=twitter.com,github.com \ --wait=2 \ --random-wait \ --timeout=30 \ --tries=3 \ --directory-prefix=/data/backup/blog-example \ https://blog.example.com/
逻辑说明:--recursive开启递归抓取,--level=inf表示不限制抓取深度,让站内文章和标签归档页都能被覆盖。--no-host-directories --cut-dirs=0控制本地存储路径,前者让blog.example.com不会成为本地一个单独的顶层目录,后者保留完整路径。--page-requisites是核心参数,它会额外下载每个页面引用的图片、CSS、JS 资源,离线页面能正常显示样式全靠它。--convert-links会在下载全部完成后统一把站内绝对链接改写成相对链接。
参数说明:--domains是允许抓取的域名白名单,注意我有意把 CDN 域名加了进去。--exclude-domains是黑名单,社交平台链接不会被误抓。--wait=2 --random-wait让两次请求间隔 2 到 4 秒随机,降低对源站的压力。--timeout=30是单次请求超时,--tries=3是失败重试次数,这两个参数应对网络抖动很管用。--restrict-file-names=windows把文件名里不安全的字符全部替换掉,防止你之后把备份拷到 Windows 机器上出现路径解析错误。
这份命令已经能应对超过九成的个人博客场景。跑完用浏览器打开本地index.html,点击几篇文章和图片链接,如果都能正常显示,说明骨架搭好了。
3.2 控制抓取频率与并发:参数怎么设才不至于被源站屏蔽
频率控制是博客下载工具里最看“分寸感”的部分。同一台服务器短时间收到几百个密集请求,很容易触发源站的限流,轻则你的 IP 被临时封禁,重则站长直接给站点加防护规则,害得后来用同一网络出口的人都抓不了。我一般按站点体量区分三档策略:
- 小型博客(几百篇文章):
--wait=2,并发保持默认 1,一口气跑完,耗时在十几分钟级别。 - 中型博客(几千篇文章/带图片):
--wait=5,开启--random-wait,分两天跑完,每天手动中断后第二天用同一目录继续。 - 大型站点(上万页面/含归档列表):
--wait=10,配合定时任务每天夜间跑一段时间,持续一周。
这里有个容易误用的地方:--wait默认指两次请求之间的间隔,但如果你开了--recursive,wget 会对同一页面里的图片、CSS 发并行请求,此时--wait控制不了这些“附属请求”的并发。需要加--limit-rate=200k来限制总带宽,把每秒请求量压到个位数。更精细的做法是把--wait加大到 8 秒以上,牺牲时间换安全。
关于并发,wget 自身不擅长,它的后继者 aria2 可以通过-x16 -j16拉高单文件的连接数和整体并发任务数。但我的经验是,下载博客内容用 aria2 容易把源站打挂,想快就分段跑:先跑文章列表,再跑文章详情页,最后补抓图片资源。每个阶段之间用产物清单比对决定下一轮要抓什么,而不是盲目并发。
3.3 断点续抓与增量更新:中断之后怎么不从头再来
用 wget 跑中大型博客,网络中断、机器重启、源站超时是常态。没有断点机制的工具跑第二次只能从零开始,时间和带宽都扛不住。wget 的断点逻辑基于本地已存在的文件:对同一 URL,如果本地文件已存在且大小一致,它默认跳过。但注意一个陷阱:如果源站返回的响应头里没有Last-Modified或ETag,wget 只能比对文件大小,而 HTML 页面是动态生成的,每次返回内容长度可能都有变化,会导致重复下载。
我的做法是升级到增量同步思维:第一轮全量抓取完成后,后续每天只抓取源站的 sitemap.xml 或 feed 文件,解析出新增和修改过的 URL 列表,再定向抓取这批条目。这个思路从工具上落地长这样:
# 先抓站点地图,提取需要更新的文章地址 wget --quiet https://blog.example.com/sitemap.xml -O /tmp/sitemap.xml # 用 python 脚本过滤出本周新增的文章 python3 - <<'PY' from urllib.parse import urlparse import re, sys sitemap = open('/tmp/sitemap.xml', encoding='utf-8').read() urls = re.findall(r'<loc>(.*?)</loc>', sitemap) # 只保留 /posts/ 路径下的,过滤标签页和分页 posts = [u for u in urls if '/posts/' in urlparse(u).path] with open('/tmp/new-posts.txt', 'w') as f: f.write('\n'.join(posts)) PY # 增量抓取,不重复下载已有文件 wget \ --input-file=/tmp/new-posts.txt \ --no-host-directories \ --page-requisites \ --convert-links \ --directory-prefix=/data/backup/blog-example逻辑说明:--input-file让 wget 从一个文本文件读取 URL 列表而不是从页面递归发现链接,这天然适合增量场景——你已经知道要抓什么,不必再让程序去遍历整个站。配合--page-requisites,每篇新增文章引用的新图片也会被一并抓下来,老图片因为文件已存在自动跳过,这就是断点续抓的实质。
参数说明:增量抓取不建议加--recursive,否则程序会顺着文章正文里的链接又跳回归档页和标签页,等于把全站重新扫一遍。让每个 URL 只抓页面本身和它的附属资源(图片、CSS、JS),增量效率最高。另外,--convert-links会对已下载的所有 HTML 重新执行链接转换,如果你文章的 URL 结构没变,这个操作幂等,不会产生副作用。
4. 用 Python 脚本处理复杂站点:定制抓取、解析正文与二级资源补全
4.1 什么样的博客必须上脚本而不是命令行
wget 能解决大部分静态博客,但至少三类站点必须脚本化定制。第一类是动态渲染站:页面内容由 JavaScript 从接口加载,直接用 wget 抓到的 HTML 只有空壳,正文不在里面。判断方法很简单,看源码里有没有<div id="content"></div>或者是大量<script>后置渲染逻辑。这类站点需要改用无头浏览器渲染后再抓取,wget 天然做不到。第二类是登录墙与付费墙:部分技术社区的文章要求登录后才能看全文,wget 没有会话管理能力。第三类是内容在接口返回 JSON 里的站点:典型如博客平台类站点,页面是一个统一模板,真正的文章内容由前端用接口数据填充。
对这三类站点,我的方案是:用 Python 的requests管理会话和 cookies,用解析库提取接口数据,必要时再引入浏览器渲染。脚本的定位是在 wget 建立好的目录骨架上做内容补齐——wget 负责抓静态资源顺手,脚本负责处理动态内容和结构化提取,两条腿走路。
4.2 抓取接口返回的文章列表与正文:requests + BeautifulSoup 的完整流程
下面脚本对应“通过站点提供的内容接口拉取文章列表,再逐篇保存正文”的典型场景,适用于大量使用 Vue/React 的博客站点:
import requests from bs4 import BeautifulSoup from urllib.parse import urljoin import time, json, os HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", "Accept": "application/json, text/plain, */*", "Referer": "https://blog.example.com/", } BASE_URL = "https://blog.example.com/api/posts" SAVE_DIR = "/data/backup/blog-example/posts" def fetch_post_list(page: int) -> list: """取第 page 页的文章元数据,返回 [{title, url, published_at}]""" resp = requests.get( BASE_URL, params={"page": page, "page_size": 50}, headers=HEADERS, timeout=30, ) resp.raise_for_status() data = resp.json() return data.get("items", []), data.get("total_pages", 0) def save_post(item: dict) -> None: """根据列表元数据抓取单篇文章完整内容并落盘""" detail = requests.get( item["url"], headers=HEADERS, timeout=30, ) soup = BeautifulSoup(detail.text, "html.parser") article = soup.select_one("article.post-content") if article is None: return # 页面结构变化时跳过,保留列表元数据供人工排查 # 提取并修正正文里图片的相对路径 for img in article.find_all("img"): img["src"] = urljoin(item["url"], img.get("src", "")) os.makedirs(SAVE_DIR, exist_ok=True) filepath = os.path.join(SAVE_DIR, f"{item['published_at'][:10]}-{item['slug']}.html") with open(filepath, "w", encoding="utf-8") as f: f.write(str(article)) # 控制抓取节奏,避免接口限流 time.sleep(1.5) page = 1 while True: items, total_pages = fetch_post_list(page) for it in items: save_post(it) print(f"page {page}/{total_pages} done") if page >= total_pages: break page += 1逻辑说明:脚本先调用文章列表接口获取分页数据,拿到每篇文章的详情 URL、标题、发布时间、slug;随后对每篇文章单独发起请求,用 BeautifulSoup 选中article.post-content节点作为正文容器;图片相对路径统一用urljoin拼成完整地址,防止离线后相对路径失效;最后按“日期-slug”规则命名保存。time.sleep(1.5)控制请求频率,必要时根据源站响应头里的限流信息动态调整。
这个脚本的两个关键参数值得多解释一句:timeout=30防止某一篇文章卡死拖慢整批;page_size=50是接口分页的常见上限,太大可能被拒绝。如果你遇到接口分页最大只能到 20,就把page_size改小即可,代码逻辑不用动。另外HEADERS里的Referer某些站点会校验,必须设置成真实页面地址,否则接口可能返回 403;User-Agent别用默认的 python-requests,识别度太高。
4.3 离线页面的懒加载图片补全:data-src 与 srcset 的花式坑
无论你用 wget 还是脚本,都会遇到同一个问题:图片资源是懒加载的。页面初始加载时,<img>标签的src常常是一个 1x1 占位图,真实图片地址放在>from bs4 import BeautifulSoup from urllib.parse import urljoin import pathlib, requests, os, re html_root = pathlib.Path("/data/backup/blog-example") img_dir = html_root / "images" img_dir.mkdir(exist_ok=True) for html_file in html_root.rglob("*.html"): with open(html_file, encoding="utf-8") as f: soup = BeautifulSoup(f.read(), "html.parser") changed = False for img in soup.find_all("img"): real_src = img.get("data-src") or img.get("data-original") or img.get("src") if not real_src: continue img["src"] = urljoin("https://blog.example.com", real_src) for attr in ("data-src", "data-original"): if img.has_attr(attr): del img[attr] changed = True if changed: with open(html_file, "w", encoding="utf-8") as f: f.write(str(soup))
逻辑说明:这段代码遍历备份目录下所有 HTML 文件,找到每张图片的懒加载真实地址,替换成正常的src,并把># 统计站点地图里的 URL 数 grep -o '<loc>[^<]*</loc>' /tmp/sitemap.xml | wc -l # 统计本地 HTML 文件数 find /data/backup/blog-example -name '*.html' | wc -l # 统计所有已下载文件的总大小和数量 find /data/backup/blog-example -type f | wc -l du -sh /data/backup/blog-example
如果本地 HTML 数量明显少于站点地图 URL 数,常见原因是 –level 递归深度不够,有些归档页里的文章链接需要更深一跳才能到达。改进办法是调整抓取深度,或者干脆放弃递归,直接用站点地图作为输入列表抓取所有文章页。后者对大站点更可靠,因为站点地图已经是全量 URL 清单,业务上不依赖页面之间的链接关系。
5.2 全站离线链接有效性检测:找出所有指向本地缺失文件的死链
备份里最容易出现“目录里文件都在、打开却缺东少西”的情况。链接有效性检测专门管这件事:遍历所有本地 HTML,检查其中每个src、href属性指向的本地文件是否真实存在。下面这个脚本是离线副本的体检医生:
import pathlib, re from urllib.parse import urlparse, unquote html_root = pathlib.Path("/data/backup/blog-example") broken_links = [] for html_file in html_root.rglob("*.html"): text = html_file.read_text(encoding="utf-8", errors="ignore") # 只匹配本地链接(相对路径或 file 协议),不碰外站 for src in re.findall(r'(?:src|href)="([^"]+)"', text): if src.startswith(("http://", "https://", "mailto:", "tel:", "javascript:")): continue src = unquote(src).split("#")[0].split("?")[0] if not src: continue target = (html_file.parent / src).resolve() # 确保目标仍在备份根目录内,防止 ../ 越界 if not str(target).startswith(str(html_root.resolve())): continue if not target.exists(): broken_links.append((str(html_file.relative_to(html_root)), src)) print(f"total broken: {len(broken_links)}") for page, link in broken_links[:30]: print(page, "->", link)逻辑说明:脚本遍历每个 HTML 文件,提取所有src和href属性的值,过滤掉外站链接,把剩余的值视为相对路径,从当前 HTML 文件所在目录出发解析出绝对路径,最后检查这个文件在不在备份目录里。unquote处理%20这类编码空格,split("#")去掉锚点,split("?")去掉查询参数。
需要说明的一个边界:../越界检查不是可有可无。有些文章的链接是原站根路径算起的绝对路径,当 wget 没有正确改写时,href="/posts/foo.html"在本地会被解析到磁盘根目录,加了越界判断才能把这类链接标记出来,而不是让脚本往磁盘深处乱找。跑完如果有死链,分两种处理:命中的文件确实不存在,回源补抓;命中的文件在另一层目录,说明链接改写规则出了问题,检查 wget 的--convert-links是否正常执行。
5.3 正文可读性抽检:编码、乱码和缺失内容的三类告警
文件数和链接都过了,还有一道关是内容质量。我的抽检方法很朴素:随机挑 10 篇不同时间段的文章,从三个维度看。第一,页面顶部的标题栏和正文首段是否完整。如果你抓的是动态渲染站点而渲染逻辑判断失败,页面会保存成白板——文件大小看起来正常,实际没内容。统计 HTML 文件大小分布,找出大小异常偏小的那批单独检查。第二,编码是否一致。国内博客常见 UTF-8 和 GBK 混用,wget 默认按响应头判断编码,但响应头缺失时容易乱。发现乱码文章时,查看文件开头有没有<meta charset="gb2312">,有的话需要转码脚本处理。第三,评论区、相关文章这些“非正文元素”是否缺失。对备份来说,正文和图片是底线,评论丢失可以接受,但如果页面主要价值就在评论区(比如技术求助帖),那丢失就是不能接受的。
有一类内容质量问题是工具抓不到的:图片下载成功但内容本身损坏。检查方法是随机抽几张图片,用file命令查看真实类型是不是JPEG、PNG,如果显示HTML或TEXT,说明源站的防盗链机制返回了错误页面,而你把它当图片存了下来。处理方式是重新请求并带上Referer头,同时校验响应头里的Content-Type。这个细节说明一条规律:验证环节不是做一遍就完,而是应当沉淀成一整套巡检清单,每轮增量更新后都完整跑一遍。
6. 常见问题与避坑:编码、动态站、防盗链和站点反爬的硬骨头
6.1 抓下来的页面全是乱码:meta 声明与实际编码不一致
现象:wget 抓回来的 HTML 用浏览器打开,中文全部变成类似ä¸Âæ–‡的乱码串。原因:源站返回的 HTTP 响应头没有声明charset,但页面内部<meta charset="gb2312">明确写了编码,wget 优先使用了默认的 UTF-8 做解码,于是字符被错误解释。解决:对已抓取的文件做一次统一检测与转换,用 Python 的chardet库批量识别实际编码,再统一转成 UTF-8 重写文件。
import pathlib, chardet for p in pathlib.Path("/data/backup/blog-example").rglob("*.html"): raw = p.read_bytes() detected = chardet.detect(raw)["encoding"] if detected and detected.lower() not in ("utf-8", "ascii"): text = raw.decode(detected, errors="replace") p.write_text(text, encoding="utf-8")6.2 页面内容在但图片全部裂掉:懒加载与防盗链的叠加效应
现象:正文文字都在,所有<img>图标都显示不出来,浏览器控制台报错 403。原因:图片链接指向的 CDN 域名配置了防盗链,只允许带源站域名Referer的请求访问图片文件。wget 默认不发送Referer头,请求被 CDN 拒绝。解决:在 wget 命令里加--referer=https://blog.example.com/,让所有请求都带上这个头,一般 CDN 校验就会通过。如果你用的是 Python 脚本,在headers里同时设置Referer和User-Agent,效果相同。已经抓下来的 403 图片文件,清除掉然后重新抓取一次。
6.3 抓取过程中 IP 被封:频率、User-Agent 和入口页三重问题
现象:跑到中途,突然连续出现 403 错误,源站返回验证页或者直接拒绝连接。原因:短时间内请求过多触发了源站防护规则,或者User-Agent暴露了工具身份,被站点主动拉黑。解决:先停止抓取,等 30 分钟到 1 小时让限制自动解除,然后用更低的频率重新开始。具体三重参数调整:--wait=10起步,把User-Agent设置成当前主流的浏览器版本,不要用命令行工具默认值;如果源站首页做了特殊防护,入口页改从文章详情页进入,避免首页被识别为爬虫并对全站开启防护。
6.4 动态站点只有骨架没有正文:wget 抓回来全是空壳页面
现象:抓回来的 HTML 文件有几十到几百 KB,打开却只有一个顶部导航和空白内容区。原因:正文由 JavaScript 通过 Ajax 拉取后在浏览器里渲染,wget 的 HTML 解析器不执行 JavaScript。解决:换思路,不要在 HTML 层面对抗。先用浏览器的开发者工具或直接抓包看接口请求,找到文章列表和文章详情的 JSON 接口,然后像上面第 2 章那样用 Python 脚本请求接口、解析 JSON、会写 HTML。如果你要抓的站点接口很复杂,可以考虑引入渲染工具转成静态快照后再下载,但成本更高,通常的博客场景用接口方案足够。
6.5 图片成功下载但全是占位图:data-src 不包含原始图
现象:文件在本地也能打开,但图片大小普遍在几十字节,内容全是透明或灰色占位图。原因:抓取时图片的真实地址在style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />