xpath在HTML解析中的应用(更新加强版)

前一阵参加了一个Python的活动,其间老董的讲座是讨论网页爬虫技术的。其中提到了一下关于页面解析的问题,他推荐了三种技术。其中有用到libxml2里的xpath来处理,我就跟令狐谈到我曾经也用过这个东东。令狐建议我把这个东东说一下,于是我就写了这一篇。

惭 愧的是我最初在python里用xpath时用的不是libxml2,而是一个不记得是什么的XML库。后来因为那个库不知道为什么找不到了或者是新版本 不再提供xpath支持等原因,才通过google找到libxml2。就像对pcre的误解一样,我原来还以为libxml2是python的库,后来 才知道它是C的库,我用的只是python的包装而已。

很多对XML接触不多的人,刚开始的印象都会觉得XML不就是一种数据存储方式嘛,但是实际上在一些极端XMLer看来,XML是一种强大的编程语言。不相信的话试着写一个XSL就知道了。我对xpath的了解其实也是源于多年写的一个程序需要而对XSLT作了点研究。

关于xpath的权威资料,当然要算W3C的XPath官方文档

下面以一个例子来说明吧。假设现在我们需要解析这样一个“页面”,取出其中所有“档案”段的内容,并且解析为一个个月份值。这个功能当然也可以用正则表达式(RE)来式来实现——这也是老董提到的三种技术之一——而且事实上有些情况下使用RE并不会比用xpath麻烦。

首先,我们需要安装一个支持xpath的python库。目前在libxml2的网站上被推荐的python binding是lxml(我以前用的不是这个,不过我也不记得是哪个了),所以现在就以这个为例吧。

安装方法很简单: easy_install lxml 即可——什么?你不知道什么叫easy_install?那就猛戳PEAK这里学习一下吧。

个么然后是不是就可以直接用了呢?试试看吧:

import codecs
from lxml import etree

f=codecs.open("raptor.htm","r","utf-8")
tree=etree.parse(f)

很不幸,可耻滴失败鸟。因为这个页面并不是一个qualified的XHTML——别说中国了,就算在外国也没有那么多合格的XHTML页面。

要解决这个问题,当然可以通过HTML tidy之类的来做一个预处理,或者用RE做一个预处理。不过还好经过一番研究,发现libxml2其实已经内置了解决方案——即使是不很规范的HTML也可以。

现在来看如何直接使用lxml处理:

import codecs
from lxml import etree

f=codecs.open("raptor.htm","r","utf-8")
content=f.read()
f.close()
tree=etree.HTML(content)

Bingo!果然成功。关键就在于etree提供了HTML这个解析函数。之后的事情就好办多了,因为可以直接对HTML使用xpath。

在使用xpath之前我们先来看看作为对照的jQuery和RE。

在jQuery里要处理这种东西就很简单,特别是假如那个ul节点有id的话(比如是<ul id='archive'>):

$("#archive").each(function(){...});

就这个实际的例子来说则稍微复杂一些:

$("#leftmenu").children("h3:contains('档案')").next("ul").each(function(){...});
意思是:找到id为leftmenu的节点,在其下找到一个内容包含为"档案"的h3节点,再取其接下来的一个ul节点。

但是在python里要是用RE来处理就略麻烦一些,比如这样:

block_pattern=re.compile(u"<h3>档案</h3>(.*?)<h3>", re.I | re.S)
m=block_pattern.findall(content)
item_pattern=re.compile(u"<li>(.*?)</li>", re.I | re.S)
items=item_pattern.findall(m[0])
for i in items:
print i

那么换成用xpath要怎么做呢?其实跟jQuery是差不多的,如果有id的话:

nodes=tree.xpath("/descendant::ul[@id='archive']")
当然,现在没有的话也就只能用类似于jQuery的方法了。完整的xpath应该是这样写的(注意,原文件中的TAG有大小写的情况,但是在XPATH里只能用小写):
nodes=tree.xpath(u"/html/body/form/div[@id='leftmenu']/h3[text()='档案']/following-sibling::ul[1]")

这句xpath的意思是寻找这样一个东东。

<html>
<body>
<form>
<div id='leftmenu'>
<h3>档案</h3>
<ul><!-- 找到这里 --></ul>
</div>
</form>
</body>
</html>

当然更简单的方法就是像jQuery那样直接根据id定位:

nodes=tree.xpath(u"//div[@id='leftmenu']/h3[text()='档案']/following-sibling::ul[1]")

这两种方法返回的结果中,nodes[0]就是那个“档案”的h3节点后面紧跟的第一个ul节点。

之后就可以把每个月份的文本列出(注意,是以上面取得的ul节点为起点):

nodes=nodes[0].xpath("li/a")
for n in nodes:
print n.text

这段的意思是取得这个ul节点下的所有<li><a>节点。之后的循环就是把这些节点的文本内容列出。

对比三种方法应该可以看出xpath和jQuery对于页面的解析都是基于XML的语义进行,而RE则纯粹是基于plain text。RE对付简单的页面是没有问题,如果页面结构复杂度较高的时候(比如一堆的DIV来回嵌套之类),设计一个恰当的RE pattern可能会远比写一个xpath要复杂。特别是目前主流的基于CSS的页面设计方式,其中大部分关键节点都会有id——对于使用jQuery的页面来说则更是如此,这时xpath相比RE就有了决定性的优势。

libxml2实在是太强大了。

附录:XPATH的简单语法介绍

XPATH基本上是用一种类似目录树的方法来描述在XML文档中的路径。比如用“/”来作为上下层级间的分隔。第一个“/”表示文档的根节点(注意,不是指文档最外层的tag节点,而是指文档本身)。比如对于一个HTML文件来说,最外层的节点应该是"/html"。

同样的,“..”和“.”分别被用来表示父节点和本节点。

XPATH返回的不一定就是唯一的节点,而是符合条件的所有节点。比如在HTML文档里使用“/html/head/scrpt”就会把head里的所有script节点都取出来。

为了缩小定位范围,往往还需要增加过滤条件。过滤的方法就是用“[”“]”把过滤条件加上。比如在HTML文档里使用“/html/body/div[@id='main']”,即可取出body里id为main的div节点。

其中@id表示属性id,类似的还可以使用如@name, @value, @href, @src, @class....

而 函数text()的意思则是取得节点包含的文本。比如:<div>hello<p>world</p>< /div>中,用"div[text()='hello']"即可取得这个div,而world则是p的text()。

函数position()的意思是取得节点的位置。比如“li[position()=2]”表示取得第二个li节点,它也可以被省略为“li[2]”。

不过要注意的是数字定位和过滤 条件的顺序。比如“ul/li[5][@name='hello']”表示取ul下第五项li,并且其name必须是hello,否则返回空。而如果用 “ul/li[@name='hello'][5]”的意思就不同,它表示寻找ul下第五个name为"hello“的li节点。

此外,“*”可以代替所有的节点名,比如用"/html/body/*/span"可以取出body下第二级的所有span,而不管它上一级是div还是p或是其它什么东东。

而 “descendant::”前缀可以指代任意多层的中间节点,它也可以被省略成一个“/”。比如在整个HTML文档中查找id为“leftmenu”的 div,可以用“/descendant::div[@id='leftmenu']”,也可以简单地使用“ //div[@id='leftmenu']”。

至于“following-sibling::”前缀就如其名所说,表示同一层的下一个节点。"following-sibling::*"就是任意下一个节点,而“following-sibling::ul”就是下一个ul节点。

更复杂的XPATH语法还是请参考官文档《XML Path Language (XPath)》。