博客园(Cnblogs)在国内技术圈子里算老牌阵地,从 .NET 到 Python、从架构到算法,文章沉淀得非常深。我前段时间在整理自己的离线知识库,想把一批感兴趣的技术博客统一转成 Markdown 存档,方便后续检索和二次加工,于是顺手写了个小爬虫。它要做的事情很具体:从博客园批量抓取文章的标题、发布日期、标签、HTML 正文,把页面里的广告和无关模块清洗掉,再输出成干净的Markdown 文档,同时留一份结构化 JSON 方便查重和索引。这篇文章就是完整实现记录,适合那种已经会写一点 Python、但还没完整跑过爬虫项目的读者直接抄作业。我会把思路、页面分析、代码、踩过的坑一起说清楚。
1. 项目设计与技术选型:从需求到方案的思路
1.1 为什么选博客园作为采集对象
先回答一个很现实的问题:练手爬虫的目标网站那么多,为什么偏偏是博客园?
第一,博客园是典型的服务端渲染站点。列表页、详情页里的核心数据都直接写在 HTML 源码里,不需要等 JavaScript 异步加载,这意味着用最简单的 requests 就能把内容拿全,对新手极其友好。相比之下,很多现代前端站点数据是接口返回的,你得先分析 XHR 请求,学习成本直接翻倍。
第二,博客园的内容形态非常适合"另存为 Markdown"这个场景。技术文章里大量出现代码块、列表、引用、表格,这些都是 Markdown 能很好表达的元素。把一个 HTML 正文清洗干净、转成 Markdown,等于把网页里的噪音去掉,只留下可读性很强的纯文本结构。
第三,博客园的反爬策略比较温和。它主要靠 UA 检查、请求频率控制来防采集,没有复杂的 JS 验证码。但这不代表可以乱来,频率控制依然要做,这个我后面会专门展开。
1.2 需求拆解:抓哪些字段,存成什么
动手写代码之前,一定要先把需求拆清楚。这个项目需求其实很明确:采集文章标题、发布日期、标签、HTML 正文,然后输出 Markdown。我再顺手把作者、原文链接、阅读数和评论数也带上,反正列表页里就有,不抓白不抓。
| 目标字段 | 数据来源 | 最终用途 |
|---|---|---|
| 文章标题 | 列表页a.titlelnk/ 详情页#cb_post_title_id | Markdown 一级标题、文件名前缀 |
| 发布日期 | 列表页.post_item_foot/ 详情页.postDesc | 文件名前缀、按日期排序 |
| 标签 | 详情页.post-tags或.taglist | Markdown 元信息头,便于分类 |
| HTML 正文 | 详情页#cnblogs_post_body | 清洗后转成 Markdown |
| 原文链接 | 详情页<link rel="canonical"> | 溯源和回访 |
| 作者 | 列表页脚注作者链接 | JSON 汇总 |
| 阅读数/评论数 | 列表页脚注 | JSON 汇总,可选扩展字段 |
输出结构上,我的设计是两套文件并行:
- 每篇文章一个
.md文件,文件名叫日期_标题.md,带元信息头; - 一个
summary.json汇总所有文章的元数据,方便后续去重、排序、做索引。
1.3 技术选型复盘:requests + BeautifulSoup + html2text 的组合逻辑
这个组合是我每次提到轻量爬虫都会优先推荐的,理由有三个:
- requests足够轻。对于这种"拿到 URL、发 GET、拿 HTML"的简单流程,它是最直接的库,没有复杂的学习曲线。
- BeautifulSoup4的 CSS 选择器写起来非常顺手。
.select_one('.post_item')这种方式,比我早年用正则硬抠 HTML 强太多。正则写页面解析又丑又脆,稍微改个标签就崩。 - html2text解决的是"HTML 转 Markdown"这个痛点。很多人自己写转换器,最后发现代码缩进、表格、引用全都要自己处理,非常痛苦。html2text 这个库已经把这些规则做好了,大部分文章转换出来可直接用。
那为什么不用 Scrapy?如果目标是一口气抓上万个 URL、要做分布式爬取、断点续爬、数据管道,Scrapy 确实更工程化。但就对博客园这种"跑一两个小时能抓几百上千篇"的场景来说,Scrapy 的调度器、中间件、配置文件一层套一层,反而拖慢开发速度。先上手跑通最小 Demo,确实需要扩展时再迁移到 Scrapy 完全来得及。
为什么不用 Selenium 或 Playwright?前面已经说了,博客园是静态渲染页面,关键内容都在 HTML 里,浏览器自动化完全是杀鸡用牛刀,不仅启动慢、占内存,还更容易被站点识别为异常流量。记住一个原则:能用静态请求解决的,绝不上浏览器自动化。
2. 网页结构拆解:定位标题、日期、标签与正文的锚点
2.1 列表页的URL规律与分页策略
博客园首页是https://www.cnblogs.com/,滚动到底部后有一个分页区域。我实际用的是它的站点聚合分页地址,规则非常规律:
https://www.cnblogs.com/sitehome/p/page/1 https://www.cnblogs.com/sitehome/p/page/2 https://www.cnblogs.com/sitehome/p/page/3页码从 1 开始递增。每一页大概 20 篇文章,以.post_item这个 div 为基本单元。每个单元里包含标题链接、摘要、作者、评论数、阅读数、发布时间。
用浏览器开发者工具(F12)检查后,典型的 HTML 结构长这样:
<div class="post_item"> <div class="post_item_body"> <h3> <a class="titlelnk" href="https://www.cnblogs.com/xxx/p/12345678.html"> Python爬虫实战:采集博客园文章 </a> </h3> <p class="post_item_summary">摘要文字……</p> <div class="post_item_foot"> <a href="https://www.cnblogs.com/xxx/">某开发者</a> <span>评论(12)</span> <span>阅读(345)</span> <span>2024-06-18 10:30</span> </div> </div> </div>发布日期的位置在.post_item_foot里,它是一段文本,不是独立属性。所以解析时最好的办法是对脚注整体取文本,再用正则把2024-06-18 10:30这种格式抠出来。这样即使前端改了一点样式,只要日期字符串还在,解析就不会断。
2.2 详情页核心字段所在位置
点进任意一篇文章,详情页的核心节点如下:
<h1> <a id="cb_post_title_id" href="https://www.cnblogs.com/xxx/p/12345678.html"> Python爬虫实战:采集博客园文章 </a> </h1> <div class="postDesc"> posted @ 2024-06-18 10:30 某开发者 阅读(345) 评论(12) </div> <div id="cnblogs_post_body" class="blogpost-body"> <!-- 正文 HTML 内容 --> </div> <div class="post-tags"> 标签: <a href="...">Python</a> <a href="...">爬虫</a> </div>这几个节点里,#cnblogs_post_body是最稳定的锚点。博客园有一套固定的博客皮肤系统,不管博主换成什么模板,文章正文容器 id 基本都是cnblogs_post_body。标题的#cb_post_title_id也相对稳定。
但要说句实话:详情页里标签的位置在不同皮肤下是有差异的。有的博主模板把标签放在正文上方,有的放在文末,还有的根本没有.post-tags这个 div,而是直接写在.postDesc里,形如"标签: Python、爬虫"。所以标签解析必须做多重回退,不能只认一个选择器。
2.3 正文清洗的边界:哪些必须删,哪些必须留
很多人写完爬虫,发现 Markdown 文件里混进去一堆"推荐阅读""打赏""一键打卡"之类的内容。这就是清洗没做好的表现。
博客园的详情页里,除了正文#cnblogs_post_body,周围还有大量附加模块,常见的有:
| 模块 | 描述 |
|---|---|
#cnblogs_c1 | 侧边广告位 |
#cnblogs_ch | 底部广告位 |
#under_post_news | "相关阅读"推荐块 |
#under_post_kb | 博客园公告 / 推荐块 |
#HistoryToday | 历史上的今天 |
#div_digg | 点赞 / 收藏按钮 |
#MySignature | 博主签名档 |
.c_ad_block | 文章内插入的广告块 |
这些模块必须在正文清洗阶段直接删掉。除此之外,正文里的script、style、iframe、form也要清除,否则 html2text 转 Markdown 时会出现脚本代码或者被跳过的空白块。
那哪些必须留?pre、code、img、table、blockquote、ul/ol都要保留。不能直接对正文调用get_text()提取纯文本,因为代码块里的缩进、表格结构、图片链接都会丢失。正确做法是:保留正文 HTML 的完整结构,只去除噪音标签,然后再交给 html2text 转换。
3. 完整代码实现:从列表页一路写到 Markdown 文件
3.1 环境准备与依赖安装
需要 Python 3.9 以上,安装三个库就够了:
pip install requests beautifulsoup4 html2text它们分别负责网络请求、HTML 解析、HTML 转 Markdown。没有需要编译的依赖,Windows、macOS、Linux 都能跑。
3.2 请求模块:伪装UA、超时与重试机制
先写一个公用的请求函数。这里有几个细节要注意:
- User-Agent 一定要伪装。默认的
python-requestsUA 很容易被识别; - 必须设 timeout。否则某个请求卡住会让整个程序停在那里;
- 要带重试逻辑。网络波动很正常,简单的指数退避就能覆盖。
import time import random import requests HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0 Safari/537.36" ), "Referer": "https://www.cnblogs.com/", } def fetch_html(url, max_retries=3, timeout=10): """获取页面 HTML,带重试机制,失败返回 None。""" for attempt in range(max_retries): try: resp = requests.get(url, headers=HEADERS, timeout=timeout) if resp.status_code == 200: resp.encoding = "utf-8" return resp.text if resp.status_code in (403, 429): print(f"收到状态码 {resp.status_code},等待更长时间后重试") time.sleep(5 * (attempt + 1)) else: print(f"异常状态码:{resp.status_code},URL={url}") except requests.RequestException as exc: print(f"请求异常:{exc},第 {attempt + 1} 次重试") time.sleep(2 ** attempt) return Noneresp.encoding = "utf-8"这行很关键。requests 有时候会根据响应头猜编码,如果猜错就会出现乱码,显式指定最稳妥。
3.3 列表页解析:标题、日期、链接一次拿全
列表页解析的核心是用select("div.post_item")取出所有文章单元,再对每个单元提取字段。
import re from urllib.parse import urljoin from bs4 import BeautifulSoup BASE_URL = "https://www.cnblogs.com/" def parse_list(html): """解析列表页,返回文章元数据列表。""" soup = BeautifulSoup(html, "html.parser") articles = [] for item in soup.select("div.post_item"): title_el = item.select_one("a.titlelnk") if not title_el: continue article = { "title": title_el.get_text(strip=True), "url": urljoin(BASE_URL, title_el.get("href")), "date": None, } foot = item.select_one("div.post_item_foot") if foot: foot_text = foot.get_text(" ", strip=True) match = re.search(r"\d{4}-\d{1,2}-\d{1,2} \d{1,2}:\d{2}", foot_text) if match: article["date"] = match.group(0) author_el = foot.select_one("a") if author_el: article["author"] = author_el.get_text(strip=True) articles.append(article) return articles这里有个经验:解析时的选择器要尽量选语义明确的 class。a.titlelnk是博客园列表页文章标题的标准类名,比div h3 a这种层级选择器更抗改动。日期提取用正则对整段文本做匹配,也比尝试定位某个 span 更健壮,因为不同皮肤下脚注里 span 的个数和顺序不完全一样。
3.4 详情页解析:正文、标签、原文链接
详情页解析是整个项目最核心的部分。先写一个正文清洗函数,再写详情解析函数。
def clean_body(body_el): """删除正文 html 中的广告、脚本、无用模块。""" remove_selectors = [ "script", "style", "iframe", "form", "#cnblogs_c1", "#cnblogs_ch", "#under_post_news", "#under_post_kb", "#HistoryToday", "#div_digg", "#MySignature", ".cnblogs_code_toolbar", ".code_img_closed", ".c_ad_block", ] for selector in remove_selectors: for node in body_el.select(selector): node.decompose() return str(body_el)decompose()这个方法值得专门说一句:它会把整个节点连同所有子节点一起从 DOM 树中移除,是清理不需要区块的最好用的方法。相比之下extract()也能移除,但decompose()更干脆、不留引用关系,性能也好一些。
下面是详情页解析函数:
def parse_detail(html, page_url): """解析详情页,返回标题、日期、标签、正文 HTML 与原文链接。""" soup = BeautifulSoup(html, "html.parser") title_el = soup.select_one("#cb_post_title_id") title = title_el.get_text(strip=True) if title_el else None date = None desc = soup.select_one(".postDesc") if desc: desc_text = desc.get_text(" ", strip=True) match = re.search(r"\d{4}-\d{1,2}-\d{1,2} \d{1,2}:\d{2}", desc_text) if match: date = match.group(0) body_el = soup.select_one("#cnblogs_post_body") content_html = clean_body(body_el) if body_el else "" # 标签:优先 .post-tags,其次 .taglist,最后从 .postDesc 正则兜底 tags = [] tags_el = soup.select_one(".post-tags") or soup.select_one(".taglist") if tags_el: tags = [a.get_text(strip=True) for a in tags_el.select("a")] elif desc: label_match = re.search(r"标签[::]\s*(.+)", desc_text) if label_match: tags = [t.strip() for t in label_match.group(1).split("、")] # 原文链接:优先 canonical,其次从标题 a 标签取 source_url = None canonical = soup.select_one("link[rel='canonical']") if canonical and canonical.get("href"): source_url = canonical.get("href") elif title_el and title_el.get("href"): source_url = urljoin(page_url, title_el.get("href")) return { "title": title, "date": date, "tags": tags, "content_html": content_html, "source_url": source_url, }标签解析这段我故意加了多重回退,这是实战中很重要的思路。不要把页面结构当成一成不变的真理,尤其像博客园这种允许不同博主自定义皮肤的社区,同一个字段在不同页面可能出现在不同位置。做多重回退,哪怕回退逻辑丑一点,也比直接抛一个None导致整篇数据残缺要好。
3.5 Markdown 转换与文件落盘
HTML 转 Markdown 直接用 html2text,只需要配好几个关键参数。
import html2text def html_to_markdown(html_content): converter = html2text.HTML2Text() converter.ignore_links = False # 保留链接 converter.ignore_images = False # 保留图片 converter.body_width = 0 # 不自动换行 converter.unicode_snob = True # 优先保留 Unicode 字符 return converter.handle(html_content)这几个参数里,body_width = 0我特别提一下。默认情况下 html2text 会在大约 80 个字符处强制换行,这在英文文章里问题不大,但中文文章很容易被切出奇怪的换行,导致 Markdown 渲染时段落间距不均匀。设为 0 就是告诉它"不要擅自换行,保持原始段落结构"。
文件落盘时,要注意三点:文件名里的非法字符、文件编码、JSON 的ensure_ascii参数。
import os import json def safe_filename(text, max_len=60): """去掉 Windows / Mac 下不能用于文件名的字符。""" text = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", text) return text[:max_len].strip(" ._") def save_article(article, md_text, output_dir="output"): os.makedirs(output_dir, exist_ok=True) stem = safe_filename(f"{article.get('date', 'no_date')}_{article['title']}") md_path = os.path.join(output_dir, stem + ".md") json_path = os.path.join(output_dir, stem + ".json") header = ( f"# {article['title']}\n\n" f"> 发布日期:{article.get('date', '未知')} \n" f"> 标签:{', '.join(article.get('tags', [])) or '无'} \n" f"> 原文链接:{article.get('source_url', '')} \n" f"> 作者:{article.get('author', '未知')} \n\n" "---\n\n" ) with open(md_path, "w", encoding="utf-8") as f: f.write(header + md_text) # JSON 里保留 HTML 原样,方便以后重新转换或做别的处理 with open(json_path, "w", encoding="utf-8") as f: json.dump(article, f, ensure_ascii=False, indent=2, default=str) return md_pathensure_ascii=False这行很多人会漏掉。默认情况下 json.dump 会把中文转成\uXXXX形式的转义序列,Excel 或者文本编辑器打开来看全是乱码,加了ensure_ascii=False之后存的就是正常中文,一目了然。
3.6 主流程调度:分页遍历与随机间隔
主流程的思路是:先遍历列表页,再从每页取前几篇文章抓详情,控制好请求频率,最后汇总。
def main(): total_pages = 2 # 先跑 2 页试试 detail_limit = 3 # 每页最多抓 3 篇详情 seen = set() summary = [] for page in range(1, total_pages + 1): list_url = f"{BASE_URL}sitehome/p/page/{page}" print(f"处理列表页:{list_url}") list_html = fetch_html(list_url) if not list_html: continue articles = parse_list(list_html) for article in articles[:detail_limit]: if article["url"] in seen: continue seen.add(article["url"]) print(f"抓取详情:{article['title']}") detail_html = fetch_html(article["url"]) if not detail_html: continue detail = parse_detail(detail_html, article["url"]) article.update(detail) md_text = html_to_markdown(article.get("content_html", "")) save_article(article, md_text) summary.append(article) time.sleep(random.uniform(1, 2)) time.sleep(random.uniform(2, 3)) with open("output/summary.json", "w", encoding="utf-8") as f: json.dump(summary, f, ensure_ascii=False, indent=2, default=str) print(f"全部完成,共保存 {len(summary)} 篇文章")请求间隔这里我用了random.uniform(1, 2)和random.uniform(2, 3)。为什么要随机?因为固定间隔的请求在服务端日志里看起来是匀速的机械行为,比随机间隔更容易触发频率限制。随机化之后请求时间分布在 1 到 3 秒之间,再加上 403 时的退避等待,整体看起来更像人工操作。这是所有爬虫项目都应该养成的习惯。
3.7 实际输出的 Markdown 文档预览
跑完程序后,生成的 Markdown 文件长这样,这里展示一段脱敏后的效果预览:
# Python爬虫实战:采集博客园文章并转存为 Markdown > 发布日期:2024-06-18 10:30 > 标签:Python, 爬虫, BeautifulSoup, 博客园 > 原文链接:https://www.cnblogs.com/demo-user/p/18000000.html > 作者:某开发者 --- ## 引言 技术文章散落在网页里,检索和沉淀都很麻烦。本文介绍如何用 Python 采集博客园文章,并将 HTML 正文清洗后转为 Markdown 存档。 ## 核心步骤 1. 解析列表页拿到文章 URL; 2. 访问详情页提取标题、日期、标签、HTML 正文; 3. 清洗正文中的广告和无关模块; 4. 使用 html2text 将 HTML 转为 Markdown 并保存。 **注意事项** 请求频率不要过高,目标站点也需要正常服务。转换出来的文档把原来的广告、按钮、签名档全部过滤掉了,标题、日期、标签、链接都完整保留。我实际转换过几十篇文章,代码块缩进、图片引用、表格这些元素都能正确转成 Markdown 语法,整体可读性比直接在网页里看还要清爽。
4. 踩坑记录:状态码、乱码、选择器失效的排查方案
4.1 返回 403:被拦截时先检查这个
最常见的 403 原因是 User-Agent 暴露了爬虫身份。requests 默认 UA 是python-requests/x.x,这种标识在目标站点的访问日志里几乎等于直接自首。遇到 403 先检查两件事:
- UA 是否已经伪装成常见浏览器;
- 是否带上了合法的 Referer。
如果这两项都做了还 403,那就说明请求频率可能太高了,停下来等几十分钟再跑,或者加大随机 sleep 的下限。
4.2 日期提取为 None:结构变了怎么兜底
我在测试时就碰到过一种情况:某个博主换了皮肤,.post_item_foot里日期不是一个<span>,而是夹杂在多个<a>之间的纯文本。如果代码只盯着某个 class 去取,很容易取不到。
对策就是现在代码里的方案——对整段文本用正则匹配\d{4}-\d{1,2}-\d{1,2} \d{1,2}:\d{2}。这个正则覆盖了一年、两位年、一位月日、一位小时分钟等各种常见变体,只要页面上存在这个格式的字符串就能抓到,不依赖具体 DOM 层级。
4.3 正文混入广告和无关模块
第一次跑完整批数据后,我发现有的 Markdown 文件末尾带着"相关推荐"和"历史今天"的内容,非常影响阅读。这是因为不同博主皮肤下的广告模块 DOM 结构不完全一样,只删固定 id 不够。
后面我把清理选择器列表扩充到了现在的规模,并且每次都先打印一段转换后的 Markdown 人工抽查。清洗这一步没有一劳永逸的解法,只能靠多观察、多维护选择器列表。如果你的目标站点结构变化频繁,建议把清理规则抽成一个单独的函数,改成配置文件,这样维护起来更轻松。
4.4 写入文件出现乱码
乱码一般发生在两个环节:读取响应和写入文件。
- 读取响应时不指定编码,requests 可能根据
Content-Type里的 charset 猜编码,猜错就会出现中文乱码。博客园页面声明了 UTF-8,直接resp.encoding = "utf-8"就能解决。 - 写入文件时不指定
encoding="utf-8",在 Windows 上默认可能是 GBK,Markdown 里的中文就乱了。写文件一定要显式带上encoding="utf-8"。
另外,JSON 序列化时记得ensure_ascii=False,否则中文会变成\u开头的一堆转义,虽然不是乱码,但完全不可读。
4.5 请求频率过高被限流
限流的症状一般不是立刻 403,而是连续几篇详情页返回空内容或跳转到验证页面。如果出现这种情况,说明你的请求间隔太短。
我的实用建议是:单篇请求间隔下限不要低于 1 秒,批量跑的时候建议 1-3 秒随机。如果只是抓几十篇做测试,间隔可以稍微短一点,但也不要低于 0.5 秒。另外,全程用try/except包住请求,遇到异常不要崩进程,直接跳过当前文章继续跑,这样大批量任务不至于因为一条网络波动就前功尽弃。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 连续返回 403 | UA 未伪装 / 频率过高 | 检查请求头,增大随机间隔 |
| 日期为空 | 页面皮肤结构差异 | 用正则对脚注整段文本匹配 |
| 正文带广告内容 | 清理规则不完整 | 在清理列表里补充对应选择器 |
| 中文乱码 | 响应编码/文件编码问题 | 强制utf-8,JSON 加ensure_ascii=False |
| 详情页反复失败 | 请求过于密集 | 降低频率、加随机间隔、跳过失败文章 |
5. 合规边界与后续扩展方向
5.1 采集内容前先想清楚这三件事
技术文章的内容安全边界一定要放在开头和结尾反复提醒。做爬虫不是把数据拽下来就万事大吉,有三件事必须想清楚:
- 看站点声明。博客园的 robots.txt 里对爬虫有明确的声明,采集前先去看一眼,至少做到不访问明确禁爬的路径。
- 控制频率。不管目标站点反爬强不强,单线程 + 随机 1-3 秒间隔都是底线。爬虫跑在别人的服务器上,每一帧请求都在占用别人的带宽,频率太高会直接影响站点稳定性。
- 数据用途。采集的文章建议只用于个人学习、离线阅读、知识管理,不要批量转载或用于商业产品。技术作者的劳动成果应该被尊重,你采集来是为了沉淀知识,不是搬运别人的内容。
5.2 从最小 Demo 到完整项目的扩展思路
这个项目目前只做了一个最小闭环:列表页 → 详情页 → Markdown。如果想让它变成真正长期使用的基础设施,下面这几个方向可以继续搞:
- 增量爬取。把已处理的 URL 存进一个
visited.txt或 SQLite 表,下次跑的时候跳过已存在文章,避免重复抓取。 - 图片本地化。博客园的图片有防盗链设置,Markdown 里的远程图片在离线时经常加载失败。可以在保存时把正文图片下载到本地
images/目录,再用相对路径替换 Markdown 里的图片链接。 - 数据库存储。文章数量一旦超过几千篇,JSON 文件查起来会吃力,可以换成 SQLite,既支持去重、分类查询,也不会引入额外的服务器依赖。
- 定时任务。用系统自带的计划任务或者一个简单的定时脚本,每天自动跑一次增量采集,把当天精选文章同步到本地知识库。
我个人在实际跑这个项目时,最深的感触是:大多数爬虫项目的难点不在"发出请求",而在"解析和清洗"。把一个 HTML 页面里的有效信息稳稳地摘出来,再把噪音清干净,比什么都重要。也正是因为这样,这个项目里代码最长的部分不是网络请求,而是clean_body和标签解析的回退逻辑。
最后再分享一个小技巧:写任何爬虫之前,先用浏览器开发者工具手动打开一个列表页、一个详情页,把要取的节点对着 HTML 源码确认一遍。这个步骤只要花五分钟,但能帮你省掉后面至少一个小时的调试时间。页面结构在心里有数了,代码自然写得顺畅。