上一章中,我們實現了一個最基本的爬蟲,但提取頁面資訊時使用的是正規表示式,這還是比較煩瑣,而且萬一有地方寫錯了,可能導致匹配失敗,所以使用正規表示式提取頁面資訊多多少少還是有些不方便。
對於網頁的節點來說,它可以定義id
、class
或其他屬性。而且節點之間還有層次關係,在網頁中可以通過XPath或CSS選擇器來定位一個或多個節點。那麼,在頁面解析時,利用XPath或CSS選擇器來提取某個節點,然後再呼叫相應方法獲取它的正文內容或者屬性,不就可以提取我們想要的任意資訊了嗎?
在Python中,怎樣實現這個操作呢?不用擔心,這種解析庫已經非常多,其中比較強大的庫有lxml、Beautiful Soup、pyquery等,本章就來介紹這3個解析庫的用法。有了它們,我們就不用再為正規表示式發愁,而且解析效率也會大大提高。
XPath,全稱XML Path Language,即XML路徑語言,它是一門在XML文件中查詢資訊的語言。它最初是用來搜尋XML文件的,但是它同樣適用於HTML文件的搜尋。
所以在做爬蟲時,我們完全可以使用XPath來做相應的資訊抽取。本節中,我們就來介紹XPath的基本用法。
1. XPath概覽
XPath的選擇功能十分強大,它提供了非常簡潔明瞭的路徑選擇表示式。另外,它還提供了超過100個內建函式,用於字串、數值、時間的匹配以及節點、序列的處理等。幾乎所有我們想要定位的節點,都可以用XPath來選擇。
XPath於1999年11月16日成為W3C標準,它被設計為供XSLT、XPointer以及其他XML解析軟體使用,更多的文件可以訪問其官方網站:www.w3.org/TR/xpath/。
2. XPath常用規則
表4-1列舉了XPath的幾個常用規則。
表4-1 XPath常用規則
這裡列出了XPath的常用匹配規則,示例如下:
//title[@lang=`eng`]
複製程式碼
這就是一個XPath規則,它代表選擇所有名稱為title
,同時屬性lang
的值為eng
的節點。
後面會通過Python的lxml庫,利用XPath進行HTML的解析。
3. 準備工作
使用之前,首先要確保安裝好lxml庫,若沒有安裝,可以參考第1章的安裝過程。
4. 例項引入
現在通過例項來感受一下使用XPath來對網頁進行解析的過程,相關程式碼如下:
from lxml import etree
text = ```
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>
```
html = etree.HTML(text)
result = etree.tostring(html)
print(result.decode(`utf-8`))
複製程式碼
這裡首先匯入lxml庫的etree模組,然後宣告瞭一段HTML文字,呼叫HTML類進行初始化,這樣就成功構造了一個XPath解析物件。這裡需要注意的是,HTML文字中的最後一個li
節點是沒有閉合的,但是etree模組可以自動修正HTML文字。
這裡我們呼叫tostring()
方法即可輸出修正後的HTML程式碼,但是結果是bytes
型別。這裡利用decode()
方法將其轉成str
型別,結果如下:
<html><body><div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</li></ul>
</div>
</body></html>
複製程式碼
可以看到,經過處理之後,li
節點標籤被補全,並且還自動新增了body
、html
節點。
另外,也可以直接讀取文字檔案進行解析,示例如下:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = etree.tostring(html)
print(result.decode(`utf-8`))
複製程式碼
其中test.html的內容就是上面例子中的HTML程式碼,內容如下:
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>
複製程式碼
這次的輸出結果略有不同,多了一個DOCTYPE
的宣告,不過對解析無任何影響,結果如下:
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body><div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</li></ul>
</div></body></html>
複製程式碼
5. 所有節點
我們一般會用//開頭的XPath規則來選取所有符合要求的節點。這裡以前面的HTML文字為例,如果要選取所有節點,可以這樣實現:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//*`)
print(result)
複製程式碼
執行結果如下:
[<Element html at 0x10510d9c8>, <Element body at 0x10510da08>, <Element div at 0x10510da48>, <Element ul at 0x10510da88>, <Element li at 0x10510dac8>, <Element a at 0x10510db48>, <Element li at 0x10510db88>, <Element a at 0x10510dbc8>, <Element li at 0x10510dc08>, <Element a at 0x10510db08>, <Element li at 0x10510dc48>, <Element a at 0x10510dc88>, <Element li at 0x10510dcc8>, <Element a at 0x10510dd08>]
複製程式碼
這裡使用*代表匹配所有節點,也就是整個HTML文字中的所有節點都會被獲取。可以看到,返回形式是一個列表,每個元素是Element
型別,其後跟了節點的名稱,如html
、body
、div
、ul
、li
、a
等,所有節點都包含在列表中了。
當然,此處匹配也可以指定節點名稱。如果想獲取所有li
節點,示例如下:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//li`)
print(result)
print(result[0])
複製程式碼
這裡要選取所有li
節點,可以使用//
,然後直接加上節點名稱即可,呼叫時直接使用xpath()
方法即可。
執行結果:
[<Element li at 0x105849208>, <Element li at 0x105849248>, <Element li at 0x105849288>, <Element li at 0x1058492c8>, <Element li at 0x105849308>]
<Element li at 0x105849208>
複製程式碼
這裡可以看到提取結果是一個列表形式,其中每個元素都是一個 Element
物件。如果要取出其中一個物件,可以直接用中括號加索引,如[0]
。
6. 子節點
我們通過/
或//
即可查詢元素的子節點或子孫節點。假如現在想選擇li
節點的所有直接a
子節點,可以這樣實現:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//li/a`)
print(result)
複製程式碼
這裡通過追加/a
即選擇了所有li
節點的所有直接a
子節點。因為//li
用於選中所有li
節點,/a
用於選中li
節點的所有直接子節點a
,二者組合在一起即獲取所有li
節點的所有直接a
子節點。
執行結果如下:
[<Element a at 0x106ee8688>, <Element a at 0x106ee86c8>, <Element a at 0x106ee8708>, <Element a at 0x106ee8748>, <Element a at 0x106ee8788>]
複製程式碼
此處的/
用於選取直接子節點,如果要獲取所有子孫節點,就可以使用//
。例如,要獲取ul
節點下的所有子孫a
節點,可以這樣實現:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//ul//a`)
print(result)
複製程式碼
執行結果是相同的。
但是如果這裡用//ul/a
,就無法獲取任何結果了。因為/
用於獲取直接子節點,而在ul
節點下沒有直接的a
子節點,只有li
節點,所以無法獲取任何匹配結果,程式碼如下:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//ul/a`)
print(result)
複製程式碼
執行結果如下:
[]
複製程式碼
因此,這裡我們要注意/
和//
的區別,其中/
用於獲取直接子節點,//
用於獲取子孫節點。
7. 父節點
我們知道通過連續的/
或//
可以查詢子節點或子孫節點,那麼假如我們知道了子節點,怎樣來查詢父節點呢?這可以用..
來實現。
比如,現在首先選中href
屬性為link4.html
的a
節點,然後再獲取其父節點,然後再獲取其class
屬性,相關程式碼如下:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//a[@href="link4.html"]/../@class`)
print(result)
複製程式碼
執行結果如下:
[`item-1`]
複製程式碼
檢查一下結果發現,這正是我們獲取的目標li
節點的class
。
同時,我們也可以通過parent::
來獲取父節點,程式碼如下:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//a[@href="link4.html"]/parent::*/@class`)
print(result)
複製程式碼
8. 屬性匹配
在選取的時候,我們還可以用@
符號進行屬性過濾。比如,這裡如果要選取class
為item-1
的li
節點,可以這樣實現:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//li[@class="item-0"]`)
print(result)
複製程式碼
這裡我們通過加入[@class="item-0"]
,限制了節點的class
屬性為item-0
,而HTML文字中符合條件的li
節點有兩個,所以結果應該返回兩個匹配到的元素。結果如下:
[<Element li at 0x10a399288>, <Element li at 0x10a3992c8>]
複製程式碼
可見,匹配結果正是兩個,至於是不是那正確的兩個,後面再驗證。
9. 文字獲取
我們用XPath中的text()
方法獲取節點中的文字,接下來嘗試獲取前面li
節點中的文字,相關程式碼如下:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//li[@class="item-0"]/text()`)
print(result)
複製程式碼
執行結果如下:
[`
`]
複製程式碼
奇怪的是,我們並沒有獲取到任何文字,只獲取到了一個換行符,這是為什麼呢?因為XPath中text()
前面是/
,而此處/
的含義是選取直接子節點,很明顯li
的直接子節點都是a
節點,文字都是在a
節點內部的,所以這裡匹配到的結果就是被修正的li
節點內部的換行符,因為自動修正的li
節點的尾標籤換行了。
即選中的是這兩個節點:
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</li>
複製程式碼
其中一個節點因為自動修正,li
節點的尾標籤新增的時候換行了,所以提取文字得到的唯一結果就是li
節點的尾標籤和a
節點的尾標籤之間的換行符。
因此,如果想獲取li
節點內部的文字,就有兩種方式,一種是先選取a
節點再獲取文字,另一種就是使用//
。接下來,我們來看下二者的區別。
首先,選取到a
節點再獲取文字,程式碼如下:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//li[@class="item-0"]/a/text()`)
print(result)
複製程式碼
執行結果如下:
[`first item`, `fifth item`]
複製程式碼
可以看到,這裡的返回值是兩個,內容都是屬性為item-0
的li
節點的文字,這也印證了前面屬性匹配的結果是正確的。
這裡我們是逐層選取的,先選取了li
節點,又利用/
選取了其直接子節點a
,然後再選取其文字,得到的結果恰好是符合我們預期的兩個結果。
再來看下用另一種方式(即使用//
)選取的結果,程式碼如下:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//li[@class="item-0"]//text()`)
print(result)
複製程式碼
執行結果如下:
[`first item`, `fifth item`, `
`]
複製程式碼
不出所料,這裡的返回結果是3個。可想而知,這裡是選取所有子孫節點的文字,其中前兩個就是li
的子節點a
節點內部的文字,另外一個就是最後一個li
節點內部的文字,即換行符。
所以說,如果要想獲取子孫節點內部的所有文字,可以直接用//
加text()
的方式,這樣可以保證獲取到最全面的文字資訊,但是可能會夾雜一些換行符等特殊字元。如果想獲取某些特定子孫節點下的所有文字,可以先選取到特定的子孫節點,然後再呼叫text()
方法獲取其內部文字,這樣可以保證獲取的結果是整潔的。
10. 屬性獲取
我們知道用text()
可以獲取節點內部文字,那麼節點屬性該怎樣獲取呢?其實還是用@
符號就可以。例如,我們想獲取所有li
節點下所有a
節點的href
屬性,程式碼如下:
from lxml import etree
html = etree.parse(`./test.html`, etree.HTMLParser())
result = html.xpath(`//li/a/@href`)
print(result)
複製程式碼
這裡我們通過@href
即可獲取節點的href
屬性。注意,此處和屬性匹配的方法不同,屬性匹配是中括號加屬性名和值來限定某個屬性,如[@href="link1.html"]
,而此處的@href
指的是獲取節點的某個屬性,二者需要做好區分。
執行結果如下:
[`link1.html`, `link2.html`, `link3.html`, `link4.html`, `link5.html`]
複製程式碼
可以看到,我們成功獲取了所有li
節點下a
節點的href
屬性,它們以列表形式返回。
11. 屬性多值匹配
有時候,某些節點的某個屬性可能有多個值,例如:
from lxml import etree
text = ```
<li class="li li-first"><a href="link.html">first item</a></li>
```
html = etree.HTML(text)
result = html.xpath(`//li[@class="li"]/a/text()`)
print(result)
複製程式碼
這裡HTML文字中li
節點的class
屬性有兩個值li
和li-first
,此時如果還想用之前的屬性匹配獲取,就無法匹配了,此時的執行結果如下:
[]
複製程式碼
這時就需要用contains()
函式了,程式碼可以改寫如下:
from lxml import etree
text = ```
<li class="li li-first"><a href="link.html">first item</a></li>
```
html = etree.HTML(text)
result = html.xpath(`//li[contains(@class, "li")]/a/text()`)
print(result)
複製程式碼
這樣通過contains()
方法,第一個引數傳入屬性名稱,第二個引數傳入屬性值,只要此屬性包含所傳入的屬性值,就可以完成匹配了。
此時執行結果如下:
[`first item`]
複製程式碼
此種方式在某個節點的某個屬性有多個值時經常用到,如某個節點的class
屬性通常有多個。
12. 多屬性匹配
另外,我們可能還遇到一種情況,那就是根據多個屬性確定一個節點,這時就需要同時匹配多個屬性。此時可以使用運算子and
來連線,示例如下:
from lxml import etree
text = ```
<li class="li li-first" name="item"><a href="link.html">first item</a></li>
```
html = etree.HTML(text)
result = html.xpath(`//li[contains(@class, "li") and @name="item"]/a/text()`)
print(result)
複製程式碼
這裡的li
節點又增加了一個屬性name
。要確定這個節點,需要同時根據class
和name
屬性來選擇,一個條件是class
屬性裡面包含li
字串,另一個條件是name
屬性為item
字串,二者需要同時滿足,需要用and
操作符相連,相連之後置於中括號內進行條件篩選。執行結果如下:
[`first item`]
複製程式碼
這裡的and
其實是XPath中的運算子。另外,還有很多運算子,如or
、mod
等,在此總結為表4-2。
表4-2 運算子及其介紹
此表參考來源:www.w3school.com.cn/xpath/xpath…。
13. 按序選擇
有時候,我們在選擇的時候某些屬性可能同時匹配了多個節點,但是隻想要其中的某個節點,如第二個節點或者最後一個節點,這時該怎麼辦呢?
這時可以利用中括號傳入索引的方法獲取特定次序的節點,示例如下:
from lxml import etree
text = ```
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>
```
html = etree.HTML(text)
result = html.xpath(`//li[1]/a/text()`)
print(result)
result = html.xpath(`//li[last()]/a/text()`)
print(result)
result = html.xpath(`//li[position()<3]/a/text()`)
print(result)
result = html.xpath(`//li[last()-2]/a/text()`)
print(result)
複製程式碼
第一次選擇時,我們選取了第一個li
節點,中括號中傳入數字1即可。注意,這裡和程式碼中不同,序號是以1開頭的,不是以0開頭。
第二次選擇時,我們選取了最後一個li
節點,中括號中傳入last()
即可,返回的便是最後一個li
節點。
第三次選擇時,我們選取了位置小於3的li
節點,也就是位置序號為1和2的節點,得到的結果就是前兩個li
節點。
第四次選擇時,我們選取了倒數第三個li
節點,中括號中傳入last()-2
即可。因為last()
是最後一個,所以last()-2
就是倒數第三個。
執行結果如下:
[`first item`]
[`fifth item`]
[`first item`, `second item`]
[`third item`]
複製程式碼
這裡我們使用了last()
、position()
等函式。在XPath中,提供了100多個函式,包括存取、數值、字串、邏輯、節點、序列等處理功能,它們的具體作用可以參考:www.w3school.com.cn/xpath/xpath…。
14. 節點軸選擇
XPath提供了很多節點軸選擇方法,包括獲取子元素、兄弟元素、父元素、祖先元素等,示例如下:
print(result)
複製程式碼
執行結果如下:
[<Element html at 0x107941808>, <Element body at 0x1079418c8>, <Element div at 0x107941908>, <Element ul at 0x107941948>]
[<Element div at 0x107941908>]
[`item-0`]
[<Element a at 0x1079418c8>]
[<Element span at 0x107941948>]
[<Element a at 0x1079418c8>]
[<Element li at 0x107941948>, <Element li at 0x107941988>, <Element li at 0x1079419c8>, <Element li at 0x107941a08>]
複製程式碼
第一次選擇時,我們呼叫了ancestor
軸,可以獲取所有祖先節點。其後需要跟兩個冒號,然後是節點的選擇器,這裡我們直接使用*,表示匹配所有節點,因此返回結果是第一個li
節點的所有祖先節點,包括html
、body
、div
和ul
。
第二次選擇時,我們又加了限定條件,這次在冒號後面加了div
,這樣得到的結果就只有div
這個祖先節點了。
第三次選擇時,我們呼叫了attribute
軸,可以獲取所有屬性值,其後跟的選擇器還是*,這代表獲取節點的所有屬性,返回值就是li
節點的所有屬性值。
第四次選擇時,我們呼叫了child
軸,可以獲取所有直接子節點。這裡我們又加了限定條件,選取href
屬性為link1.html
的a
節點。
第五次選擇時,我們呼叫了descendant
軸,可以獲取所有子孫節點。這裡我們又加了限定條件獲取span
節點,所以返回的結果只包含span
節點而不包含a
節點。
第六次選擇時,我們呼叫了following
軸,可以獲取當前節點之後的所有節點。這裡我們雖然使用的是*匹配,但又加了索引選擇,所以只獲取了第二個後續節點。
第七次選擇時,我們呼叫了following-sibling
軸,可以獲取當前節點之後的所有同級節點。這裡我們使用*匹配,所以獲取了所有後續同級節點。
以上是XPath軸的簡單用法,更多軸的用法可以參考:www.w3school.com.cn/xpath/xpath…。
15. 結語
到現在為止,我們基本上把可能用到的XPath選擇器介紹完了。XPath功能非常強大,內建函式非常多,熟練使用之後,可以大大提升HTML資訊的提取效率。
如果想查詢更多XPath的用法,可以檢視:www.w3school.com.cn/xpath/index…。
如果想查詢更多Python lxml庫的用法,可以檢視lxml.de/。
本資源首發於崔慶才的個人部落格靜覓: Python3網路爬蟲開發實戰教程 | 靜覓
如想了解更多爬蟲資訊,請關注我的個人微信公眾號:進擊的Coder
weixin.qq.com/r/5zsjOyvEZ… (二維碼自動識別)