☰
博客离线下载工具实战:正文提取、Markdown归档与增量备份
2026/10/20 17:26:10 网站建设 项目流程

简介:这是一个CSDN博客批量备份与搬迁工具,面向希望把个人多年长文完整导出的博主,以及需要研究页面解析和爬虫逻辑的开发者,整体属于博客备份场景中的轻量级解决方案。压缩包仅有272KB,却收纳了7个文件:一个可编辑的Java源文件、两个编译后的class文件、一个核心爬虫依赖jar包,以及project、classpath等Eclipse工程配置文件,体量紧凑,适合直接运行或导入开发环境学习。该资源目前已有470人次学习下载,属于较实用的小工具,也适合用作爬虫入门案例。作者在本地调试改进后,成功下载30余篇长文,甚至1GB规模的内容也能完整抓取,稳定性不错;同时附带了排错过程,遇到解析器版本兼容问题可参考解决。需要留意的是,源码中部分CSDN域名固定写死,若打算抓取其他博客平台,需要全局替换域名并重新编译。

1. 博客下载工具:为什么我需要它

一个很常见的场景:你在某个技术博客上读到一篇好文章,想存下来慢慢看,但浏览器收藏夹里的链接有一天突然变成 404。博主删文、平台迁移、域名过期,任何原因都能让一篇文章从互联网上彻底消失。博客下载工具就是为了解决这个问题而生的——它能把一个博客站点上的文章批量抓下来存到本地,保存成结构化文件,让你拥有属于自己的离线副本。

这类工具适合三类人:写技术笔记的开发者,想把别人的优秀文章归档下来做资料库;准备做博客迁移的作者,想把自己的站点完整备份到本地;还有做数据分析的人,想把一批博客作为语料抓下来做处理。它听起来像爬虫,但比爬虫更聚焦——目标明确是博客站点,输出要求是干净可读的正文,而不是一堆混杂的 HTML 标签。我把自己的方案叫某跨平台系统,下面把完整思路、代码和踩过的坑都拆开讲。

2. 核心思路与技术选型:先想清楚再动手

2.1 这类工具的共同架构:抓取、解析、存储三层分离

一个博客下载器无论用什么语言写,核心架构基本都是三层。第一层是抓取层,负责把网页 HTML 下载下来;第二层是解析层,负责从混着导航、侧栏、评论的 HTML 里把正文提取出来;第三层是存储层,把提取到的内容落盘。这三层必须分开设计和实现,否则工具很快就会变成一坨改不了的黑匣子。

我见过不少下载工具翻车,翻车原因几乎都出在三层耦合在一起。比如抓取逻辑里直接写了正文提取的 CSS 选择器,一旦目标博客换了主题,选择器失效,整个工具就没法用了。正确的做法是把每一层做成独立的模块,接口固定好——抓取层只负责返回原始 HTML 和响应状态,解析层只负责输入 HTML 输出结构化内容,存储层只负责写入。这样任何一层出了问题,替换掉那一层就行,不需要推倒重来。

图省事直接用现成的通用爬虫框架(比如 Scrapy 之类)去抓博客也可以,但对博客下载这个场景来说有点重。博客站点的规模通常不大,少则几十篇,多则几千篇,用不着分布式抓取和任务队列。我更倾向于用轻量方案:Node.js 或者 Python 写脚本,配合几个成熟库就能搞定。Node.js 的优势是异步 IO 顺手,处理并发请求非常自然;Python 的优势是生态全,后续要把正文做 NLP 处理会很方便。如果是纯下载归档,我一般选 Node.js + TypeScript。

2.2 为什么选这些库:各有各的不可替代性

抓取层最常用的 HTTP 客户端,Node 环境下我用 fetch(Node 18 以上内置)或者 axios。fetch 内置意味着少装一个依赖,但 axios 在超时、重试、拦截器这些细节上更顺手。解析层 - 一个博客的主题模板再五花八门,最终解析 HTML 也会用到同一个底层方案:Node 生态用 cheerio,Python 生态用 BeautifulSoup。cheerio 的 API 和 jQuery 几乎一致,上手成本极低,而且性能不错。Python 里我偶尔用 parsel,它基于 lxml,CSS 选择器和 XPath 都支持。

正文转 Markdown 这一步,turndown 是 Node 生态里最成熟的方案。它能把 HTML 转成干净的 Markdown,对代码块、表格、链接这些元素的支持都比较好。存储层则看输出需求:如果只是给人读,Markdown 文件就是最佳格式,一个文件一篇文章;如果还要做全文搜索和数据分析,用 SQLite 存结构化字段更合适。我两个都做——文件方便阅读和 Git 管理,SQLite 方便检索。

选型有一个原则要记住:越贴近标准越好。不要用某个博客平台专用的 SDK 去抓,因为博客随时可能换平台,而 HTML 是永远存在的接口。

2.3 抓取频率与礼貌策略:什么参数才算合理

抓博客不是攻击站点,频率控制是关键。我一般设置每抓一篇文章间隔 0.5 到 2 秒随机延时,并发控制在 3 到 5 个。太慢的话抓一个几百篇文章的站点要等很久;太快的话容易被限流甚至封 IP。还有一个必须做的事情是设置 User-Agent,写清楚是哪个工具在抓、干什么用,这是基本的网络礼仪,也是很多站点封禁策略的判断依据。

robots.txt 值得尊重但不能完全依赖。有些博客根本没配 robots.txt,有些配了但是允许抓取;最稳妥的做法是看站点有没有提供 sitemap.xml,有的话优先从 sitemap 拿文章 URL 列表,这比从首页和归档页去解析链接要准确得多。一个站点的文章列表可能有分页、有标签页、有归档页,从 sitemap 拿到的 URL 是最全的。

3. 写抓取脚本:URL 归一、并发控制与增量更新

3.1 抓取一个博客的最小脚本:从入口 URL 到全部文章

先给一个可以直接跑的完整脚本骨架,目标是把某个博客的所有文章链接收集起来。核心思路是:访问博客首页,解析出所有可能的文章链接,然后循环访问这些链接,确认它们是文章页面而不是标签页或分类页。

import { load } from 'cheerio'; import fs from 'node:fs/promises'; // 抓取单个页面的 HTML async function fetchHtml(url, timeout = 10000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'Mozilla/5.0 BlogArchiver/1.0 (+https://example.com/bot)', 'Accept': 'text/html,application/xhtml+xml' } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.text(); } finally { clearTimeout(timer); } } // 从 HTML 中提取所有站内链接 function extractLinks(html, baseUrl) { const $ = load(html); const links = new Set(); $('a[href]').each((_, el) => { const href = $(el).attr('href'); if (!href) return; try { const absolute = new URL(href, baseUrl).href; // 只保留同域名的 http/https 链接 if (absolute.startsWith('http') && new URL(absolute).hostname === new URL(baseUrl).hostname) { links.add(absolute.split('#')[0].replace(/\/$/, '')); } } catch { // 解析失败的链接直接跳过 } }); return [...links]; } // 判断一个 URL 是否像文章页(通过 URL 特征过滤) function isLikelyPost(url) { // 排除明显的非文章页面 const excludePatterns = [/\/(tag|category|author|page)\//, /\?page=\d+/, /\/feed\/?$/, /\/wp-json\//, /\.(jpg|png|gif|pdf|zip)$/i]; if (excludePatterns.some((re) => re.test(url))) return false; // 常见博客 URL 形态:/post-name /archives/123.html /YYYY/MM/DD/name return /\/(archives|p)\/\d+/.test(url) || /\/(20\d{2})\/(0[1-9]|1[0-2])\//.test(url); } async function main() { const entryUrl = 'https://example-blog.com/'; const html = await fetchHtml(entryUrl); const allLinks = extractLinks(html, entryUrl); const postUrls = [...new Set(allLinks.filter(isLikelyPost))]; // 这里只输出链接数量,下一节再实现完整抓取 console.log(`发现 ${postUrls.length} 个疑似文章链接`); await fs.writeFile('post-urls.json', JSON.stringify(postUrls, null, 2)); } main().catch(console.error);

这段代码解决了两个核心问题:链接去重和 URL 归一化。很多博客在首页、归档页、标签页会重复出现同一篇文章的链接,用 Set 去重是第一步;URL 归一化则是把https://site.com/post/和https://site.com/post以及https://site.com/post#comments都视为同一个链接,统一去掉锚点和末尾斜杠。isLikelyPost 的过滤规则是经验值,不同博客平台的 URL 形态差异很大,实际使用时需要根据目标站点调整。

3.2 如何导出博客全部文章的 URL 列表:两个入口、两条路

只抓首页能拿到的链接非常有限,通常只有最近几篇。要拿到一个博客的全部文章链接,常见做法是两条路:一条是从 sitemap.xml 拿,这是最可靠的;另一条是递归遍历分页归档。sitemap 的解析非常简单——它本质是一个 XML 文件,里面每一个<loc>标签就是一条 URL。分页遍历则要从首页找到分页链接,访问第二页、第三页直到没有下一页。

我一般优先尝试 sitemap,找不到再走分页。有一个边界情况要注意:部分建站工具的 sitemap 是分多个文件的,根 sitemap 只给索引,真正的 URL 在子 sitemap 里。代码里判断<sitemapindex>和<urlset>这两种根节点,如果是索引就递归解析子 sitemap。分页遍历则要小心中断条件——有些博客的最后一页会循环回到第一页,导致死循环。解决方法:维护一个已访问 URL 集合,遇到重复 URL 就停止。

3.3 并发控制与重试机制:不要让脚本断在半路

文章列表拿到手之后,真正的抓取工作才开始。一个几百篇文章的博客,顺序抓会非常慢,全部并发抓又容易被封。实现一个简单的并发池,控制同时最多 4 个请求,配合失败重试,这样效率和稳定性都有保障。

async function runWithConcurrency(tasks, concurrency = 4) { const results = new Array(tasks.length); let current = 0; async function worker() { while (current < tasks.length) { const index = current++; try { results[index] = await tasks[index](); } catch (err) { results[index] = { error: err.message }; } } } const workers = Array.from({ length: concurrency }, () => worker()); await Promise.all(workers); return results; } // 带重试的抓取 async function fetchWithRetry(url, retries = 3) { for (let i = 0; i < retries; i++) { try { return await fetchHtml(url); } catch (err) { if (i === retries - 1) throw err; // 指数退避:第一次失败等 2s,第二次等 4s await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); } } } // 抓取所有文章 HTML 并保存原始文件 async function crawlPosts(postUrls, outputDir) { const tasks = postUrls.map((url) => async () => { const html = await fetchWithRetry(url); const slug = new URL(url).pathname.replace(/\//g, '_').slice(0, 80); await fs.writeFile(`${outputDir}/${slug}.html`, html); return { url, status: 'ok' }; }); const results = await runWithConcurrency(tasks, 4); const failed = results.filter((r) => r.error); console.log(`成功 ${results.length - failed.length} 篇,失败 ${failed.length} 篇`); // 把失败的 URL 单独存文件,方便二次补抓 if (failed.length > 0) { const failedUrls = postUrls.filter((_, i) => results[i].error); await fs.writeFile('failed-urls.json', JSON.stringify(failedUrls, null, 2)); } }

保存原始 HTML 是一个很重要的中间步骤,后面解析正文、重新转换格式都靠这份原始文件,不用重新请求站点。失败 URL 单独存文件的意义在于:网络波动导致的部分失败不会让整个任务白跑,下次跑脚本直接读 failed-urls.json 补抓就行。参数方面,重试次数 3 和退避基数 1000 毫秒是经验值;站点响应明显慢的时候,把退避基数调到 2000 更稳妥。

3.4 增量抓取:只下载新文章的快与省

全量下载一次之后,再跑脚本如果还要重新抓几百篇文章,时间和带宽都很浪费。增量更新的核心是:记录每一篇文章的状态(已下载页面的内容哈希或最后修改时间),下次抓取时对比判断内容有没有变化。

对于博客下载工具,一个比较实用的判断依据是文章列表页的内容——如果列表页没有出现新的链接,那就不需要重新抓取。更精细的做法是对比已存文件的哈希值和线上 HTML 的哈希值,但这样还是需要下载全文,省不了带宽。折中方案是访问文章页时带上 If-Modified-Since 或 ETag 头,如果服务端返回 304 Not Modified,就说明文章没变过。

// 增量抓取核心逻辑 async function incrementalCrawl(postUrls, downloadStateFile) { const state = JSON.parse(await fs.readFile(downloadStateFile, 'utf-8')); for (const url of postUrls) { const prevState = state[url]; const res = await fetch(url, { headers: { 'If-Modified-Since': prevState?.lastModified || '' } }); if (res.status === 304) { console.log(`跳过(无变化): ${url}`); continue; } const html = await res.text(); const lastModified = res.headers.get('last-modified') || new Date().toUTCString(); state[url] = { lastModified, downloadedAt: Date.now() }; // 保存 HTML、更新状态文件的代码省略,参考上一节 } await fs.writeFile(downloadStateFile, JSON.stringify(state, null, 2)); }

这个方案依赖服务端正确返回 Last-Modified 头。很多静态博客托管平台是支持的,但部分动态站点可能不返回。对这种站点的降级方案是:定期做一次全量抓取,两次全量之间用增量。对于文章不频繁更新的个人博客,一个月全量一次就足够了。

4. 从 HTML 到干净 Markdown:正文识别与去噪

4.1 正文识别为什么难:博客模板的三种差异

HTML 抓下来了,最麻烦的部分才开始。一个博客页面里混着导航栏、侧边栏、相关文章、评论、广告位、页脚——这些噪音的体积常常是正文的几倍。正文识别的难点在于:不同博客平台的模板结构千差万别,老式博客用 table 布局,新式博客用 div + class,还有不少用 article 语义标签。

三种常见差异决定了不能用一套固定选择器打天下。第一种是结构差异:有的正文在<article>标签里,有的在<div class="post-content">里,有的在<div id="main">里。第二种是标记差异:代码高亮有的用<pre><code>,有的用<code class="language-js">,有的用<pre>包住一切。第三种是内容格式差异:有的正文里嵌广告、有的嵌推文、有的文章本来就是纯图片分享。

4.2 多级策略:从最可靠到最通用的降级方案

正文提取我做成多级策略,从精确到宽泛逐级尝试。第一级:查<article>标签,这是 HTML5 语义化标签,可靠度最高。第二级:查常见的内容类名,如post-content、article-content、entry-content、markdown-body等,这个列表需要维护,不同博客平台有不同的命名习惯。第三级:用文本密度算法判断——取页面里文本密度最高的容器元素,这是没有明显标记时的兜底方案。

文本密度算法的核心逻辑很简单:统计每个 div 容器内的文本字符数,以及文本与标签的比例,取文本量最大且比例最高的那个容器作为正文区。实现上可以用 cheerio 依次遍历所有 div,计算它们的文本长度,找到文本最长的那个,再向上回溯两到三级找到合适的包裹节点。

4.3 从原始 HTML 到干净 Markdown 的完整管线

用 turndown 转 Markdown 的关键前置工作是清理 HTML。先把导航和评论区砍掉,再处理正文内部的广告、无关图片、分享按钮。下面是这条清洗管线的核心实现:

from bs4 import BeautifulSoup import re def extract_article_content(html: str): soup = BeautifulSoup(html, 'lxml') # 第一步:移除明显的噪音节点 for tag in soup(['script', 'style', 'nav', 'aside', 'footer', 'form']): tag.decompose() # 移除评论区域(评论区通常在特定 id 或 class 中) for comment_area in soup.select('[id*="comment"], [class*="comment"]'): comment_area.decompose() # 第二步:按优先级查找正文容器 article = soup.find('article') if article: return article content_selectors = [ '.post-content', '.article-content', '.entry-content', '.markdown-body', '#main-content', '.post-body' ] for sel in content_selectors: node = soup.select_one(sel) if node and len(node.get_text()) > 200: return node # 第三步:兜底用文本密度——找文本最长的 div best_candidate = None max_text_len = 0 for div in soup.find_all('div'): text_len = len(div.get_text(strip=True)) # 过滤掉只有少量文本但嵌套很深的容器 if text_len > max_text_len: max_text_len = text_len best_candidate = div return best_candidate # HTML -> 正文 -> 去掉残余噪音 -> 干净的 HTML 片段 def clean_content(html: str): content = extract_article_content(html) if not content: return None # 移除正文里残留的分享按钮、作者简介等 for hidden in content.select('.share, .social, .author-box, .related-posts'): hidden.decompose() # 处理代码块:保留 pre/code 结构,去掉多余的包裹标签 for pre in content.find_all('pre'): # 确保 pre 内部是干净文本 text = pre.get_text() pre.clear() pre.string = text # 转换相对链接为绝对链接(依赖外部 URL) return str(content)

这段代码解决了两个关键点:评论区的移除和代码块的收敛。评论区用属性匹配的方式做模糊删减,比精确匹配 id 更通用,代价是可能误伤名字里带 comment 的正文容器——所以这个操作要在定位正文之前执行。代码块处理有个细节:<pre>内部如果有嵌套的<code>标签,直接 get_text() 会把语法高亮的 span 全剥掉,得到干净代码文本,这正是 Markdown 需要的。

4.4 图片与附件下载:让离线阅读真正离线

正文提取出来了,但里面的图片还是远程链接。如果目标是完全离线阅读,图片就得下载到本地,然后把正文里的链接换成相对路径。这一步在实现上很容易踩坑:很多博客的图片做了懒加载,图片的真实地址不在 src 属性里,而在>def process_images(content_tag, base_url: str, img_dir: str): img_index = 0 for img in content_tag.find_all('img'): src = img.get('src') # 懒加载的图片地址通常在>-- 建表与全文索引 CREATE VIRTUAL TABLE posts_fts USING fts5( title, url, content_tokenized, tokenize = 'unicode61' ); -- 查询时按 BM25 排序 SELECT title, url, snippet(posts_fts, 2, '[[', ']]', '…', 12) AS excerpt FROM posts_fts WHERE posts_fts MATCH ? ORDER BY bm25(posts_fts);

查询接口我用 Node.js 起一个 Express 服务,API 路径是/search?q=关键词,返回结果带摘要。摘要功能用 FTS5 内置的 snippet 函数实现,在命中词的上下文截取一段文字,比返回整篇文章再高亮体验好得多。每次下载完成之后跑一次索引重建脚本,把新增的 Markdown 文件切词入库,增量更新即可。这个本地检索服务的全部代码量控制在一两百行以内,简单实用。

最后是血泪经验总结:写博客下载工具这件事,最大的教训是永远不要把抓取逻辑和解析逻辑耦合在一起。我第一版工具就是耦合写法,后来目标博客换了一次主题,整个脚本直接报废,花了整整一个下午重构。现在我的习惯是 HTML 抓下来先存原始文件,解析流程独立跑,这样就算解析出了 bug,原始数据还在,重新解析一遍就行。用这个思路做出来工具,希望你也能少走这些弯路,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询