Bug or Feature?藏在 requests_html 中的陷阱

青南發表於2020-02-27

在寫爬蟲的過程中,我們經常使用 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 來提取裡面的你好世界你好產品經理

於是我們寫出下圖所示的程式碼:

Bug or Feature?藏在 requests_html 中的陷阱

我們也可以使用 Scrapy 的 Selector 執行相同的 XPath,結果是一樣的:

Bug or Feature?藏在 requests_html 中的陷阱

首先使用 XPath 獲取class="one"這個 div 標籤。由於這裡有兩個這樣的標籤,所以第28行的 for 迴圈會執行兩次。在迴圈裡面,使用.//獲取子孫節點或更深層的div標籤的正文。似乎邏輯沒有什麼問題。

但是,requests的作者開發了另一個庫requests_html,它整合了網頁獲取和資料提取的多個功能,號稱Pythonic HTML Parsing for Humans

但如果你使用這個庫的話,你會發現提取的結果與上面的不一致:

Bug or Feature?藏在 requests_html 中的陷阱

完全一樣的 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 來提取資料,我們的程式碼寫為:

Bug or Feature?藏在 requests_html 中的陷阱

注意畫紅線的位置,.//p/text()——當你在某個 XPath 返回的 HtmlElement 物件下面繼續執行 XPath 時,如果新的 XPath 不是直接子節點的標籤開頭,而是更深的後代節點的標籤開頭,就需要使用.//來表示。這裡的p標籤不是class="one"這個 div 標籤的直接子標籤,而是孫標籤,所以需要使用.//開頭。

如果不遵從這個規則,直接寫成//,那麼執行效果如下圖所示:

Bug or Feature?藏在 requests_html 中的陷阱

雖然你在class="one"這個 div 標籤返回的 HtmlElement 中執行//開頭的 XPath,但是新的 XPath依然會從整個 HTML 中尋找結果。這看起來不符合自覺,但它的邏輯就是這樣的。

而如果使用requests_html,就不用遵守這個規則:

Bug or Feature?藏在 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,如下圖所示:

Bug or Feature?藏在 requests_html 中的陷阱

當我們執行selector.xpath的時候,程式碼就執行到了這裡。

程式碼執行到第255行,通過呼叫self.lxml.xpath真正執行了 XPath 語句。而這裡的self.lxml,實際上對應了原始碼中的第154行的lxml方法:

Bug or Feature?藏在 requests_html 中的陷阱

大家在這裡是不是看到一個很屬性的身影?第162行的lxml.html.fromstring。就是標準的 lxml 解析 HTML 的模組。不過它是第160行執行失敗的時候才會被使用。而第160行使用的soup_parse,實際上也是來自於 lxml 庫。我們看原始碼最上面,第19行:

Bug or Feature?藏在 requests_html 中的陷阱

實際上使用的是lxml.html.soupparser.fromstring

所以,requests_html庫本質上還是使用 lxml 來執行 XPath 的!

那麼是不是lxml.html.soupparser.fromstring這個模組具有上述的神奇能力呢?實際上不是。我們可以自己寫程式碼來進行驗證:

Bug or Feature?藏在 requests_html 中的陷阱

執行結果與我們直接使用lxml.html.fromstring返回的結果完全一致。

為了證明這一點,我們在requests_html的第257行下一個斷點,讓程式停在這裡。如下圖所示:

Bug or Feature?藏在 requests_html 中的陷阱

此時,是程式剛剛把class="one"的兩個標籤通過 XPath 提取出來,生成 HtmlElement 的時候,此時第255行的變數selected是一個列表,列表裡面有兩個 HtmlElement 物件。我們現在如果直接對這兩個物件中的一個執行以//開頭的 XPath 會怎麼樣呢?點選紅色箭頭指向的計算器按鈕(Evaluate Expression),輸入程式碼selected[0].xpath('//p/text()')並點選Evaluate按鈕,效果如下圖所示:

Bug or Feature?藏在 requests_html 中的陷阱

這個返回結果說明,到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,我們發現,竟然已經實現了混用.////了。如下圖所示:

Bug or Feature?藏在 requests_html 中的陷阱

這就說明,requests_html的所謂人性化 XPath 的關鍵,就藏在Element這個物件中。我們轉到程式碼第365行,檢視Element類的定義,如下圖所示:

Bug or Feature?藏在 requests_html 中的陷阱

這個類是BaseParser的子類,並且它本身的程式碼很少。它沒有.xpath方法,所以當我們上面呼叫elements[0].xpath('//p/text()')時,執行的應該是BaseParser中的.xpath方法。

我們來看一BaseParser.xpath方法,程式碼在第236行:

Bug or Feature?藏在 requests_html 中的陷阱

等等,不太對啊。。。

Bug or Feature?藏在 requests_html 中的陷阱

Bug or Feature?藏在 requests_html 中的陷阱

這段程式碼似曾相識,怎麼又轉回來了???

Bug or Feature?藏在 requests_html 中的陷阱

先不要驚慌。

Bug or Feature?藏在 requests_html 中的陷阱

我們繼續看第255行,大家突然意識到一個問題,我們現在是對誰執行的 XPath?selected = self.lxml.xpath(selector)說明,我們現在是對self.lxml這個物件執行的 XPath。

我們回到第160行。

Bug or Feature?藏在 requests_html 中的陷阱

soup_parse的第一個引數self.html是什麼?我們轉到原始碼第100行:

Bug or Feature?藏在 requests_html 中的陷阱

如果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行:

Bug or Feature?藏在 requests_html 中的陷阱

如果在初始化BaseParser時傳入了 html 引數並且它是字串型別,那麼self._html就把 html 引數字串編碼為 bytes 型資料。如果它不是字串,或者沒有傳入,那麼傳什麼就用什麼。

我們現在回到Element類定義的__init__函式中:

Bug or Feature?藏在 requests_html 中的陷阱

注意第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會產生什麼效果呢?我們來做個實驗:

Bug or Feature?藏在 requests_html 中的陷阱

etree.tostring可以把一個HtmlElement物件重新轉換為 Html 原始碼!

Bug or Feature?藏在 requests_html 中的陷阱

所以在requests_htmls中,它先把我們傳給Element的 HtmlElement 物件轉成 HtmL 原始碼,然後再把原始碼使用lxml.html.soupparser.fromstring重新處理一次生成新的HtmlElement 物件。這樣做,就相當於把原始 HTML 中,不相關的內容直接刪掉了,只保留當前這個class="one"的 div 標籤下面的內容,當然可以直接使用//來查詢後代標籤了,因為干擾的資料完全沒有了!

這就相當於在處理第一層 XPath 返回的 HtmlElement時,程式碼變成了:

Bug or Feature?藏在 requests_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>
複製程式碼

在對//div[@class="one"]返回的 HtmlElement 再次執行XPath 時,程式碼等價於對:

<div class="one">
    不需要的資料
    <span>
        <div class="1">你好</div>
        <div class="2">世界</div>
    </span>
</div>
複製程式碼

執行//div/text(),自然就會把不需要的資料也提取下來:

Bug or Feature?藏在 requests_html 中的陷阱

所以,requests_html的這個特性,到底是功能還是 Bug?我自己平時主要使用 lxml.html.fromstring 或者 Scrapy,所以熟悉了使用.//後,我個人傾向於requests_html這個特性是一個 bug。

Bug or Feature?藏在 requests_html 中的陷阱

相關文章