續上篇文章,網頁抓取到手之后就是解析網頁了。
在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也是一個不錯的選擇。