在寫爬蟲的過程中,我們經常使用 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 來提取裡面的你好
、世界
、你好
、產品經理
。
於是我們寫出下圖所示的程式碼:
我們也可以使用 Scrapy 的 Selector 執行相同的 XPath,結果是一樣的:
首先使用 XPath 獲取class="one"
這個 div 標籤。由於這裡有兩個這樣的標籤,所以第28行的 for 迴圈會執行兩次。在迴圈裡面,使用.//
獲取子孫節點或更深層的div
標籤的正文。似乎邏輯沒有什麼問題。
但是,requests
的作者開發了另一個庫requests_html
,它整合了網頁獲取和資料提取的多個功能,號稱Pythonic HTML Parsing for Humans
。
但如果你使用這個庫的話,你會發現提取的結果與上面的不一致:
完全一樣的 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>
</span
</div>
</body>
</html>
複製程式碼
現在,如果我們使用原生的 lxml 來提取資料,我們的程式碼寫為:
注意畫紅線的位置,.//p/text()
——當你在某個 XPath 返回的 HtmlElement 物件下面繼續執行 XPath 時,如果新的 XPath 不是直接子節點的標籤開頭,而是更深的後代節點的標籤開頭,就需要使用.//
來表示。這裡的p
標籤不是class="one"
這個 div 標籤的直接子標籤,而是孫標籤,所以需要使用.//
開頭。
如果不遵從這個規則,直接寫成//
,那麼執行效果如下圖所示:
雖然你在class="one"
這個 div 標籤返回的 HtmlElement 中執行//
開頭的 XPath,但是新的 XPath依然會從整個 HTML 中尋找結果。這看起來不符合自覺,但它的邏輯就是這樣的。
而如果使用requests_html
,就不用遵守這個規則:
對子 HtmlElement 執行//
開頭的 XPath,那麼它就確實是只在這個 HtmlElement 對應的原始碼中尋找資料。看起來更加符合直覺。
這看起來是一個非常人性化的功能
。但是,上面我們遇到的那個異常情況,恰恰就是這個人性化的功能帶來的怪現象。
為了解釋其中的原因,我們來看 requests_html
的原始碼。本文使用requests_html
的0.10.0版本。
requests_html
的原始碼只有一個檔案,非常容易閱讀。
用 PyCharm 編寫上述程式碼,在 macOS 下,按住鍵盤Command
並用滑鼠左鍵點選上圖程式碼第24行的xpath
;Windows 系統按住Ctrl
並用滑鼠左鍵點選24行的xpath
,跳轉到原始碼中。沒有 PyCharm 的同學可以開啟 Github 線上閱讀它的原始碼但行數可能與本文不一致。
在原始碼第237行,我們可以看到一個方法叫做xpath
,如下圖所示:
當我們執行selector.xpath
的時候,程式碼就執行到了這裡。
程式碼執行到第255行,通過呼叫self.lxml.xpath
真正執行了 XPath 語句。而這裡的self.lxml
,實際上對應了原始碼中的第154行的lxml
方法:
大家在這裡是不是看到一個很屬性的身影?第162行的lxml.html.fromstring
。就是標準的 lxml 解析 HTML 的模組。不過它是第160行執行失敗的時候才會被使用。而第160行使用的soup_parse
,實際上也是來自於 lxml 庫。我們看原始碼最上面,第19行:
實際上使用的是lxml.html.soupparser.fromstring
。
所以,requests_html
庫本質上還是使用 lxml 來執行 XPath 的!
那麼是不是lxml.html.soupparser.fromstring
這個模組具有上述的神奇能力呢?實際上不是。我們可以自己寫程式碼來進行驗證:
執行結果與我們直接使用lxml.html.fromstring
返回的結果完全一致。
為了證明這一點,我們在requests_html
的第257行下一個斷點,讓程式停在這裡。如下圖所示:
此時,是程式剛剛把class="one"
的兩個標籤通過 XPath 提取出來,生成 HtmlElement 的時候,此時第255行的變數selected
是一個列表,列表裡面有兩個 HtmlElement 物件。我們現在如果直接對這兩個物件中的一個執行以//
開頭的 XPath 會怎麼樣呢?點選紅色箭頭指向的計算器按鈕(Evaluate Expression),輸入程式碼selected[0].xpath('//p/text()')
並點選Evaluate
按鈕,效果如下圖所示:
這個返回結果說明,到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
,我們發現,竟然已經實現了混用.//
與//
了。如下圖所示:
這就說明,requests_html
的所謂人性化 XPath 的關鍵,就藏在Element
這個物件中。我們轉到程式碼第365行,檢視Element
類的定義,如下圖所示:
這個類是BaseParser
的子類,並且它本身的程式碼很少。它沒有.xpath
方法,所以當我們上面呼叫elements[0].xpath('//p/text()')
時,執行的應該是BaseParser
中的.xpath
方法。
我們來看一BaseParser
的.xpath
方法,程式碼在第236行:
等等,不太對啊。。。
這段程式碼似曾相識,怎麼又轉回來了???
先不要驚慌。
我們繼續看第255行,大家突然意識到一個問題,我們現在是對誰執行的 XPath?selected = self.lxml.xpath(selector)
說明,我們現在是對self.lxml
這個物件執行的 XPath。
我們回到第160行。
soup_parse
的第一個引數self.html
是什麼?我們轉到原始碼第100行:
如果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行:
如果在初始化BaseParser
時傳入了 html 引數並且它是字串型別,那麼self._html
就把 html 引數字串編碼為 bytes 型資料。如果它不是字串,或者沒有傳入,那麼傳什麼就用什麼。
我們現在回到Element
類定義的__init__
函式中:
注意第379行,Element
類初始化地時候,給 BaseParser
傳入的引數,沒有html
引數!
所以在BaseParser
的__init__
方法中,self._html
為None
!
所以在第100行的html
屬性中,執行的是第107行程式碼!
而第107行程式碼,傳給etree.tostring
的這個self.element
,實際上就是我們第一輪在第257-261行傳給Element
類的引數,也就是使用 lxml 查詢//div[@class="one"]
時返回的兩個 HtmlElement 物件!
那麼,把HtmlElement
物件傳入etree.tostring
會產生什麼效果呢?我們來做個實驗:
etree.tostring
可以把一個HtmlElement
物件重新轉換為 Html 原始碼!
所以在requests_htmls
中,它先把我們傳給Element
的 HtmlElement 物件轉成 HtmL 原始碼,然後再把原始碼使用lxml.html.soupparser.fromstring
重新處理一次生成新的HtmlElement 物件。這樣做,就相當於把原始 HTML 中,不相關的內容直接刪掉了,只保留當前這個class="one"
的 div 標籤下面的內容,當然可以直接使用//
來查詢後代標籤了,因為干擾的資料完全沒有了!
這就相當於在處理第一層 XPath 返回的 HtmlElement時,程式碼變成了:
但是成也蕭何,敗也蕭何。這種處理方式雖然確實有點小聰明,但是如果原始的 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[@class="one"]
返回的 HtmlElement 再次執行XPath 時,程式碼等價於對:
<div class="one">
不需要的資料
<span>
<div class="1">你好</div>
<div class="2">世界</div>
</span>
</div>
複製程式碼
執行//div/text()
,自然就會把不需要的資料
也提取下來:
所以,requests_html
的這個特性,到底是功能還是 Bug?我自己平時主要使用 lxml.html.fromstring 或者 Scrapy,所以熟悉了使用.//
後,我個人傾向於requests_html
這個特性是一個 bug。