# BeautifulSoup4 套件 [![hackmd-github-sync-badge](https://hackmd.io/RoQ206rOQJOmviuHHou-og/badge)](https://hackmd.io/RoQ206rOQJOmviuHHou-og) ![image](https://hackmd.io/_uploads/rJxARitDa.png) BeautifulSoup 4 (簡稱 BS4) 是一個專門用於解析 HTML 及 XML 文件的 Python 套件。它本身不負責下載網頁(通常搭配 `requests` 套件),而是專注於解析下載回來的原始碼。 BS4 的核心理念是**提供一個統一且友善的介面**,讓你能夠輕鬆地從複雜的 HTML 結構中提取所需資料。它**依賴底層的解析器 (Parser)** 來將 HTML 文本轉換為一個可操作的物件樹,並能自動修復格式不佳的 HTML,大幅提升爬蟲的穩定性。 BS4 支援多種解析器,你可以根據需求靈活選擇: - **`html.parser`**:Python 內建,無需額外安裝,速度適中,容錯性不錯。 - **`lxml`**:第三方套件,基於 C 語言,**速度極快**,容錯能力強,是**最推薦**的選擇。 - **`html5lib`**:第三方套件,最符合瀏覽器標準,**容錯能力最強**,能處理最棘手的 HTML,但速度較慢。 > BS4 扮演類似 PHP 中 PDO 的角色,使用統一的介面存取不同的資料庫。 :::info **相關工具** `PyQuery` 是另一個優秀的網頁解析套件,它模仿了前端 jQuery 的語法,特別適合熟悉 CSS 選擇器和鏈式操作的開發者,但它固定使用 `lxml` 解析器。 ::: ## 一、環境安裝 在開始之前,建議為您的專案建立一個獨立的虛擬環境。 ```bash # 1. 建立虛擬環境 py -m venv env # Windows python3 -m venv env # macOS / Linux # 2. 啟動虛擬環境 env\Scripts\activate # Windows source env/bin/activate # macOS / Linux # 3. 安裝核心套件 (BS4 + 推薦的解析器 + 網頁請求工具) (env) pip install beautifulsoup4 lxml requests # (可選) 安裝其他解析器 (env) pip install html5lib # 4. 檢查安裝結果 (env) pip list # 5. 匯出環境依賴 (env) pip freeze > requirements.txt # 6. 離開虛擬環境 (env) deactivate ``` :::info `soupsieve` 是 BS4 的一個依賴套件,它為 BS4 提供了強大的 CSS 選擇器功能。安裝 `beautifulsoup4` 時會自動安裝。 ::: ## 二、基本使用:建立 BeautifulSoup 物件 要使用 BS4,首先需要將 HTML 原始碼和指定的解析器傳入 `BeautifulSoup` 類別,建立一個「湯」(soup) 物件。 ![image](https://hackmd.io/_uploads/H1O0SX37Jl.png) 以下範例展示了如何從不同來源(網路、本地檔案、字串)載入 HTML,並使用不同解析器。 ```python import requests from bs4 import BeautifulSoup # --- 來源 1: 從網路請求 --- # 使用 lxml 解析器 (推薦:速度快、容錯性強) try: response = requests.get("https://example.com/") response.raise_for_status() # 確保請求成功 soup_lxml = BeautifulSoup(response.text, "lxml") print("--- 使用 lxml 解析成功 ---") # print(soup_lxml.prettify()) # prettify() 可美化輸出,方便除錯 except requests.RequestException as e: print(f"網路請求失敗: {e}") # --- 來源 2: 從本地 HTML 檔案 --- # 假設我們有一個名為 index.html 的檔案 html_content_file = "<h1>這是一個本地檔案</h1>" with open("index.html", "w", encoding="utf-8") as f: f.write(html_content_file) with open("index.html", "r", encoding="utf-8") as file: # 使用 Python 內建的 html.parser soup_parser = BeautifulSoup(file, "html.parser") print("\n--- 使用 html.parser 解析成功 ---") if soup_parser.h1: print(soup_parser.h1.text) # --- 來源 3: 從 HTML 字串 --- # 這段 HTML 標籤未閉合,結構不完整 html_string_broken = """ <html><head><title>美味的湯</title></head> <body> <p>這是一個不完整的段落 </body> """ # 使用 html5lib 解析器 (容錯能力最強,會自動補全標籤) soup_html5lib = BeautifulSoup(html_string_broken, "html5lib") print("\n--- 使用 html5lib 修復並解析 ---") print(soup_html5lib.prettify()) ``` :::success **選擇建議**:在絕大多數情況下,`lxml` 都是最佳選擇,它在性能和容錯性之間取得了完美的平衡。 ::: ## 三、BS4 的四大物件類型 BS4 會將複雜的 HTML 文件解析成一個由四種物件組成的樹狀結構,理解它們是操作 BS4 的基礎。 | 物件類型 | 說明 | 範例 | | :--- | :--- | :--- | | **`BeautifulSoup`** | 代表整個 HTML 文件,是樹狀結構的根。 | `soup = BeautifulSoup(html, 'lxml')` | | **`Tag`** | HTML 中的一個標籤,如 `<div>`, `<a>`。**這是你最常操作的物件**。 | `soup.div` 或 `soup.find('div')` | | **`NavigableString`** | 標籤內的文字內容。 | `soup.title.string` | | **`Comment`** | HTML 中的註解 `<!-- ... -->`,是 `NavigableString` 的一種特殊子類。 | 少用,可透過特定方法找到。 | ```python= from bs4 import BeautifulSoup, Comment # 解析 HTML 文件 html = """ <html> <head> <title>範例網頁</title> </head> <body> <div class="content"> <p>這是一段內容。</p> <p><!-- 這是一個註解 --></p> </div> </body> </html> """ soup = BeautifulSoup(html, "lxml") # 1. BeautifulSoup 物件:代表整個 HTML 文件 print(type(soup)) # <class 'bs4.BeautifulSoup'> print(soup.title) # <title>範例網頁</title> # 2. Tag 物件:存取 HTML 標籤 div_tag = soup.div if div_tag: print(type(div_tag)) # <class 'bs4.element.Tag'> print(div_tag["class"]) # ['content'] # 取得 class 屬性 print(div_tag.get("class")) # 使用 .get() 更安全,即使 'class' 屬性不存在也不會報錯 # 3. NavigableString 物件:取得標籤內的文字 if soup.title: title_string = soup.title.string print(type(title_string)) # <class 'bs4.element.NavigableString'> print(title_string) # 範例網頁 title_text = soup.title.text print(type(title_text)) # 'string' print(title_text) # 範例網頁 # 4. Comment 物件:存取 HTML 註解 comment = soup.find(string=lambda text: isinstance(text, Comment)) print(type(comment)) # <class 'bs4.element.Comment'> print(comment) # 這是一個註解 ``` ### Tag 物件的重要屬性與方法 每個 HTML 標籤會被轉換為 Tag 物件,我們可以透過 "物件.屬性" 的方式來存取標籤的名稱、屬性、內容等資訊,常見的屬性如下表 | 屬性/方法 | 描述 | 範例與說明 | | :--- | :--- | :--- | | **`.name`** | 獲取標籤名稱(字串)。 | `soup.h1.name` → `'h1'` | | **`.attrs`** | 獲取標籤所有屬性(字典)。 | `soup.div.attrs` → `{'class': ['content'], 'id': 'main'}` | | `tag['attr']` | 獲取指定屬性的值。 | `soup.div['id']` → `'main'` | | **`.get('attr')`** | **更安全**的獲取屬性值,若屬性不存在返回 `None`。 | `soup.div.get('id')` → `'main'`, `soup.div.get('style')` → `None` | | **`.text`** | 獲取標籤**及其所有子孫標籤**的文字內容,並拼接成單一字串。 | `soup.body.text` → `'這是一段內容。'` (已去除註解) | | **`.string`** | 僅當標籤**只有一個直接的 `NavigableString` 子節點**時,返回該文字,否則返回 `None`。 | `soup.p.string` → `'這是一段內容。'`, `soup.div.string` → `None` (因為 div 內還有 p 標籤) | | **`.get_text()`** | 功能類似 `.text`,但提供更多選項,如 `strip=True` 去除空白,`separator=' '` 指定分隔符。 | `tag.get_text(strip=True)` 是最常用的純文字提取方式。 | | `.contents` | 以**列表**形式返回標籤的所有**直接**子節點(包含 `Tag` 和 `NavigableString`)。 | | | `.children` | 功能同 `.contents`,但返回的是一個**產生器 (iterator)**,更省記憶體。 | `for child in tag.children:` | | `.parent` | 返回父標籤。 | | **範例1:** 取得標籤文字的屬性與方法 ```python= from bs4 import BeautifulSoup html = """ <div> <p> Hello, <b>world </b>!</p> </div> """ soup = BeautifulSoup(html, 'lxml') # print(str(soup)) # 顯示未排版的網頁原始碼 # soup.p 是 soup.find('p') 的語法糖,但在正式腳本中建議使用 find() 語意更清楚 p_tag = soup.p print(p_tag.string) # None,因為 <p> 標籤內有 <b> 標籤 print(p_tag.text) # Hello, world ! print(p_tag.get_text(strip=True)) # Hello, world! # get_text() 與 text 屬性都會提取子標籤的文字,但 get_text() 提供了更多的選項來控制輸出,例如分隔符和去除多餘的空白。 ``` :::success **靜態型別檢查提示** 如果在 VSCode 中這三行出現了紅色的波浪符,代表是 VSCode 中的靜態型別檢查 (Static Type Checking) 通常是 Pylance 發出的告警。 ![image](https://hackmd.io/_uploads/ryeA1tA6lg.png) Pylance 在分析 p_tag = soup.p 這一行時,得出的結論是:p_tag 這個變數的型別可能是 Tag,也可能是 None。如果它真的是 None,你的程式在執行到這裡時會立刻崩潰,並拋出 AttributeError: 'NoneType' object has no attribute 'string' 的錯誤。Pylance 必須提醒你這個潛在的風險! 此時你有二種做法 1. 在改行程式碼後加上 `# type: ignore`,代表身為程式設計師的你保證 p_tag 絕對不會是 None。 2. 使用條件判斷 (最佳實踐) ```python= if p_tag: # 只要程式能進入這個 if 區塊,Pylance 就知道 p_tag 絕對不是 None # 因此,這裡面的所有程式碼都不會再有警告 print(p_tag.string) print(p_tag.text) print(p_tag.get_text(strip=True)) else: print("在 HTML 中沒有找到 <p> 標籤。") ``` ::: **範例2:** 使用了更多的標籤物件屬性,請耐心的逐一慢慢理解 ```python= from bs4 import BeautifulSoup from bs4.element import NavigableString, Tag # 使用 html 字串作為 HTML 來源 html = """ <html> <head> <title>The Dormouse's story</title> </head> <body> <div id="content" class="main"> <h1 class="first">Hello, Beautiful Soup</h1> <div> <p class="brief">paragraph 1</p> <p class="brief">paragraph 2<a href="https://www.google.com.tw">Google</a></p> </div> </div> </body> </html> """ soup = BeautifulSoup(html, "lxml") # 先用 find() 明確地找到我們要操作的目標 first_div = soup.find("div") # 使用 isinstance() 進行精確的型別檢查。 # 這一步會向靜態分析器(Pylance)和Python直譯器斷言 first_div 是一個 Tag 物件, # 從而安全地存取其屬性,並完全消除所有相關的警告。 if isinstance(first_div, Tag): # --- 進入此區塊後,Pylance 100% 確定 first_div 是 Tag 物件 --- print("--- 1. 獲取標籤名稱與屬性 ---") print(f"標籤名稱 (.name): {first_div.name}") print(f"所有屬性 (.attrs): {first_div.attrs}") print(f"獲取 id 屬性 (['id']): {first_div['id']}") print(f"獲取 class 屬性 (.get()): {first_div.get('class')}") print(f"獲取不存在的 style 屬性 (.get()): {first_div.get('style', 'N/A')}") print("\n--- 2. 獲取標籤內容 ---") h1_tag = first_div.find("h1") # 對於 find() 的每一次結果,都進行檢查是一種好習慣 if isinstance(h1_tag, Tag): # .string vs .text 的經典對比 print(f"h1 的 .string: {h1_tag.string}") print(f"h1 的 .text: {h1_tag.text}") # 找到包含子標籤的 p,用它來展示 .string 和 .text 的區別 p_with_a = first_div.find("p", class_="brief", string=lambda s: "Google" in s) # type: ignore if isinstance(p_with_a, Tag): print(f"複雜 p 的 .string (因包含<a>標籤而為 None): {p_with_a.string}") print(f"複雜 p 的 .text (會合併所有文字): {p_with_a.text}") print("\n--- 3. 遍歷 HTML 樹 ---") # .contents:清晰地展示它是一個包含不同類型元素的列表 print("div 的直接子節點 (.contents):") for item in first_div.contents: if isinstance(item, NavigableString) and item.strip() == "": print(" - 類型: NavigableString (空白換行)") elif isinstance(item, Tag): print(f" - 類型: Tag, 名稱: <{item.name}>") # .children:演示它作為一個產生器的用法 print("\n使用 .children 產生器遍歷:") for child in first_div.children: if isinstance(child, Tag): print(f" - 找到子標籤: <{child.name}>") # .parent:檢查父節點是否也是一個 Tag if isinstance(first_div.parent, Tag): print(f"\ndiv 的父節點 (.parent) 名稱: <{first_div.parent.name}>") else: # 如果連第一個 div 都沒找到,給出提示 print("在 HTML 文件中未找到任何 <div> 標籤。") ``` ## 四、核心定位方法:查找元素 下圖請以 div 為中心來看 ![image](https://hackmd.io/_uploads/SkMEOq5P6.png) ### 1. 尋找單一元素:找到返回 Tag,找不到則返回 None 1. find(tag, attributes, recursive, string, kwargs):找到第一個滿足條件的標籤,返回 Tag 或 None。 * string 參數支援正規表達式,可以使用正規表達式來匹配文字。 * limit 參數可以限制返回的結果數量,提高效能。 2. select_one(selector):使用 CSS 選擇器找到第一個匹配的元素,之後可再使用以下的 [CSS3 偽類選擇器](https://www.webdesigns.com.tw/CSS3-nth-child.asp)。 * :first-child:可選擇第一個子元素 * :last-child:可選擇最後一個子元素 * :nth-child(n):可選擇第 n 個子元素 * n:從 1 開始 * odd:選取奇數 * even:選取偶數 3. find_next(tag, attributes, string, **kwargs): 找到符合條件的第一個後續節點。 4. find_previous(tag, attributes, string, **kwargs): 找到符合條件的第一個前一節點。 5. find_next_sibling():找到下一個滿足條件的兄弟元素。 6. find_previous_sibling():找到上一個滿足條件的兄弟元素。 7. find_parent():找到父元素 8. get() 方法:從 Tag 物件中取得屬性值 get() 方法用於從 Tag 物件中取得屬性值,當屬性不存在時不會報錯,而是返回 None,這樣的特性可以避免程式發生錯誤。 9. get_text(separator, strip):返回標籤含子節點內的所有文字內容,可指定分隔符號與去除空白。此方法若不加上參數跟直接取用 tag.text 是一樣的。 * separator(分隔符號): 用於區隔相鄰文字的分隔符號,預設為換行符號。 * strip(去除空白): 一個布林值,用於指定是否去除文字內容的前後空白。預設為 False。 > 上述 3-7 的方法適合在 HTML 結構固定但缺乏 class/id 標識時,從一個已知點出發去尋找周邊元素。 ![image](https://hackmd.io/_uploads/BylSO5cw6.png) **範例:** ```python= from bs4 import BeautifulSoup # HTML 字串 html = """ <html> <head> <title>BeautifulSoup範例</title> </head> <body> <div id="content"> <h1 class="title">你好,BeautifulSoup!</h1> <div class="container"> <p class="inner-paragraph">內部段落 1</p> <p class="inner-paragraph">內部段落 2</p> <p class="inner-paragraph">內部段落 3</p> </div> <h1 class="title">歡迎!</h1> <p class="paragraph">這是一個範例段落。</p> <p class="paragraph">另一個段落在這裡。</p> </div> </body> </html> """ # 使用BeautifulSoup解析HTML soup = BeautifulSoup(html, 'html.parser') # 1. find(tag, attributes, recursive, string, kwargs) first_paragraph = soup.find('p', class_='paragraph') print("1. 尋找 P 標籤 class='paragraph': ", first_paragraph, sep='\n') # 回應:<p class="paragraph">這是一個範例段落。</p> first_paragraph = soup.find('p', string=lambda s: s and '另一個段落' in s) # type: ignore print("1.1 尋找 P 標籤內文字內容為:另一個段落: ", first_paragraph, sep='\n') # 回應:<p class="paragraph">另一個段落在這裡。</p> # 2. select_one(selector) title_element = soup.select_one('.title') print("2. 使用 CSS 選擇器尋找標籤 class='title': ", title_element, sep='\n') # 回應:<h1 class="title">你好,BeautifulSoup!</h1> # 2-1. select_one 搭配 :first-child 偽類選擇器可選擇第一個子元素 first_child_paragraph = soup.select_one('div.container :first-child') print("2-1. div.container 第一個子元素: ", first_child_paragraph, sep='\n') # 回應:<p class="inner-paragraph">內部段落 1</p> # 2-2. select_one 搭配 :last-child 偽類選擇器可選擇最後一個子元素 last_child_paragraph = soup.select_one('div.container :last-child') print("2-2. div.container 最後一個子元素: ", last_child_paragraph, sep='\n') # 回應:<p class="inner-paragraph">內部段落 3</p> # 2-3. select_one 搭配 :nth-child 偽類選擇器可選擇第 N 個子元素 second_child_paragraph = soup.select_one('div.container :nth-child(3)') print("2-3. div.container 的第3個子元素: ", second_child_paragraph, sep='\n') # 回應:<p class="inner-paragraph">內部段落 3</p> # 2-4. select 搭配 :nth-child(odd) 偽類選擇器選擇所有奇數位置的子元素 odd_paragraphs = soup.select('div.container :nth-child(odd)') print("2-4. div.container 下所有奇數位置的子元素: ", odd_paragraphs, sep='\n') # 回應:[<p class="inner-paragraph">內部段落 1</p>, <p class="inner-paragraph">內部段落 3</p>] for tag in odd_paragraphs: print(tag) # 回應: # <p class="inner-paragraph">內部段落 1</p> # <p class="inner-paragraph">內部段落 3</p> # 3. find_next(tag, attributes, string, **kwargs) next_node = soup.find('div', class_='container').find_next() # type: ignore print("3. div.container 的下一個節點: ", next_node, sep='\n') # 回應:<p class="inner-paragraph">內部段落 1</p> # 4. find_previous(tag, attributes, string, **kwargs) previous_node = soup.find('div', class_='container').find_previous() # type: ignore print("4. div.container 的上一個節點: ", previous_node, sep='\n') # 回應:<h1 class="title">你好,BeautifulSoup!</h1> # 5. find_next_sibling() second_paragraph = soup.find('div', class_='container').find_next_sibling() # type: ignore print("5. div.container 的下一個兄弟元素: ", second_paragraph, sep='\n') # 回應:<h1 class="title">歡迎!</h1> # 6. find_previous_sibling() previous_paragraph = soup.find('div', class_='container').find_previous_sibling() # type: ignore print("6. div.container 的上一個兄弟元素: ", previous_paragraph, sep='\n') # 回應:<h1 class="title">你好,BeautifulSoup!</h1> # 7. find_parent(tag, attributes, string, **kwargs) parent_of_second_paragraph = soup.find('p', class_='inner-paragraph').find_parent() # type: ignore print("7. 內部段落的父節點: ", parent_of_second_paragraph, sep='\n') # 回應: # <div class="container"> # <p class="inner-paragraph">內部段落 1</p> # <p class="inner-paragraph">內部段落 2</p> # <p class="inner-paragraph">內部段落 3</p> # </div> # 8. get(key, default=None) tags_attribute = parent_of_second_paragraph.get('class') # type: ignore # 先決定要取得哪一個標籤的屬性,再取得該標籤的屬性值 print("8. 取得 div 標籤的 class 屬性: ", tags_attribute, sep='\n') # 9. get_text(separator, strip) container_div = soup.select_one('div.container') all_text = container_div.get_text(separator=' ', strip=True) # type: ignore print("9.1 以 tag.get_text() 取得 p 標籤文字: ", all_text, sep='\n') # 回應:內部段落 1 內部段落 2 內部段落 3 all_text = container_div.text.strip() # type: ignore print("9.2 以 tag.text 取得 p 標籤文字: ", all_text, sep='\n') # 回應: # 內部段落 1 # 內部段落 2 # 內部段落 3 ``` ### 2. 尋找多個元素:返回 ResultSet(包含多個 Tag 的集合)或列表 1. find_all(tag, attributes, recursive, string, limit, kwargs):找到所有滿足條件的標籤。 2. select(selector):使用 CSS 選擇器找到標籤。 3. find_next_siblings():找到下一個滿足條件的所有兄弟元素。 4. find_previous_siblings():找到上一個滿足條件的所有兄弟元素。 **原始碼範例:** ```python= from bs4 import BeautifulSoup # HTML範例 html = """ <html> <head> <title>BeautifulSoup 範例</title> </head> <body> <div id="content"> <h1 class="title">你好,BeautifulSoup!</h1> <p class="paragraph">這是一個範例段落。</p> <p class="paragraph">這是另一個段落。</p> <div class="container"> <p class="inner-paragraph">內部段落 1</p> <p class="inner-paragraph">內部段落 2</p> <p class="inner-paragraph">內部段落 3</p> </div> </div> </body> </html> """ # 使用BeautifulSoup解析HTML soup = BeautifulSoup(html, 'html.parser') # 1. find_all(tag, attributes, recursive, string, limit, kwargs) paragraphs = soup.find_all('p', class_='paragraph') print("1. 尋找所有標籤為 p 且 class 為 'paragraph' 的元素: ", paragraphs, sep='\n') # 回應:[<p class="paragraph">這是一個範例段落。</p>, <p class="paragraph">這是另一個段落。</p>] # 2. select(selector) inner_paragraphs = soup.select('.container .inner-paragraph') print("2. 使用 CSS 選擇器尋找 class 為 container 且 class 為 inner-paragraph 的標籤:", inner_paragraphs, sep='\n') # 回應:[<p class="inner-paragraph">內部段落 1</p>, <p class="inner-paragraph">內部段落 2</p>, <p class="inner-paragraph">內部段落 3</p>] # 3. find_next_siblings() first_inner_paragraph = soup.find('p', class_='inner-paragraph') following_paragraphs = first_inner_paragraph.find_next_siblings('p') # type: ignore print("3. 尋找第一個內部段落後的所有兄弟元素: ", following_paragraphs, sep='\n') # 回應:[<p class="inner-paragraph">內部段落 2</p>, <p class="inner-paragraph">內部段落 3</p>] # 4. find_previous_siblings() preceding_paragraphs = inner_paragraphs[2].find_previous_siblings('p') print("4. 尋找第三個內部段落前的所有兄弟元素: ", preceding_paragraphs, sep='\n') # 回應:[<p class="inner-paragraph">內部段落 2</p>, <p class="inner-paragraph">內部段落 1</p>] ``` ### 3. 其它方法 1. `.replace_with()`: 用新的內容替換掉一個 Tag 或 NavigableString。 1. `.prettify()`: 美化輸出整個 soup 物件,方便除錯時觀察 HTML 結構。 **原始碼範例:** ```python= from bs4 import BeautifulSoup # HTML範例 html = """ <html> <head><title>BeautifulSoup 範例</title></head> <body> <div id="content"><h1 class="title"> 你好,BeautifulSoup! </h1> <div id="container"> <p class="paragraph">這是一個範例段落。</p> <p class="paragraph">另一個段落在這裡。</p> </div> </div> </body> </html> """ # 使用BeautifulSoup解析HTML soup = BeautifulSoup(html, 'html.parser') # 1. 使用 replace() 替換文字 title_tag = soup.find('title') print("1.1 原始 title 內容:", title_tag.get_text()) # type: ignore # 輸出:BeautifulSoup 範例 # 替換 title 內容 title_tag.string.replace_with('新的標題') # type: ignore print("1.2 修改後的 title 內容:", title_tag.get_text()) # type: ignore # 輸出:新的標題 # 2. prettify() pretty_html = soup.prettify() print("\n2. 以漂亮的格式列印整個檔的內容: ", pretty_html, sep='\n') ``` ## 六、編解碼 (進階) ### 1. 呼叫 UnicodeDammit 解碼 在現代 Python 環境中,`requests` 和 BS4 通常能很好地自動處理編碼。你只需要注意兩個關鍵點: 1. **讀寫檔案時**:始終明確指定 `encoding="utf-8"`,這是最穩妥的做法。 ```python with open("data.txt", "w", encoding="utf-8") as f: f.write(some_text) ``` 2. **儲存為 JSON 時**:如果內容包含中文,使用 `ensure_ascii=False` 來確保中文能被正確寫入,而不是被轉換成 `\uXXXX` 格式。 ```python import json data = {"title": "中文標題"} with open("data.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) ``` 在極少數情況下,如果 `requests` 無法正確解碼網頁,BS4 內部提供了一個名為 `UnicodeDammit` 的工具可以輔助偵測編碼。 UnicodeDammit 是 BeautifulSoup 庫中的一個工具,用於處理和轉換不同編碼的文字,使其成為 Unicode 格式。這對於解析來自不同來源的網頁內容時特別重要,因為網頁可能使用多種不同的字元編碼。 當你從網頁獲取內容並需要解析時,如果不確定該網頁使用的字元編碼,或者該網頁的字元編碼可能會變化,這時就應該使用 UnicodeDammit。它會嘗試使用多種編碼來解碼文字,直到找到一個合適的編碼。 **範例:** 以下是一個簡單的範例,展示如何使用 UnicodeDammit 和 BeautifulSoup 來解析網頁內容,並輸出網頁的 prettified 內容。 ```python= import requests from bs4 import BeautifulSoup, UnicodeDammit # 假設 response 是從網頁獲取的內容 url = "https://www.ncut.edu.tw" response = requests.get(url) # 使用 UnicodeDammit 嘗試多種編碼來解碼內容 dammit = UnicodeDammit(response.content, ["utf-8", "latin-1", "iso-8859-1", "windows-1251"]) # 使用 BeautifulSoup 解析解碼後的內容 soup = BeautifulSoup(dammit.unicode_markup, 'lxml') # type: ignore # 使用 BeautifulSoup 解析網頁內容 print(soup.prettify()) # 顯示美化後的 HTML 結構,便於閱讀 ``` **說明:** 1. 導入必要的套件:包括 requests、BeautifulSoup 和 UnicodeDammit。 2. 發送 HTTP 請求:使用 requests.get 發送請求,獲取網頁內容。 3. 使用 UnicodeDammit 處理編碼:將網頁內容轉換為 Unicode 格式,並嘗試使用多種編碼來解碼內容。 4. 使用 BeautifulSoup 解析內容:將解碼後的內容傳遞給 BeautifulSoup 進行解析。 5. 輸出網頁內容:使用 soup.prettify() 方法輸出解析後的網頁內容。 這個範例展示了如何使用 UnicodeDammit 來處理不同編碼的網頁內容,確保能夠正確解析並輸出網頁的內容。 ### 2. BeautifulSoup 的輸出編碼處理 在處理並輸出解析後的 HTML 或文字時,可能需要轉換為不同的編碼格式,特別是當目標環境(如終端機或檔案系統)不支援 Unicode 時,這時候就要使用 encode() 方法了。 encode() 方法可將 BeautifulSoup 解析後的內容轉換為特定的編碼格式,並將無法轉換的字元處理為替代字元或 HTML 實體。 ```python= from bs4 import BeautifulSoup html = "<html><head><title>範例</title></head><body><p>這是段落。</p></body></html>" soup = BeautifulSoup(html, 'lxml') # 1. 標準輸出,不做任何格式化,寫入時使用 UTF-8 編碼 with open('output_pretty_unicode.html', 'w', encoding='utf-8') as file: file.write(soup.prettify()) # 2. 使用 prettify() 格式化輸出並轉換為 Big5 編碼 pretty_html_big5 = soup.prettify().encode('big5', errors='xmlcharrefreplace') # 轉換後輸出到終端機(需解碼以正確顯示) print(pretty_html_big5.decode('big5')) # 將格式化的內容寫入檔案 with open('output_pretty_big5.html', 'wb') as file: file.write(pretty_html_big5) # 3. 輸出格式化的 HTML 並轉換為 UTF-8 編碼 pretty_html_utf8 = soup.prettify().encode('utf-8') # 轉換後輸出到終端機(需解碼以正確顯示) print(pretty_html_utf8.decode('utf-8')) # 將格式化的內容寫入檔案 with open('output_pretty_utf8.html', 'wb') as file: file.write(pretty_html_utf8) ``` 說明: 1. prettify() 格式化輸出:將 HTML 結構化,方便閱讀。 2. encode() 轉換編碼:可指定不同的編碼(如 Big5 或 UTF-8)來處理輸出。 3. 處理無法轉換的字元:errors='xmlcharrefreplace' 將無法轉換的字元替換為 HTML 實體,以避免丟失重要資訊。 ## 七、爬蟲實戰範例 ![image](https://hackmd.io/_uploads/ryDyE0mVJx.png) ### 範例 1:爬取原價屋文章標題 以 CSS 選擇器,配合 select_one() 與 select() 方法抓取所有文章標題,最後存到 JSON 檔 ```python= import json import requests from bs4 import BeautifulSoup URL = "http://www.coolpc.com.tw/phpBB2/portal.php" try: response = requests.get(URL) response.raise_for_status() soup = BeautifulSoup(response.text, 'lxml') if soup.title: print(f"頁面標題: {soup.title.string.strip()}\n") if soup.title.string else print("找不到頁面標題") # 使用 select 方法抓取所有符合條件的項目 article_links = soup.select(".ultimate-layouts-title-link") titles = [link.get_text(strip=True) for link in article_links] print("--- 所有文章標題 ---") for i, title in enumerate(titles, 1): print(f"{i}. {title}") # 將結果存入 JSON 檔案 with open("coolpc_titles.json", "w", encoding="utf-8") as f: json.dump(titles, f, ensure_ascii=False, indent=2) print("\n資料已儲存至 coolpc_titles.json") except requests.RequestException as e: print(f"請求失敗: {e}") ``` ### 範例 2:爬取博客來新書榜 這個範例結合了 `find_all`、`select_one` 和正規表達式來提取更複雜的資訊。 ```python= import json import re import requests from bs4 import BeautifulSoup from bs4.element import Tag # 明確導入 Tag 類型,以便進行檢查 URL = "https://www.books.com.tw/web/sys_newtopb/books/" try: response = requests.get(URL) response.raise_for_status() soup = BeautifulSoup(response.content, "lxml") # 存取屬性前,先檢查 Tag 是否存在 if soup.title: print(f"頁面標題:{soup.title.text.strip()}\n") else: print("頁面標題:未找到標題\n") # 1. 先獲取整個列表,而不是直接存取索引 book_list_uls = soup.find_all("ul", {"class": "clearfix"}) # 2. 檢查列表長度是否足夠,這一步就防止了 IndexError if len(book_list_uls) > 2: book_list_ul = book_list_uls[2] # 3. 進行類型檢查 (這一步現在主要是為了 Pylance,因為我們幾乎可以確定它是 Tag) if isinstance(book_list_ul, Tag): books = [] # 遍歷每個 <li> item (現在這裡已無任何警告) for item in book_list_ul.select(".item"): rank_tag = item.select_one(".stitle .no") title_tag = item.select_one(".type02_bd-a h4 a") author_tag = item.select_one(".type02_bd-a .msg li a") price_tag = item.select_one(".type02_bd-a .msg .price_a") if not (rank_tag and title_tag and price_tag): continue rank = rank_tag.text.strip() title = title_tag.text.strip() author = author_tag.text.strip() if author_tag else "N/A" price_match = re.search(r"(\d+)元", price_tag.text) price = price_match.group(1) if price_match else "N/A" books.append( {"rank": rank, "title": title, "author": author, "price": price} ) # 格式化輸出和存檔... for book in books: print( f"TOP {book['rank']:<3} | 書名: {book['title']:<40} | 作者: {book['author']:<15} | 價格: {book['price']}元" ) with open("books_ranking.json", "w", encoding="utf-8") as f: json.dump(books, f, ensure_ascii=False, indent=2) print("\n資料已儲存至 books_ranking.json") else: # 如果列表長度不夠,給出清晰的提示 print("頁面結構可能已變更,找不到指定的書籍列表。") except requests.RequestException as e: print(f"請求失敗: {e}") ``` ## 參考資料 * [給初學者的 Python 網頁爬蟲與資料分析 (3) 解構並擷取網頁資料](https://blog.castman.net/%E6%95%99%E5%AD%B8/2016/12/22/python-data-science-tutorial-3.html) - [Beautiful Soup 官方文件 (英文)](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)