news 2026/10/29 9:41:23

博客下载工具完全指南:从静态抓取到动态渲染的离线备份

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
博客下载工具完全指南:从静态抓取到动态渲染的离线备份

简介:博客下载工具是面向 CSDN 博主与需迁移站点的开发者的 Java 命令行/Eclipse 小工具,可批量抓取博文与附件,解决手动保存费时、站点关闭或改版后内容难找回的问题。作者在本地调试后,已成功完成 30 多篇长文、约 1GB 规模的完整下载;源码基于 htmlparser.jar 实现正文解析,但因解析库版本更新存在小 bug,且部分域名被写死,抓取其他平台博客时需全局替换域名后重新编译。压缩包为 zip 格式,共 7 个文件,主要由 DoBackup.java 源码、编译后的 class、Eclipse 工程配置(.project、.classpath、prefs)及 htmlparser.jar 组成,体积仅 272KB,结构简洁,适合有一定 Java 基础的读者阅读排错。该资源已有 470 人学习,对希望二次开发或自制博客备份工具的人有直接参考价值。

1. 博客下载工具:比爬虫更省心的离线备份方案

你可能遇到过这种场景:想把自己写了三年的博客完整备份一份,或者把某个技术博主的一系列文章离线收藏,结果发现浏览器“另存为”只能存一页,还得手动点下一页、翻评论、存图片。博客下载工具就是专门解决这类需求的——它能自动识别博客的文章列表、正文容器和图片资源,把整站内容批量拉取到本地,输出成 HTML、Markdown 或 PDF。和通用爬虫比,它的优势在于“懂博客”:知道 RSS、sitemap、分页规则,也知道怎么过滤导航栏和广告。它适合三类人:定期备份自己博客的站长、维护本地知识库的读者、需要为搜索引擎或语义分析准备语料的开发者。下面我会从架构选型讲到避坑,最后给出一套可以照着复现的流程。

2. 拆解博客下载的三种主流架构:静态抓取、动态渲染与API优先

2.1 静态博客(Hexo/Hugo)怎么抓:直接下 HTML 还是克隆仓库

静态博客的全站都是预生成的 HTML 文件,目录结构基本就是public/目录的完整映像。这类站点抓起来最省心:你先拿到文章列表页,解析出正文页链接,下载 HTML 后,图片和 CSS 要么是相对路径,要么有固定的 CDN 前缀。但“直接下 HTML”和“克隆生成仓库”是两条完全不同的路线。如果博客源码本身是公开的 Git 仓库,我更推荐git clone——不仅拿到成品 HTML,还拿到 Markdown 源文件,后续转格式完全不依赖网页结构。缺点是很多博主只部署了生成器产物,没开源源码;这时候只能用下载器按页面抓取。

实际判断方法很简单:在文章页右键查看源码,如果能看到完整的文章纯文本(而不是 JS 变量里的数据),就是静态渲染。用curl看一眼响应头,如果server字段是GitHub Pages、Vercel或者没有任何动态渲染痕迹,基本就可以按静态处理。

curl -I "https://example.com/posts/hello-world/" | head -n 10

返回里没有x-powered-by: PHP、Set-Cookie这类动态站特征,就可以走静态抓取流程。参数上要注意:curl -I是只取请求头,head -n 10控制输出行数,避免头信息太长刷屏。如果手头没有 Linux 命令,用浏览器开发者工具的网络面板看响应头也是一样的效果。

2.2 动态博客(WordPress/Typecho)如何定位正文:RSS、sitemap 还是解析 DOM

动态博客的文章列表通常是数据库查询结果拼出来的,分类、标签、分页能生成无数个 URL。最可靠的做法不是硬解析首页,而是先找 RSS 或 sitemap。WordPress 默认有/feed/,Typecho 也支持/feed/,很多程序还提供/sitemap.xml。你只需要从 RSS 里解析<link>节点,就能拿到全部文章地址,而且 RSS 里通常带了摘要和发布时间,可以直接用来做索引。

但 RSS 不一定完整:有的作者只输出摘要,有的 feed 地址做了改写,有的站点干脆没开。此时用 sitemap 更稳妥,因为它会列出所有公开页面。我一般先用一个脚本把 sitemap 解析成 URL 清单,再过滤出包含文章特征路径的地址(如/archives/、/posts/)。DOM 解析只作为兜底方案——当 RSS 和 sitemap 都拿不到时,才去解析首页和分页的<a>标签。优先级建议是:sitemap > RSS > 首页分页解析,这样能最大程度避免漏抓。

curl -s "https://example.com/sitemap.xml" | grep -o '<loc>[^<]*</loc>' | sed 's/<\/\?loc>//g'

这条命令把 sitemap 里的所有 URL 提取出来。grep -o只输出匹配部分,sed去掉标签。注意有些博客的sitemap.xml是索引文件,里面嵌套了子 sitemap,你需要先解析出子文件地址,再逐个下载。这一步是最容易漏的,我见过有人抓了 500 个 URL 就以为全站备份完了,实际上博客有 8000 篇文章,就是因为只扫描了外层索引。

2.3 单页应用(Ghost/自研前端)为什么需要渲染引擎:无头浏览器的取舍

Ghost 博客的官方前端是服务端渲染的,直接抓 HTML 没问题。但很多自研博客用了 React 或 Vue,文章正文是 JS 从接口拉回来再渲染的,请求 HTML 只能拿到一个空壳<div id="app">。这时必须用无头浏览器,比如 Playwright 或 Puppeteer,等页面渲染完成后抓取 DOM。代价是内存占用高、抓取速度慢、容易触发目标站点的性能防护。取舍标准是:先查有没有公开 API,比如 Ghost 有/ghost/api/,自研站点可能暴露 JSON 数据端点。有 API 就优先走 API,数据干净还自带字段;没有 API 才上无头浏览器,而且只对文章详情页用渲染,列表页尽量用普通请求。

from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(user_agent="Mozilla/5.0 BlogDownloader") page.goto("https://example.com/posts/hello/", wait_until="networkidle", timeout=20000) html = page.content() browser.close()

这段代码用 Playwright 打开页面,等网络空闲后取整个 HTML。wait_until="networkidle"是个双刃剑:对于依赖多个接口的页面,它能保证数据加载完;但如果页面有长轮询或一直有请求,这个参数会卡到超时。我建议优先用domcontentloaded配合固定等待page.wait_for_timeout(1500),对大多数博客更稳。参数headless=True是无头模式,timeout=20000是导航超时 20 秒。无头浏览器这块属于“黑匣子”环节,容易翻车在页面是否真的渲染完,需要先手动在浏览器里确认接口响应时间,再来设等待值。

博客类型典型特征第一推荐兜底方案
静态博客HTML 自带全文git clone批量下载 HTML
动态博客响应头有 PHP/Pythonsitemap.xmlRSS / 分页解析
SPA 博客JS 渲染空壳找后端 API无头浏览器渲染

这张表的价值在于,你在接到一个“下载这个博客”的需求时,先用 5 分钟判断类型,就能决定整个方案的方向。方向错了,后面做得再精细都会白费。

3. 用 Python + Requests + BeautifulSoup 跑通最小下载流程

3.1 准备工作:确认目标 URL 规则与 robots.txt

动手前一定要看robots.txt,这不是形式主义,而是为了知道哪些路径被允许抓取。同时确认 URL 规则是否规整,比如日期前缀/2024/01/、ID 后缀_123.html。规整的 URL 让后续构造分页几乎是白送的。这一步用代码做最快。

import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) BlogBackupBot/1.0", "Accept-Language": "zh-CN,zh;q=0.9", } robots = requests.get("https://example.com/robots.txt", headers=headers, timeout=10) print(robots.text)

headers里的User-Agent建议写成带联系方式的爬虫标识,很多站点对无 UA 的请求直接拒绝。timeout=10是连接和读超时,不设的话一旦服务器不响应,脚本会挂到默认的几分钟。如果 robots 里写着Disallow: /wp-admin/,你在后面解析链接时要主动过滤这类路径。另外,robots.txt 只告诉你哪些路径被爬虫协议限制,不代表服务器真的不会拒绝你,后面还要靠请求频率控制。

3.2 从列表页解析文章链接:选择器与去重

以常见的ul.article-list > li > a结构为例,用 BeautifulSoup 提取链接并做归一化。归一化指的是把相对路径拼接成绝对路径、去掉 URL 里的统计参数、转成统一协议。这一步骤最容易被忽略的是重复链接,比如?utm_source=rss和?from=home指向同一篇文章,需要去重。

from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse html = requests.get("https://example.com/page/1/", headers=headers, timeout=10).text soup = BeautifulSoup(html, "html.parser") links = [] for a in soup.select("ul.article-list > li > a"): href = a.get("href") if not href: continue full_url = urljoin("https://example.com/page/1/", href) parsed = urlparse(full_url) if parsed.path.count("/") < 3: # 太浅的路径多半是栏目页,不是文章 continue links.append(full_url) links = list(dict.fromkeys(links)) # 保留顺序去重 print(len(links))

urljoin是处理相对路径的标准方法,第二个参数可以是./hello.html这种形式。parsed.path.count("/") >= 3是个启发式过滤,能挡掉大量标签、分类页,比如/tag/python/的路径段不够深,通常不是文章。dict.fromkeys(links)用字典键去重,同时保留首次出现的顺序,比set更稳。注意这里只抓了第 1 页,后面还要处理翻页。

3.3 下载文章并保存正文与图片:并发控制在个位数

对每篇文章,先请求 HTML,再用选择器切出article节点,最后把正文中的图片 URL 下载到本地文件夹。并发控制是关键,concurrent.futures的ThreadPoolExecutor我一般只用 3 到 5 个 worker,避免打爆小站。

import re import os from concurrent.futures import ThreadPoolExecutor def save_post(url): html = requests.get(url, headers=headers, timeout=10).text soup = BeautifulSoup(html, "html.parser") article = soup.select_one("article.post-content") or soup.select_one(".entry-content") if not article: return url, False title = soup.select_one("h1").get_text(strip=True) if soup.select_one("h1") else url # 清理脚本、样式和 iframe for tag in article(["script", "style", "iframe"]): tag.decompose() os.makedirs("backup", exist_ok=True) file_name = re.sub(r'[\\/:*?"<>|]', "_", title) + ".html" with open(os.path.join("backup", file_name), "w", encoding="utf-8") as f: f.write(f"<h1>{title}</h1>{article.prettify()}") return url, True urls = ["https://example.com/post/1/", "https://example.com/post/2/"] with ThreadPoolExecutor(max_workers=3) as pool: results = list(pool.map(save_post, urls))

这里if not article比assert article更安全,因为有些页面解析失败会返回空,不应该让脚本整体崩溃。tag.decompose()把干扰元素移除,注意它会连同子节点一起销毁,别误删正文里的代码块。写文件时encoding="utf-8"必须显式指定,否则 Windows 下会用 GBK 写入,之后再转换会乱码。re.sub把标题里的非法字符替换成下划线,这是跨平台写文件的必修课。这个版本刻意不并发下载图片,因为图片请求多,和文章 HTML 并发叠加容易触发限流。

3.4 第一次跑通的验收标准

跑完上面的脚本,至少要检查三件事:文章数是否和列表页一致、HTML 是否可以本地打开、图片是否完整。我通常会用一条命令统计文件数。

find backup -name "*.html" | wc -l

如果数量明显少于预期,优先去日志里看哪些 URL 抛了异常。下载器不怕慢,怕静默失败——Requests 默认不会把 HTTP 404、500 当时的响应体打出来,建议在save_post里加一句print(url, resp.status_code),先确保每个请求状态都是 200,再谈解析。这一步是很多人跳过的“后悔药”,后面调试时能省一半时间。

4. 处理分页、图片与正文提取:必须调好的三个环节

4.1 分页类型的判断与翻页终止条件

博客的分页看起来简单,实际上有四种形态。URL 参数型长这样:/?paged=2,路径型长这样:/page/2/,这两种用for循环就能翻页。第三种是“加载更多”按钮,点击后通过 POST 请求返回 HTML 片段,拼接路径的逻辑完全不同。最麻烦的是第四种无限滚动,正文和列表页都在同一页面里追加渲染,URL 不变,这时必须抓包找到 JSON 接口,或者用无头浏览器模拟滚动。

判断方法很直接:打开浏览器开发者工具的网络面板,点击第二页,看新的请求 URL 和方式。如果 URL 规律变化,就是第一或第二种;如果只有 XHR 请求,就是第三或第四种。拿到规律后写循环,但一定要设终止条件。最常见的错误是循环里请求一个不存在的页号,服务器返回 200 和空列表,脚本傻傻地一直翻下去。我一般会预取一个上限页数,比如先从第一页解析出“下一页”的链接,如果下一页等于当前页就停止;或者在连续两个空列表后强制 break。

page = 1 while True: url = f"https://example.com/page/{page}/" soup = fetch_soup(url) items = soup.select("li.article a") if not items: break for item in items: process_item(item) page += 1 if page > 100: # 安全上限 break

fetch_soup是封装好的抓取函数,里面要带 headers 和 timeout。if not items是空页终止,page > 100是防御性上限,避免坏数据让脚本跑到天荒地老。如果博客真的有一百多页,你就把上限抬高,但要确保不是死循环。更优雅的做法是检查link[rel=next]标签,不过有些主题不输出它,所以我用空列表判断为主、上限兜底。

4.2 图片懒加载与防盗链:Header、Referer 和重试机制

懒加载是博客下载的一大障碍。不同主题写法不一样,常见属性有这些。

属性名出现位置真实地址含义
>import time import random def download_image(img_url, referer, local_path): for attempt in range(3): try: resp = requests.get(img_url, headers={"User-Agent": headers["User-Agent"], "Referer": referer}, timeout=10) if resp.status_code == 200 and len(resp.content) > 100: with open(local_path, "wb") as f: f.write(resp.content) return True except requests.RequestException: pass time.sleep(random.uniform(1, 3)) return False

重试循环里attempt范围是 0、1、2,最多 3 次。resp.status_code == 200且len(resp.content) > 100是为了滤掉错误页和防盗链图。time.sleep(random.uniform(1, 3))是随机退避,避免每次失败都隔着完全一样的间隔,这对服务器日志来说更像正常用户。这里有个重要参数:wb写二进制,一定不要写成w,不然图片文件会损坏。

4.3 正文提取的两种路线:Readability 算法与手动 DOM 清理

正文提取决定下载下来的是“干净文章”还是“网页垃圾箱”。Mozilla 的 Readability 算法在 Python 里有readability-lxml封装,它返回一个 JSON,包含标题、正文 HTML 和纯文本,对绝大多数博客一次命中。用法是加载 HTML 后直接解析。

from readability import Document doc = Document(raw_html) content = doc.summary(html_partial=True) title = doc.short_title()

short_title()有时会把站名拼进去,比如“我的博客 - Hello World”,需要再切一下。html_partial=True表示只返回正文片段,不套<html>壳。Readability 的局限在于,如果正文被切成多页(比如“阅读全文”到第二页),它只能抓到第一页,而且对表格密集的博文可能会漏掉表格。遇到这种就切换手动清理:用select_one("article")圈定容器,再调用decompose()清掉script、iframe、.ad、.social-share,最后只保留<h1>到<h6>、<p>、<pre>、<img>、<table>这些语义标签。清理时要小心decompose()是连同子节点一起销毁,别误删正文里的代码块。

验证提取效果有个土办法:把提取出来的文本字符数和文章在页面上的肉眼字数对比,如果差了一倍以上,多半是漏了某些懒加载区块。另外,有些博客把正文放在 JSON-LD 结构化数据里,比如articleBody字段,这种直接用json.loads解析反而更简单。遇到代码类博客时,还要检查<pre>里的空格是否被压缩,必要时在保存前把white-space: pre的样式固化到 HTML 里。

5. 博客下载避坑指南:5 个最容易翻车的问题与排查方法

5.1 现象:下载的 HTML 用浏览器打开全是乱码

原因:服务器返回的是 GBK 编码,你的脚本按 UTF-8 解析了;或者写文件时系统默认用了 GBK。解决:先让 Requests 自动猜测编码,resp.encoding = resp.apparent_encoding,再在写 HTML 时显式encoding="utf-8"并在 HTML 头部加<meta charset="utf-8">。排查时看一眼保存的文件头,乱码多发生在中文博客的老主题上,尤其是 2008 年以前的 WordPress。这个坑属于“早踩早发现”,一旦备份了上千个文件才发现,转编码的成本会高到你怀疑人生。

5.2 现象:文章正文一半能抓到,一半是空

原因:目标站点用了懒加载的正文分块,或者正文是多个接口拼接的,Readability 算法只等到第一条数据返回。解决:不要用networkidle等待,改成轮询判断:每 200 毫秒检查一次正文容器的字数,如果连续 5 次字数不再变化,认为渲染完成。这个方法对 SPA 博客同样有效,在 Playwright 里用page.wait_for_function就能实现。实际项目里我见过最离谱的情况是正文分三段接口返回,第二段接口因为鉴权失败返回空,页面不报错,只少一块内容。遇到这种必须打开浏览器控制台看网络请求有没有红色报错。

5.3 现象:本地打开备份的 HTML,图片全是裂图

原因:图片 URL 是相对路径,但你的备份目录结构和原站不一致;或者图床防盗链只认博客域名。解决:下载图片到本地后,用urljoin把相对路径变成绝对路径再去请求;存本地时把img.src改成本地路径,建议统一为images/文件夹,并保留原始文件名。注意文件名可能重复,加个哈希前缀能防止覆盖,比如md5(img_url)[:8] + "_" + original_name。这个问题的隐蔽之处在于,浏览器打开本地文件时 Referer 为空,防盗链图片全部静默失败,控制台只报net::ERR_FAILED。

5.4 现象:重复跑下载脚本,文件越来越多

原因:没有做增量更新,每次都是全量抓取,覆盖策略不对。解决:记录每篇文章的 URL 和最后修改时间(可以从Last-Modified响应头获得),存到 SQLite 里。二次抓取时先查数据库,如果 URL 已存在且内容长度没变化,就跳过。这样能省大量请求,也减少被封风险。我习惯用文章的规范 URL 做主键,因为同一个页面可能通过?from=rss和?from=home两个地址访问,本质上是一个文章。如果数据表里出现 URL 不同但标题和正文完全相同的记录,说明去重逻辑还不够严。

5.5 现象:连续抓几十页后突然全部 403

原因:请求频率太高,触发了站点的反爬策略。解决:先停半小时,然后把每次请求的间隔从 0.2 秒拉到 2 秒以上,并关闭并发。用time.sleep控制;同时给User-Agent加上版本号,让服务器觉得你是一个浏览器。最怕的是脚本把 500 错误当成正常页面继续跑,一定要检查resp.status_code,如果连续出现多个 403 或 5xx,立即暂停。这个暂停逻辑可以写成熔断:连续失败 5 次后,后续请求全部排队,等时间窗口过了再放行。

提示:以上所有方案都遵循一个原则——先小规模试跑 10 篇文章,确认输出质量后再全量。全量前把time.sleep和重试写对,比任何并发技巧都重要。

6. 把下载结果转成 Markdown、PDF 与索引页:最后的整理技巧

下载下来的原始 HTML 通常还带着主题样式,不便阅读和检索。我习惯用 Pandoc 统一转成 Markdown,再按需生成 PDF。Pandoc 的命令很简单,但有几个参数值得注意。

pandoc article.html -f html -t gfm -o article.md

-f html -t gfm指定输入和输出格式,gfm是 GitHub 风格 Markdown,对代码块和表格支持最好。如果文章里有大量代码块,转换后要检查```是否有丢失,尤其是原 HTML 里嵌着行内代码时。转 PDF 我一般用weasyprint或 LaTeX 引擎,但中文字体配置比较折腾,建议直接用系统自带的wkhtmltopdf先出一版,确认没有乱码再精细化。

生成索引页是让备份真正“可用”的一步。我用一个简单的 Python 脚本扫描所有 Markdown 文件,提取title和date,然后按月份分组生成index.html,这样每次打开备份文件夹就能看到文章时间线。脚本本身不复杂,但要注意文件名排序是字符串排序,2024/10和2024/9会排错,需要转成YYYY-MM格式再分组。

我自己的习惯是:下载脚本跑完后,先随机抽 3 篇文章手工检查,再跑一遍总数统计,最后用diff对比前一次备份的索引文件,看有没有异常增删。有一次备份某技术博客时,图片全部下载成功,但正文里有一半是“该内容已被作者删除”的占位符,图片却还在——那天如果只看图片完整性,就会漏掉大量无效文章。后来我在验收逻辑里加了一条:必须存在articleBody或正文文本字符数大于 500,才认定文章有效。

博客下载工具这块,最容易翻车的永远不是代码逻辑,而是对目标站点结构的理解。先把类型判断做对,再追求速度,最后再考虑格式转换。这个顺序反了,就是在垃圾数据上盖高楼。希望这套流程能帮你少踩几个坑,顺利把喜欢的博客搬回家。

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

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

工业级电源路径保护:TPS259483AYWPR与STM32F767ZG协同设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/29 9:41:19

HC32系列MCU量产烧录:J-Flash定制化配置与产线避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/29 9:40:15

工业级电源路径保护:TPS259483AYWPR与PIC18F45K50软硬协同设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/29 9:39:04

OpenShell 开放外壳设计:从能力注册到插件化扩展的工程实践

1. OpenShell 是什么&#xff1a;从一个“壳”字说起第一次看到 OpenShell 这个名字&#xff0c;很多人会下意识把它和 Linux 的 shell、终端、命令行工具联系在一起。这个直觉不算错&#xff0c;但也不完全对。OpenShell 的核心定位&#xff0c;是给一个已有的系统或程序套上一…

作者头像 李华
网站建设 2026/10/29 9:38:22

ESP-IDF 上跑 AI Agent:端云协同的物联网智能交互实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/29 9:38:10

STM32+TPS259483智能电源路径保护设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华

关于博客

这是一个专注于编程技术分享的极简博客,旨在为开发者提供高质量的技术文章和教程。

订阅更新

输入您的邮箱,获取最新文章更新。

© 2025 极简编程博客. 保留所有权利.