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

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 的介面規範。

    所以說即使Google瀏覽器的解析引擎很牛逼,但因和 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 對象。

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

現以一個案例逐一理解每一個對象的作用。

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

3.1 查找目標 Tag

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

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

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

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

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

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

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

from bs4 import BeautifulSoup
import requests
# 伺服器地址
url = "//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="//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': '//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)
'''
輸出結果
//img1.doubanio.com/view/photo/s_ratio_poster/public/p2670448229.jpg
'''

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

完整的程式碼:

from bs4 import BeautifulSoup
import requests
# 伺服器地址
url = "//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="//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="//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 = "//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="//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 = "//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 = "//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 了。