资讯动态

博客离线下载工具实战:正文提取、Markdown归档与增量备份

发布时间:2026/10/20 17:26:20 来源:尧图企业网站定制
简介这是一个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 环境下我用 fetchNode 18 以上内置或者 axios。fetch 内置意味着少装一个依赖但 axios 在超时、重试、拦截器这些细节上更顺手。解析层 - 一个博客的主题模板再五花八门最终解析 HTML 也会用到同一个底层方案Node 生态用 cheerioPython 生态用 BeautifulSoup。cheerio 的 API 和 jQuery 几乎一致上手成本极低而且性能不错。Python 里我偶尔用 parsel它基于 lxmlCSS 选择器和 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/xhtmlxml } }); 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 classpost-content里有的在div idmain里。第二种是标记差异代码高亮有的用precode有的用code classlanguage-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(stripTrue)) # 过滤掉只有少量文本但嵌套很深的容器 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原始数据还在重新解析一遍就行。用这个思路做出来工具希望你也能少走这些弯路希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑