资讯动态

从零实现技术博客文章批量抓取与Markdown转换工具

发布时间:2026/10/14 19:56:10 来源:尧图企业网站定制
去年我给自己定了个小目标把某技术博客平台上排名靠前的几位博主的高质量文章系统性地离线读一遍。最开始打算直接用现成的网页转Markdown工具试了一圈发现要么年久失修、要么对文章内的代码块处理得乱七八糟后来干脆自己写了一个定向抓取小工具把选定博主的所有文章抓下来统一转成Markdown格式按博主建文件夹归档到本地。这个工具最后长成了一个能跑完整流程的东西自动解析博主列表、翻页抓取文章链接、提取正文、清洗无关模块、转Markdown、补全元信息、按目录保存。整个过程里踩了不少坑尤其是页面结构变动、编码问题、下载频率控制这几块折腾了好几个晚上。这篇博文就是把这个项目的完整过程拆开从需求分析、技术选型到核心代码实现、稳定性设计再到常见问题排查一步步讲清楚。如果你也想把手头喜欢的几个博主文章存成干净的Markdown慢慢读或者刚接触爬虫想找一个不太复杂又很有代表性的练手场景这篇文章很适合你。代码我用的Python核心依赖只有requests、BeautifulSoup、html2text这几个没有太重的东西照着我写的思路改一改就能跑。1. 需求拆解与整体设计1.1 这个工具到底解决什么问题先别急着写代码我把标题里的几个关键词拆开来看前十博主、文章爬取、转换为md。这三个词各自对应一个核心需求。前十博主说的是目标范围。到底是哪十位、按什么标准排的这个必须提前定义清楚。我的做法是不依赖网站本身的排行榜接口因为这些榜单经常换算法、换规则今天抓到的前十跟明天抓到的可能完全不是一批人而且榜单数据往往是通过异步接口加载的直接解析HTML页面很容易扑空。更稳妥的做法是维护一个固定博主白名单抓取名单里每个人的文章主页。怎么获取这个名单手动去平台上搜一圈、找一个方向下的头部作者把主页链接存下来就行。这样工具本身逻辑简单结果也可控不会哪天因为榜单变动导致整个流程白跑。文章爬取是整个项目的主体。爬哪些字段、按什么频率爬、爬到什么程度算完成这些都需要细化。我的字段设计只有一个原则够用就好。考虑到最终产物是Markdown我不需要保存页面上的阅读数、点赞数、榜单徽章这类东西只需要标题、发布时间、作者名、正文里的结构化内容包括标题层级、代码块、图片、表格。至于目录页我抓的是每个博主文章列表里能拿到的文章链接和标题正文页我抓的是正文容器里的HTML片段。其他一概不碰。转换为md是输出的关键。这个最容易被低估很多人以为HTML转成Markdown 去掉标签实际上完全不是这么回事。代码块要保留语法高亮需要的语言标识图片要处理为本地文件引用表格要转成Markdown表格语法文章内的标题层级也要跟原文一致。如果只是把HTML里的文本节点拼起来出来的东西基本不能看。所以我在转换这一步单独引入了一个专职转换库并叠加了自定义清洗逻辑后面会细讲。1.2 方案选型为什么不直接用现成抓取工具老实说这个需求市面上确实有一些现成方案。有的浏览器插件支持把当前页面保存为Markdown有的开源项目支持输入文章URL一键拉取正文。但它们的短板也很明显。第一是扩展性差。这些工具面向的是单篇网页不是博主维度的批量归档。面对一位博主平均两三百篇文章、十位博主就两三千篇的情况手动一篇篇点击保存显然不现实而且分页列表的翻页操作很多工具根本不支持。第二是转换质量不可控。很多插件转换出来的Markdown里代码块缩进丢失、行内代码混成普通文本、图片变成超长base64串处理起来比原始HTML还麻烦。第三是数据无法标准化。我希望文章按作者/文章标题.md的规则落盘并统一在文件顶部写入时间、标签等元信息现成工具做不到。所以我决定自己写。但也不是一上来就上重型框架我手里三类方案对比过纯requests 正则太脆页面结构一调整就崩正则没法稳定提取嵌套的HTML结构。Scrapy功能强适合大规模爬取但项目前期配置分布式代理、Pipeline这些有点杀鸡用牛刀。requests BeautifulSoup html2text轻量、直观、可控对静态渲染的HTML页面非常够用。我最后选的是第三个。这套组合每层都能人工介入结构什么时候变了、哪块解析不了都能快速定位。短板我自己也清楚如果哪天目标页面改成复杂的前端动态渲染就需要引入无头浏览器我会在后面的扩展部分聊这个。2. 技术选型与前置准备2.1 核心依赖与选型理由整个项目用到的东西非常收敛核心就五个Python 3.10现在新的系统基本都是这个起步语法上我用了类型标注方便维护。requests用于发送HTTP请求。选它不选urllib是因为requests的会话管理、超时控制、Cookie维持写起来更顺手代码量少一半。BeautifulSoup4 lxml 解析器用于解析HTML文档树。lxml比Python内置的html.parser快很多在这个场景下体感差别很明显。html2text专门把HTML转成Markdown文本。它是这个项目里最关键的库负责把h2转成##、把precode转成代码块、把table转成表格语法。retrying用来做请求重试。虽然也可以自己写循环但lib库的装饰器方式更直观。这些库安装就一条命令没有让人头疼的环境依赖。使用时唯一的注意点是html2text的版本不同版本对body_width、ignore_links这些参数的默认值有差异我在代码里会显式传参避免行为不一致。为什么不引入无头浏览器现在的技术博客平台大多是服务端渲染正文文章内容在静态HTML里就能拿到引入浏览器反而会拉低抓取速度还会显著增加被识别为机器访问的概率。等哪天发现目标页面内容需要JS动态加载后再加不迟。2.2 抓取前的页面结构分析与访问约定写爬虫不要边猜边写必须先打开页面结构看一遍。我用浏览器自带的开发者工具随便点开目标平台的一篇文章页重点看三个地方。第一是正文容器长什么样。我习惯先在HTML面板里搜索正文里的某句话然后向上找包含它的父级节点确认这个容器的class或id。目标平台的正文容器通常是某个article节点或带markdown_views、content这类名字的div节点。第二是文章列表的分页URL规律。打开一位博主的文章列表页点下一页看地址栏里的page参数怎么变化以及页面上有没有翻页按钮对应的真实链接。第三是文章列表的条目结构。每条文章标题的a节点长什么样href是相对路径还是绝对路径标题是不是被嵌套在别的标签里。分析完之后还要看网站的 robots 协议。虽然个人学习用途的抓取并不涉及复杂合规问题但我还是建议先看一眼目标站点根目录下的robots.txt确认对方是否明确对某些路径设置了禁止抓取。如果禁止就别去硬闯。技术能力是一回事尊重对方服务器资源和版权边界是另一回事。另外我给自己定了几条访问约定写死在代码里单次请求间隔不小于1.5秒最好还能随机不超过0.5秒的抖动。单次运行最多爬一个博主的前50篇需要更多就分批跑。遇到HTTP 4xx或5xx时立即停止当前循环不暴力重试。每次运行结束后写一份日志记录成功和失败的文章URL方便续跑。这些约定看起来简单但真的能让脚本长时间稳定运行。很多人的爬虫跑半小时就IP被限制基本都是频率和重试策略没控制好。3. 核心实现拆解3.1 获取目标博主列表博主列表我前面说了不用榜单页用白名单。白名单的格式非常简单一个纯文本文件每行一个博主主页链接后面跟着可选的备注名。有了这个脚本启动时先读文件再逐个处理。from pathlib import Path from typing import List, Tuple def load_bloggers(file_path: str bloggers.txt) - List[Tuple[str, str]]: bloggers [] path Path(file_path) if not path.exists(): raise FileNotFoundError(f未找到博主名单文件: {file_path}) for line in path.read_text(encodingutf-8).splitlines(): line line.strip() if not line or line.startswith(#): continue parts line.split(,) name parts[0].strip() url parts[1].strip() if len(parts) 1 else if url: bloggers.append((name, url)) return bloggers这里有个小细节博主名字我直接放在文件里因为后面保存文章时要建文件夹。如果脚本自己从页面里抓博主名万一页面结构变了抓不到文件路径就会乱套。宁可让使用者手动维护一小份名单也不让它自动推断。榜单路径也不是完全没用。如果某天你想抓全站热门榜只需在load_bloggers函数之外加一个解析榜单页的函数把解析出来的博主主页URL喂给同一个主流程就行。我一开始就是从这个思路做的把“博主来源”和“抓取流程”解耦后面扩展就轻松很多。3.2 抓取文章列表有了博主主页URL下一步是抓这个博主的文章列表。文章列表一般有两种形态一种是服务端渲染HTML里直接有所有文章条目另一种是异步加载HTML里只有一个空壳数据靠后续的XHR接口返回。目标平台属于前者我直接解析HTML。列表页需要关注的字段有三个文章标题、文章链接、发布时间。发布时间的格式各平台不统一我统一解析成YYYY-MM-DD写进Markdown元信息里。import requests import time import random from bs4 import BeautifulSoup from typing import List, Dict, Optional HEADERS { User-Agent: Mozilla/5.0 (...) Chrome/120.0 Safari/537.36 } def fetch_article_list(blogger_home: str, max_pages: int 5) - List[Dict[str, str]]: articles [] session requests.Session() session.headers.update(HEADERS) page_urls [f{blogger_home}/article/list/{i} for i in range(1, max_pages 1)] for page_url in page_urls: try: resp session.get(page_url, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding except requests.RequestException as e: print(f列表页请求失败: {page_url}, 错误: {e}) continue soup BeautifulSoup(resp.text, lxml) # 这里的选择器以实际页面结构为准示例只表达思路 items soup.select(div.article-item a.title) for item in items: title item.get_text(stripTrue) href item.get(href, ) if href.startswith(/): href fhttps://example.com{href} articles.append({title: title, url: href}) time.sleep(random.uniform(1.5, 2.0)) return articles代码里有两处需要特别注意。一处是resp.encoding resp.apparent_encoding这一步很关键。很多页面声明的charset和实际内容不一致直接按声明编码解析会出现中文乱码。apparent_encoding是requests根据内容推测出来的编码通常更准。另一处是翻页URL的构造不同博主可能因为粉丝量差异拥有不同的总页数request.get(...)前我会先尝试请求第二页如果状态码异常就说明没有那么多页停止翻页。这个逻辑我没有写在上面的示例里自己实现时加上会更稳。文章列表抓下来之后建议先做一步去重。博主可能把旧文章删了重新发同一个链接出现在两个分页里。去重用URL就行不要用标题标题可能重名而URL不会。3.3 正文提取与清洗拿到文章URL就该抓正文了。这一步我踩的坑最多因为正文页里的干扰元素实在太多了推荐文章、目录锚点、评论框、扫码关注浮层全混在页面里。如果直接整个页面转Markdown产出的文件会非常臃肿。我的思路是先定位正文容器然后只保留这个容器内的HTML节点再在这个范围里做清洗。清洗规则我用一个列表管理方便后续增删。def clean_content(soup: BeautifulSoup) - BeautifulSoup: # 删除常见干扰节点 for selector in [ div.recommend, div.related-article, #comment-box, div.qrcode, aside, div.article-copyright ]: for node in soup.select(selector): node.decompose() # 删除空段落和无意义的包装节点 for p in soup.find_all(p): if not p.get_text(stripTrue) and not p.find(img): p.decompose() # 代码块内的空行压缩交给后续转换处理这里保留结构 return soup清洗的思路可以概括成三步走。第一步是黑名单删除把确定不用的模块按选择器删掉。第二步是空节点删除把没有任何内容的白板标签清理掉避免Markdown里出现大段空行。第三步是白名单保护确保pre、code、img、table这些承载关键信息的节点不被误删。第三步是关键很多刚写爬虫的人容易犯一个错就是先删后保结果把代码块里的内容也删了。正文定位完成后还有一个重要操作把图片链接转换成可下载的绝对地址。博客平台的反盗链机制比较常见直接拿相对路径存进Markdown里本地打开就会发现图片全裂。我的做法是遍历容器里所有img标签把src补全为绝对URL并加入一个>import html2text def html_to_markdown(html: str, base_url: str ) - str: converter html2text.HTML2Text() converter.body_width 0 converter.ignore_images False converter.ignore_tables False converter.ignore_emphasis False converter.protect_links True converter.unicode_snob True markdown_text converter.handle(html) if base_url: markdown_text markdown_text.replace(](/, f]({base_url}/) return markdown_textbody_width 0是为了不让长代码行被硬换行。很多转Markdown的工具默认会让文本在80列处换行这在普通段落里无所谓但在代码块里会导致缩进错乱所以必须关掉。unicode_snob True是为了保留全角符号、箭头这类字符不强制转成ASCII。文章文件命名我选用了{发布时间}_{标题}.md的格式发布日期放最前面按文件名排序时自然就是时间顺序非常方便。目录结构是这样archive/ 博主A/ 2024-03-01_标题一.md 2024-03-15_标题二.md 博主B/ 2024-05-01_标题三.mdMarkdown文件的头部还应该写入一段元信息用YAML格式这样后续接入其他工具时能直接解析。我的模板长这样--- title: 标题一 author: 博主A date: 2024-03-01 10:30 tags: [] url: https://example.com/article/123 --- 正文内容从这里开始这段元信息看起来不起眼但对后续使用帮助很大。你可以直接把它当成一个轻量的文章数据库也可以接入其他个人知识库工具。我用了最朴素的方式写文件没有额外引入数据库。4. 抓取稳定性与访问频率控制经验4.1 基础防护UA、频率与重试这一节不教怎么绕过限制只讲怎么让自己像一个正常的访问者降低对目标站点的压力也让脚本跑得更顺。首先是User-Agent。不要用默认的python-requests/2.31.0一眼就能看出是脚本。换成一个常见的浏览器UA比如Chrome或Safari最新版的UA字符串。如果代码跑在服务器上还要注意一些平台的接口会根据IP段判断地理位置这个我们控制不了也不用管。其次是请求频率。我前面提到随机间隔1.5到2秒这个数字不是拍脑袋定的。按这个频率抓一位博主50篇文章只需要两三分钟完全够用。如果把间隔降到0.2秒确实能快很多但每次请求后连着收到几次429状态码体验反而更差。无脑快刷是最不划算的做法。再次是重试策略。我用的retrying库装饰器里配置了三个参数stop_max_attempt_number3、wait_fixed3000、retry_on_exception只捕获网络层异常。遇到HTTP 4xx不重试因为重试没意义问题在页面或权限那边。from retrying import retry def retry_if_network_error(exception): return isinstance(exception, (requests.ConnectionError, requests.Timeout)) retry(stop_max_attempt_number3, wait_fixed3000, retry_on_exceptionretry_if_network_error) def safe_get(session, url, **kwargs): resp session.get(url, timeout10, **kwargs) resp.raise_for_status() return resp这里有个容易踩的坑retry_on_exception的返回值语义比较反直觉返回True才会触发重试False则不重试。我第一次用的时候写反了导致所有请求都不重试后来看文档才反应过来。4.2 登录态与验证码的降级处理有的博主设置了文章必须登录后才能看这种情况下直接抓会拿到登录页的HTML解析出来的正文是空的。我的处理原则是检测到验证码或登录弹窗就停止整个任务不要尝试绕过。理由很现实一是绕过验证码的技术风险和法律边界都比较麻烦二是这种博主的内容抓下来本身也涉及版权问题不值得为几篇文章冒风险。怎么检测登录墙我用的方案是检查页面里是否存在特定关键字。比如页面标题变成长得不像文章标题的固定文案或者正文容器为空但出现了登录按钮就触发降级逻辑。降级不等于失败我会把这个URL记录到skipped.log里然后继续下一篇而不是让整个脚本卡死。def is_login_wall(soup: BeautifulSoup) - bool: if soup.find(div, class_login-mask): return True if 验证码 in soup.get_text()[:2000]: return True if soup.select_one(button.login-button): return True return False这个函数必须在解析正文之前调用因为一旦正文提取逻辑跑完你可能误判为“这篇文章没有正文”错的排查方向会浪费很多时间。另一个经验是Cookie过期问题。如果脚本支持带Cookie跑比如你曾经登录过目标站把Cookie导出到文件里要格外小心Cookie过期导致的状态异常。我的建议是尽量不用登录Cookie宁可只抓公开可见的文章也不把自己账号跟脚本绑在一起省得哪天被平台的风控策略误伤。4.3 并发下载收益与代价为了提高效率我一开始确实考虑过并发下载。主流的做法是ThreadPoolExecutor开8个线程同时抓文章正文。试跑的效果是8个线程大概能把总耗时压到串行的四分之一看起来不错。但我很快就踩了坑。并发太高时请求之间的间隔控制会失效即使每个线程内部都随机sleep整体来看仍然像是密集扫描。我遇到了几次404和频繁的响应超时排查了半小时才发现是对面平台开始限流。降低到3个线程之后情况就好多了。最终我也没有全面铺开并发。现在的主流程是文章列表串行抓正文抓取最多开3个线程而且线程之间共享一个带频率控制器的Session。如果你要跑大批量任务我建议先把串行流程跑通再决定要不要并发。不要一开始就上高并发不然问题排查难度会指数级上升。5. 常见问题与排查实录5.1 实战踩坑记录先说乱码问题。我第一次跑脚本的时候输出到文件里的中文全是锟斤拷典型的编码错误。原因是我直接用requests.get()返回的resp.text这个属性默认使用HTTP头里声明的编码而目标页面在部分情况下头部的编码声明是错的。解决办法前面已经说过用apparent_encoding覆盖。这个坑大概率每个写过爬虫的人都会遇到一次。然后是正文抓取为空。我排查一圈发现不是所有文章都空只有发布满一年以上的老文章空白。后来把老文章的URL手动在浏览器里打开才发现有的老文章被平台折叠到了一个二级页面正文容器的class名称跟新文章完全不同。解决方法很朴素先看新老文章各一篇统一选择器让代码同时兼容两套class。再说图片路径问题。Markdown里图片正常显示需要绝对URL但很多文章的配图地址是相对路径或者带变了参数的防盗链地址。我一开始在清洗阶段就统一拼了绝对路径结果有些图片仍然显示不出来因为它们需要特定的Referer头。这个问题只有真正下载图片到本地才能根治所以我把下载做了三步走读取图片URL、加Referer请求、保存到{文章同名}_assets目录然后把Markdown里的图片引用改成相对路径。5.2 问题速查表现象可能原因解决思路文章全是乱码编码声明错误使用resp.apparent_encoding覆盖某篇文章正文为空登录墙或页面结构差异检查是否触发登录检测抓取两篇新老文章对比容器图片在本地打不开防盗链或相对路径下载图片到本地修改Markdown引用为相对路径博主文章列表只有前几页分页URL规律变化或异步加载打开页面分析真实请求接口跑了十分钟后请求超时频率过高触发限流降低频率、减少并发、增加随机间隔HTML转Markdown后代码块错乱body_width默认换行设置body_width 0某些表格变成普通文本转换器忽略表格设置ignore_tables False文件名包含非法字符标题里有/、?等写文件前统一替换非法字符这张表是我在实际调试中积累下来的每一个都对应过一次现场排障。你如果遇到表里没有的情况优先做两件事打印HTML片段到本地看结构对比正常文章和异常文章的差异。爬虫调试80%的时间都在做对比剩下的时间才是写代码。6. 后续扩展建议与个人心得6.1 还能怎么扩展这个工具做到现在已经不只是“爬取转Markdown”了更像是一个轻量级的离线知识库入口。顺着这个思路我列过几个后续扩展方向第一个是增量更新。现在的流程是每次全量抓取跑一次要几分钟对十位博主来说倒是还好但文章数量涨到几千篇之后就不划算了。增量更新的核心是记录每篇文章的URL指纹下次跑的时候只抓列表里新增的URL。这里有个细节文章被博主重新编辑后URL不变但内容变了如果你靠URL做唯一判断会漏掉更新。比较稳妥的做法是用URL加标题的组合来判断文章重发时标题通常会变。第二个是全文搜索。Markdown文件是纯文本直接拿grep或系统自带搜索其实也行但我想做得更顺手一点可以给所有文章生成一个汇总索引导航文件用表格列出每篇文章的标题、作者、日期和文件路径看起来非常直观。再进一步可以配合轻量级的本地文档工具生成一个可浏览的HTML网站。第三个是内容分析。既然已经离线了就可以对正文做词频统计、主题聚类。我之前跑过一次词云发现几位博主的写作话题重叠度相当高都是围绕工程化落地、性能优化和最佳实践这几个方向。这个信息用来辅助挑文章阅读挺有用。第四个也是我重点想做的是多平台适配。当前工具强依赖目标平台的HTML结构如果换一个技术博客平台选择器基本全得重写。改进思路是抽象出PageParser接口每个平台实现一个解析器把共同的抓取逻辑沉淀成公共模块。这样代码也不容易乱后面维护成本更低。6.2 最后分享的几个小技巧最后聊几个我用下来觉得特别顺手、但大多数教程里不会写的习惯。第一个习惯是抓HTML务必存一份原始归档。我在debug/目录下把每次抓到的正文页面原样保存为.html文件保留完整时间戳。这样后续调试、确认页面结构调整、重新跑转换逻辑都不需要重新请求线上既省流量又省时间。第二个习惯是给自己的脚本写一个--dry-run模式。只解析链接、不发送正文请求先把整个流程跑通一遍确认没有404、确认所有选择器都能命中再正式抓。很多翻页参数的错误在这个模式下就能暴露。第三个习惯是运行前先看一眼目标平台有没有接口调整公告。技术博客平台偶尔会改版改版当天爬虫基本全废提前看一眼能省下大把排查时间。个人使用这个东西最舒服的一点是彻底摆脱了在线阅读的干扰。本地打开一个Markdown文件没有推荐流、没有点赞弹窗只有文章本身的文字和代码阅读专注度完全不一样。我现在的阅读习惯已经变成了每月固定时间运行一次脚本抓完后随手翻一翻新增文章列表挑几篇感兴趣的存进自己的阅读清单。这东西从定位上说已经从“爬虫工具”变成了我的私人阅读器。

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

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

免费获取报价 →
↑