第3章 网页数据的解析提取

缺页说明

源 PDF 缺失书页 124-125。本章已按现有页面整理,缺失处保留为内容缺口。

上一章我们实现了一个最基本的爬虫,但提取页面信息时使用的是正则表达式,过程比较烦琐,而 且万一有地方写错了,可能会导致匹配失败,所以使用正则表达式提取页面信息多少还是有些不方便。

对于网页的节点来说,可以定义 id、class 或其他属性,而且节点之间还有层次关系,在网页中 可以通过 XPath 或 CSS 选择器来定位一个或多个节点。那么,在解析页面时,利用 XPath 或 CSS 选 择器提取某个节点,然后调用相应方法获取该节点的正文内容或者属性,不就可以提取我们想要的任 意信息了吗?

在 Python 中,怎样实现上述操作呢?不用担心,相关的解析库非常多,其中比较强大的有 lxml、 Beautiful Soup、pyquery、parsel 等。本章就来介绍这几个解析库的用法。有了它们,我们就不用再为 正则表达式发愁,解析效率也会大大提高。

3.1 XPath 的使用

XPath 的全称是 XML Path Language,即 XML 路径语言,用来在 XML 文档中查找信息。它虽然 最初是用来搜寻 XML 文档的,但同样适用于 HTML 文档的搜索。

所以在做爬虫时,我们完全可以使用 XPath 实现相应的信息抽取。本节我们就介绍一下 XPath 的 基本用法。

1. XPath 概览

XPath 的选择功能十分强大,它提供了非常简洁明了的路径选择表达式。另外,它还提供了 100 多 个内建函数,用于字符串、数值、时间的匹配以及节点、序列的处理等。几乎所有我们想要定位的节 点,都可以用 XPath 选择。

XPath 于 1999 年 11 月 16 日成为 W3C 标准,它被设计出来,供 XSLT、XPointer 以及其他 XML 解 析软件使用。

2. XPath 常用规则

表 3-1 列举了 XPath 的几个常用规则。

表 3-1 XPath 的常用规则

表达式描述
`nodename`选取此节点的所有子节点
`/`从当前节点选取直接子节点
`//`从当前节点选取子孙节点
`.`选取当前节点
`..`选取当前节点的父节点
`@`选取属性

这里列出了 XPath 的一个常用匹配规则,如下:

//title[@lang='eng']

它代表选择所有名称为 title,同时属性 lang 的值为 eng 的节点。

后面会通过 Python 的 lxml 库,利用 XPath 对 HTML 进行解析。

3. 准备工作

使用 lxml 库之前,首先要确保其已安装好。

可以使用 pip3 来安装:

pip3 install lxml

更详细的安装说明可以参考:https://setup.scrape.center/lxml。

安装完成后,就可以进入接下来的学习了。

4. 实例引入

下面通过实例感受一下使用 XPath 对网页进行解析的过程,相关代码如下:

from lxml import etree
text = '''
<div>
    <ul>
        <li class="item-0"><a href="link1.html">first item</a></li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-inactive"><a href="link3.html">third item</a></li>
        <li class="item-1"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a>
    </ul>
</div>
'''
html = etree.HTML(text)
result = etree.tostring(html)
print(result.decode('utf-8'))

这里首先导入 lxml 库的 etree 模块,然后声明了一段 HTML 文本,接着调用 HTML 类进行初始化, 这样就成功构造了一个 XPath 解析对象。此处需要注意一点,HTML 文本中的最后一个 li 节点是没 有闭合的,而 etree 模块可以自动修正 HTML 文本。

之后调用 tostring 方法即可输出修正后的 HTML 代码,但是结果是 bytes 类型。于是利用 decode 方法将其转换成 str 类型,结果如下:

<html><body><div>
    <ul>
        <li class="item-0"><a href="link1.html">first item</a></li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-inactive"><a href="link3.html">third item</a></li>
        <li class="item-1"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a>
    </li></ul>
</div>
</body></html>

可以看到,经过处理之后的 li 节点标签得以补全,并且自动添加了 body、html 节点。

另外,也可以不声明,直接读取文本文件进行解析,实例如下:

from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = etree.tostring(html)
print(result.decode('utf-8'))

其中 test.html 的内容就是上面例子中的 HTML 代码,内容如下:

<div>
    <ul>
        <li class="item-0"><a href="link1.html">first item</a></li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-inactive"><a href="link3.html">third item</a></li>
        <li class="item-1"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a>
    </ul>
</div>

这次的输出结果略有不同,多了一个 DOCTYPE 声明,不过对解析无任何影响,结果如下:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body><div>
    <ul>
        <li class="item-0"><a href="link1.html">first item</a></li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-inactive"><a href="link3.html">third item</a></li>
        <li class="item-1"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a>
    </li></ul>
</div></body></html>

5. 所有节点

我们一般会用以 // 开头的 XPath 规则,来选取所有符合要求的节点。这里还是以第一个实例中 的 HTML 文本为例,选取其中所有节点,实现代码如下:

from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//*')
print(result)

运行结果如下:

[<Element html at 0x10510d9c8>, <Element body at 0x10510da08>, <Element div at 0x10510da48>, <Element ul at
0x10510da88>, <Element li at 0x10510dac8>, <Element a at 0x10510db48>, <Element li at 0x10510db88>, <Element
a at 0x10510dbc8>, <Element li at 0x10510dc08>, <Element a at 0x10510db08>, <Element li at 0x10510dc48>, <Element
a at 0x10510dc88>, <Element li at 0x10510dcc8>, <Element a at 0x10510dd08>]

这里使用 * 代表匹配所有节点,也就是获取整个 HTML 文本中的所有节点。从运行结果可以看到, 返回形式是一个列表,其中每个元素是 Element 类型,类型后面跟着节点的名称,如 html、body、div、 ul、li、a 等,所有节点都包含在了列表中。

当然,此处匹配也可以指定节点名称。例如想获取所有 li 节点,实例如下:

from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li')
print(result)
print(result[0])

这里选取所有 li 节点,可以使用 //,然后直接加上节点名称,调用时使用 xpath 方法即可。

运行结果如下:

[<Element li at 0x105849208>, <Element li at 0x105849248>, <Element li at 0x105849288>, <Element li at
0x1058492c8>, <Element li at 0x105849308>]
<Element li at 0x105849208>

可以看到,提取结果也是一个列表,其中每个元素都是 Element 类型。要是想取出其中一个对象, 可以直接用中括号加索引获取,如 [0]

6. 子节点

通过 /// 即可查找元素的子节点或子孙节点。假如现在想选择 li 节点的所有直接子节点 a,

则可以这样实现:

from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li/a')
print(result)

这里通过添加 /a 的方式,选择了所有 li 节点的所有直接子节点 a。其中 //li 用于选中所有 li 节 点,/a 用于选中 li 节点的所有直接子节点 a。

运行结果如下:

[<Element a at 0x106ee8688>, <Element a at 0x106ee86c8>, <Element a at 0x106ee8708>, <Element a at 0x106ee8748>,
<Element a at 0x106ee8788>]

上面的 / 用于选取节点的直接子节点,如果要获取节点的所有子孙节点,可以使用 //。例如,要 获取 ul 节点下的所有子孙节点 a,可以这样实现:

from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//ul//a')
print(result)

运行结果是相同的。

但是如果这里用 //ul/a,就无法获取任何结果了。因为 / 用于获取直接子节点,而 ul 节点下没 有直接的 a 子节点,只有 li 节点,所以无法获取任何匹配结果,代码如下:

from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//ul/a')
print(result)

运行结果如下:

[]

因此这里要注意 /// 的区别,前者用于获取直接子节点,后者用于获取子孙节点。

7. 父节点

通过连续的 /// 可以查找子节点或子孙节点,那么假如知道了子节点,怎样查找父节点呢? 这可以用 .. 实现。

例如,首先选中 href 属性为 link4.html 的 a 节点,然后获取其父节点,再获取父节点的 class 属 性,相关代码如下:

from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//a[@href="link4.html"]/../@class')
print(result)

运行结果如下:

['item-1']

检查一下结果发现,这正是我们获取的目标 li 节点的 class 属性。

也可以通过 parent:: 获取父节点,代码如下:

from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//a[@href="link4.html"]/parent::*/@class')
print(result)

8. 属性匹配

在选取节点的时候,还可以使用 @ 符号实现属性过滤。例如,要选取 class 属性为 item-0 的 li 节 点,可以这样实现:

from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]')
print(result)

这里通过加入 [@class="item-0"],限制了节点的 class 属性为 item-0。HTML 文本中符合这个条 件的 li 节点有两个,所以结果应该返回两个元素。结果如下:

[<Element li at 0x10a399288>, <Element li at 0x10a3992c8>]

可见,匹配结果正是两个,至于是不是正确的那两个,后面再验证。

9. 文本获取

用 XPath 中的 text 方法可以获取节点中的文本,接下来尝试获取前面 li 节点中的文本,相关代 码如下:

from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]/text()')
print(result)

运行结果如下:

['\n    ']

奇怪的是,我们没有获取任何文本,只获取了一个换行符,这是为什么呢?因为 xpath 中 text 方 法的前面是 /,而 / 的含义是选取直接子节点,很明显 li 的直接子节点都是 a 节点,文本都是在 a 节 点内部的,所以这里匹配到的结果就是被修正的 li 节点内部的换行符,因为自动修正的 li 节点的尾 标签换行了。

即选中的是这两个节点:

<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</li>

其中一个节点因为自动修正,li 节点的尾标签在添加的时候换行了,所以提取文本得到的唯一结 果就是 li 节点的尾标签和 a 节点的尾标签之间的换行符。

因此,如果想获取 li 节点内部的文本,就有两种方式,一种是先选取 a 节点再获取文本,另一 种是使用 //。接下来,我们看下两种方式的区别。

先选取 a 节点,再获取文本的代码如下:

from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]/a/text()')
print(result)

运行结果如下:

['first item', 'fifth item']

可以看到,这里有两个返回值,内容都是 class 属性为 item-0 的 li 节点的文本,这也印证了前 面属性匹配的结果是正确的。

这种方式下,我们是逐层选取的,先选取 li 节点,然后利用 / 选取其直接子节点 a,再选取节点

a 的文本,得到的两个结果恰好是符合我们预期的。

再来看一下使用 // 能够获取什么样的结果,代码如下:

from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]//text()')
print(result)

运行结果如下:

['first item', 'fifth item', '\n    ']

不出所料,这里的返回结果是三个。可想而知,这里选取的是所有子孙节点的文本,其中前两个 是 li 的子节点 a 内部的文本,另外一个是最后一个 li 节点内部的文本,即换行符。

由此,要想获取子孙节点内部的所有文本,可以直接使用 // 加 text 方法的方式,这样能够保证 获取最全面的文本信息,但是可能会夹杂一些换行符等特殊字符。如果想获取某些特定子孙节点下的 所有文本,则可以先选取特定的子孙节点,再调用 text 方法获取其内部的文本,这样可以保证获取 的结果是整洁的。

10. 属性获取

我们已经可以用 text 方法获取节点内部文本,那么节点属性该怎样获取呢?其实依然可以用 @ 符 号。例如,通过如下代码获取所有 li 节点下所有 a 节点的 href 属性:

from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li/a/@href')
print(result)

这里通过 @href 获取节点的 href 属性。注意,此处和属性匹配的方法不同,属性匹配是用中括号 加属性名和值来限定某个属性,如 [@href="link1.html"],此处的 @href 是指获取节点的某个属性, 二者需要做好区分。

运行结果如下:

['link1.html', 'link2.html', 'link3.html', 'link4.html', 'link5.html']

可以看到,我们成功获取了所有 li 节点下 a 节点的 href 属性,并以列表形式返回了它们。

11. 属性多值匹配

有时候,某些节点的某个属性可能有多个值,例如:

from lxml import etree
text = '''
<li class="li li-first"><a href="link.html">first item</a></li>
'''
html = etree.HTML(text)
result = html.xpath('//li[@class="li"]/a/text()')
print(result)

这里 HTML 文本中 li 节点的 class 属性就有两个值:li 和 li-first。此时如果还用之前的属性 匹配获取节点,就无法进行了,运行结果如下:

[]

这种情况需要用到 contains 方法,于是代码可以改写如下:

from lxml import etree
text = '''
<li class="li li-first"><a href="link.html">first item</a></li>
'''
html = etree.HTML(text)
result = html.xpath('//li[contains(@class, "li")]/a/text()')
print(result)

上面使用了 contains 方法,给其第一个参数传入属性名称,第二个参数传入属性值,只要传入的 属性包含传入的属性值,就可以完成匹配了。

此时运行结果如下:

['first item']

contains 方法经常在某个节点的某个属性有多个值时用到。

12. 多属性匹配

我们还可能遇到一种情况,就是根据多个属性确定一个节点,这时需要同时匹配多个属性。运算 符 and 用于连接多个属性,实例如下:

from lxml import etree
text = '''
<li class="li li-first" name="item"><a href="link.html">first item</a> </li>
'''
html = etree.HTML(text)
result = html.xpath('//li[contains(@class, "li") and @name="item"]/a/text()')
print(result)

这里的 li 节点又增加了一个属性 name。因此要确定 li 节点,需要同时考察 class 和 name 属性, 一个条件是 class 属性里面包含 li 字符串,另一个条件是 name 属性为 item 字符串,这二者同时得到 满足,才是 li 节点。class 和 name 属性需要用 and 运算符相连,相连之后置于中括号内进行条件筛 选。运行结果如下:

['first item']

这里的 and 其实是 XPath 中的运算符。除了它,还有很多其他运算符,如 or、mod 等,在此总结 为表 3-2。

表 3-2 运算符及其介绍

运算符描述实例返回值
`or``age=19 or age=20`如果 age 是 19,则返回 true。如果 age 是 21,则返回 false
`and``age>19 and age<21`如果 age 是 20,则返回 true。如果 age 是 18,则返回 false
`mod`计算除法的余数`5 mod 2``1`
`|`计算两个节点集`//book|//cd`返回所有拥有 book 和 cd 元素的节点集
`+`加法`6 + 4``10`
`-`减法`6 - 4``2`
`*`乘法`6 * 4``24`
`div`除法`8 div 4``2`
`=`等于`age=19`如果 age 是 19,则返回 true。如果 age 是 20,则返回 false
`!=`不等于`age!=19`如果 age 是 18,则返回 true。如果 age 是 19,则返回 false
`<`小于`age<19`如果 age 是 18,则返回 true。如果 age 是 19,则返回 false
`<=`小于或等于`<=19`如果 age 是 19,则返回 true。如果 age 是 20,则返回 false
`>`大于`age>19`如果 age 是 20,则返回 true。如果 age 是 19,则返回 false
`>=`大于或等于`age>=19`如果 age 是 19,则返回 true。如果 age 是 18,则返回 false

13. 按序选择

在选择节点时,某些属性可能同时匹配了多个节点,但我们只想要其中的某一个,如第二个或者 最后一个,这时该怎么办呢?

可以使用往中括号中传入索引的方法获取特定次序的节点,实例如下:

from lxml import etree

text = '''
<div>
    <ul>
        <li class="item-0"><a href="link1.html">first item</a></li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-inactive"><a href="link3.html">third item</a></li>
        <li class="item-1"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a>
    </ul>
</div>
'''
html = etree.HTML(text)
result = html.xpath('//li[1]/a/text()')
print(result)
result = html.xpath('//li[last()]/a/text()')
print(result)
result = html.xpath('//li[position()<3]/a/text()')
print(result)
result = html.xpath('//li[last()-2]/a/text()')
print(result)

上述代码中,第一次选择时选取了第一个 li 节点,往中括号中传入数字 1 即可实现。注意,这 里和写代码不同,序号以 1 开头,而非 0。

第二次选择时,选取了最后一个 li 节点,在中括号中调用 last 方法即可实现。

第三次选择时,选取了位置小于 3 的 li 节点,也就是位置序号为 1 和 2 的节点,得到的结果就 是前两个 li 节点。

第四次选择时,选取了倒数第三个 li 节点,在中括号中调用 last 方法再减去 2 即可实现。因为 last 方法代表最后一个,在此基础上减 2 得到的就是倒数第三个。

运行结果如下:

['first item']
['fifth item']
['first item', 'second item']
['third item']

在这个实例中,我们使用了 last、position 等方法。XPath 提供了 100 多个方法,包括存取、数 值、字符串、逻辑、节点、序列等处理功能。

14. 节点轴选择

XPath 提供了很多节点轴的选择方法,包括获取子元素、兄弟元素、父元素、祖先元素等,实例 如下:

from lxml import etree

text = '''
<div>
    <ul>
        <li class="item-0"><a href="link1.html"><span>first item</span></a></li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-inactive"><a href="link3.html">third item</a></li>
        <li class="item-1"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a>
    </ul>
</div>
'''
html = etree.HTML(text)
result = html.xpath('//li[1]/ancestor::*')
print(result)
result = html.xpath('//li[1]/ancestor::div')
print(result)
result = html.xpath('//li[1]/attribute::*')
print(result)
result = html.xpath('//li[1]/child::a[@href="link1.html"]')
print(result)
result = html.xpath('//li[1]/descendant::span')
print(result)
result = html.xpath('//li[1]/following::*[2]')
print(result)
result = html.xpath('//li[1]/following-sibling::*')
print(result)

运行结果如下:

[<Element html at 0x107941808>, <Element body at 0x1079418c8>, <Element div at 0x107941908>, <Element ul at
0x107941948>]
[<Element div at 0x107941908>]
['item-0']
[<Element a at 0x1079418c8>]
[<Element span at 0x107941948>]
[<Element a at 0x1079418c8>]
[<Element li at 0x107941948>, <Element li at 0x107941988>, <Element li at 0x1079419c8>, <Element li at
0x107941a08>]

上述代码中第一次选择时,调用了 ancestor 轴,可以获取所有祖先节点。其后需要跟两个冒号, 然后是节点的选择器,这里我们直接使用 *,表示匹配所有节点,因此相应返回结果是第一个 li 节点 的所有祖先节点,包括 html、body、div 和 ul。

第二次选择时,又加了限定条件,这次是在冒号后面加了 div,于是得到的结果就只有 div 这个 祖先节点了。

第三次选择时,调用了 attribute 轴,可以获取所有属性值,其后跟的选择器还是 *,代表获取 节点的所有属性,返回值就是 li 节点的所有属性值。

第四次选择时,调用了 child 轴,可以获取所有直接子节点。这里我们又加了限定条件,选取 href 属性为 link1.html 的 a 节点。

第五次选择时,调用了 descendant 轴,可以获取所有子孙节点。这里我们又加了限定条件——获 取 span 节点,所以返回结果只包含 span 节点,不包含 a 节点。

第六次选择时,调用了 following 轴,可以获取当前节点之后的所有节点。这里我们虽然使用的 是 * 匹配,但又加了索引选择,所以只获取了第二个后续节点。

第七次选择时,调用了 following-sibling 轴,可以获取当前节点之后的所有同级节点。这里我 们使用 * 匹配,所以获取了所有的后续同级节点。

15. 总结

到现在为止,我们把可能用到的 XPath 选择器基本介绍完了。XPath 功能非常强大,内置函数非 常多,熟练使用之后,可以大大提升提取 HTML 信息的效率。

本节代码参见:https://github.com/Python3WebSpider/XPathTest。

3.2 Beautiful Soup 的使用

第 2 章介绍了正则表达式的相关用法,只是一旦正则表达式写得有问题,得到的结果就可能不是 我们想要的了。而且每一个网页都有一定的特殊结构和层级关系,很多节点都用 id 或 class 作区分, 所以借助它们的结构和属性来提取不也可以吗?

本节我们就介绍一个强大的解析工具——Beautiful Soup,其借助网页的结构和属性等特性来解析 网页。有了它,我们不需要写复杂的正则表达式,只需要简单的几个语句,就可以完成网页中某个元 素的提取。

废话不多说,接下来就感受一下 Beautiful Soup 的强大之处吧。

1. Beautiful Soup 的简介

简单来说,Beautiful Soup 是 Python 的一个 HTML 或 XML 的解析库,我们用它可以方便地从网 页中提取数据,其官方解释如下:

Beautiful Soup 提供一些简单的、Python 式的函数来处理导航、搜索、修改分析树等功能。它是 一个工具箱,通过解析文档为用户提供需要抓取的数据,因为简单,所以无须很多代码就可以写出一 个完整的应用程序。Beautiful Soup 自动将输入文档转换为 Unicode 编码,将输出文档转换为 utf-8 编 码。你不需要考虑编码方式,除非文档没有指定具体的编码方式,这时你仅仅需要说明一下原始编码 方式就可以了。Beautiful Soup 已成为和 lxml、html5lib 一样出色的 Python 解释器,为用户灵活提供 不同的解析策略或强劲的速度。

总而言之,利用 Beautiful Soup 可以省去很多烦琐的提取工作,提高解析网页的效率。

2. 解析器

实际上,Beautiful Soup 在解析时是依赖解析器的,它除了支持 Python 标准库中的 HTML 解析器, 还支持一些第三方解析器(例如 lxml)。表 3-3 列出了 Beautiful Soup 支持的解析器。

表 3-3 Beautiful Soup 支持的解析器

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

通过表 3-3 的对比可以看出,LXML 解析器有解析 HTML 和 XML 的功能,而且速度快、容错能 力强,所以推荐使用它。

使用 LXML 解析器,只需在初始化 Beautiful Soup 时,把第二个参数改为 lxml 即可:

from bs4 import BeautifulSoup
soup = BeautifulSoup('<p>Hello</p>', 'lxml')
print(soup.p.string)

在后面,统一用这个解析器演示 Beautiful Soup 的用法实例。

3. 准备工作

在开始之前,请确保已经正确安装好 Beautiful Soup 和 lxml 这两个库。Beautiful Soup 直接使用

pip3 安装即可,命令如下:
pip3 install beautifulsoup4

更加详细的安装说明可以参考:https://setup.scrape.center/beautifulsoup。

另外,我们使用的是 lxml 这个解析器,所以还需要额外安装 lxml 这个库,其安装方法见 3.1 节。

以上两个库都安装完成后,就可以进行接下来的学习了。

4. 基本使用

下面首先通过实例看看 Beautiful Soup 的基本用法:

html = """
<html><head><title>The Dormouse's story</title></head>
<body>
<p class="title" name="dromouse"><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>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.prettify())
print(soup.title.string)

运行结果如下:

<html>
 <head>
  <title>
   The Dormouse's story
  </title>
 </head>
 <body>
  <p class="title" name="dromouse">
   <b>
    The Dormouse's story
   </b>
  </p>
  <p class="story">
   Once upon a time there were three little sisters; and their names were
   <a class="sister" href="http://example.com/elsie" id="link1">
    <!-- Elsie -->
   </a>
   ,
   <a class="sister" href="http://example.com/lacie" id="link2">
    Lacie
   </a>
   and
   <a class="sister" href="http://example.com/tillie" id="link3">
    Tillie
   </a>
   ;
                  and they lived at the bottom of a well.
  </p>
  <p class="story">
   ...
  </p>
 </body>
</html>
The Dormouse's story

这里首先声明一个变量 html,这是一个 HTML 字符串。但是需要注意的是,它并不是一个完整 的 HTML 字符串,因为 body 节点和 html 节点都没有闭合。接着,我们将它当作第一个参数传给 BeautifulSoup 对象,该对象的第二个参数为解析器的类型(这里使用 lxml),此时就完成了 BeautifulSoup 对象的初始化。然后,将这个对象赋值给 soup 变量。

之后就可以调用 soup 的各个方法和属性解析这串 HTML 代码了。

首先,调用 prettify 方法。这个方法可以把要解析的字符串以标准的缩进格式输出。这里需要注 意的是,输出结果里包含 body 和 html 节点,也就是说对于不标准的 HTML 字符串 BeautifulSoup, 可以自动更正格式。这一步不是由 prettify 方法完成的,而是在初始化 BeautifulSoup 的时候就完成了。

然后调用 soup.title.string,这实际上是输出 HTML 中 title 节点的文本内容。所以,通过 soup.title 选出 HTML 中的 title 节点,再调用 string 属性就可以得到 title 节点里面的文本了。你 看,我们通过简单调用几个属性就完成了文本提取,是不是非常方便?

5. 节点选择器

直接调用节点的名称即可选择节点,然后调用 string 属性就可以得到节点内的文本了。这种选 择方式速度非常快,当单个节点结构层次非常清晰时,可以选用这种方式来解析。

下面再用一个例子详细说明选择节点的方法:

html = """
<html><head><title>The Dormouse's story</title></head>
<body>
<p class="title" name="dromouse"><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>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.title)
print(type(soup.title))
print(soup.title.string)
print(soup.head)
print(soup.p)

运行结果:

<title>The Dormouse's story</title>
<class 'bs4.element.Tag'>
The Dormouse's story
<head><title>The Dormouse's story</title></head>
<p class="title" name="dromouse"><b>The Dormouse's story</b></p>

这里依然使用刚才的 HTML 代码,首先打印出 title 节点的选择结果,输出结果正是 title 节点 及里面的文字内容。接下来,输出 title 节点的类型,是 bs4.element.Tag,这是 Beautiful Soup 中一 个重要的数据结构,经过选择器选择的结果都是这种 Tag 类型。Tag 具有一些属性,例如 string 属性, 调用该属性可以得到节点的文本内容,所以类型的输出结果正是节点的文本内容。

输出文本内容后,又尝试选择了 head 节点,结果也是节点加其内部的所有内容。最后,选择了 p 节点。不过这次情况比较特殊,因为结果是第一个 p 节点的内容,后面的几个 p 节点并没有选取到。 也就是说,当有多个节点时,这种选择方式只会选择到第一个匹配的节点,后面的其他节点都会忽略。

6. 提取信息

上面演示了通过调用 string 属性获取文本的值,那么如何获取节点名称?如何获取节点属性的 值呢?接下来我们就统一梳理一下信息的提取方式。

获取名称

利用 name 属性可以获取节点的名称。还是以上面的文本为例,先选取 title 节点,再调用 name 属 性就可以得到节点名称:

print(soup.title.name)

运行结果:

title

获取属性

一个节点可能有多个属性,例如 id 和 class 等,选择这个节点元素后,可以调用 attrs 获取其 所有属性:

print(soup.p.attrs)
print(soup.p.attrs['name'])

运行结果:

{'class': ['title'], 'name': 'dromouse'}
dromouse

可以看到,调用 attrs 属性的返回结果是字典形式,包括所选择节点的所有属性和属性值。因此 要获取 name 属性,相当于从字典中获取某个键值,只需要用中括号加属性名就可以了。例如通过 attrs['name'] 获取 name 属性。

其实这种方式有点烦琐,还有一种更为简单的获取属性值的方式:不用写 attrs,直接在节点元 素后面加中括号,然后传入属性名就可以了。样例如下:

print(soup.p['name'])
print(soup.p['class'])

运行结果如下:

dromouse
['title']

这里需要注意,有的返回结果是字符串,有的返回结果是由字符串组成的列表。例如,name 属 性的值是唯一的,于是返回结果就是单个字符串。而对于 class 属性,一个节点元素可能包含多个

class,所以返回的就是列表。在实际处理过程中,我们要注意判断类型。

获取内容

这点在前面也提到过,可以利用 string 属性获取节点元素包含的文本内容,例如用如下实例获 取第一个 p 节点的文本:

print(soup.p.string)

运行结果如下:

The Dormouse's story

再次注意一下,这里选取的 p 节点是第一个 p 节点,获取的文本也是第一个 p 节点里面的文本。

嵌套选择

在上面的例子中,我们知道所有返回结果都是 bs4.element.Tag 类型,Tag 类型的对象同样可以继

续调用节点进行下一步的选择。例如,我们获取了 head 节点,就可以继续调用 head 选取其内部的 head 节点:

html = """
<html><head><title>The Dormouse's story</title></head>
<body>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.head.title)
print(type(soup.head.title))
print(soup.head.title.string)

运行结果如下:

<title>The Dormouse's story</title>
<class 'bs4.element.Tag'>
The Dormouse's story

运行结果的第一行是调用 head 之后再调用 title,而选择的 title 节点。第二行打印出了它的类 型,可以看到,仍然是 bs4.element.Tag 类型。也就是说,我们在 Tag 类型的基础上再次选择,得到 的结果依然是 Tag 类型。既然每次返回的结果都相同,那么就可以做嵌套选择了。

最后一行结果输出了 title 节点的 string 属性,也就是节点里的文本内容。

7. 关联选择

在做选择的过程中,有时不能一步就选到想要的节点,需要先选中某一个节点,再以它为基准选 子节点、父节点、兄弟节点等,下面就介绍一下如何选择这些节点。

子节点和子孙节点

选取节点之后,如果想要获取它的直接子节点,可以调用 contents 属性,实例如下:

html = """
<html>
<head>
<title>The Dormouse's story</title>
</head>
<body>
<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">
<span>Elsie</span>
</a>
and
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a>
<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>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.p.contents)

运行结果如下:

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

可以看到,返回结果是列表形式。p 节点里既包含文本,又包含节点,这些内容会以列表形式统 一返回。

需要注意的是,列表中的每个元素都是 p 节点的直接子节点。像第一个 a 节点里面包含的 span 节 点,就相当于孙子节点,但是返回结果并没有把 span 节点单独选出来。所以说,contents 属性得到 的结果是直接子节点组成的列表。

同样,我们可以调用 children 属性得到相应的结果:

from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.p.children)
for i, child in enumerate(soup.p.children):
    print(i, child)

运行结果如下:

<list_iterator object at 0x1064f7dd8>
0
              Once upon a time there were three little sisters; and their names were

1 <a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
2

3 <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>
4
              and

5 <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>
6
              and they lived at the bottom of a well.

还是同样的 HTML 文本,这里调用 children 属性来选择,返回结果是生成器类型。然后,我们 用 for 循环输出了相应的内容。

如果要得到所有的子孙节点,则可以调用 descendants 属性:

from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.p.descendants)
for i, child in enumerate(soup.p.descendants):
    print(i, child)

运行结果如下:

<generator object descendants at 0x10650e678>
0
              Once upon a time there were three little sisters; and their names were

1 <a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
2

3 <span>Elsie</span>
4 Elsie
5

6

7 <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>
8 Lacie
9
              and
10 <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>
11 Tillie
12
              and they lived at the bottom of a well.

你会发现,此时返回结果还是生成器。遍历输出一下可以看到,这次的输出结果中就包含了 span 节点,因为 descendants 会递归查询所有子节点,得到所有的子孙节点。

父节点和祖先节点

如果要获取某个节点元素的父节点,可以调用 parent 属性:

html = """
<html>
    <head>
        <title>The Dormouse's story</title>
    </head>
    <body>
        <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">
                <span>Elsie</span>
            </a>
        </p>
        <p class="story">...</p>
    </body>
</html>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.a.parent)

运行结果如下:

<p class="story">
            Once upon a time there were three little sisters; and their names were
            <a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>

这里我们选择的是第一个 a 节点的父节点元素。很明显,a 节点的父节点是 p 节点,所以输出结 果便是 p 节点及其内部内容。

需要注意,这里输出的仅仅是 a 节点的直接父节点,而没有再向外寻找父节点的祖先节点。如果 想获取所有祖先节点,可以调用 parents 属性:

html = """
<html>
    <body>
        <p class="story">
            <a href="http://example.com/elsie" class="sister" id="link1">
                <span>Elsie</span>
            </a>
        </p>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(type(soup.a.parents))
print(list(enumerate(soup.a.parents)))

运行结果如下:

<class 'generator'>
[(0, <p class="story">
<a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>), (1, <body>
<p class="story">
<a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>
</body>), (2, <html>
<body>
<p class="story">
<a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>
</body></html>), (3, <html>
<body>
<p class="story">
<a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>
</body></html>)]

可以发现,返回结果是生成器类型。这里用列表输出了其索引和内容,列表中的元素就是 a 节点 的祖先节点。

兄弟节点

子节点和父节点的获取方式已经介绍完毕,如果要获取同级节点,也就是兄弟节点,又该怎么办 呢?实例如下:

html = """
<html>
<body>
<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">
<span>Elsie</span>
</a>
Hello
<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>
</body>
</html>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print('Next Sibling', soup.a.next_sibling)
print('Prev Sibling', soup.a.previous_sibling)
print('Next Siblings', list(enumerate(soup.a.next_siblings)))
print('Prev Siblings', list(enumerate(soup.a.previous_siblings)))

运行结果如下:

Next Sibling
              Hello

Prev Sibling
              Once upon a time there were three little sisters; and their names were

Next Siblings [(0, '\n Hello\n'), (1, <a class="sister" href="http://example.com/
lacie" id="link2">Lacie</a>), (2, ' \n and\n'), (3, <a class="sister" href=
"http://example.com/tillie" id="link3">Tillie</a>), (4, '\n and they lived at the bottom of a
well.\n')]
Prev Siblings [(0, '\n Once upon a time there were three little sisters; and their names
were\n')]

可以看到,这里调用了 4 个属性。next_sibling 和 previous_sibling 分别用于获取节点的下一个 和上一个兄弟节点,next_siblings 和 previous_siblings 则分别返回后面和前面的所有兄弟节点。

提取信息

前面讲过关联元素节点的选择方法,如果想要获取它们的一些信息,例如文本、属性等,也可以 用同样的方法,实例如下:

html = """
<html>
<body>
<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">Bob</a><a href=
"http://example.com/lacie" class="sister" id="link2">Lacie</a>
</p>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print('Next Sibling:')
print(type(soup.a.next_sibling))
print(soup.a.next_sibling)
print(soup.a.next_sibling.string)
print('Parent:')
print(type(soup.a.parents))
print(list(soup.a.parents)[0])
print(list(soup.a.parents)[0].attrs['class'])

运行结果如下:

Next Sibling:
<class 'bs4.element.Tag'>
<a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>
Lacie
Parent:
<class 'generator'>
<p class="story">
              Once upon a time there were three little sisters; and their names were
              <a class="sister" href="http://example.com/elsie" id="link1">Bob</a><a class="sister"
href="http://example.com/lacie" id="link2">Lacie</a>
</p>
['story']

如果返回结果是单个节点,那么可以直接调用 string、attrs 等属性获得其文本和属性;如果返 回结果是包含多个节点的生成器,则可以先将结果转为列表,再从中取出某个元素,之后调用 string、 attrs 等属性即可获取对应节点的文本和属性。

8. 方法选择器

前面讲的选择方法都是基于属性来选择的,这种方法虽然快,但是在进行比较复杂的选择时,会 变得比较烦琐,不够灵活。幸好,Beautiful Soup 还为我们提供了一些查询方法,例如 find_all 和 find 等,调用这些方法,然后传入相应的参数,就可以灵活查询了。

find_all

find_all,顾名思义就是查询所有符合条件的元素,可以给它传入一些属性或文本来得到符合条 件的元素,功能十分强大。它的 API 如下:

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

name

我们可以根据 name 参数来查询元素,下面用一个实例来感受一下:

html = '''
<div class="panel">
    <div class="panel-heading">
        <h4>Hello</h4>
    </div>
    <div class="panel-body">
        <ul class="list" id="list-1">
            <li class="element">Foo</li>
            <li class="element">Bar</li>
            <li class="element">Jay</li>
        </ul>
        <ul class="list list-small" id="list-2">
            <li class="element">Foo</li>
            <li class="element">Bar</li>
        </ul>
    </div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find_all(name='ul'))
print(type(soup.find_all(name='ul')[0]))

运行结果如下:

[<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>, <ul class="list list-small" id="list-2">
<li class="element">Foo</li>
<li class="element">Bar</li>
</ul>]
<class 'bs4.element.Tag'>

这里我们调用了 find_all 方法,向其中传入 name 参数,其参数值为 ul,意思是查询所有 ul 节 点。返回结果是列表类型,长度为 2,列表中每个元素依然都是 bs4.element.Tag 类型。

因为都是 Tag 类型,所以依然可以进行嵌套查询。下面这个实例还是以同样的文本为例,先查询 所有 ul 节点,查出后再继续查询其内部的 li 节点:

for ul in soup.find_all(name='ul'):
    print(ul.find_all(name='li'))

运行结果如下:

[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>]
[<li class="element">Foo</li>, <li class="element">Bar</li>]

返回结果是列表类型,列表中的每个元素依然是 Tag 类型。

接下来我们就可以遍历每个 li 节点,并获取它的文本内容了。

for ul in soup.find_all(name='ul'):
    print(ul.find_all(name='li'))
    for li in ul.find_all(name='li'):
        print(li.string)

运行结果如下:

[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>]
Foo
Bar
Jay
[<li class="element">Foo</li>, <li class="element">Bar</li>]
Foo
Bar

attrs

除了根据节点名查询,我们也可以传入一些属性进行查询,下面用一个实例感受一下:

html = '''
<div class="panel">
    <div class="panel-heading">
        <h4>Hello</h4>
    </div>
    <div class="panel-body">
        <ul class="list" id="list-1" name="elements">
            <li class="element">Foo</li>
            <li class="element">Bar</li>
            <li class="element">Jay</li>
        </ul>
        <ul class="list list-small" id="list-2">
            <li class="element">Foo</li>
            <li class="element">Bar</li>
        </ul>
    </div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find_all(attrs={'id': 'list-1'}))
print(soup.find_all(attrs={'name': 'elements'}))

运行结果如下:

[<ul class="list" id="list-1" name="elements">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>]
[<ul class="list" id="list-1" name="elements">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>]

这里查询的时候,传入的是 attrs 参数,其属于字典类型。例如,要查询 id 为 list-1 的节点, 就可以传入 attrs={'id': 'list-1'} 作为查询条件,得到的结果是列表形式,列表中的内容就是符合 id 为 list-1 这一条件的所有节点。在上面的实例中,符合条件的元素个数是 1,所以返回结果是长度 为 1 的列表。

对于一些常用的属性,例如 id 和 class 等,我们可以不用 attrs 传递。例如,要查询 id 为 list-1 的节点,可以直接传入 id 这个参数。还是使用上面的文本,只不过换一种方式来查询:

from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find_all(id='list-1'))
print(soup.find_all(class_='element'))

运行结果如下:

[<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>]
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>, <li
class="element">Foo</li>, <li class="element">Bar</li>]

这里直接传入 id='list-1',就可以查询 id 为 list-1 的节点元素了。而对于 class 来说,由于 class 在 Python 里是一个关键字,所以后面需要加一个下划线,即 class_='element',返回结果依然是 Tag 对

象组成的列表。

text

text 参数可以用来匹配节点的文本,其传入形式可以是字符串,也可以是正则表达式对象,实例 如下:

import re
html = '''
<div class="panel">
    <div class="panel-body">
        <a>Hello, this is a link</a>
        <a>Hello, this is a link, too</a>
    </div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find_all(text=re.compile('link')))

运行结果如下:

['Hello, this is a link', 'Hello, this is a link, too']

这里有两个 a 节点,其内部包含文本信息。这里在 find_all 方法中传入 text 参数,该参数为正 则表达式对象,返回结果是由所有与正则表达式相匹配的节点文本组成的列表。

find

除了 find_all 方法,还有 find 方法也可以查询符合条件的元素,只不过 find 方法返回的是单个 元素,也就是第一个匹配的元素,而 find_all 会返回由所有匹配的元素组成的列表。实例如下:

html = '''
<div class="panel">
    <div class="panel-heading">
        <h4>Hello</h4>
    </div>
    <div class="panel-body">
        <ul class="list" id="list-1">
            <li class="element">Foo</li>
            <li class="element">Bar</li>
            <li class="element">Jay</li>
        </ul>
        <ul class="list list-small" id="list-2">
            <li class="element">Foo</li>
            <li class="element">Bar</li>
        </ul>
    </div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find(name='ul'))
print(type(soup.find(name='ul')))
print(soup.find(class_='list'))

运行结果如下:

<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>
<class 'bs4.element.Tag'>
<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>

可以看到,返回结果不再是列表形式,而是第一个匹配的节点元素,类型依然是 Tag 类型。

另外还有许多查询方法,用法与介绍过的 find_all、find 完全相同,区别在于查询范围不同,在 此做一下简单的说明。

find_parents 和 find_parent:前者返回所有祖先节点,后者返回直接父节点。

find_next_siblings 和 find_next_sibling:前者返回后面的所有兄弟节点,后者返回后面第 一个兄弟节点。

find_previous_siblings 和 find_previous_sibling:前者返回前面的所有兄弟节点,后者返回 前面第一个兄弟节点。

find_all_next 和 find_next:前者返回节点后面所有符合条件的节点,后者返回后面第一个符 合条件的节点。

find_all_previous 和 find_previous:前者返回节点前面所有符合条件的节点,后者返回前面 第一个符合条件的节点。

9. CSS 选择器

Beautiful Soup 还提供了另外一种选择器——CSS 选择器。如果你熟悉 Web 开发,那么肯定对 CSS 选择器不陌生。

使用 CSS 选择器,只需要调用 select 方法,传入相应的 CSS 选择器即可。我们用一个实例感受 一下:

html = '''
<div class="panel">
    <div class="panel-heading">
        <h4>Hello</h4>
    </div>
    <div class="panel-body">
        <ul class="list" id="list-1">
            <li class="element">Foo</li>
            <li class="element">Bar</li>
            <li class="element">Jay</li>
        </ul>
        <ul class="list list-small" id="list-2">
            <li class="element">Foo</li>
            <li class="element">Bar</li>
        </ul>
    </div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.select('.panel .panel-heading'))
print(soup.select('ul li'))
print(soup.select('#list-2 .element'))
print(type(soup.select('ul')[0]))

运行结果如下:

[<div class="panel-heading">
<h4>Hello</h4>
</div>]
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>, <li
class="element">Foo</li>, <li class="element">Bar</li>]
[<li class="element">Foo</li>, <li class="element">Bar</li>]
<class 'bs4.element.Tag'>

这里我们用了 3 次 CSS 选择器,返回结果均是由符合 CSS 选择器的节点组成的列表。例如, select('ul li') 表示选择所有 ul 节点下面的所有 li 节点,结果便是所有 li 节点组成的列表。

在最后一句中,我们打印输出了列表中元素的类型。可以看到,类型依然是 Tag 类型。

嵌套选择

select 方法同样支持嵌套选择,例如先选择所有 ul 节点,再遍历每个 ul 节点,选择其 li 节点, 实例如下:

from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
for ul in soup.select('ul'):
    print(ul.select('li'))

运行结果如下:

[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>]
[<li class="element">Foo</li>, <li class="element">Bar</li>]

可以看到,正常输出了每个 ul 节点下所有 li 节点组成的列表。

获取属性

既然知道节点是 Tag 类型,于是获取属性依然可以使用原来的方法。还是基于上面的 HTML 文本, 这里尝试获取每个 ul 节点的 id 属性:

from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
for ul in soup.select('ul'):
    print(ul['id'])
    print(ul.attrs['id'])

运行结果如下:

list-1
list-1
list-2
list-2

可以看到,直接将属性名传入中括号和通过 attrs 属性获取属性值,都是可以成功获取属性的。

获取文本

要获取文本,当然也可以用前面所讲的 string 属性。除此之外,还有一个方法,就是 get_text, 实例如下:

from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
for li in soup.select('li'):
    print('Get Text:', li.get_text())
    print('String:', li.string)

运行结果如下:

Get Text: Foo
String: Foo
Get Text: Bar
String: Bar
Get Text: Jay
String: Jay
Get Text: Foo
String: Foo
Get Text: Bar
String: Bar

二者的实现效果完全一致,都可以获取节点的文本值。

10. 总结

到此,Beautiful Soup 的介绍基本就结束了,最后做一下简单的总结。

推荐使用 LXML 解析库,必要时使用 html.parser。

节点选择器筛选功能弱,但是速度快。

建议使用 find、find_all 方法查询匹配的单个结果或者多个结果。

如果对 CSS 选择器熟悉,则可以使用 select 选择法。

本节代码参见:https://github.com/Python3WebSpider/BeautifulSoupTest。

3.3 pyquery 的使用

3.2 节介绍了 Beautiful Soup 的用法,这是一个非常强大的网页解析库,你是否觉得它的一些方法 用起来有点不适应?有没有觉得它的 CSS 选择器的功能没那么强大?

如果你对 Web 编程有所了解,如果你比较喜欢用 CSS 选择器,如果你对 jQuery 有所了解,那么 这里有一个更适合你的解析库——pyquery。

接下来,一起感受一下 pyquery 的强大之处。

1. 准备工作

同样,在本节开始之前请确保已经安装好了 pyquery 库,如没有安装,可以使用 pip3 安装:

pip3 install pyquery

更加详细的安装说明可以参考:https://setup.scrape.center/pyquery。

安装完成之后,我们便可以开始接下来的学习了。

2. 初始化

在用 pyquery 库解析 HTML 文本的时候,需要先将其初始化为一个 PyQuery 对象。

初始化方式有很多种,例如直接传入字符串、传入 URL、传入文件名,等等。下面我们详细介绍 一下这些方式。

字符串初始化

这种方式是直接把 HTML 的内容当作初始化参数,来初始化 PyQuery 对象。可以用一个实例来感 受一下:

html = '''
<div>
    <ul>
        <li class="item-0">first item</li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
        <li class="item-1 active"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a></li>
    </ul>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
print(doc('li'))

这里首先引入 PyQuery 这个对象,取别名为 pq。然后声明一个长 HTML 字符串,并将其当作参数 传递给 PyQuery 类,这样就成功完成了初始化。接着,将初始化的对象传入 CSS 选择器。在这个实例 中,我们传入 li 节点,这样就可以选择所有的 li 节点了。

运行结果如下:

<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>

URL 初始化

初始化的参数除了能以字符串形式传递,还能是网页的 URL,此时只需要指定 PyQuery 对象的参 数为 url 即可:

from pyquery import PyQuery as pq
doc = pq(url='https://cuiqingcai.com')
print(doc('title'))

运行结果如下:

<title>静觅 | 崔庆才的个人站点</title>

这样的话,PyQuery 对象会首先请求这个 URL,然后用得到的 HTML 内容完成初始化,其实就相 当于把网页的源代码以字符串形式传递给 PyQuery 类,来完成初始化操作。

下面代码实现的功能是相同的:

from pyquery import PyQuery as pq
import requests
doc = pq(requests.get('https://cuiqingcai.com').text)
print(doc('title'))

文件初始化

除了上面两种,还可以传递本地的文件名,此时将参数指定为 filename 即可:

from pyquery import PyQuery as pq
doc = pq(filename='demo.html')
print(doc('li'))

当然,这里需要有一个本地 HTML 文件 demo.html,其内容是待解析的 HTML 字符串。这样, PyQuery 对象会首先读取本地的文件内容,然后将文件内容以字符串的形式传递给 PyQuery 类进行初 始化。

以上 3 种初始化方式均可采用。当然,最常用的还是以字符串形式传递。

3. 基本 CSS 选择器

首先用一个实例感受一下 pyquery 库的 CSS 选择器的用法:

html = '''
<div id="container">
    <ul class="list">
        <li class="item-0">first item</li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
        <li class="item-1 active"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a></li>
    </ul>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
print(doc('#container .list li'))
print(type(doc('#container .list li')))

运行结果如下:

<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
<class 'pyquery.pyquery.PyQuery'>

这里我们初始化 PyQuery 对象之后,传入了一个 CSS 选择器 #container .list li,它的意思是 先选取 id 为 container 的节点,再选取其内部 class 为 list 的节点内部的所有 li 节点,然后打印输 出。从运行结果可以看到,我们成功获取了符合条件的节点。

最后,将符合条件的节点的类型打印输出,可以看到依然是 PyQuery 类型。

下面,我们直接遍历获取的节点,然后调用 text 方法,就可以直接获取节点的文本内容了,代 码如下:

for item in doc('#container .list li').items():
    print(item.text())

运行结果如下:

first item
second item
third item
fourth item
fifth item

怎么样?我们这里没有写正则表达式,直接通过选择器和 text 方法,就得到了想要提取的文本 信息,是不是方便多了?

下面我们再来详细了解一下 pyquery 库的用法,包括如何查找节点、遍历节点,并获取各种信息 等。掌握了这些,我们才能更高效地提取数据。

4. 查找节点

下面是一些常用的查询方法。

子节点

查找子节点时,需要用到 find 方法,其参数是 CSS 选择器。这里我们还是以上面的 HTML 为例:

from pyquery import PyQuery as pq
doc = pq(html)
items = doc('.list')
print(type(items))
print(items)
lis = items.find('li')
print(type(lis))
print(lis)

运行结果如下:

<class 'pyquery.pyquery.PyQuery'>
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>

这里我们先通过 .list 参数选取 class 为 list 的节点。然后调用 find 方法,并给其传入 CSS 选 择器,选取其内部的 li 节点,最后打印输出。可以发现,find 方法会将所有符合条件的节点选择出 来,结果是 PyQuery 类型。

其实 find 方法的查找范围是节点的所有子孙节点。如果只想查找子节点,那么可以用 children 方法:

lis = items.children()
print(type(lis))
print(lis)

运行结果如下:

<class 'pyquery.pyquery.PyQuery'>
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>

如果要筛选所有子节点中符合条件的节点,例如想筛选出子节点中 class 为 active 的节点,则可 以向 children 方法传入 CSS 选择器 .active,代码如下:

lis = items.children('.active')
print(lis)

运行结果如下:

<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>

可以看到,输出结果已经是筛选过的,只留下了 class 为 active 的节点。

父节点

我们可以用 parent 方法获取某个节点的父节点,下面用一个实例感受一下:

html = '''
<div class="wrap">
    <div id="container">
        <ul class="list">
            <li class="item-0">first item</li>
            <li class="item-1"><a href="link2.html">second item</a></li>
            <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
            <li class="item-1 active"><a href="link4.html">fourth item</a></li>
            <li class="item-0"><a href="link5.html">fifth item</a></li>
        </ul>
    </div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
items = doc('.list')
container = items.parent()
print(type(container))
print(container)

运行结果如下:

<class 'pyquery.pyquery.PyQuery'>
<div id="container">
    <ul class="list">
        <li class="item-0">first item</li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
        <li class="item-1 active"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a></li>
    </ul>
</div>

这里我们首先用 .list 选取 class 为 list 的节点,然后调用 parent 方法得到其父节点,其类型 依然是 PyQuery。

这里的父节点是指直接父节点,也就是说,parent 方法不会继续查找父节点的父节点,即祖先节点。

但是如果就想获取某个祖先节点,要怎么办呢?这时可以用 parents 方法:

from pyquery import PyQuery as pq
doc = pq(html)
items = doc('.list')
parents = items.parents()
print(type(parents))
print(parents)

运行结果如下:

<class 'pyquery.pyquery.PyQuery'>
<div class="wrap">
    <div id="container">
        <ul class="list">
            <li class="item-0">first item</li>
            <li class="item-1"><a href="link2.html">second item</a></li>
            <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
            <li class="item-1 active"><a href="link4.html">fourth item</a></li>
            <li class="item-0"><a href="link5.html">fifth item</a></li>
        </ul>
    </div>
</div>
<div id="container">
    <ul class="list">
        <li class="item-0">first item</li>
        <li class="item-1"><a href="link2.html">second item</a></li>
        <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
        <li class="item-1 active"><a href="link4.html">fourth item</a></li>
        <li class="item-0"><a href="link5.html">fifth item</a></li>
    </ul>
</div>

可以看到,输出结果有两个:一个是 class 为 wrap 的节点,一个是 id 为 container 的节点。也 就是说,parents 方法会返回所有祖先节点。

如果想要筛选某个祖先节点,可以向 parents 方法传入 CSS 选择器,这样就会返回祖先节点中符 合 CSS 选择器的节点:

parent = items.parents('.wrap')
print(parent)

运行结果如下:

<div class="wrap">
    <div id="container">
        <ul class="list">
            <li class="item-0">first item</li>
            <li class="item-1"><a href="link2.html">second item</a></li>
            <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
            <li class="item-1 active"><a href="link4.html">fourth item</a></li>
            <li class="item-0"><a href="link5.html">fifth item</a></li>
        </ul>
    </div>
</div>

可以看到,输出结果少了一个节点,只保留了 class 为 wrap 的节点。

兄弟节点

前面我们说明了子节点和父节点的用法,还有一种节点就是兄弟节点。获取兄弟节点可以使用 siblings 方法。这里还是以上面的 HTML 文本为例:

from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.list .item-0.active')
print(li.siblings())

这里首先选择 class 为 list 的节点内部的 class 为 item-0 和 active 的节点,也就是第三个 li 节 点。那么,很明显,其兄弟节点有 4 个,就是第一个、第二个、第四个、第五个 li 节点。

运行结果如下:

<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0">first item</li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>

可以看到,结果正是我们刚才说的那 4 个节点。

如果要筛选某个兄弟节点,依然可以向 siblings 方法传入 CSS 选择器,这样就能从所有兄弟节 点中挑选出符合条件的节点了:

from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.list .item-0.active')
print(li.siblings('.active'))

这里我们筛选了 class 为 active 的节点,通过刚才的结果可以观察到,class 为 active 的兄弟节 点只有第 4 个 li 节点满足,所以结果应该只包含一个节点。

我们再看一下运行结果:

<li class="item-1 active"><a href="link4.html">fourth item</a></li>

结果确实符合我们的预期。

5. 遍历节点

可以观察到,pyquery 库的选择结果可能是多个节点,也可能是单个节点,类型都是 PyQuery 类 型,并没有像 Beautiful Soup 那样返回列表。

如果结果是单个节点,既可以直接打印输出,也可以直接转成字符串:

from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.item-0.active')
print(li)
print(str(li))

运行结果如下:

<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>

如果结果是多个节点,就需要遍历获取了。需要调用 items 方法:

from pyquery import PyQuery as pq
doc = pq(html)
lis = doc('li').items()
print(type(lis))
for li in lis:
    print(li, type(li))

这里把所有 li 节点遍历了一遍。运行结果如下:

<class 'generator'>
<li class="item-0">first item</li>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-1"><a href="link2.html">second item</a></li>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-0"><a href="link5.html">fifth item</a></li>
<class 'pyquery.pyquery.PyQuery'>

可以发现,调用 items 方法后,会得到一个生成器,对其进行遍历,就可以逐个得到 li 节点对象 了,它的类型也是 PyQuery。还可以调用前面所说的方法对 li 节点进行选择,例如继续查询子节点、 寻找某个祖先节点等,非常灵活。

获取信息

提取到节点后,我们的最终目的当然是提取节点包含的信息了。比较重要的信息有两类,一是属 性,二是文本,下面分别进行说明。

获取属性

提取到某个 PyQuery 类型的节点后,可以调用 attr 方法获取其属性:

html = '''
<div class="wrap">
    <div id="container">
        <ul class="list">
            <li class="item-0">first item</li>
            <li class="item-1"><a href="link2.html">second item</a></li>
            <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
            <li class="item-1 active"><a href="link4.html">fourth item</a></li>
            <li class="item-0"><a href="link5.html">fifth item</a></li>
        </ul>
    </div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
a = doc('.item-0.active a')
print(a, type(a))
print(a.attr('href'))

运行结果如下:

<a href="link3.html"><span class="bold">third item</span></a> <class 'pyquery.pyquery.PyQuery'>
link3.html

这里首先选中 class 为 item-0 和 active 的 li 节点内的 a 节点,其类型是 PyQuery 类型。

然后调用 attr 方法。在这个方法中传入属性的名称,就可以得到对应的属性值了。

此外,也可以通过调用 attr 属性来获取属性值,用法如下:

print(a.attr.href)

结果如下:

link3.html

两种方法的结果完全一样。

如果选中的是多个元素,这种情况下调用 attr 方法,会出现怎样的结果呢?我们用实例来测试 一下:

a = doc('a')
print(a, type(a))
print(a.attr('href'))
print(a.attr.href)

运行结果如下:

<a href="link2.html">second item</a><a href="link3.html"><span class="bold">third item</span></a><a
href="link4.html">fourth item</a><a href="link5.html">fifth item</a> <class 'pyquery.pyquery.PyQuery'>
link2.html
link2.html

照理来说,我们选中的 a 节点应该有 4 个,所以打印结果也应该是 4 个。但是当我们调用 attr 方 法时,返回结果却只有第一个。这是因为,当返回结果包含多个节点时,调用 attr 方法,只会得到 第一个节点的属性。

那么,这时如果想获取 a 节点的所有属性,就要用到前面所说的遍历了:

from pyquery import PyQuery as pq
doc = pq(html)
a = doc('a')
for item in a.items():
    print(item.attr('href'))

运行结果如下:

link2.html
link3.html
link4.html
link5.html

因此,在获取属性时,可以观察返回的节点是一个还是多个,如果是多个,则需要遍历才能依次 获取每个节点的属性。

获取文本

获取节点之后的另一个主要操作就是获取其内部的文本,此时可以调用 text 方法实现:

html = '''
<div class="wrap">
    <div id="container">
        <ul class="list">
            <li class="item-0">first item</li>
            <li class="item-1"><a href="link2.html">second item</a></li>
            <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
            <li class="item-1 active"><a href="link4.html">fourth item</a></li>
            <li class="item-0"><a href="link5.html">fifth item</a></li>
        </ul>
    </div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
a = doc('.item-0.active a')
print(a)
print(a.text())

运行结果如下:

<a href="link3.html"><span class="bold">third item</span></a>
third item

这里首先选中 a 节点,然后调用 text 方法,就可以获取其内部的文本信息。此时 text 方法会忽 略节点内部包含的所有 HTML,只返回纯文字内容。

要想获取节点内部的 HTML 文本,需要用 html 方法:

from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.item-0.active')
print(li)
print(li.html())

这里我们选中了第三个 li 节点,然后调用了 html 方法,返回结果应该是 li 节点内的所有 HTML 文本。

运行结果如下:

<a href="link3.html"><span class="bold">third item</span></a>

这里同样有一个问题,如果我们选中的是多个节点,那么 text 或 html 方法会返回什么内容?不 妨用实例来看一下:

html = '''
<div class="wrap">
    <div id="container">
        <ul class="list">
            <li class="item-1"><a href="link2.html">second item</a></li>
            <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
            <li class="item-1 active"><a href="link4.html">fourth item</a></li>
            <li class="item-0"><a href="link5.html">fifth item</a></li>
        </ul>
    </div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('li')
print(li.html())
print(li.text())
print(type(li.text()))

运行结果如下:

<a href="link2.html">second item</a>
second item third item fourth item fifth item
<class 'str'>

结果可能比较出乎意料,html 方法返回的是第一个 li 节点内部的 HTML 文本,而 text 返回了所 有的 li 节点内部的纯文本,各节点内容中间用一个空格分割开,即返回结果是一个字符串。

所以这个地方值得注意,如果得到的结果是多个节点,并且想获取所有节点的内部 HTML 文本, 就需要遍历这些节点。而 text 方法不需要遍历即可获取,会对所有节点取文本之后合并成一个字符串。

6. 节点操作

pyquery 库提供了一系列方法对节点进行动态修改,例如为某个节点添加一个 class,移除某个节 点等,有时候这些操作会为提取信息带来极大的便利。

节点操作的方法太多,下面仅举几个典型的例子来说明其用法。

addClass 和 removeClass

我们先用一个实例感受一下:

html = '''
<div class="wrap">
    <div id="container">
        <ul class="list">
            <li class="item-0">first item</li>
            <li class="item-1"><a href="link2.html">second item</a></li>
            <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
            <li class="item-1 active"><a href="link4.html">fourth item</a></li>
            <li class="item-0"><a href="link5.html">fifth item</a></li>
        </ul>
    </div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.item-0.active')
print(li)
li.removeClass('active')
print(li)
li.addClass('active')
print(li)

首先选中了第三个 li 节点,然后调用 removeClass 方法,将其中的 active 这个 class 移除,然 后又调用 addClass 方法,将这个 class 添加回来。每执行一次操作,就会打印一次当前 li 节点的内容。

运行结果如下:

<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>

可以看到,一共输出了 3 次。第二次输出时,li 节点的 active 这个 class 已经不见了,第三次 这个 class 又有了。

所以说,addClass 和 removeClass 方法可以动态改变节点的 class 属性。

attr、text 和 html

当然,除了 addClass 和 removeClass 方法,也可以用 attr 方法对属性进行操作。此外,text 和 html 方法可以用来改变节点内部的内容。实例如下:

html = '''
<ul class="list">
    <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
</ul>
'''
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.item-0.active')
print(li)
li.attr('name', 'link')
print(li)
li.text('changed item')
print(li)
li.html('<span>changed item</span>')
print(li)

这里我们首先选中 li 节点,然后调用 attr 方法修改其属性,该方法的第一个参数为属性名,第二 个参数为属性值。接着,调用 text 和 html 方法改变 li 节点内部的内容。每次操作后,都会打印出 当前的 li 节点。

运行结果如下:

<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0 active" name="link"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0 active" name="link">changed item</li>
<li class="item-0 active" name="link"><span>changed item</span></li>

可以发现,调用 attr 方法后,li 节点多了一个原本不存在的属性 name,其值为 link。接着调用 text 方法,传入文本之后,li 节点内部的文本全被改为传入的字符串文本了。最后,调用 html 方法 传入 HTML 文本后,li 节点内部又变为了传入的 HTML 文本。

所以说,如果 attr 方法只传入第一个参数,即属性名,则表示获取这个属性值;如果传入第二 个参数,则可以用来修改属性值。text 和 html 方法如果不传参数,表示的是获取节点内的纯文本和 HTML 文本;如果传入参数,则表示进行赋值。

remove

顾名思义,remove 方法的作用是移除,有时会为信息的提取带来非常大的便利。下面有一段 HTML 文本:

html = '''
<div class="wrap">
Hello, World
<p>This is a paragraph.</p>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
wrap = doc('.wrap')
print(wrap.text())

现在想提取文本中的 Hello, World 这个字符串,而不要 p 节点内部的字符串,该怎样操作呢?

这里直接先尝试提取 class 为 wrap 的节点中的内容,看看是不是我们想要的。运行结果如下:

Hello, World This is a paragraph.

这个结果中包含着 p 节点的内容,也就是说 text 方法把所有纯文本全提取出来了。要想去掉 p 节 点内部的文本,可以再把 p 节点内的文本提取一遍,然后从整个结果中移除这个子串,显然这个做法 比较烦琐。

这时 remove 方法就派上用场了,可以这么做:

wrap.find('p').remove()
print(wrap.text())

首先选中 p 节点,然后调用 remove 方法将其移除,这时 wrap 内部就只剩下 Hello, World 这句话 了,再利用 text 方法提取即可。

其实还有很多操作节点的方法,例如 append、empty 和 prepend 等。

7. 伪类选择器

CSS 选择器之所以强大,还有一个很重要的原因,就是它支持多种多样的伪类选择器,例如选择 第一个节点、最后一个节点、奇偶数节点、包含某一文本的节点等。实例如下:

html = '''
<div class="wrap">
    <div id="container">
        <ul class="list">
            <li class="item-0">first item</li>
            <li class="item-1"><a href="link2.html">second item</a></li>
            <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
            <li class="item-1 active"><a href="link4.html">fourth item</a></li>
            <li class="item-0"><a href="link5.html">fifth item</a></li>
        </ul>
    </div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('li:first-child')
print(li)
li = doc('li:last-child')
print(li)

容提取出来。

运行结果如下:

first item
third item
fifth item

get 方法的作用是从 SelectorList 里面提取第一个 Selector 对象,然后输出其中的结果。

我们再看一个实例:

result = selector.xpath('//li[contains(@class, "item-0")]//text()').get()
print(result)

输出结果如下:

first item

这里我们使用 //li[contains(@class, "item-0")]//text() 选取了所有 class 包含 item-0 的 li 节 点的文本内容。准确来说,返回结果 SelectorList 应该对应三个 li 对象,而这里 get 方法仅仅返回 了第一个 li 对象的文本内容,因为它其实只会提取第一个 Selector 对象的结果。

那有没有能够提取所有 Selector 对应内容的方法呢?有,那就是 getall 方法。

所以如果要提取所有对应的 li 节点的文本内容,写法可以改写为如下内容:

result = selector.xpath('//li[contains(@class, "item-0")]//text()').getall()
print(result)

输出结果如下:

['first item', 'third item', 'fifth item']

这时候,我们得到的就是列表类型的结果,其中的每一项和 Selector 对象是一一对应的。

因此,如果要提取 SelectorList 里面对应的结果,可以使用 get 或 getall 方法,前者会获取第 一个 Selector 对象里面的内容,后者会依次获取每个 Selector 对象对应的结果。

另外在上述案例中,如果把 xpath 方法改写成 CSS 方法,可以这么实现:

result = selector.css('.item-0 *::text').getall()
print(result)

这里 * 用来提取所有子节点(包括纯文本节点),提取文本需要再加上 ::text,最终的运行结果和 上面是一样的。

到这里,我们就简单了解了提取文本的方法。

5. 提取属性

刚才我们演示了 HTML 中的文本提取,直接在 XPath 中加入 //text() 即可,那提取属性怎么实 现呢?方式是类似的,也是直接在 XPath 或者 CSS 选择器中表示出来就可以了。

例如我们提取第三个 li 节点内部的 a 节点的 href 属性,写法如下:

from parsel import Selector
selector = Selector(text=html)
result = selector.css('.item-0.active a::attr(href)').get()
print(result)
result = selector.xpath('//li[contains(@class, "item-0") and contains(@class, "active")]/a/@href').get()
print(result)

这里我们实现了两种写法,分别用 css 和 xpath 方法实现。我们以同时包含 item-0 和 active 两 个 class 为依据,来选取第三个 li 节点,然后进一步选取了里面的 a 节点。对于 CSS 选择器,选取

属性需要加 ::attr(),并传入对应的属性名称才可选取;对于 XPath,直接用 /@ 再加属性名称即可选 取。最后统一用 get 方法提取结果。

运行结果如下:

link3.html
link3.html

可以看到两种方法都正确提取到了对应的 href 属性。

6. 正则提取

除了常用的 css 和 xpath 方法,Selector 对象还提供了正则表达式提取方法,我们用一个实例来 了解下:

from parsel import Selector
selector = Selector(text=html)
result = selector.css('.item-0').re('link.*')
print(result)

这里先用 css 方法提取了所有 class 包含 item-0 的节点,然后使用 re 方法传入了 link.*,用来 匹配包含 link 的所有结果。

运行结果如下:

['link3.html"><span class="bold">third item</span></a></li>', 'link5.html">fifth item</a></li>']

可以看到,re 方法在这里遍历了所有提取到的 Selector 对象,然后根据传入的正则表达式,查 找出符合规则的节点源码并以列表形式返回。

当然,如果在调用 css 方法时,已经提取了进一步的结果,例如提取了节点文本值,那么 re 方 法就只会针对节点文本值进行提取:

from parsel import Selector
selector = Selector(text=html)
result = selector.css('.item-0 *::text').re('.*item')
print(result)

运行结果如下:

['first item', 'third item', 'fifth item']

我们也可以利用 re_first 方法来提取第一个符合规则的结果:

from parsel import Selector
selector = Selector(text=html)
result = selector.css('.item-0').re_first('<span class="bold">(.*?)</span>')
print(result)

这里调用了 re_first 方法,提取的是被 span 标签包含的文本值,提取结果用小括号括起来表示 一个提取分组,最后输出的结果就是小括号包围的部分,运行结果如下:

third item

通过这几个例子,我们知道了正则匹配的一些使用方法,re 对应多个结果,re_first 对应单个结 果,在不同情况下可以选择合适的方法进行提取。

7. 总结

parsel 库是一个融合了 XPath、CSS 选择器和正则表达式的提取库,功能强大又灵活,建议好好 学习一下,同时可以为之后学习 Scrapy 框架打下基础,有关 parsel 更多的用法可以参考其官方文档

https://parsel.readthedocs.io/。

本节代码参见:https://github.com/Python3WebSpider/ParselTest。