1. 为什么需要 BeautifulSoup:从手动抠数据到结构化提取
写爬虫这事儿,很多人一上来就栽在“解析”这一关。用 requests 拿到网页源码后,面对几百上千行的 HTML 字符串,第一反应就是写正则硬抠。比如想抓一个页面里所有标题,有人会写re.search(r'<title>(.*?)</title>', html),能跑,但问题在于:只要网页结构稍微一变,或者标签里带了奇怪的属性,这段代码就开始摆烂。我在最初学爬虫的时候,正则是主力,但每次看那串反斜杠和问号,都觉得像在解一道没有意义的谜语。
BeautifulSoup 就是为这个痛点而生的。它的定位是 HTML/XML 解析库,能把你用 requests 拿回来的那坨字符串,变成一棵有层级、可导航、可搜索的“标签树”。你可以像用浏览器开发者工具一样去定位元素:找标签、找 id、找 class、找子节点、找兄弟节点,甚至直接拿 CSS 选择器把目标选出来。对于入门级的爬虫来说,requests 负责“把页面拿回来”,BeautifulSoup 负责“把页面看懂”,这两步配合好,剩下就是存数据的问题了。
这篇笔记是系列第五篇,前置内容是:你已经会用 requests 发请求、能拿到响应文本。如果你还没看过前几篇,也不碍事,这篇会从 BeautifulSoup 自己的逻辑讲起,讲完就能立刻上手用。适合的人群是:刚学完 Python 基础、想做爬虫但被 HTML 结构搞蒙的初学者,以及想摆脱一正则套到底的老手。
2. 安装与初识:从一个真实示例开始解析
2.1 安装和导入
安装异常简单,一行命令:
pip install beautifulsoup4注意包名是beautifulsoup4,但导入的名字是bs4。这俩不一致搞得很多人第一次摸不着头脑:
from bs4 import BeautifulSoup这里我说明白一点:你装的是第四版,导入模块的时候用的bs4这个名字。以后看到bs4开头的报错,不用担心,就是它。顺带需要装一个解析器,爬虫入门阶段推荐lxml:
pip install lxmllxml 是一个 C 语言的解析库,解析速度快,容错性也比 Python 自带的 html.parser 好不少。在网页结构比较脏乱的时候,lxml 往往能扛住。
2.2 BeautifulSoup 的基本套路
BeautifulSoup 的使用流程,万变不离其宗:
- 把一个 HTML 字符串喂给 BeautifulSoup,指定解析器
- 拿到一个 BeautifulSoup 对象,也就是那棵“标签树”
- 在这棵树上既可以根据标签、属性、文本内容去 find,也可以用 CSS 选择器去 select
举个最经典的例子吧。假设我们拿到了一段小页面:
html_doc = """ <html> <head><title>某技术社区首页</title></head> <body> <p class="intro">这是一个测试页面的欢迎语</p> <p class="article">第一篇文章标题:爬虫入门</p> <p class="article">第二篇文章标题:数据清洗技巧</p> <a href="/page/1">下一页</a> </body> </html> """from bs4 import BeautifulSoup soup = BeautifulSoup(html_doc, "lxml") print(soup.title.string) # 某技术社区首页 print(soup.p) # 第一个 p 标签,完整对象 print(soup.p["class"]) # ['intro'] print(soup.find_all("p")) # 所有 p 标签,返回列表看,这比写正则舒服多了。soup.title.string直接拿到标题的文本,不用去管尖括号怎么嵌套;soup.find_all("p")把页面里所有p标签都拎出来,一个个遍历就行。
2.3 解析器的选择逻辑
BeautifulSoup 支持三种解析器,我列个表对比一下:
| 解析器 | 速度 | 容错性 | 安装要求 |
|---|---|---|---|
| html.parser | 一般 | 一般 | Python 内置,无需安装 |
| lxml | 快 | 好 | 需安装 |
| html5lib | 慢 | 极好(完全模拟浏览器) | 需安装 |
我的建议是:入门阶段只用 lxml。html5lib 虽然容错最强,但慢得让人着急;html.parser 对不规范的标签处理起来经常出小毛病。用 lxml 就足够了,它能把大多数“写得很随性”的网页都正常解析出来。
有一点要注意:解析器不同,解析出的树结构可能会有细微差异。比如同一份 HTML,用 html.parser 和 lxml 解析,某些位置的空白文本节点可能不一样。在做数据提取的时候,尽量保持同一个解析器从头用到尾。换解析器导致代码突然崩掉我是踩过的。
3. 核心机制:四大对象体系与节点导航
网上很多教程把 BeautifulSoup 的四大对象叫“对象模型”,名字听起来吓人,实际上没那么神秘。这四种对象分别是:Tag、NavigableString、BeautifulSoup、Comment。
- Tag:就是一个个标签对象,比如
<p class="intro">,它有名字(p)、有属性(class)、还能往下找子节点。 - NavigableString:标签里面的文本内容。拿
soup.p.string取到的就是这种对象,本质上是个字符串,支持直接转 str。 - BeautifulSoup:整个文档对象,你可以理解为最大最外层的 Tag,用
soup这个名字引用。 - Comment:HTML 注释。这东西比较坑,因为注释也是
<!-- … -->的形式,初学者很容易把它当成普通文本提取出来。
我见过一个很阴间的场景:某页面把重要数据写成 HTML 注释,有人改了正则去抠注释里的内容,结果各种转义错误。用 BeautifulSoup 就可以判断节点的类型:
from bs4 import Comment for child in soup.find_all(string=True): if isinstance(child, Comment): print("这是注释:", child)3.1 找到你想找的:find 与 find_all
整个库用的最多的,就是这两个方法。
find_all返回所有符合条件的标签列表,find返回第一个符合条件的标签。它俩接收的参数是一样的,最常见的用法是:
soup.find_all("a") # 所有 a 标签 soup.find_all("a", href="/page/1") # 指定属性筛选 soup.find_all(class_="article") # 注意是 class_,因为 class 是 Python 关键字 soup.find_all(id="top-bar") # 按 id 查找 soup.find_all("p", class_="intro") # 同时按标签和属性筛这里有个最容易踩的坑:class_后面那个下划线。我一开始总是写成class=,报语法错。原因很简单啊,class是 Python 的保留字,不能直接拿来当参数名,所以 BeautifulSoup 用了class_。这个不是 BeautifulSoup 发明的规矩,很多跟class相关的库都这么干。
find_all还支持正则表达式和自定义函数匹配。比如找出所有以>import re # 所有 href 以 /page/ 开头的标签 soup.find_all(href=re.compile("^/page/")) # 写函数:标签是 p,且文本里有“文章”二字 def is_article_tag(tag): return tag.name == "p" and "文章" in tag.get_text() soup.find_all(is_article_tag)
前者是正则匹配属性,后者是函数式筛选。这个函数式筛选的写法,遇到花哨需求时非常香。比如你要找“某个 div 下的所有第三层 a 标签”,直接写函数判断结构就可以。
3.2 CSS 选择器:select 方法
find_all写多了你会发现,有的筛选条件很啰嗦。比如要找div下面.article类里的a标签,写成find_all就得嵌套好几层。这时候可以用select方法:
soup.select("p.article") soup.select("#top-bar a") soup.select("div.content > p:first-child")CSS 选择器的威力在于:你在浏览器开发者工具里怎么定位元素,在 select 里就怎么写。我拿到一个陌生页面的时候,第一件事就是按 F12,找到目标元素,右键 Copy → Copy selector,然后直接丢进 select 里。大多数情况下能直接跑通,省心得很。
不过 select 返回的也是一个列表。哪怕你确信页面上只有一个元素,也要记得取[0],否则拿到的就是列表对象,要啥啥不对。
3.3 在树里上下左右移动
HTML 是一棵树,BeautifulSoup 给了你一堆在树里“走动”的接口。拿一个简单的结构举例:
<div class="post"> <h2>标题A</h2> <p>内容一</p> <p>内容二</p> </div>div = soup.select_one(".post") div.contents # 所有直接子节点列表 div.children # 直接子节点迭代器 div.descendants # 所有后代节点迭代器 div.parent # 父节点 div.next_sibling # 下一个兄弟节点 div.previous_sibling # 上一个兄弟节点有一个新人很容易搞混的概念:contents和children很像,返回的都是直接子节点,区别在于前者是列表,后者是迭代器。还需要注意,它们都会把空白文本也算进去。如果一个标签换行缩进写的,contents里常会出现'\n'这种字符串,判断的时候要留意。想要过滤这些,可以直接找 Tag 类型的节点:
for child in div.find_all(recursive=False): print(child.name)recursive=False的意思是只在当前标签的直接子节点里找,不要往下深挖。这个参数配合结构分析特别好用。
4. 提取数据的基础功:文本、属性与实操示例
4.1 拿文本的三种方式:string、text、get_text()
这是入门阶段最混乱的地方。三种写法看着差不多,行为上却有细微差别。
tag.string:如果标签里只有一段文本,返回这段文本;如果有子标签,返回 None。tag.text/tag.get_text():返回标签内所有文本拼起来的结果,子标签的文本也会被包含。tag.get_text(separator=" "):拼接的时候加分隔符,万金油写法。
举个例子:
<p>欢迎来到 <b>技术社区</b> 的首页</p>p = soup.select_one("p") print(p.string) # None,因为里面有 b 标签 print(p.text) # '欢迎来到 技术社区 的首页' print(p.get_text("|")) # '欢迎来到|技术社区|的首页'我个人的习惯是:入门阶段直接用get_text(strip=True),它在拿文本的同时把首尾空白去掉,干净利落。等你要抓的内容结构复杂了,再慢慢体会 string 和 text 的区别也不迟。
4.2 拿属性的两种方式:字典式访问与 get
任何 Tag 对象都像字典一样,可以拿属性值:
a = soup.select_one("a") print(a["href"]) # 拿不到会报 KeyError print(a.get("href")) # 拿不到返回 None建议一律用get。因为网页上有些 a 标签可能不带 href,直接用a["href"]遇到这种情况,程序就崩了。在遍历一堆标签的时候,一个缺失属性就能中断全流程,用get加上默认值,就能把这种不可控处理掉。
4.3 综合实战:抓取一个模拟页面的新闻列表
只讲 API 不讲实战等于白讲。我写一个模拟的新闻列表页面,直接演示一次完整的提取过程。假设这是某新闻网站的结构:
<div class="news-list"> <div class="news-item">from bs4 import BeautifulSoup soup = BeautifulSoup(html_doc, "lxml") news_list = soup.select(".news-item") result = [] for item in news_list: title_tag = item.select_one(".title > a") date_tag = item.select_one(".date") summary_tag = item.select_one(".summary") result.append({ "id": item.get("data-id"), "title": title_tag.get_text(strip=True) if title_tag else "", "url": title_tag.get("href") if title_tag else "", "date": date_tag.get_text(strip=True) if date_tag else "", "summary": summary_tag.get_text(strip=True) if summary_tag else "", }) print(result)这段代码看着简单,其实包含了几个非常实用的习惯:
- 每个
tag都用if tag else ""保护了一下,防止某个字段缺失直接 KeyError。 - 标题和链接都取自同一个
a标签,所以在title_tag里同时取出文本和属性,比分别 find 一个a再 find 一个h2更稳。 - 用
select_one而不是find_all(...)[0],如果没找到,前者返回 None,后者直接 IndexError。这两者在异常行为上差别巨大。
实际抓取的时候,一个页面可能有几千条这样的结构,循环一跑,数据就进了列表。之后你想存 CSV、存数据库还是写进 Excel,就都是后半段的事了。
5. 常见问题与避坑清单
讲到这块,我感觉才是整篇最值钱的部分。BeautifulSoup 的 API 本身好学,难的是它在实战中冒出来的各种奇怪问题。
5.1 解析器差异导致的空结果
同一份 HTML,用不同解析器解析出来的树结构有时候不一样。这个我前面提过,再展开讲讲。最常见的情况是:你用 html.parser 解析时,某些标签的层级是 A;换 lxml 后,同一个标签的层级变成了 B。于是soup.select(".news-item > .title")这种直接子选择器,可能在一种解析器下有结果,在另一种下没结果。
避免方案:项目里统一用 lxml,写代码之前先设定好解析器,不要到处切换。
5.2 页面结构变化导致的定位失效
这是爬虫界最宿命的问题:别人网站一改版,你的选择器就废了。写爬虫的人要有这个意识——没有一劳永逸的解析逻辑,一定要做异常处理。
我自己写爬虫有个习惯:定位关键数据时,不用那种“一次性”的深层路径,比如html > body > div:nth-of-type(2) > div > div > ul > li,这种路径只要中间多一个标签,全套崩盘。更稳的做法是找目标附近的“锚点属性”。比如数据的父级有 id,或者有 stable 的 class 名,从锚点开始往下找。
举个对比:
# 脆弱的写法,多一层 div 就完蛋 soup.select("body > div:nth-of-type(2) > div > div > ul > li") # 更稳的写法,锚点是 id soup.select("#news-list > li")如果你发现页面里到处都是动态生成的随机 class 名,那就得换思路了:用文本内容定位,或者用>import requests from bs4 import BeautifulSoup r = requests.get("某页面地址") r.encoding = r.apparent_encoding # 根据内容猜测编码 html_text = r.text soup = BeautifulSoup(html_text, "lxml")
或者另一种思路:直接用r.content(bytes 类型),BeautifulSoup 支持自己检测编码:
soup = BeautifulSoup(r.content, "lxml")两种方式我都试过。对于大多数中文站,r.content直接喂给 BeautifulSoup 的表现都不错,它内部会检测 meta charset,自动解码。如果是r.text,就要手动设置一下 encoding 才稳。
5.4 性能问题:能选局部就不要全页爬
BeautifulSoup 操作本身不算慢,但网页一旦达到几百 KB,频繁调用soup.find_all也会有卡顿感。我优化过一个批量采集脚本,原来每个页面跑 20 多秒,后来在流程里加了两个优化,直接降到 3 秒。
第一个优化是缩小范围。不要总在soup上全局找,先用select_one定位一个大容器,再在容器内部用find_all。
第二个优化是减少解析次数。有时候一个页面的数据可以从一个大的Tag里全部提取,就尽量不要循环里反复soup.find_all。把大容器先拿出来,它本身也是一个 BeautifulSoup 对象,支持继续调用find_all和select。
还有一个懒人技巧:如果只是要某一类标签,可以用soup.find_all(text=re.compile(...)),直接命中文本节点,省去一层树遍历。这在提取脚本中如果用了正则揉文本,会比较顺手。
6. 我的经验:解析库只是起点,重点是结构意识
写得越多,越觉得学习 BeautifulSoup 最大的收获不是记住 API,而是开始用“结构”的眼光看网页。以前拿到 HTML 源代码就是一堆密密麻麻的尖括号,现在看到的是:这层是 header、那层是 main、这个列表项是重复卡片、那个链接是分页。
所以我的建议是:学完这份笔记,不要急着去抓什么大型网站,找个简单的静态页面练手,重复做这三件事——用 requests 拿页面、用 BeautifulSoup 解析、把数据整理成结构化结果。等这流程跑顺了,再去研究动态页面、登录、反爬机制那些更深的东西,会踏实很多。
BeautifulSoup 对初学者来说,是一个极其友好的起点。它把繁琐的解析变得像浏览器开发者工具那样直观。线上爬虫技术日新月异,但解析的基本功永远不会过时。