最近有个开发者朋友问我能不能把CSDN上排名靠前博主的文章批量抓下来统一转成Markdown文件丢进他自己的本地文档库方便离线阅读和在笔记软件里检索。他收藏夹里攒了好几千条链接真到用的时候还是抓瞎。这个问题我太有共鸣了于是花了一个周末把整套流程做成了可复用的脚本。如果你也有类似需求想抓某几个博主的文章或者是想为某个细分领域搭建一份本地知识库这篇文章的思路和代码可以直接照着改。先说明一个大前提抓取公开文章只用于个人学习备份别做二次发布也别把请求频率调到让人家服务器难受的程度。写爬虫的人越是有分寸这种需求就越能长久存在。下面我讲的是一套以requests、parsel、html2text为核心的轻量方案不涉及浏览器模拟也不依赖重型分布式爬虫框架普通笔记本就能跑。1. 目标拆解前十博主是参考线而不是硬指标1.1 为什么大家执着于“前十博主”“前十博主”听起来像是一个明确的分界线可落到实际场景里它更像是一层心理滤镜。读者对某个技术领域产生信任感往往来自少数几位作者比如对方更新稳定、踩坑记录写得细致、代码示例能直接跑通。大家想抓的其实是“我觉得值得读的那几个人的所有文章”而不是排行榜本身。但真要写脚本时我建议你不要把“前十”写死在代码里。原因很简单排行榜的数据会随时间变化博主的写作方向也可能调整今天排在前面的人下个月可能已经不更新了。把“前十”理解成一个可配置参数比如在博主列表文件里放十个人的主页地址脚本的寿命会比你想象中长得多。我自己的做法是先把榜单页或人工整理的主页列表落到一个 bloggers.yaml 文件里再让脚本去展开抓取。这样结构清楚后期想换成“前二十”“前五十”也只是改配置文件的事。1.2 榜单数据的三种拿法有人喜欢把“前十博主”当成一个自动化的目标说一定要从排行榜页抓取。这里我把几种方案摆出来对比一下方案具体做法优点风险方案A抓榜单页解析排名页面取前十个作者主页链接一次拿到全自动榜单页结构不稳定还可能触发频率限制方案B反向统计抓一段时间内的热榜文章列表按作者聚合排序能发现新面孔请求量更大逻辑复杂度高方案C手动维护自己整理十个博主主页地址零风险、可解释人工维护成本但一般只需做一次我用的最多的是方案C偶尔配合方案A。原因是抓取的核心价值在“文章内容”越早把作者列表稳定下来就越早进入批量处理阶段。方案B适合你打算长期观察作者成长曲线而不是做一次离线备份的时候选。如果你只是想要前十名的资料手写十行主页地址最稳妥不依赖任何页面结构。2. 写代码之前先摸清页面骨架列表页、详情页和字段来源2.1 文章列表页的分页规律无论抓哪个博客平台第一步都是先搞清楚文章列表页是怎么翻页的。CSDN这类技术博客的作者主页通常是每页固定展示若干篇文章页面上有上一页、下一页按钮或者URL里能直接看到 page 参数。拿到作者主页地址后你在浏览器里手动翻两页就能发现规律比闭着眼睛猜选择器快得多。我在脚本里一般会把翻页逻辑做成一个循环每页解析出来的文章链接先做一次去重再放进待抓列表。这里要特别留意有些页面里会混入“置顶”“推荐”模块它们会重复出现在每一页如果不加去重后面抓到的文件会重复。用Python的set或者字典按URL去重是最简单也最保险的方式。2.2 详情页的正文容器与辅助信息文章详情页的结构相对稳定但信息密度大。真正要拿的核心内容只有几块文章标题、发布时间、标签、正文HTML、原文链接。其他像点赞数、阅读数、评论区、右侧推荐栏、面包屑导航对本地知识库没有太大用反而会污染Markdown文件。所以详情页解析要做减法先定位正文容器再把容器里不需要的节点删掉。这篇文章会看到一个 id 比较有辨识度的正文区块你只需要用parsel取到对应的HTML片段剩下的交给转换阶段。帖子里的发布时间在页面源码里一般能找到比较规整的日期格式如果时间格式不统一就用正则统一成 ISO 格式也就是“2026-02-14 10:30”这种写法方便排序。2.3 抓取范围与频率控制的底线这一节内容虽然写在前面但它其实是整个脚本能不能长期跑下去的关键。写爬虫不是跟站点对抗而是尽量礼貌地读取公开页面。打开目标域名下的 robots.txt 看一眼能帮你避开明确禁止的路径。我的控制策略是这样的每抓完一页固定 sleep 1到1.5秒详情页同样保持串行请求不做并发。有人会觉得这样太慢但实际上一个作者两三百篇文章算下来也就是十几分钟的事完全在可接受范围。真正的资源瓶颈从来不是速度而是你重试机制写得好不好。如果某一次请求超时隔两三秒重试一次最多三次重试完还失败就直接记录到日志里不要无限循环。3. 用requests、parsel、html2text把流水线跑通3.1 为什么选这三件套先聊聊技术选型。requests是Python里最常用的HTTP客户端API设计清晰处理超时和重试都很方便。parsel是爬虫框架里的解析层支持CSS选择器和XPath比直接用正则解析HTML要稳得多。html2text则负责把HTML片段转成Markdown它能把等标签处理成代码块也能保留链接地址。有人会问为什么不用Selenium或Playwright做浏览器自动化不是不能用而是对纯服务端渲染的页面来说引入浏览器驱动会让抓取速度慢一个数量级内存占用也大而且浏览器指纹更容易被站点识别。如果你的目标页面不依赖JavaScript动态渲染path级别解析是完全够用的。CSDN的文章页在我测试时是服务端直出的所以这套轻量方案跑得很舒服。3.2 第一步从作者主页解析文章链接下面这段代码展示的是列表页解析逻辑。我故意没有把选择器写得太死因为平台的页面结构迟早会调整你只需要维护这个函数就行。import time import requests from parsel import Selector HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., Referer: https://www.csdn.net/, } def get_article_links(home_url, max_pages10): links [] for page in range(1, max_pages 1): page_url f{home_url}?page{page} try: resp requests.get(page_url, headersHEADERS, timeout10) if resp.status_code ! 200: continue sel Selector(textresp.text) # 选择器以实际页面为准跑之前先在浏览器里确认一下 for href in sel.css(h4 a::attr(href)).getall(): if href.startswith(http) and href not in links: links.append(href) except requests.RequestException as e: print(f[warn] {page_url} - {e}) time.sleep(1.2) return links这个函数只做三件事拼接分页地址、解析每页文章链接、按URL去重。max_pages可以按需调整如果你只想要最近的文章设成两三页就够想要更全的历史文章就设大一点。注意Referer我写成平台首页有些站点会校验来源页面带上它会减少被拒绝的概率。3.3 第二步抓详情页并抽正文拿到文章链接之后剩下的就是逐条抓详情页。这里务必做超时和重试不能请求失败就丢掉。我的习惯是这么写的。def fetch_article(url): for attempt in range(3): try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() return resp.text except requests.RequestException: time.sleep(2 * (attempt 1)) return None def extract_content(html_text): sel Selector(texthtml_text) title sel.css(h1::text).get().strip() pub_time sel.css(.time::text).get().strip() # 选择器按实际页面调整 content_html sel.css(#content_views).get() # 选择器按实际页面调整 return title, pub_time, content_html重试间隔翻倍是避免在目标服务短暂抖动时连续施压。extract_content里的选择器每个人抓到的页面可能不一样跑之前先在开发者工具里确认一下正文容器的id或class。公众号这类站点变数更多改选择器时注意不要误匹配到评论区的HTML。3.4 第三步正文转Markdown并写入本地现在到了核心环节把正文HTML交给html2text。这是一个很完善的库能把标题、段落、列表、代码块、表格转成对应的Markdown语法。我的配置方式如下。import html2text def convert_to_md(content_html): converter html2text.HTML2Text() converter.body_width 0 # 不自动换行避免把代码行拆断 converter.ignore_links False converter.ignore_images False return converter.handle(content_html)然后把标题、时间、标签、原文链接这些元信息拼成一个文件头。我习惯用YAML风格的front matter这样导入一些笔记软件时可以被系统直接识别索引。def save_article(title, pub_time, tags, url, md_text, author_dir): safe_title sanitize_filename(title) path fblog_md/{author_dir}/{pub_time[:10]}_{safe_title}.md with open(path, w, encodingutf-8) as f: f.write(f---\ntitle: {title}\ndate: {pub_time}\nurl: {url}\ntags: {tags}\n---\n\n) f.write(md_text) return pathsanitize_filename用来清理标题里的特殊字符这个函数在后面避坑章节会展开讲。到这里一个最小可用的抓取到转换的流程已经通了。4. 转换结果最容易翻车的地方与逐项修复4.1 代码块语言标识丢了第一次跑完打开生成的Markdown文件检查时最常发现的问题就是代码块没有语言标识。比如原文章里的Python代码块html2text可能给的是空标记渲染出来就没有语法高亮。修复办法很简单转换后统一扫描代码块边界如果某个代码块开头没有语言标识就补成text。这样虽然损失了高亮但至少保证代码块格式完整复制出来不会乱。import re def fix_code_blocks(md_text): lines md_text.split(\n) inside_code False for i, line in enumerate(lines): if line.startswith(): if not inside_code: # 开标签且没有语言标识 if len(line.strip()) 3: lines[i] text inside_code True else: inside_code False return \n.join(lines)这种逐行扫描的方式比正则更稳因为代码块是嵌套最容易出错的地方。如果原站点的代码块都有语言classhtml2text会自己带上语言标识这个问题就只会出现在没有class的代码块上。4.2 图片懒加载与离线化很多技术博客的文章里图片不是直接放在src里的而是放在data-src这类属性里src只是一个占位图。直接按src抓你会下载到一堆空白缩略图。解析详情页的时候要把data-src也考虑进去。做完解析之后还有个选择图片要不要下载到本地。如果你的知识库要长时间离线使用建议下载。文章少的时候可以手动下载文章多就要自动化了。我的做法是先扫描Markdown里的图片链接再用requests把文件存到 assets/作者名/文章名/ 目录最后把Markdown里的链接地址替换成相对路径。这里有个小细节图片文件名最好用hash命名避免不同文章里的同名图片互相覆盖。用URL做hash的话同一张图片在不同文章里的结果一致还能顺便去重。4.3 文件名清洗与重复标题写文件到本地时Windows、macOS、Linux对合法文件名的要求不完全一样但最稳妥的准则是把所有常见的非法字符都替换掉。反斜杠、斜杠、冒号、星号、问号、双引号、尖括号、竖线这些在文件系统里都可能出问题。def sanitize_filename(title): title re.sub(r[\\/:*?|], _, title) title title.strip( .) title re.sub(r\s, , title) return title[:120] or untitled标题截断到120个字符是为了照顾文件路径长度的限制。标题重复也是常见问题尤其是一些作者喜欢写“XXX一”“XXX二”这种系列标题。我建议在保存之前先检查今天这个作者文件夹里是否已经有同名文件如果有就加一个短ID后缀比如文章的末尾几位数字。5. 让这套脚本变成能长期维护的本地资料工具5.1 输出目录与状态记录如果你的目标是“一次跑完就算完”那目录结构随意一些没问题。但如果想持续更新最好从一开始就把目录和状态文件设计好。我用的是这样的结构blog_md/ bloggers.yaml author_a/ 2026-01-01_配置中心设计模式.md 2026-01-15_配置中心二次踩坑.md author_b/ ... assets/ author_a/ 2026-01-01_配置中心设计模式/ image_hash_a.png image_hash_b.jpgbloggers.yaml记录作者主页地址和是否启用抓取状态记录则维护一个 JSON 文件保存每个文章URL对应的本地文件名和时间。下次抓取时先读取状态记录已经存在的URL就跳过。这样增量更新很高效也不会因为重复抓取把本地文件覆盖掉。5.2 增量更新与失败重试增量更新听起来高大上其实就是查一下URL在不在已处理集合里。这里有一个经验不要只比较URL因为文章有更新版本标题有可能变了。更好的做法是记录“URL 文章更新时间”如果原页面上的最后修改时间比本地记录新就重新抓一遍。失败重试方面我喜欢把失败的请求单独写一个 retry.json里面带上失败URL和失败原因。跑完一轮之后程序会问你要不要针对失败列表再试一次这样既不阻塞主流程又不会静默丢数据。重试仍然失败的文章最后统一打印清单你在浏览器里手动看两眼就好。5.3 多花十分钟做的运行日志脚本写完之后千万别省掉日志。我始终觉得能长期维护的抓取脚本最关键的不是抓得有多快而是出了问题时能快速定位。在关键节点打日志比如某页抓取失败、某篇文章转换异常、某张图片下载失败能让后续排障轻松一大截。我会在每次运行结束后输出一个简短的统计报告今天新增了多少篇、更新了多少篇、失败了多少篇、下载了多少张图片、总共耗时多久。这个统计看似简单但它能让你及早发现页面结构变化。比如某一天“新增率”突然跳水很可能是选择器失配了而不是作者真的没写文章。抓下来之后怎么用才是这套脚本真正的价值脚本写完只是开始把这堆Markdown真正变成有用的本地知识库才算完成闭环。我个人会在文章里保留原文链接方便需要时回原文核对也会在知识库软件里做一个标签索引把技术关键词统一打上这样后续不管是训练模型还是查资料效率都高不少。这套流程我到现在还保持着低频定期更新的节奏每次跑完基本不会出什么幺蛾子。如果你对CSDN或者其他技术博客也有类似的整理需求建议先拿一个博主的小量文章试跑确认输出质量之后再把博主列表扩到十个或者更多。爬虫的代码并不复杂复杂的是你对内容结构的理解和对边界频率的控制。前者决定一次抓多少后者决定你能跑多久。