☰
Python爬虫必备:BeautifulSoup解析HTML从入门到实战
2026/10/17 9:56:10 网站建设 项目流程

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容错差
html5libBeautifulSoup(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_encoding

apparent_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帮你解决后半段。把这两样东西用熟,你就已经有能力解决大量真实场景下的数据采集需求了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询