Python 從底層結構聊 Beautiful Soup 4(內建豆瓣最新電影排行榜爬取案例)

一枚大果殼發表於2022-03-15

1. 前言

什麼是 Beautiful Soup 4 ?

Beautiful Soup 4(簡稱 BS4,後面的 4 表示最新版本)是一個 Python 第三方庫,具有解析 HTML 頁面的功能,爬蟲程式可以使用 BS4 分析頁面無素、精準查詢出所需要的頁面資料。有 BS4 的爬蟲程式爬行過程愜意且輕快。

BS4 特點是功能強大、使用簡單。相比較只使用正規表示式的費心費力,BS4 有著彈指一揮間的豪邁和瀟灑。

2. 安裝 Beautiful Soup 4

BS4 是 Python 第三庫,使用之前需要安裝。

pip install beautifulsoup4

2.1 BS4 的工作原理

要真正認識、掌握 BS4 ,則需要對其底層工作機制有所瞭解。

BS4 查詢頁面資料之前,需要載入 HTML 檔案HTML 片段,並在記憶體中構建一棵與 HTML 文件完全一一對映的樹形物件(類似於 W3C 的 DOM 解析。為了方便,後面簡稱 BS 樹),這個過程稱為解析。

BS4 自身並沒有提供解析的實現,而是提供了介面,用來對接第三方的解析器(這點是很牛逼的,BS4 具有很好的擴充套件性和開發性)。無論使用何種解析器,BS4 遮蔽了底層的差異性,對外提供了統一的操作方法(查詢、遍歷、修改、新增……)。

認識 BS4 先從構造 BeautifulSoup 物件開始。BeautifulSoup 是對整個文件樹的引用,或是進入文件樹的入口物件。

分析 BeautifulSoup 構造方法,可發現在構造 BeautifulSoup 物件時,可以傳遞很多引數。但一般只需要考慮前 2 個引數。其它引數採用預設值,BS4 就能工作很好(約定大於配置的典範)。

def __init__(self, markup="", features=None, builder=None,
      parse_only=None, from_encoding=None, exclude_encodings=None,element_classes=None, **kwargs):
  • markup: HTML 文件。可以是字串格式的 HTML 片段、也可以是一個檔案物件。
from bs4 import BeautifulSoup
# 使用 HTML 程式碼片段
html_code = "<h1>BeautifulSoup 4 簡介</h1>"
bs = BeautifulSoup(html_code, "lxml")
print(bs)

以下使用檔案物件做為引數。

from bs4 import BeautifulSoup
file = open("d:/hello.html", encoding="utf-8")
bs = BeautifulSoup(file, "lxml")
print(bs)

Tip: 使用檔案物件時,編碼方式請選擇 unicode 編碼(utf-8 是 unicode 的具體實現)。

  • features: 指定解析器程式。解析器是 BS4 的靈魂所在,否則 BS4 就是一個無本之源的空殼子。

    BS4 支援 Python 內建的 HTML 解析器 ,還支援第三方解析器:lxml、 html5lib……

    Tip: 任何人都可以定製一個自己的解析器,但請務必遵循 BS4 的介面規範。

    所以說即使谷歌瀏覽器的解析引擎很牛逼,但因和 BS4 介面不吻合,彼此之間也只能惺惺相惜一番。

如果要使用是第三方解析器,使用之前請提前安裝:

安裝 lxml :

pip install lxml

安裝 html5lib:

pip install html5lib

幾種解析器的縱橫比較:

解析器 使用方法 優勢 劣勢
Python標準庫 BeautifulSoup(markup, "html.parser") 執行速度適中
文件容錯能力強
Python 2.7.3 or 3.2.2 前的版本文件容錯能力差
lxml HTML 解析器 BeautifulSoup(markup, "lxml") 速度快
文件容錯能力強
需要 C 語言庫的支援
lxml XML 解析器 BeautifulSoup(markup, ["lxml-xml"]) BeautifulSoup(markup, "xml") 速度快
唯一支援 XML 的解析器
需要 C 語言庫的支援
html5lib BeautifulSoup(markup, "html5lib") 最好的容錯性
以瀏覽器的方式解析文件
生成HTML5格式的文件
速度慢
不依賴外部擴充套件

每一種解析器都有自己的優點,如 html5lib 的容錯性就非常好,但一般優先使用 lxml 解析器,更多時候速度更重要。

2.2 解析器的差異性

解析器的功能是載入 HTML(XML) 程式碼,在記憶體中構建一棵層次分明的物件樹(後面簡稱 BS 樹)。雖然 BS4 從應用層面統一了各種解析器的使用規範,但各有自己的底層實現邏輯。

當然,解析器在解析格式正確、完全符合 HTML 語法規範的文件時,除了速度上的差異性,大家表現的還是可圈可點的。想想,這也是它們應該提供的最基礎功能。

但是,當文件格式不標準時,不同的解析器在解析時會遵循自己的底層設計,會弱顯出差異性。

看來, BS4 也無法掌管人家底層邏輯的差異性。

2.2.1 lxml

使用 lxml 解析HTML程式碼段。

from bs4 import BeautifulSoup
html_code = "<a><p><p>"
bs = BeautifulSoup(html_code, "lxml")
print(bs)
'''
輸出結果
<html><body><a><p></p><p></p></a></body></html>
'''

lxml 在解析時,會自動新增上 html、body 標籤。並自動補全沒有結束語法結構的標籤。 如上 a 標籤是後面 2 個標籤的父標籤,第一個 p 標籤是第二 p 標籤的為兄弟關係。

使用 lxml 解析如下HTML 程式碼段。

from bs4 import BeautifulSoup
html_code = "<a></p>"
bs = BeautifulSoup(html_code, "lxml")
print(bs)
'''
輸出結果
<html><body><a></a></body></html>
'''

lxml 會認定只有結束語法沒有開始語法的標籤結構是非法的,拒絕解析(也是挺剛的)。即使是非法,丟棄是理所當然的。

2.2.2 html5lib

使用 html5lib 解析不完整的HTML程式碼段。

from bs4 import BeautifulSoup
html_code = "<a><p><p>"
bs = BeautifulSoup(html_code, "html5lib")
print(bs)
'''
輸出結果
<html><head></head><body><a><p></p><p></p></a></body></html>
'''

html5lib 在解析j時,會自動加上 html、head、body 標籤。 除此之外如上解析結果和 lxml 沒有太大區別,在沒有結束標籤語法上,大家還是英雄所見略同的。

使用 html5lib 解析下面的HTML 程式碼段。

from bs4 import BeautifulSoup
html_code = "<a></p>"
bs = BeautifulSoup(html_code, "html5lib")
print(bs)
'''
輸出結果:
<html><head></head><body><a><p></p></a></body></html>
'''

html5lib 對於沒有結束語法結構的標籤,會為其補上開始語法結構html5lib 遵循的是 HTML5 的部分標準。意思是既然都來了,也就不要走了,html5lib 都會盡可能補全。

2.2.3 pyhton 內建解析器

from bs4 import BeautifulSoup
html_code = "<a><p><p>"
bs = BeautifulSoup(html_code, "html.parser")
print(bs)
'''
輸出結果
<a><p><p></p></p></a>
'''

與前面 2 類解析器相比較,沒有新增 、、 任一標籤,會自動補全結束標籤結構。但最終結構與前 2 類解析器不同。a 標籤是後 2 個標籤的父親,第一個 p 標籤是第二個 p 標籤的父親,而不是兄弟關係。

歸納可知:對於 lxml、html5lib、html.parser 而言,對於沒有結束語法結構的標籤都認為是可以識別的。

from bs4 import BeautifulSoup
html_code = "<a></p>"
bs = BeautifulSoup(html_code, "html.parser")
print(bs)
'''
輸出結果
<a></a>
'''

對於沒有開始語法結構的標籤的處理和 lxml 解析器相似,會丟棄掉。

從上面的程式碼的執行結果可知,html5lib 的容錯能力是最強的,在對於文件要求不高的場景下,可考慮使用 html5lib。在對文件格式要求高的應用場景下,可選擇 lxml

3. BS4 樹物件

BS4 記憶體樹是對 HTML 文件或程式碼段的記憶體對映,記憶體樹由 4 種型別的 python 物件組成。分別是 BeautifulSoupTagNavigableStringComment

  • BeautifulSoup物件 是對整個 html 文件結構的對映,提供對整個 BS4 樹操作的全域性方法和屬性。也是入口物件。

    class BeautifulSoup(Tag):
    	pass
    
  • Tag物件(標籤物件) 是對 HTML 文件中標籤的對映,或稱其為節點(物件名與標籤名一樣)物件,提供對頁面標籤操作的方法和屬性。本質上 BeautifulSoup 物件也 Tag 物件。

    Tip: 解析頁面資料的關鍵,便是找到包含內容的標籤物件(Tag)BS4 提供了很多靈活、簡潔的方法。

    使用 BS4 就是以 BeautifulSoup 物件開始,逐步查詢目標標籤物件的過程。

  • NavigableString物件 是對 HTML 標籤中所包含的內容體的對映,提供有對文字資訊操作的方法和屬性。

    Tip: 對於開發者而言,分析頁面,最終就要要獲取資料,所以,掌握此物件的方法和屬性尤為重要。

    使用 標籤物件的 string 屬性就可以獲取。

  • Comment 是對文件註釋內容的對映物件。此物件用的不多。

再總結一下:使用 BS4 的的關鍵就是如何以一個 Tag 物件(節點物件)為參考,找到與其關聯的其它 Tag 物件。剛開始出場時就一個 BeautifulSoup 物件。

為了更好的以一個節點找到其它節點,需要理解節點與節點的關係:主要有父子關係、兄弟關係

現以一個案例逐一理解每一個物件的作用。

案例描述:爬取豆瓣電影排行榜上的最新電影資訊。https://movie.douban.com/chart),並以CSV 文件格式儲存電影資訊。

3.1 查詢目標 Tag

獲取所需資料的關鍵就是要找到目標 TagBS4 提供有豐富多變的方法能幫助開發者快速、靈活找到所需 Tag 物件。通過下面的案例,讓我們感受到它的富裕變化多端的魔力。

先獲取豆瓣電影排行榜的入口頁面路徑 https://movie.douban.com/chart

使用谷歌瀏覽器瀏覽頁面,使用瀏覽器提供的開發者工具分析一下頁面中電影資訊的 HTML 程式碼片段。 由簡入深,從下載第一部電影的資訊開始。

Tip: 這個排行榜隨時變化,大家所看到的第一部電影和下圖可能不一樣。

居然使用的是表格佈局。表格佈局非常有規則,這對於分析結構非常有利。

先下載第一部電影的圖片和電影名。圖片當然使用的是 img 標籤,使用 BS4 解析後, BS4 樹上會有一個對應的 img Tag 物件。

樹上的 img Tag 物件有很多,怎麼找到第一部電影的圖片標籤?

from bs4 import BeautifulSoup
import requests
# 伺服器地址
url = "https://movie.douban.com/chart"
# 偽裝成瀏覽器
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'}
# 傳送請求
resp = requests.get(url, headers=headers)
html_code = resp.text
# 得到 BeautifulSoup 物件。萬里長征的第一步。
bs = BeautifulSoup(html_code, "lxml")
# 要獲得 BS4 樹上的 Tag 物件,最簡單的方法就是直接使用標籤名。簡單的不要不要的。
img_tag = bs.img
# 返回的是 BS4 樹上的第一個 img Tag 物件
print(type(img_tag))
print(img_tag)
'''
輸出結果
<class 'bs4.element.Tag'>
<img alt="青春變形記" class="" src="https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2670448229.jpg" width="75"/>

'''

這裡有一個運氣成分,bs.img 返回的恰好是第一部電影的圖片標籤(也意味著第一部電影的圖片標籤是整個頁面的第一個圖片標籤)。

找到了 img 標籤物件,再分析出其圖片路徑就容易多了,圖片路徑儲存在 img 標籤的 src 屬性中,現在只需要獲取到 img 標籤物件的 src 屬性值就可以了。

Tag 物件提供有 attrs 屬性,可以很容易得到一個 Tag 物件的任一屬性值。

使用語法:

Tag["屬性名"]或者使用 Tag.attrs 獲取到 Tag 物件的所有屬性。

下面使用 atts 獲取標籤物件的所有屬性資訊,返回的是一個 python 字典物件。

# 省略上面程式碼段
img_tag_attrs = img_tag.attrs
print(img_tag_attrs)
'''
輸出結果:以字典格式返回 img Tag 物件的所有屬性
{'src': 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2670448229.jpg', 'width': '75', 'alt': '青春變形記', 'class': []}
'''

單值屬性返回的是單值,因 class 屬性(多值屬性)可以設定多個類樣式,返回的是一個陣列。現在只想得到圖片的路徑,可以使用如下方式。

img_tag_attrs = img_tag.attrs
# 第一種方案
img_tag_src=img_tag_attrs["src"]  
# 第二種方案
img_tag_src = img_tag["src"]
print(img_tag_src)
'''
輸出結果
https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2670448229.jpg
'''

上述程式碼中提供 2 種方案,其本質是一樣的。有了圖片路徑,剩下的事情就好辦了。

完整的程式碼:

from bs4 import BeautifulSoup
import requests
# 伺服器地址
url = "https://movie.douban.com/chart"
# 偽裝成瀏覽器
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'}
# 傳送請求
resp = requests.get(url, headers=headers)
html_code = resp.text
bs = BeautifulSoup(html_code, "lxml")
img_tag = bs.img
# img_tag_attrs = img_tag.attrs
# img_tag_src=img_tag_attrs["src"]
img_tag_src = img_tag["src"]
# 根據圖片路徑下載圖片並儲存到本地
img_resp = requests.get(img_tag_src, headers=headers)
with open("D:/movie/movie01.jpg", "wb") as f:
    f.write(img_resp.content)

3.2 過濾方法

得到圖片後,怎麼得到電影的名字,以及其簡介。如下為電影名的程式碼片段。

<a href="https://movie.douban.com/subject/35284253/" class="">青春變形記/ <span style="font-size:13px;">熊抱青春記(港) / 青春養成記(臺)</span></a>

電影名包含在一個 a 標籤中。如上所述,當使用 bs.標籤名 時,返回的是整個頁面程式碼段中的第一個同名標籤物件。

顯然,第一部電影名所在的 a 標籤不可能是頁面中的第一個(否則就是運氣爆棚了),無法直接使用 bs.a 獲取電影名所在 a 標籤,且此 a 標籤也無特別明顯的可以區分和其它 a 標籤不一樣的特徵。

這裡就要想點其它辦法。以此 a 標籤向上找到其父標籤 div。

<div class="pl2">
	<a href="https://movie.douban.com/subject/35284253/" class="">青春變形記/ <span style="font-size:13px;">熊抱青春記(港) / 青春養成記(臺)</span>
	</a>
	<p class="pl">2022-03-11(美國網路) / 姜晉安 / 吳珊卓 / 艾娃·摩士 / 麥特里伊·拉瑪克里斯南 / 樸惠仁 / 奧賴恩·李 / 何煒晴 / 特里斯坦·艾瑞克·陳 / 吳漢章 / 菲尼亞斯·奧康奈爾 / 喬丹·費舍 / 託菲爾-恩戈 / 格雷森·維拉紐瓦 / 喬什·列維 / 洛瑞·坦·齊恩...</p>
	<div class="star clearfix">
	<span class="allstar40"></span>
	<span class="rating_nums">8.2</span>
	<span class="pl">(45853人評價)</span>
	</div>
</div>

同理,div 標籤在整個頁面程式碼中也有很多,又如何獲到到電影名所在的 div 標籤,分析發現此 div 有一個與其它 div 不同的屬性特徵。class="pl2"。 可以通過這個屬性特徵對 div 標籤進行過濾。

什麼是過濾方法?

過濾方法是 BS4 Tag 標籤物件的方法,用來對其子節點進行篩選。

BS4 提供有 find( )、find_all( ) 等過濾方法。此類方法的作用如其名可以在一個群體(所有子節點)中根據個體的特徵進行篩選。

Tip: 如果使用 BeautifulSoup物件 呼叫這類方法,則是對整個 BS4 樹上的節點進行篩選。

​ 如果以某一個具體的 Tag 標籤物件呼叫此類方法以,則是對 Tag 標籤下的子節點進行篩選。

find()和 find_all( ) 方法的引數是一樣的。兩者的區別:前者搜尋到第一個滿足條件就返回,後者會搜尋所有滿足條件的物件。

find_all( name , attrs , recursive , string , **kwargs )
find( name , attrs , recursive , string , **kwargs )

引數說明

  • name: 可以是標籤名、正規表示式、列表、布林值或一個自定義方法。變化多端。
# 標籤名:查詢頁面中的第一個 div 標籤物件
div_tag = bs.find("div")
# 正規表示式:搜尋所有以 d 開始的標籤
div_tag = bs.find_all(re.compile("^d"))
# 列表:查詢 div 或  a 標籤
div_tag = bs.find_all(["div","a"])
# 布林值:查詢所有子節點
bs.find_all(True)
#自定義方法:搜尋有 class 屬性而沒有 id 屬性的標籤物件。
def has_class_but_no_id(tag):
    return tag.has_attr('class') and not tag.has_attr('id')
bs.find_all(has_class_but_no_id)

  • attrs: 可以接收一個字典型別。以鍵、值對的方式描述要搜尋的標籤物件的屬性特徵。
# 在整個樹結果中查詢 class 屬性值是 pl2 的標籤物件
div_tag = bs.find(attrs={"class": "pl2"})

Tip: 使用此屬性時,可以結合 name 引數把範圍收窄。

div_tag = bs.find("div",attrs={"class": "pl2"})

查詢 class 屬性值是 pl2 的 div 標籤物件。

  • string 引數: 此引數可以是 字串、正規表示式、列表 布林值。通過標籤內容匹配查詢。
# 搜尋標籤內容是'青春' 2 字開頭的 span 標籤物件
div_tag = bs.find_all("span", string=re.compile(r"青春.*"))
  • limit 引數: 可以使用 limit 引數限制返回結果的數量。

  • recursive 引數: 是否遞迴查詢節點下面的子節點,預設 是 True ,設定 False 時,只查詢直接子節點。

簡單介紹過濾方法後,重新回到問題上來,查詢第一部電影的電影名、簡介。靈活使用過濾方法,則能很輕鬆搜尋到所需要的標籤物件。

from bs4 import BeautifulSoup
import requests
# 伺服器地址
url = "https://movie.douban.com/chart"
# 偽裝成瀏覽器
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'}
# 傳送請求
resp = requests.get(url, headers=headers)
html_code = resp.text
# 使得解析器構建 BeautifulSoup 物件
bs = BeautifulSoup(html_code, "lxml")
# 使用過濾方法在整個樹結構中查詢 class 屬性值為 pl2 的 div 物件。其實有多個,這裡查詢第一個
div_tag = bs.find("div", class_="pl2")
# 查詢 div 標籤物件下的第一個 a 標籤
div_a = div_tag.find("a")
# 得到  a 標籤下所有子節點
name = div_a.contents
# 得到 文字
print(name[0].replace("/", '').strip())
'''
輸出結果:
青春變形記
'''

程式碼分析:

  1. 使用 bs.find("div", class_="pl2") 方法搜尋到包含第一部電影的 div 標籤。
  2. 電影名包含在 div 標籤的子標籤 a 中,繼續使用 div_tag.find("a") 找到 a 標籤。
<a href="https://movie.douban.com/subject/35284253/" class="">青春變形記/ <span style="font-size:13px;">熊抱青春記(港) / 青春養成記(臺)</span>
	</a>
  1. a 標籤中的內容就是電影名。BS4 為標籤物件提供有 string 屬性,可以獲取其內容,返回 NavigableString 物件。但是如果標籤中既有文字又有子標籤時, 則不能使用 string 屬性。如上 a 標籤的 string 返回為 None。
  2. BS4 樹結構中文字也是節點,可以以子節點的方式獲取。標籤物件有 contentschildren 屬性獲取子節點。前者返回一個列表,後者返回一個迭代器。另有 descendants 可以獲取其直接子節點和孫子節點。
  3. 使用 contents 屬性,從返回的列表中獲取第一個子節點,即文字節點。文字節點沒有 string 屬性。

獲取電影簡介相對而言就簡單的多,其內容包含在 div 標籤的 p 子標籤中。

# 獲取電影的簡介
div_p = div_tag.find("p")
movie_desc = div_p.string.strip()
print(movie_desc)

下面可以把電影名和電影簡介以 CSV 的方式儲存在檔案中。完整程式碼:

from bs4 import BeautifulSoup
import requests
import csv

# 伺服器地址
url = "https://movie.douban.com/chart"
# 偽裝成瀏覽器
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'}
# 傳送請求
resp = requests.get(url, headers=headers)
html_code = resp.text
bs = BeautifulSoup(html_code, "lxml")
div_tag = bs.find("div", class_="pl2")
div_a = div_tag.find("a")
div_a_name = div_a.contents
# 電影名
movie_name = div_a_name[0].replace("/", '').strip()
# 獲取電影的簡介
div_p = div_tag.find("p")
movie_desc = div_p.string.strip()

with open("d:/movie/movies.csv", "w", newline='') as f:
    csv_writer = csv.writer(f)
    csv_writer.writerow(["電影名", "電影簡介"])
    csv_writer.writerow([movie_name, movie_desc])

是時候小結了,使用 BS4 的基本流程:

  1. 通過指定解析器獲取到 BS4 物件。
  2. 指定一個標籤名獲取到標籤物件。如果無法直接獲取所需要的標籤物件,則使用過濾器方法進行一層一層向下過濾。
  3. 找到目標標籤物件後,可以使用 string 屬性獲取其中的文字,或使用 atrts 獲取屬性值。
  4. 使用獲取到的資料。

3.3 遍歷所有的目標

如上僅僅是找到了第一部電影的資訊。如果需要查詢到所有電影資訊,則只需要在上面程式碼的基礎之上新增迭代便可。

from bs4 import BeautifulSoup
import requests
import csv

all_movies = []
# 伺服器地址
url = "https://movie.douban.com/chart"
# 偽裝成瀏覽器
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'}
# 傳送請求
resp = requests.get(url, headers=headers)
html_code = resp.text
bs = BeautifulSoup(html_code, "lxml")
# 查詢到所有 <div class="pl2"></div>
div_tag = bs.find_all("div", class_="pl2")
for div in div_tag:
    div_a = div.find("a")
    div_a_name = div_a.contents
    # 電影名
    movie_name = div_a_name[0].replace("/", '').strip()
    # 獲取電影的簡介
    div_p = div.find("p")
    movie_desc = div_p.string.strip()
    all_movies.append([movie_name, movie_desc])

with open("d:/movie/movies.csv", "w", newline='') as f:
    csv_writer = csv.writer(f)
    csv_writer.writerow(["電影名", "電影簡介"])
    for movie in all_movies:
        csv_writer.writerow(movie)

本文主要講解 BS4 的使用,僅爬取了電影排行榜的第一頁資料。至於資料到手後,如何使用,則根據應用場景來決定。

4. 總結

BS4 還提供有很多方法,能根據當前節點找到父親節點、子節點、兄弟節點……但其原理都是一樣的。只要找到了內容所在的標籤(節點)物件,一切也就OK 了。

相關文章