在寫爬蟲的過程當中,咱們常用 XPath 來從 HTML 中提取數據。例如給出下面這個 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 來提取裏面的你好
、世界
、你好
、產品經理
。git
因而咱們寫出下圖所示的代碼:github
咱們也可使用 Scrapy 的 Selector 執行相同的 XPath,結果是同樣的:函數
首先使用 XPath 獲取class="one"
這個 div 標籤。因爲這裏有兩個這樣的標籤,因此第28行的 for 循環會執行兩次。在循環裏面,使用.//
獲取子孫節點或更深層的div
標籤的正文。彷佛邏輯沒有什麼問題。編碼
可是,requests
的做者開發了另外一個庫requests_html
,它集成了網頁獲取和數據提取的多個功能,號稱Pythonic HTML Parsing for Humans
。lua
但若是你使用這個庫的話,你會發現提取的結果與上面的不一致:spa
徹底同樣的 XPath,可是返回的結果裏面多出了一些髒數據。3d
爲何會出現這樣的狀況呢?咱們須要從一個功能提及。調試
咱們修改一下 HTML 代碼,移除其中的髒數據,並對一些標籤更名:code
<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。