1. 为什么拿到HTML之后,第一件事不是写正则
很多人刚开始学爬虫,拿到网页源码第一反应就是掏出正则表达式一顿匹配。这个做法在少量简单场景下确实能用,但只要页面结构稍微复杂一点,嵌套层级深一点,正则就会变成一场噩梦。因为你匹配的不是“内容”,而是“标签之间的文本”,而HTML这种带嵌套结构的文本,恰好是正则最不擅长处理的东西。
我写过一段很蠢的代码:用正则去匹配一个列表页里的所有商品名称,结果因为某个商品描述里多包含了一个尖括号,整个匹配结果直接错乱。后来排查了大半个小时,才发现问题是出在正则的贪婪匹配和HTML标签闭合之上。从那以后我就明白了,解析HTML这件事,得用专门的工具。
这就是BeautifulSoup存在的意义。它的核心工作是帮你把HTML文档转换成一棵“标签树”,然后你在这棵树上按标签名、属性、文本内容去查找节点,想怎么取就怎么取。它不关心这个网页是静态写死的,还是服务端渲染出来的,只要最终浏览器能拿到的那份HTML源码摆在你面前,它就能处理。
这一篇笔记针对的是刚入门爬虫的读者。默认你已经知道怎么用requests把自己伪装成浏览器,把网页源码拿回来。如果你还没有这个基础,建议先去把请求头、响应对象这几个概念弄明白,再回来看这篇。我们这一篇只讲一件事:拿到HTML之后,怎么干净利落地把你要的数据提取出来。
需要特别说明的是,现在很多网页前端框架直接把数据渲染在JavaScript里,HTML源码里只有一堆空的div。这种情况单纯用BeautifulSoup是拿不到数据的,得配合接口请求或者动态渲染工具。但是这不影响BeautifulSoup在爬虫体系里的地位——目前大量静态页面、服务端模板渲染的页面,仍然靠它来解析,掌握它是入门爬虫必须跨过的一道坎。
2. 环境准备和解析器选型:html.parser、lxml该用哪个
2.1 安装BeautifulSoup4
先说明一点,很多人以为这个库叫BeautifulSoup,但正确的包名是beautifulsoup4。Python在导入的时候用的是bs4,你要是写from BeautifulSoup import ...这种老语法,在Python 3环境里直接报错。
安装一行命令就搞定:
pip install beautifulsoup4如果网络环境不太好,可以换国内镜像源:
pip install beautifulsoup4 -i https://pypi.tuna.tsinghua.edu.cn/simple装完之后验证一下:
from bs4 import BeautifulSoup soup = BeautifulSoup("<html><body><p>hello</p></body></html>", "html.parser") print(soup.p.string)只要能输出hello,环境就没问题。
2.2 解析器到底是个什么东西
BeautifulSoup本身不是解析器,它是一层封装。真正的解析工作,由底层的解析器来做,BeautifulSoup负责把解析结果包装成方便操作的对象。
这句话很重要。很多人用BeautifulSoup遇到莫名其妙的解析结果,比如明明页面里有某个标签却找不到,大概率不是代码写错,而是解析器选错了。
常用的解析器有这么几种,我列个表方便对比:
| 解析器 | 用法 | 优点 | 缺点 |
|---|---|---|---|
| Python标准库 | BeautifulSoup(html, "html.parser") | 无需额外安装、容错能力强 | 解析速度一般 |
| lxml的HTML解析器 | BeautifulSoup(html, "lxml") | 速度快、容错强,是最推荐的选择 | 需要额外装lxml |
| lxml的XML解析器 | BeautifulSoup(html, "xml") | 只适合解析XML文档 | 需要lxml,且对HTML容错差 |
| html5lib | BeautifulSoup(html, "html5lib") | 完全模拟浏览器解析,容错最强 | 速度最慢,依赖大 |
我的建议很明确:入门就直接选lxml。原因很简单,它快。一个几十KB的HTML页面,lxml解析耗时可能是html.parser的一半都不到,而且对于不规范标签的容错能力也强。爬虫抓回来的页面不可能都是标准规范HTML,很多页面标签不闭合、属性值不加引号,用html5lib倒是能解析,但速度让人着急,lxml在速度和容错之间平衡得最好。
安装lxml:
pip install lxml然后使用方式就变成:
soup = BeautifulSoup(html_content, "lxml")2.3 解析失败的常见原因
这里提一个特别容易踩的坑:当你用requests获取页面之后,response.text的编码可能是错的。比如一个GBK编码的页面,requests默认按ISO-8859-1解码,结果就是一串乱码。这种乱码内容和解析器没关系,你传给BeautifulSoup什么,它就解析什么。你传进去的是乱码,解析出来的标签和文本自然也是乱的。
判断是不是这个问题的办法很简单:先把抓到的HTML源码print出来看几行,如果中文都是乱码,先别往后写代码了,先把编码问题解决掉。通常的做法是用response.content拿到原始的字节数据,再调用response.apparent_encoding或者手动指定编码。
response = requests.get(url, headers=headers) response.encoding = response.apparent_encoding soup = BeautifulSoup(response.text, "lxml")这个细节值得你牢记。我在教新人写爬虫的时候,至少有三分之一的人卡在这个环节,然后跑来问我为什么代码一模一样但什么都提取不到。几乎每次都是编码没设置。
3. 从HTML到对象:理解Tag、NavigableString和BeautifulSoup的关系
3.1 BeautifulSoup把页面变成了什么
不管页面结构多复杂,BeautifulSoup都会把它整理成一棵树。这棵树上的节点,主要是三种类型:
Tag(标签节点):对应HTML里的标签,比如<html>、<body>、<p>。这个对象有名字、属性,还可以继续往下找子节点。
NavigableString(文本节点):对应标签之间的纯文本内容。比如<p>你好</p>里的“你好”。
BeautifulSoup对象:整个文档的根节点,你可以把它理解成一个特殊的Tag。
新手最需要建立的认知是:当你拿到一个Tag对象之后,它依然可以像网页一样继续向下查找。也就是说,你可以先定位到某个大的列表区域,再在这个区域内部继续用find_all,而不是每次都要从整个文档开始搜。
content_div = soup.find("div", class_="content") all_links = content_div.find_all("a")这段代码做的事情是先定位到class为content的div,然后只在它内部找所有a标签。如果页面里有其他区域的a标签,不会被带进来。这种“先缩小范围,再仔细查找”的方式,是解析页面时最实用的思路。
3.2 属性、文本和子节点
Tag对象最常见的三个操作是取属性、取文本、取子节点。
取属性:用中括号语法,和字典一样。
link = soup.find("a") href = link["href"]如果属性不存在,直接这样访问会报KeyError。所以更安全的方式是:
href = link.get("href")取文本:用.get_text()方法。这个方法会把该标签下所有子标签的文本全部拼接起来,不带任何标签痕迹。
text = link.get_text()取某个标签的直接子节点:用.contents属性,返回的是一个列表。如果要迭代所有子孙节点,用.descendants。
for child in div.contents: print(child)3.3 一个极其重要的认知:标签对象永远是“整棵子树”
新手最容易犯的一个错误是:以为把一个标签对象转成字符串,得到的就只是那一小段标签内容。其实不是的,一个Tag的字符串表现形式是完整的标签以及它的所有子标签。
设想你定位到了一个<tr>标签,当你print这个Tag时,你会看到完整的这一行表格的所有列都跟着打印出来了。这点在你调试代码、检查解析结果对不对的时候,反而是个好事——你能直接看到这个Tag到底包含了什么范围。
这个认知也解释了另一个问题:为什么find_all返回的结果里明明看起来是一个文本,你用type()一查却发现它依然是一个Tag。因为BeautifulSoup在解析时,一个包含文本的标签最终保存的是标签和文本的组合体,而不是纯文本。
4. find、find_all和select:三种最常用的查找方式,到底该用哪个
4.1 find和find_all的区别
先记住一个最基本的规则:find返回的是第一个匹配到的Tag,find_all返回的是所有匹配到的Tag组成的列表。没有匹配到的时候,find返回None,find_all返回空列表。
有兄弟俩,find是见好就收,找到一个就回头;find_all是赶尽杀绝,把符合条件的一个不剩地找出来。日常场景里,如果确定页面里只有一个目标元素,用find就够了,省得再去列表里取下标。如果不确定有几个,或者铁定有多个,那就用find_all。
# 找第一个p标签 first_p = soup.find("p") # 找所有p标签 all_p = soup.find_all("p")4.2 用属性筛选元素
最常用的筛选方式就是按class和id。但这里有两个特别容易踩的语法坑。
坑一:class是Python的保留字,所以不能用class参数,得用class_。
# 正确姿势 soup.find_all("div", class_="news-item") # 错误写法 soup.find_all("div", class="news-item")坑二:CSS里一个元素可以有多个class,BeautifulSoup也支持用列表匹配多个class。匹配规则是元素的class必须包含所有给出的class,顺序无关。
# 匹配class同时为box和accent的元素 soup.find_all("div", class_=["box", "accent"])这个列表匹配的方式在处理带多个修饰类的元素时很好用,因为class的顺序在HTML源码里经常不一致,用列表匹配就不需要担心顺序问题。
id属性没有保留字问题,直接用:
soup.find("div", id="main-content")4.3 传入函数:更灵活的筛选方式
如果觉得按标签名加属性的方式还不够灵活,find_all是支持传入函数的。你用函数的方式去表达“我要什么样的标签”,比一个个参数硬拼要优雅得多。
def has_class_and_href(tag): return tag.has_attr("class") and tag.has_attr("href") result = soup.find_all(has_class_and_href)这个函数接收到的是每一个Tag对象,你返回True就代表“这个元素我要”,返回False就代表“跳过”。在这个函数里你可以写任何逻辑,比如判断属性值是否以特定字符串开头、判断文本长度等。
不过我得说句实话,这种写法在入门阶段用得少。等你真正开始爬一些复杂页面,发现单纯按属性匹配不满足需求时,再回过头来用这个技巧,会产生恍然大悟的感觉。
4.4 CSS选择器:如果你会写CSS,那这个方式最顺手
BeautifulSoup的select方法支持完整的CSS选择器语法。
# 选择所有class为news-item的div soup.select("div.news-item") # 选择id为main的标签下的所有a soup.select("#main a") # 选择直接子元素 soup.select("ul > li") # 选择带href属性的a soup.select("a[href]") # 选择href以http开头的a soup.select("a[href^='http']")关于select和find_all怎么选,我给一个不太严谨但很实用的建议:select适合你已经在浏览器开发者工具里核对过选择器路径的情况。F12打开开发者工具,选中一个元素,右键复制selector,粘到代码里,跑一下,大部分情况下能直接匹配到。这个流程对新手来说效率极高。
find_all适合你需要在代码里动态拼接筛选条件的场景。比如你想匹配多个class中的任意一个,用find_all需要传入一个标签名才能循环,而用select就可以用逗号分隔多个选择器:
soup.select("h2, h3, h4")4.5 常用的特殊查找:find_parents、find_next_sibling等
除了一直往下找,BeautifulSoup还支持往上找、往旁边找。这些在特定场景下非常实用。
比如,你找到了一个文本节点,但你想知道它所在的整个表格行是什么样的:
text_node = soup.find(string="某个特定文本") tr = text_node.find_parent("tr")再比如,你想找一个标签的后面那个兄弟标签:
next_td = current_td.find_next_sibling("td")这些相对查找的方式,在解析表格类数据的时候几乎是必备技能。因为表格很规整,找到一行之后,要取同一行的其他列,用兄弟查找比从头再写一个find要方便得多,逻辑上也更清晰。
5. 实战:解析一个图书列表页面的完整流程
5.1 目标页面结构分析
假设我们要抓一个模拟图书网站的列表页,页面结构大概长这样:
<div class="book-list"> <div class="book-item"> <h3 class="title">Python入门到精通</h3> <span class="author">张三</span> <span class="price">¥59.00</span> <p class="intro">适合初学者的完整教程</p> </div> <div class="book-item"> <h3 class="title">数据分析实战</h3> <span class="author">李四</span> <span class="price">¥78.00</span> <p class="intro">基于真实案例的数据分析指南</p> </div> </div>这种结构在真实的静态页面里非常典型。目标就三步:拿到整个列表区域,遍历每个书本项,分别取出书名、作者、价格和简介。
5.2 第一步:把HTML装进BeautifulSoup
import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } response = requests.get("http://book-demo.example.com/list", headers=headers) response.encoding = response.apparent_encoding soup = BeautifulSoup(response.text, "lxml")5.3 第二步:定位列表和单项
先把外层列表容器找出来,再在容器里找所有书本项。
book_list = soup.find("div", class_="book-list") book_items = book_list.find_all("div", class_="book-item")这里有个值得培养的习惯:先locate容器,再找子元素。好处是即使页面其他地方也有class为book-item的元素,也不会被误抓。
5.4 第三步:提取每个单项的数据
for item in book_items: title = item.find("h3", class_="title").get_text(strip=True) author = item.find("span", class_="author").get_text(strip=True) price = item.find("span", class_="price").get_text(strip=True) intro = item.find("p", class_="intro").get_text(strip=True) print(title, author, price, intro)get_text的strip=True参数可以去掉文本两端多余空格和换行,这个参数建议时刻带着。
5.5 完整代码收尾
把每一本书的信息存到字典里,再放到列表里,方便后续写入文件或者做数据分析。
import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } response = requests.get("http://book-demo.example.com/list", headers=headers) response.encoding = response.apparent_encoding soup = BeautifulSoup(response.text, "lxml") book_list = soup.find("div", class_="book-list") book_items = book_list.find_all("div", class_="book-item") books = [] for item in book_items: book = { "title": item.find("h3", class_="title").get_text(strip=True), "author": item.find("span", class_="author").get_text(strip=True), "price": item.find("span", class_="price").get_text(strip=True), "intro": item.find("p", class_="intro").get_text(strip=True) } books.append(book) for book in books: print(book)整个流程下来不到三十行代码。你可能已经发现了:写爬虫的代码量其实不大,大部分时间都花在“搞清楚页面结构和目标数据在哪里”上面。
6. 入门阶段最容易踩的五个坑
6.1 编码问题导致中文乱码
这个在前面提过,但它值得再单独拎出来说一次。requests在获取文本时,如果响应头没有明确指定编码,requests会按照自己猜的方式去解码,而它猜错的情况非常常见。
处理办法很简单:设置response.encoding。如果你实在不确定网页是什么编码,让requests自己猜:
response.encoding = response.apparent_encodingapparent_encoding是requests根据内容字节统计出来的编码,在中文页面上通常都比较准。这个字段偶尔也会判断出错,所以最稳妥的方法还是打开网页源码看头部meta标签里怎么写的:
<meta charset="gb2312">看到了就直接指定:
response.encoding = "gb2312"6.2 get_text()和get_text()不带strip的差异
不加strip时,提取出来的文本会带着源码里的换行、缩进空格和前后空白。你以为输出的是“Python入门”,实际上可能打印出来是“\n Python入门\n ”。如果你拿这个文本去做字符串匹配,大概率什么都匹配不上。
解决方案就是老实的用法:get_text(strip=True)。
这个坑几乎每个新手都会踩一次,我当年也是调了半天才发现是文本前后的空格在捣鬼。
6.3 明明标签在那里,find却返回None
遇到这种情况,先不要怀疑代码,把目标区域打印出来看一遍。大概率是以下几种情况:
- 目标内容是通过JavaScript动态加载的,HTML源码里没有
- 标签结构嵌套比你预期的深,find只搜了符合条件的第一个,但这个第一个可能嵌套在别的地方
- 你用的class名在源码里实际是两部分拼接的,比如class="book-item active",精确匹配时用class_="book-item"能匹配,但如果你写的是class_="book-item active"里的顺序或者多了一个空格,就会失败
排查办法就是用soup.prettify()把解析后的文档打印出来,肉眼观察一下标签层级结构。先确认结构再动手写提取逻辑,永远比瞎猜要快。
6.4 不要用解析HTML的方式解析XML
爬虫偶尔会遇到XML格式的数据接口,返回的是类似<book><name>Python</name></book>这样的结构。如果你依然用lxml作为解析器传给BeautifulSoup,很可能得到的结果和预期不符。
XML解析老老实实用"xml"作为解析器:
soup = BeautifulSoup(xml_content, "xml")这个模式下,大小写敏感、属性顺序、自闭合标签的处理方式都和XML规范一致。
6.5 页面结构一旦微调,代码就废了
这是在爬虫领域无可奈何的事情。BeautifulSoup本身不维护“语义”,它只是按照HTML结构去解析。网站前端一改版,class名换了一个,你的解析代码就得跟着改。
应对方式有几个思路。一个是尽量找结构性更强、不容易被改的属性来定位,比如HTML5的data-开头的自定义属性。另一个是做好异常兜底,每个字段取值都用try/except包一下,避免因为某一个字段缺失导致整个循环崩溃。
try: price = item.find("span", class_="price").get_text(strip=True) except AttributeError: price = "暂无"这样哪怕页面后期缺字段,程序也不会直接崩掉。
7. 一个更接近真实项目的爬虫骨架
这一节给出一套可以套用的代码结构。真实的爬虫项目不只是“解析一个页面”,还需要考虑请求频率、错误重试、数据存储,以及最基本的合规意识。
import time import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def fetch_page(url, retry=3): for attempt in range(retry): try: response = requests.get(url, headers=headers, timeout=10) if response.status_code == 200: response.encoding = response.apparent_encoding return response.text except requests.RequestException as e: print(f"请求失败:{e}") if attempt == retry - 1: raise time.sleep(1) return None def parse_page(html): soup = BeautifulSoup(html, "lxml") book_items = soup.select("div.book-item") data = [] for item in book_items: book = { "title": item.select_one("h3.title").get_text(strip=True), "author": item.select_one("span.author").get_text(strip=True), "price": item.select_one("span.price").get_text(strip=True) } data.append(book) return data def main(): html = fetch_page("http://book-demo.example.com/list") if not html: return books = parse_page(html) for book in books: print(book) if __name__ == "__main__": main()这套骨架把请求、解析、主流程分离了三个函数。每个函数只干一件事,出问题的时候排查起来非常直观。
两个要注意的细节:fetch_page里的time.sleep不是为了装样子,它是在控制请求频率。爬虫请求服务器本质上和人在浏览器里访问一样,请求太快对服务器是一种压力,也是不礼貌的行为。保持合理的请求间隔,是一个爬虫开发者最基本的素养。
另外,先把页面完整抓下来,再解析。这个“分离”的思路值得练成习惯。真实项目里,先确认请求这一步结果是否正常,再去写解析逻辑,能够省掉很多两头排查的时间。
8. 写在最后的一点体会
从入门到现在,我经手的解析工作里,BeautifulSoup依然是用的最多的一个库。虽然现在有很多新的方案,比如基于浏览器的自动化工具、基于CSS选择器封装的解析库,但BeautifulSoup简单直观、足够轻量、适合快速实现,这个定位很长一段时间内都不会过时。
我通常会建议身边的初学者,把这篇笔记里的方法反复做三遍:第一遍照着代码跑通,第二遍换一个网站自己抓,第三遍把自己抓到的数据保存成文件。三遍下来,你对HTML的标签结构、属性、文本节点这些概念的敏感度会提高一大截,之后再接触任何解析相关的工具,都会觉得似曾相识。
最后说一个我自己的小习惯:抓下来的样例HTML源码不要删掉,存一份到本地。一方面,调试解析代码的时候不需要反复请求网站,直接用本地文件就能快速验证;另一方面,网站即使改版了,你手里还留着之前版本的结构,对比起来一目了然。
爬虫入门这件事,说白了就是“先拿到网页,再取出信息”。requests帮你解决前半段,BeautifulSoup帮你解决后半段。把这两样东西用熟,你就已经有能力解决大量真实场景下的数据采集需求了。