续上篇文章,网页抓取到手之后就是解析网页了。
在Python中解析网页的库不少,我最开始使用的是BeautifulSoup,貌似这个也是Python中最知名的HTML解析库。它主要的特点就是容错性很好,能很好地处理实际生活中各种乱七八糟的网页,而且它的API也相当灵活而且丰富。
但是我在自己的正文提取项目中,逐渐无法忍受BeautifulSoup了,主要是因为下面几个原因:
- 由于BeautifulSoup 3(当前的版本)依赖于Python内建的sgmllib.py,而sgmllib.py有好些无法容忍的问题,因此在解析某些网页的时候无法得到正确的结果
- 由于BeautifulSoup 3的上层和底层解析完全都是Python的,因此速度简直是无法容忍,有时候简直比网络还要慢
下面我们来一一说说上述的问题。
首先是解析上的问题,看看下面的Python代码:
from BeautifulSoup import BeautifulSoup
html = u'<div class=my-css>hello</div>'
print BeautifulSoup(html).find('div')['class']
html = u'<div class=我的CSS类>hello</div>'
print BeautifulSoup(html).find('div')['class']
html = u'<div class="我的CSS类">hello</div>'
print BeautifulSoup(html).find('div')['class']
第一次print的结果my-css,第二次是啥都没有,第三次print的结果是我们期待的”我的CSS类”。
原来,sgmllib在解析属性的时候使用的正则表达式没有考虑到非ASCII字符的问题。这个问题相对好解决一些,只要我们在程序的开头导入sgmllib模块,然后修改它对应的属性正则表达式变量就可以了,就像下面这样:
import re, sgmllib
sgmllib.attrfind = re.compile(r'\s*([a-zA-Z_][-.:a-zA-Z_0-9]*)(\s*=\s*(\'[^\']*\'|"[^"]*"|[^\s^\'^\"^>]*))?')
第二个问题就稍微麻烦一些了,还是看代码:
from BeautifulSoup import BeautifulSoup
html = u'<a onclick="if(x>10) alert(x);" href="javascript:void(0)">hello</a>'
print BeautifulSoup(html).find('a').attrs
打印的结果是[(u'onclick', u'if(x>10) alert(x);')]
。
显然,a元素的href属性被弄丢了。原因就是sgmllib库在解析属性的时候一旦遇到了>等特殊符号就会结束属性的解析。要解决这个问题,只能修改sgmllib中SGMLParser的parse_starttag
方法,找到292行,即k = match.end(0)
这一行,添加下面的代码即可:
if k > j:
match = endbracket.search(rawdata, k+1)
if not match: return -1
j = match.start(0)
至于BeautifulSoup速度慢的问题,那就无法通过简单的代码修改搞定了,只能换一个HTML解析库。实际上,我现在使用的是lxml,它是基于C语言开发的libxml2与libxslt库的。经我个人测试,速度比BeautifulSoup 3平均要快10倍。
使用lxml库解析HTML非常简单,兼容性也非常高,大部分实际网站上的网页都可以正确解析。而且lxml使用非常方便的XPath语法进行元素查询,它支持string-length、count等XPath 1.0函数(参见XPath and XSLT with lxml)。不过2.0的函数,如序列操作的函数就不行了,这需要底层libxml2和libxslt库的开发者升级这两个库,添加对XPath 2.0函数的支持才行。
假设我们得到了unicode类型的网页,想要获取所有带href属性的链接,代码如下:
import lxml.html
dom = lxml.html.fromstring(html)
all_links = dom.xpath('.//a[@href]')
使用lxml也有一些注意事项:
- 不能向
lxml.html.fromstring
传递长度为0的字符串,不然会抛出解析异常。需要事先判断一下,如果长度为零,可以传入<html></html>作为内容 - 某些网页不知什么原因,网页内容里有\x00,也就是ASCII编码为0的字符。由于lxml底层是C语言开发的,\x00在C语言里表示字符串结尾,因此需要将这些字符替换一下(
html.replace('\x00', '')
) - 一般情况下,为了减少编码猜测的错误,我们传递给
lxml.html.fromstring
的网页字符串都是unicode字符串,也就是经过编码检测和decode后的字符串。但是如果网页是<?xml开头,而且有编码设定的(如<?xml version="1.0" encoding="UTF-8" ?>
这样)的,也就是说是一个XML包裹的HTML,则我们必须将原始字符串传递给lxml,不然lxml也会报异常,因为针对这种文档,lxml会尝试使用自己的解码机制来做 - lxml兼容性是有限的,没有主流浏览器那么宽容。因此,有少部分浏览器能大致正常显示的网页,lxml仍然无法解析出来
上面几个问题中,第一二个问题好解决,但是第三个问题最麻烦。因为你事先是不知道这个网页是否是带编码的XML文档的,只有出了ValueError
异常才行,但是lxml使用ValueError
报告一切错误,因此为了精确一点,你要分析异常的字符串信息才知道是否是这个问题导致的解析异常,如果是的话,则将未解码的网页内容再给lxml传一次,如果不是就报错。
第四个问题很烦人,但是说句实话,我们也做不了太多工作,lxml的兼容性已经相当好了,可行的方法是放弃掉这些网页。要么就换一个工具,不使用lxml做网页解析,但是在Python库里很难找到比lxml更好的HTML解析库。
用lxml解析HTML代码的示例,可以参考正文提取程序里的__create_dom
方法。
另外还有一个悬疑问题,那就是在解析网页多,内存压力大的情况下,lxml似乎会出现内存溢出的问题。
我有一个程序,每天都要扫描上万个网页,解析四五千个网页。大概每过一个月或一个半月,这个程序就会导致服务器内存全满,不管是多少内存,全部吃光。我以前一直以为可能是底层代码的重入性问题(因为我用多线程来做的程序),但是后来换成多进程和单线程模式都会出现这个问题,才跟踪到出错的代码就是在调用lxml.html.fromstring
的时候溢出的。
不过这个bug超级难以重现,而且我有很多程序都会不停调用lxml(有的每天,有的每小时,有的每次会解析几百个网页)来做HTML解析的工作,可是只有这个程序会偶尔出现溢出情况,太郁闷了。
网上也有类似的报告,但是迄今仍然无法有效重现和确定这个bug。因此我只能写一个脚本限制python程序的占用内存数,然后通过这个脚本来调用python程序,像这样:
#!/bin/bash
ulimit -m 1536000 -v 1536000
python my-prog.py
解析网页还有一些其他要做的工作,例如将非标准(网站自定义的)的HTML标签转化为span或者div,这样才能被识别为正文。剩下的就是调试的工作了。
另外,就在本文写作的时候,BeautifulSoup 4将要发布了,它承诺可以支持Python内建库、lxml与html5lib等不同的HTML解析引擎来进行网页解析,也许新版本的BeautifulSoup也是一个不错的选择。