在写爬虫的过程中,我们经常使用 XPath 来从 HTML 中提取数据。例如给出下面这个 HTML:
<html>
<body>
<div class="other">不需要的数据div>
<div class="one">
不需要的数据
<span>
<div class="1">你好div>
<div class="2">世界div>
span>
div>
<div class="one">
不需要的数据
<span>
<div class="3">你好div>
<div class="4">产品经理div>
span>
不需要的数据
div>
body>
html>
如果我们使用 lxml 来提取里面的你好
、世界
、你好
、产品经理
。
于是我们写出下图所示的代码:
![be5317263d03ca871b345f1f1689353e.png](https://i-blog.csdnimg.cn/blog_migrate/5e0e5d55b7511431fa776c9f44dff354.jpeg)
我们也可以使用 Scrapy 的 Selector 执行相同的 XPath,结果是一样的:
![407545511bddc50733e39b949cf1bd27.png](https://i-blog.csdnimg.cn/blog_migrate/392ae535fef43c82e4561606e128203a.jpeg)
首先使用 XPath 获取这个 div 标签。由于这里有两个这样的标签,所以第28行的 for 循环会执行两次。在循环里面,使用
.//
获取子孙节点或更深层的div
标签的正文。似乎逻辑没有什么问题。
但是,requests
的作者开发了另一个库requests_html
,它集成了网页获取和数据提取的多个功能,号称Pythonic HTML Parsing for Humans
。
但如果你使用这个库的话,你会发现提取的结果与上面的不一致:
![4ebf7e0a42bb184dd54a37578d91c0b5.png](https://i-blog.csdnimg.cn/blog_migrate/6298a64a7cdb2b1f92a5c8e71b10777f.jpeg)
完全一样的 XPath,但是返回的结果里面多出了一些脏数据。
为什么会出现这样的情况呢?我们需要从一个功能说起。
我们修改一下 HTML 代码,移除其中的脏数据,并对一些标签改名:
<html>
<body>
<div class="other">不需要的数据div>
<div class="one">
<span>
<p class="1">你好p>
<p class="2">世界p>
span>
div>
<div class="one">
<span>
<p class="3">你好p>
<p class="4">产品经理p>
spandiv>
body>
html>
现在,如果我们使用原生的 lxml 来提取数据,我们的代码写为:
![ee447c0948fe3959a2f3850a4a1771fa.png](https://i-blog.csdnimg.cn/blog_migrate/ac1cfbeab1d914043673d27f6592d791.jpeg)
注意画红线的位置,.//p/text()
——当你在某个 XPath 返回的 HtmlElement 对象下面继续执行 XPath 时,如果新的 XPath 不是直接子节点的标签开头,而是更深的后代节点的标签开头,就需要使用.//
来表示。这里的p
标签不是这个 div 标签的直接子标签,而是孙标签,所以需要使用
.//
开头。
如果不遵从这个规则,直接写成//
,那么运行效果如下图所示:
![51cba703a08def220076b35bd8829f8f.png](https://i-blog.csdnimg.cn/blog_migrate/4dfca805526b13b35f831bf3e63b2343.jpeg)
虽然你在这个 div 标签返回的 HtmlElement 中执行
//
开头的 XPath,但是新的 XPath依然会从整个 HTML 中寻找结果。这看起来不符合自觉,但它的逻辑就是这样的。
而如果使用requests_html
,就不用遵守这个规则:
![75a871dd8430e6cb951156e87394cfdf.png](https://i-blog.csdnimg.cn/blog_migrate/26a1c748b852be6a14e99832252babaf.jpeg)
对子 HtmlElement 执行//
开头的 XPath,那么它就确实是只在这个 HtmlElement 对应的源代码中寻找数据。看起来更加符合直觉。
这看起来是一个非常人性化的功能
。但是,上面我们遇到的那个异常情况,恰恰就是这个人性化的功能带来的怪现象。
为了解释其中的原因,我们来看 requests_html
的源代码。本文使用requests_html
的0.10.0版本。
requests_html
的源代码只有一个文件,非常容易阅读。
用 PyCharm 编写上述代码,在 macOS 下,按住键盘Command
并用鼠标左键点击上图代码第24行的xpath
;Windows 系统按住Ctrl
并用鼠标左键点击24行的xpath
,跳转到源代码中。没有 PyCharm 的同学可以打开 Github 在线阅读它的源代码[1]但行数可能与本文不一致。
在源代码第237行,我们可以看到一个方法叫做xpath
,如下图所示:
![416b5f4efd75f1e2a4eda98b4abdc341.png](https://i-blog.csdnimg.cn/blog_migrate/7f8c8dba2891445579d6eecb8c11bf1d.jpeg)
当我们执行selector.xpath
的时候,代码就运行到了这里。
代码运行到第255行,通过调用self.lxml.xpath
真正执行了 XPath 语句。而这里的self.lxml
,实际上对应了源代码中的第154行的lxml
方法:
![2aff2c440ec7c0f2b088c34661588fae.png](https://i-blog.csdnimg.cn/blog_migrate/f274fcb02914ee5d2f73188ef6a2b393.jpeg)
大家在这里是不是看到一个很属性的身影?第162行的lxml.html.fromstring
。就是标准的 lxml 解析 HTML 的模块。不过它是第160行执行失败的时候才会被使用。而第160行使用的soup_parse
,实际上也是来自于 lxml 库。我们看源代码最上面,第19行:
![8cf19d71ad2e0f43eefb0c5a522aed8c.png](https://i-blog.csdnimg.cn/blog_migrate/74e75bef0b1c4e1867bf9d63f08bddb9.jpeg)
实际上使用的是lxml.html.soupparser.fromstring
。
所以,requests_html
库本质上还是使用 lxml 来执行 XPath 的!
那么是不是lxml.html.soupparser.fromstring
这个模块具有上述的神奇能力呢?实际上不是。我们可以自己写代码来进行验证:
![76104c38f67c050f5572969c4e4663cf.png](https://i-blog.csdnimg.cn/blog_migrate/8fde6635ed6ca96692feca5a6b96c592.jpeg)
执行结果与我们直接使用lxml.html.fromstring
返回的结果完全一致。
为了证明这一点,我们在requests_html
的第257行下一个断点,让程序停在这里。如下图所示:
![4b6df354c86f6067602c1242ea89ce3e.png](https://i-blog.csdnimg.cn/blog_migrate/2ff952e229316ec66fef0191f4f120d2.jpeg)
此时,是程序刚刚把的两个标签通过 XPath 提取出来,生成 HtmlElement 的时候,此时第255行的变量
selected
是一个列表,列表里面有两个 HtmlElement 对象。我们现在如果直接对这两个对象中的一个执行以//
开头的 XPath 会怎么样呢?点击红色箭头指向的计算器按钮(Evaluate Expression),输入代码selected[0].xpath('//p/text()')
并点击Evaluate
按钮,效果如下图所示:
![b7c93b47d57cba9cce8d9b9ed9996356.png](https://i-blog.csdnimg.cn/blog_migrate/a8d852439b150268c464f9bdcba4c15b.jpeg)
这个返回结果说明,到requests_html
源代码的第255行运行结束为止,XPath 的运行效果与普通的lxml.html.fromstring
保持一致。还不能混用.//
和//
。
我们再来看源代码的第257-261行,这里使用一个列表推导式生成了一个elements
列表。这个列表里面是两个Element 对象。这里的这个Element
是requests
自定义的。稍后我们再看。
在PyCharm 的调试模式中,单步执行代码到第264行,使得 elements 列表生成完成。然后我们继续在Evaluate Expression
窗口中执行Python 语句:elements[0].xpath('//p/text()')
,通过调用 Element 对象的.xpath
,我们发现,竟然已经实现了混用.//
与//
了。如下图所示:
![e121948e6939ce869af2ee1ac23c33fc.png](https://i-blog.csdnimg.cn/blog_migrate/3ae364fb4ad36526342ae00db7181d46.jpeg)
这就说明,requests_html
的所谓人性化 XPath 的关键,就藏在Element
这个对象中。我们转到代码第365行,查看Element
类的定义,如下图所示:
![d17c41f94ead48e22dc1cdca48c76db6.png](https://i-blog.csdnimg.cn/blog_migrate/50c10851a498de7742f7cca78c3c701c.jpeg)
这个类是BaseParser
的子类,并且它本身的代码很少。它没有.xpath
方法,所以当我们上面调用elements[0].xpath('//p/text()')
时,执行的应该是BaseParser
中的.xpath
方法。
我们来看一BaseParser
的.xpath
方法,代码在第236行:
![8eee01e768803508825a77a79b341bfd.png](https://i-blog.csdnimg.cn/blog_migrate/bdffb47f330884f33223d0fb7af4b025.jpeg)
等等,不太对啊。。。
![9731e93c967db92be0b76c51da1a8228.png](https://i-blog.csdnimg.cn/blog_migrate/5556ca795886244330ddebf5c0fc71d8.png)
![5a47dd72b6f23127adb67989e6c59147.png](https://i-blog.csdnimg.cn/blog_migrate/e3c2fbf58ad9eeea71936d920d71b633.png)
这段代码似曾相识,怎么又转回来了???
![ed4000f3df6f25f2e04ee1bed2e8ab55.png](https://i-blog.csdnimg.cn/blog_migrate/ffb9aa01013c545d1857e90c54365472.jpeg)
先不要惊慌。
![cefe1c4d62c2f5dbe0d39d29d2c06fad.png](https://i-blog.csdnimg.cn/blog_migrate/b75bfbde3f534edf7a2a7af4f7cf77cb.png)
我们继续看第255行,大家突然意识到一个问题,我们现在是对谁执行的 XPath?selected = self.lxml.xpath(selector)
说明,我们现在是对self.lxml
这个对象执行的 XPath。
我们回到第160行。
![b622f94aef7c26614dd3c0650f4b7a3e.png](https://i-blog.csdnimg.cn/blog_migrate/0fef219ba252dc7611b85bc12c126457.png)
soup_parse
的第一个参数self.html
是什么?我们转到源代码第100行:
![2b8523d5f8574c1ab5fbcc5de5c2a0ff.png](https://i-blog.csdnimg.cn/blog_migrate/aac9cd171cc91dceadc843cc57cf9d76.jpeg)
如果self._html
不为空,那么返回self.raw_html.decode(self.encoding, errors='replace')
,我们目前不知道它是什么,但是肯定是一个字符串。
如果self._html
为空,那么执行return etree.tostring(self.element, encoding='unicode').strip()
。
我们来看看self._html
是什么,来到BaseParser
的__init__
方法中,源代码第79行:
![11b595b8a88a5ba52e174e00c691a1d1.png](https://i-blog.csdnimg.cn/blog_migrate/2322b75592a0d71c345b99c70419f311.png)
如果在初始化BaseParser
时传入了 html 参数并且它是字符串类型,那么self._html
就把 html 参数字符串编码为 bytes 型数据。如果它不是字符串,或者没有传入,那么传什么就用什么。
我们现在回到Element
类定义的__init__
函数中:
![9e827e8b3732e5f1e9614d9759a290b2.png](https://i-blog.csdnimg.cn/blog_migrate/9794e7d189601429482bbbdf92d51272.jpeg)
注意第379行,Element
类初始化时,给 BaseParser
传入的参数,没有html
参数!
所以在BaseParser
的__init__
方法中,self._html
为None
!
所以在第100行的html
属性中,执行的是第107行代码!
而第107行代码,传给etree.tostring
的这个self.element
,实际上就是我们第一轮在第257-261行传给Element
类的参数,也就是使用 lxml 查询//div[@]
时返回的两个 HtmlElement 对象!
那么,把HtmlElement
对象传入etree.tostring
会产生什么效果呢?我们来做个实验:
![99b1b12d5807f8e2127d864f1b037a1b.png](https://i-blog.csdnimg.cn/blog_migrate/23ef09327ab8e9acf7843e1266482014.jpeg)
etree.tostring
可以把一个HtmlElement
对象重新转换为 Html 源代码!
![d96e1e2a5c3e2afd9d512e0cca226fc5.png](https://i-blog.csdnimg.cn/blog_migrate/b2b84d4ace5c15d25af9ec2fcfa115f7.png)
所以在requests_htmls
中,它先把我们传给Element
的 HtmlElement 对象转成 HtmL 源代码,然后再把源代码使用lxml.html.soupparser.fromstring
重新处理一次生成新的HtmlElement 对象。这样做,就相当于把原始 HTML 中,不相关的内容直接删掉了,只保留当前这个的 div 标签下面的内容,当然可以直接使用
//
来查询后代标签了,因为干扰的数据完全没有了!
这就相当于在处理第一层 XPath 返回的 HtmlElement时,代码变成了:
![cbac082c53a9ef3ad06ec328b8d34304.png](https://i-blog.csdnimg.cn/blog_migrate/2661086158aa7c8cf36ea258142786c3.png)
但是成也萧何,败也萧何。这种处理方式虽然确实有点小聪明,但是如果原始的 HTML 是:
<html>
<body>
<div class="other">不需要的数据div>
<div class="one">
不需要的数据
<span>
<div class="1">你好div>
<div class="2">世界div>
span>
div>
<div class="one">
不需要的数据
<span>
<div class="3">你好div>
<div class="4">产品经理div>
span>
不需要的数据
div>
body>
html>
在对//div[@]
返回的 HtmlElement 再次执行XPath 时,代码等价于对:
<div class="one">
不需要的数据
<span>
<div class="1">你好div>
<div class="2">世界div>
span>
div>
执行//div/text()
,自然就会把不需要的数据
也提取下来:
![87446bfce94f58f37c007ba34da9bfd6.png](https://i-blog.csdnimg.cn/blog_migrate/b4d2dd2d71043f02af0384f441b85aea.jpeg)
所以,requests_html
的这个特性,到底是功能还是 Bug?我自己平时主要使用 lxml.html.fromstring 或者 Scrapy,所以熟悉了使用.//
后,我个人倾向于requests_html
这个特性是一个 bug。
参考资料
[1]源代码: https://github.com/psf/requests-html/blob/master/requests_html.py