BeautifulSoup详解

我们解析网页最大的难点就在于其 HTML 源码是一长串复杂的字符串,而 BeautifulSoup 恰好提供了将其解析为特定的数据结构的能力,这样我们就可以通过 “访问属性”“调用方法” 的方式快速获取网页中的指定内容。

解析器

BeautifulSoup 支持 Python 标准库中的 HTML 解析器,还支持一些第三方的解析器,比如:lxml 或 html5lib,这些解析器都是需要额外 pip 安装的。

解析器 使用方法 优势 劣势
Python标准库 BeautifulSoup(markup, “html.parser”) Python的内置标准库,执行速度适中,文档容错能力强 文档容错能力差
lxml HTML 解析器 BeautifulSoup(markup, “lxml”) 速度快,文档容错能力强 需要安装C语言库 pip install lxml
lxml XML 解析器 BeautifulSoup(markup, “xml”) 速度快,唯一支持XML的解析器 需要安装C语言库 pip install lxml
html5lib 解析器 BeautifulSoup(markup, “html5lib”) 最好的容错性,以浏览器的方式解析文档,生成HTML5格式的文档 速度慢,不依赖外部扩展 pip install html5lib

数据结构

我们将一段关于“爱丽丝梦游仙境”的文档 HTML 源码传入 BeautifulSoup 的构造函数,就能得到一个文档的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from bs4 import BeautifulSoup

html_doc = """
<html><head><title>The Dormouse's story</title></head>

<p class="title"><b>The Dormouse's story</b></p>

<p class="story">Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1">Elsie</a>,
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a> and
<a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>;
and they lived at the bottom of a well.</p>

<p class="story">...</p>
"""

soup = BeautifulSoup(html_doc, 'html.parser')

BeautifulSoup 将复杂 HTML 文档转换成一个复杂的树形结构,每个节点都是一个 Python 对象,所有对象可以归纳为 4 种: BeautifulSoup(对应 HTML 文档), Tag(对应 HTML 标签), NavigableString(对应被 HTML 标签所修饰的字符串), Comment(对应注释及特殊字符串)。为了避免概念的混淆,我们把 Python 对象里的成员称为成员,把 HTML 原生文档中的标签称为标签,标签可以附加属性

BeautifulSoup对象

BeautifulSoup 对象表示的是一个文档的全部内容,其成员大部分都是 Tag 对象:

1
print(type(soup)) # <class 'bs4.BeautifulSoup'>

作为成员的 Tag 对象,其成员变量名与 XML 或 HTML 原生文档中的标签相同,可以通过 “.标签名” 的访问形式直接访问文档中第一个对应的标签:
1
tag = soup.title

Tag对象

Tag 对象是 BeautifulSoup 对象的成员:

1
2
3
soup = BeautifulSoup('<b class="boldest">Extremely bold</b>')
tag = soup.b
print(type(tag)) # <class 'bs4.element.Tag'>

Tag 对象中的 name 成员对应了原生文档的标签项,可以读取甚至修改:

1
2
3
print(tag.name)  # b
tag.name = "blockquote"
print(tag) # <blockquote class="boldest">Extremely bold</blockquote>

在原生文档中的标签可以附带属性,如 <b class=”boldest”> 标签有一个 “class” 的属性,值为 “boldest”。转换成 Tag 对象后,可以通过字典的方式获取之,当然也可以修改:

1
2
print(tag['class']) # boldest
print(tag.attrs) # {'class': 'boldest'}

在 HTML4/5 的语法中,有的标签其属性可以取多个值,这个时候通过 Tag 对象的字典方式访问,将得到一个列表(是否返回一个列表取决于当前标签是否有取多个值的语法规范):

1
2
css_soup = BeautifulSoup('<p class="body strikeout"></p>')
css_soup.p['class'] # ["body", "strikeout"]

原生文档中被 HTML 标签修饰的字符串,BeautifulSoup 为它们专门定义了一个类,通过访问 Tag 对象的 string 成员就可以得到该标签修饰的字符串内容:

1
2
print(tag.string)  # Extremely bold
print(type(tag.string)) # <class 'bs4.element.NavigableString'>

被 Tag对象修饰的字符串不能编辑,但是可以被替换成其它的字符串,用 replace_with() 方法:
1
2
tag.string.replace_with("No longer bold")
print(tag) # <blockquote>No longer bold</blockquote>

Comment对象

Comment 对象其实就是一种特殊的 NavigableString 对象:

1
2
3
4
5
markup = "<b><!--Hey, buddy. Want to buy a used parser?--></b>"
soup = BeautifulSoup(markup)
comment = soup.b.string
print(comment) # Hey, buddy. Want to buy a used parser?
print(type(comment)) # <class 'bs4.element.Comment'>

可以看到,通过 Tag 对象的 string 成员获取的字符串也包含了注释部分,但其类型却是 Comment,这样也就为我们区分内容提供了可能。

文档树

我们已经搞清楚了一个 HTML 原生文档中的所有要素是如何与 BeautifulSoup 相关对象对应的了,但是,原生文档中各标签的依赖关系也需要被还原,这也正是我们前面提到的 BeautifulSoup 将复杂 HTML 文档转换成一个复杂的树形结构,我们称其为文档树。通过对文档树进行 遍历搜索,我们就能方便快捷地实现对原始网页的抽取与分析。

遍历文档树

我们还是以最开始的“爱丽丝梦游仙境”的文档来做实例:soup = BeautifulSoup(html_doc)

子节点

一个 Tag 对象可能包含多个字符串或其它的 Tag 对象,这些都是这个 Tag 对象的子节点。前面介绍了 BeautifulSoup 对象可以通过 “.标签名” 的方式直接访问文档中第一个对应的标签,这种模式同样适配于具有子节点的 Tag 对象:

1
print(soup.body.b) # <b>The Dormouse's story</b>

Tag 对象的 .contents 成员可以将子节点以列表的方式输出:
1
2
3
4
5
6
7
head_tag = soup.head
print(head_tag) # <head><title>The Dormouse's story</title></head>
print(head_tag.contents) # [<title>The Dormouse's story</title>]

title_tag = head_tag.contents[0]
print(title_tag) # <title>The Dormouse's story</title>
print(title_tag.contents) # ['The Dormouse's story']

另外,通过 Tag 对象的 .children 生成器可以对其子节点进行循环:
1
2
for child in title_tag.children:
print(child) # The Dormouse's story

可以看到,上面的介绍的 Tag 对象的成员只能访问到直接子节点(即广度遍历),如果想要访问子孙节点(即深度遍历)则需要通过 .descendants 成员进行递归循环:
1
2
3
4
5
6
7
8
9
print(len(list(soup.children))) # 1
print(len(list(soup.descendants))) # 25
for child in head_tag.descendants:
print(child)

'''
<title>The Dormouse's story</title>
The Dormouse's story
'''

当一个 Tag 对象只有一个 NavigableString 对象的子节点(其包含的字符串),那么就可以使用 .string 成员获得该字符串:
1
print(title_tag.string) # 'The Dormouse's story'

值得注意的是,当 Tag 对象有且仅有一个子节点,那么即便该子节点是一个 Tag 对象,也可以使用 .string 成员获得其包含的字符串:
1
2
3
4
print(head_tag.contents) # [<title>The Dormouse's story</title>]
print(head_tag.string) # 'The Dormouse's story'

print(soup.html.string) # None

相反,当 Tag 对象有多个子节点时,直接访问 .string 将得到一个 None 值,因为此时 Tag 对象不知道应该返回哪个字符串。但是,通过 .strings 成员可以一股脑地将当前 Tag 对象所有子节点所包含的字符串一并返回,包括原生文档中的空格或换行都会被返回,另一个成员 .stripped_strings 则可以返回不带空格或换行的结果。

父节点

一个 Tag 对象有“子节点”,自然也可能存在“父节点”,通过 .parent 成员就可以获取,相关的概念和原理与子节点是一致的:

1
2
3
4
print(title_tag.string.parent) # <title>The Dormouse's story</title>

html_tag = soup.html
print(type(html_tag.parent)) # <class 'bs4.BeautifulSoup'>

可以看到,作为文档树最顶层的 Tag 对象(<html>),其父节点是一个 BeautifulSoup 对象,可以想象该 BeautifulSoup 对象的父节点一定是 None。同样的,通过 .parents 成员可以迭代访问所有的父节点。

兄弟节点

在文档树中,使用 .next_sibling 和 .previous_sibling 属性来查询兄弟节点:

1
2
3
sibling_soup = BeautifulSoup("<a><b>text1</b><c>text2</c></b></a>", 'html.parser')
print(sibling_soup.b.next_sibling) # <c>text2</c>
print(sibling_soup.c.previous_sibling) # <b>text1</b>

上面的实例是符合我们预期的,因为传入 BeautifulSoup 的页面是没有换行的,而真实的网页是不可能没有换行的:
1
2
3
<a href="http://example.com/elsie" class="sister" id="link1">Elsie</a>,
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a> and
<a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>;

上面是开篇所示的样例文档的源码片段,第一个 <a> 标签的 .next_sibling 并不是第二个 <a> 标签,而是逗号和换行符(即与第二个 <a> 标签中间插入的所有内容),它的再下一个兄弟节点才是我们预期的第二个 <a> 标签(这是一个巨坑)。

另外,通过 .next_siblings 和 .previous_siblings 属性可以对当前节点的兄弟节点迭代输出:

1
2
3
4
5
6
7
8
9
10
for sibling in soup.a.next_siblings:
print(repr(sibling))

'''
',\n'
<a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>
' and\n'
<a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>
';\nand they lived at the bottom of a well.'
'''

回退与前进

.next_element 属性指向解析过程中下一个被解析的对象(字符串或tag),而 .previous_element 属性指向当前被解析的对象的前一个解析对象。如何理解“解析”呢?我们来看一段 html 的源码:

1
2
<html><head><title>The Dormouse's story</title></head>
<p class="title"><b>The Dormouse's story</b></p>

HTML 解析器会将上面这段源码转换成一连串的事件: “打开<html>标签”,“打开一个<head>标签”,“打开一个<title>标签”,“添加一段字符串”,“关闭<title>标签”,“打开<p>标签”等事件。那么:
1
2
3
4
last_a_tag = soup.find("a", id="link3")

print(last_a_tag) # <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>
print(last_a_tag.next_element) # Tillie

由于找到最后一个 <a&gt 标签的下一个事件是“添加 Tillie 这段文字”,所以返回是 Tillie。可以想象,当某些事件缺失的时候 .next_element 的结果可能会与 .next_sibling 相同。

另外, 通过 .next_elements 和 .previous_elements 可以迭代地向前或向后访问文档的解析内容。

1
2
3
4
5
6
7
8
9
10
11
for element in last_a_tag.next_elements:
print(repr(element))

'''
'Tillie'
';\nand they lived at the bottom of a well.'
'\n'
<p class="story">...</p>
'...'
'\n'
'''

搜索文档树

搜索文档树的方法都是以 find 开头的,且它们都具有相同的参数格式,单独 find 的函数返回的是第一个满足条件的 Tag对象,带有 find_all 的函数则返回所有满足条件的 Tag对象组成的列表。

find ( name , attrs , recursive , text , **kwargs )

find 相关的方法其参数都是如上的格式:

  • name参数:指明查找的 Tag对象,其取值可以是如下几种过滤器:
    • 字符串:BeautifulSoup 会查找与字符串完整匹配的 Tag对象
    • 正则表达式:BeautifulSoup 会通过正则表达式的 match() 来匹配内容
    • 列表:BeautifulSoup 会将与列表中任一元素匹配的 Tag对象返回
    • 方法:只能是一个返回 True/False 的方法,且只接收一个参数,BeautifulSoup 会返回所有满足该函数为 True 的 Tag对象
    • True:可以匹配任何 Tag对象
  • attr参数:指明查找的 Tag对象中的属性,可以按照关键词模式进行赋值,也可以采用 key-value 的形式传入。值得注意的是,可以在第一个参数位置上传入 attr 参数,因为该参数的固定格式可以被 BeautifulSoup 识别出来
  • recursive参数:是否搜索当前 Tag对象的所有子孙节点,默认是 True;如果设置为 False,则只搜索其直接子节点
  • text参数:指定查找的字符串内容,其取值同样支持字符串、正则表达式、列表、方法、True等过滤器
  • limit参数:指定返回查找到的 Tag对象的数量

上面梳理清楚了搜索文档树相关接口的参数含义,下面我们还是以本文中使用的“爱丽丝梦游仙境”的文档作为实例,总结一下具体的应用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# name参数使用字符串作为过滤器
print(soup.find_all('a'))
'''
[<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
<a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>,
<a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]
'''
print(soup.html.find_all("title")) # [<title>The Dormouse's story</title>]
print(soup.html.find_all("title", recursive=False)) # []
print(soup.find_all("a", text="Elsie")) # [<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>]

# name参数使用正则表达式作为过滤器
import re
for tag in soup.find_all(re.compile("^h")):
print(tag.name)
'''
html
head
'''

# name参数使用列表作为过滤器
print(soup.find_all(["a", "b"], limit=1))
'''
[<b>The Dormouse's story</b>]
'''

# name参数使用方法作为过滤器
def has_class_but_no_id(tag):
return tag.has_attr('class') and not tag.has_attr('id')
print(soup.find(has_class_but_no_id))
'''
<p class="title"><b>The Dormouse's story</b></p>
'''

可以看到,find_all 返回的都是符合查找条件的 Tag对象的列表,哪怕限制了 limit=1,也是返回一个只有一个元素的列表;而 find 返回的是单个 Tag对象。

下面我们来看一下 attr 参数的具体使用实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 使用关键词模式
print(soup.find_all(id='link2'))
print(soup.find_all(href=re.compile("elsie"), id='link1'))

# 使用key-value模式
data_soup = BeautifulSoup('<div data-foo="value">foo!</div>')
print(data_soup.find_all(attrs={"data-foo": "value"}))

'''
[<a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>]
[<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>]
[<div data-foo="value">foo!</div>]
'''

使用关键词模式,只要关键词与 Python 语法的保留字不冲突就可以直接使用;而上面举例的 data-* 属性是 HTML5 的语法,这种属性则无法通过关键词模式进行搜索,但是 key-value 模式是万能的。另外,一种很常用的搜索方式是按照 CSS 类名进行搜索,这里需要注意的是,CSS 类名的关键字 class 在 Python 中是保留字,我们需要转义为 class_ (或者用 key-value 模式):

1
2
3
4
5
6
7
print(soup.find_all("a", class_="sister"))

'''
[<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
<a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>,
<a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]
'''

与前面遍历文档树介绍的关于父辈节点、兄弟节点一样,搜索也可以在当前节点的父兄节点中进行,对应的函数是 find_parents() 和 find_next_siblings()、find_previous_siblings() 等,它们的参数定义与用法也都是相同的。

其他

以上就是 BeautifulSoup 中我们最常用的功能了,可以完全满足我们解析与抽取网页内容的工程需求。当然还有一些功能,我们简单介绍一下,尽管它们并不常用:

  • CSS选择器:BeautifulSoup 支持大部分的 CSS选择器,在 Tag 或 BeautifulSoup 对象的 .select() 方法中传入字符串参数,即可使用 CSS选择器的语法找到相应的 Tag
  • 修改文档树:被 BeautifulSoup 解析出来的文档树,可以按照对象属性的方式进行赋值修改,当然也有一些增删的方法可供调用(如:tag.append()向当前节点内添加内容、tag.decompose()将当前节点移除文档树等)
  • 输出:prettify() 方法将 BeautifulSoup 的文档树格式化后以 Unicode 编码输出,每个XML/HTML标签都独占一行

如果只想得到 Tag对象 中包含的文本内容,那么可以调用 get_text() 方法,这个方法获取到 Tag 中包含的所有文本内容,包括子孙 Tag 中的内容,并将结果作为 Unicode 字符串返回。