Bug or Feature?藏在 requests_html 中的陷阱

在寫爬蟲的過程當中,咱們常用 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 Humanslua

但若是你使用這個庫的話,你會發現提取的結果與上面的不一致: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 對象。這裏的這個Elementrequests自定義的。稍後咱們再看。

在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._htmlNone!

因此在第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。

相關文章
相關標籤/搜索