Hao-Yun Chuang | Nov 4, 2022 本文改寫自同名演講投影片,介紹如何使用 Scrapy 建立、優化並維護一個網頁爬蟲專案。
目錄
顯示目錄結構的 tree 指令
使用 Scrapy 建立爬蟲專案
核心詞彙定義
爬蟲流程 (一):基本資料擷取
爬蟲流程 (二):分類導覽與翻頁
優化爬蟲:定義 Item
解耦爬取與解析:Page Object Pattern
監控爬蟲:Spidermon
資料驗證:JSON Schema
處理動態頁面:scrapy-playwright
實用輔助函式庫
除錯筆記
Tree 指令 在開始爬蟲專案之前,先介紹一個實用的小工具 — tree,它能以樹狀結構視覺化顯示目錄下所有的檔案與資料夾,讓你對專案架構一目了然。
安裝方式 macOS(使用 Homebrew):
Windows: Windows 使用者可參考此教學將 tree 加入 Git Bash:Add tree to Git Bash on Windows 10
使用方式 安裝完成後,切換到任意目錄下執行:
輸出範例(以 macOS 為例):
1 (base ) milanochuang@zhuanghaoyundeMacBook -Pro Documents % tree
使用 Scrapy 建立爬蟲專案 為什麼選擇 Scrapy? Scrapy 是 Python 生態系中最成熟的爬蟲框架之一,具有以下三大優點:
Fast & Powerful(快速且強大) :只需定義提取規則,Scrapy 會自動處理請求、並發、重試等底層細節。
Easily Extensible(易於擴展) :採模組化設計,可以透過 middleware、pipeline、extension 輕鬆加入新功能,不需改動核心程式碼。
Portable, Python(跨平台) :以 Python 撰寫,可在 Linux、Windows、macOS 上執行。
建立虛擬環境 為了保持專案的依賴套件乾淨獨立,建議使用虛擬環境:
1 2 python3 -m venv scrapy-tutorial
啟動虛擬環境:
1 2 3 4 5 source scrapy-tutorial/bin/activate scrapy-tutorial\Scripts\activate.bat
接著 clone 範例程式碼並安裝依賴:
1 2 3 git clone https://github.com/milanochuang/scrape_tutorial.gitcd spider_tutorial pip install -r requirements.txt
核心詞彙定義 在正式撰寫爬蟲前,先釐清幾個關鍵概念:
詞彙
說明
比喻
Seed URL
爬蟲的起點網址,Spider 從這裡出發
入口
Crawling(爬取)
Spider 從頁面到頁面的導覽行為
? 挑選聖誕樹
Extraction(擷取)
從頁面中解析出所需資料的行為
? 挑選裝飾品
? 一個好的爬蟲 = 清楚的 Crawling 路徑 + 精準的 Extraction 規則
爬蟲流程(一):基本資料擷取 建立 Scrapy 專案 1 scrapy startproject books_to_scrape
執行後,你會看到類似以下的輸出:
1 2 3 4 5 6 New Scrapy project 'books_to_scrape' , using template directory '...' , created in: /Users/mi lanochuang/Documents/ code/spider_tutorial/ books_to_scrape You can start your first spider with: cd books_to_scrape scrapy genspider example example.com
Scrapy 預設的專案結構如下:
建立 Spider 以 books.toscrape.com 為爬取目標,使用 genspider 指令建立 Spider:
1 scrapy genspider books books.toscrape.com
1 Created spider 'books' using template 'basic'
建立完成後,執行 tree 查看完整專案結構:
此時可以用你喜歡的編輯器打開 books_to_scrape 目錄(外層),並在同一目錄下開啟終端機。
編寫第一個 Spider 打開 spiders/books.py,預設內容如下:
1 2 3 4 5 6 7 8 9 10 import scrapyclass BooksSpider (scrapy.Spider): name = "books" allowed_domains = ["books.toscrape.com" ] start_urls = ['http://books.toscrape.com/' ] def parse (self, response ): pass
使用 CSS Selector 取得書籍連結 觀察 books.toscrape.com 的 HTML 結構,每本書的連結對應的 CSS 路徑為 .product_pod a ::attr(href)。
接下來讓 Spider 遍歷首頁所有書籍連結,並呼叫 parse_book 函式解析書籍詳情頁:
1 2 3 4 5 6 7 8 9 10 import scrapyclass BooksSpider (scrapy.Spider): name = "books" allowed_domains = ["books.toscrape.com" ] start_urls = ['http://books.toscrape.com/' ] def parse (self, response ): for url in response.css(".product_pod a ::attr(href)" ).getall(): yield response.follow(url, callback=self .parse_book)
這裡出現了幾個重要的新概念:
yield 與 Generatoryield 是 Python 中建立 generator (生成器)的關鍵字。與 return 不同,yield 不會立刻結束函式,而是暫停執行並回傳一個值,等到下次被呼叫時再從上次暫停的地方繼續。
1 2 3 power = (i**2 for i in range (100000 ))print (power) print (type (power))
在 Scrapy 中,yield 一個 Request 物件代表「請 Scrapy 接著去爬這個網址」,yield 一個 Item 代表「這是我解析出來的資料,請存起來」。
response.follow 與 callback
response.follow(url, callback=...) 會建立一個新的請求,並在頁面載入後呼叫指定的 callback 函式。
callback=self.parse_book 表示爬完書籍詳情頁後,將 response 傳入 parse_book 方法處理。
解析書籍詳情頁 1 2 3 4 5 6 7 8 def parse_book (self, response ): item = { "title" : response.css(".product_main h1 ::text" ).get(), "price" : response.css(".product_main .price_color ::text" ).get(), "url" : response.url, "availability" : response.css(".product_main .availability ::text" ).getall() } yield item
執行爬蟲並輸出資料 1 scrapy crawl books -o data.json
遇到問題了! 開啟 data.json 後,發現 availability 欄位的資料有問題:
.getall() 回傳的是一個 list,其中包含了多餘的空白字元與換行。我們需要將它合併並清理:
1 2 3 4 5 6 7 8 9 def parse_book (self, response ): item = { "title" : response.css(".product_main h1 ::text" ).get(), "price" : response.css(".product_main .price_color ::text" ).get(), "url" : response.url, } availability = response.css(".product_main .availability ::text" ).getall() item["availability" ] = "" .join(availability).strip() yield item
再次執行,資料即可正確輸出:
爬蟲流程(二):分類導覽與翻頁 第一個版本只爬取首頁上的書籍,但 books.toscrape.com 有許多分類,每個分類也可能有多頁。我們來升級爬蟲:
目標流程:
從 Seed URL 出發
遍歷側邊欄的所有分類連結
進入各分類頁面,爬取所有書籍
處理分頁(Next Page)
儲存資料至本地
修改 parse 函式以導覽分類 1 2 3 def parse (self, response ): for url in response.css(".nav-list ul li a ::attr(href)" ).getall(): yield response.follow(url, callback=self .parse_category)
新增 parse_category 函式 1 2 3 4 5 6 7 8 9 10 11 12 13 def parse_category (self, response ): for url in response.css(".product_pod a ::attr(href)" ).getall(): category_name = response.css(".page-header h1 ::text" ).get() yield response.follow( url, callback=self .parse_book, cb_kwargs={"category_name" : category_name}, ) if next_page_url := response.css(".pager .next a ::attr(href)" ).get(): yield response.follow( next_page_url, callback=self .parse_category, )
這裡又出現了兩個新朋友:
Walrus Operator :=(Python 3.8+) := 稱為「海象運算子」(Walrus Operator),可以在運算式中同時賦值並使用該值,避免重複計算:
1 2 3 4 5 6 7 8 n = len (a)if n > 10 : print (f"List is too long ({n} elements, expected <= 10)" )if (n := len (a)) > 10 : print (f"List is too long ({n} elements, expected <= 10)" )
在爬蟲中,if next_page_url := response.css(...).get() 的意思是:取得 next page 的 URL,若有值(非 None)才進入 if 區塊並繼續爬下一頁。
cb_kwargscb_kwargs 允許在 response.follow 中傳遞額外的參數給 callback 函式。這裡我們將 category_name 傳給 parse_book,讓每一本書的 item 都帶有所屬分類資訊。
完整爬蟲程式碼 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import scrapyclass BooksSpider (scrapy.Spider): name = "books" allowed_domains = ["books.toscrape.com" ] start_urls = ['http://books.toscrape.com/' ] def parse (self, response ): for url in response.css(".nav-list ul li a ::attr(href)" ).getall(): yield response.follow(url, callback=self .parse_category) def parse_category (self, response ): for url in response.css(".product_pod a ::attr(href)" ).getall(): category_name = response.css(".page-header h1 ::text" ).get() yield response.follow( url, callback=self .parse_book, cb_kwargs={"category_name" : category_name}, ) if next_page_url := response.css(".pager .next a ::attr(href)" ).get(): yield response.follow( next_page_url, callback=self .parse_category, ) def parse_book (self, response, category_name ): item = { "title" : response.css(".product_main h1 ::text" ).get(), "price" : response.css(".product_main .price_color ::text" ).get(), "url" : response.url, } availability = response.css(".product_main .availability ::text" ).getall() item["availability" ] = "" .join(availability).strip() item["category_name" ] = category_name yield item
1 scrapy crawl books -o data.json
優化爬蟲:定義 Item Scrapy 的 Item 是一種結構化資料容器,定義了爬蟲所要擷取的欄位。正式專案中建議明確定義 Item,有以下優點:
程式碼更清晰易讀
避免拼字錯誤導致的欄位遺失
與 Pipeline、schema 驗證工具整合更方便
Scrapy 支援多種 Item 定義方式:
scrapy.Item(Scrapy 原生)
Python dataclass(推薦)
attrs 函式庫
…
使用 Python dataclass 定義 Item 打開 items.py,清除預設內容,改成以下定義:
1 2 3 4 5 6 7 8 9 10 from dataclasses import dataclassfrom typing import Optional @dataclass class BookItem : url: str category_name: Optional [str ] = None title: Optional [str ] = None price: Optional [str ] = None availability: Optional [str ] = None
url 設為必填,其餘設為 Optional,即使某些欄位爬不到也不會導致整個 item 失敗。
在 Spider 中使用 BookItem 回到 books.py,引入並使用 BookItem:
1 2 3 4 5 6 from books_to_scrape.items import BookItemyield itemyield BookItem(**item)
使用 breakpoint() 互動式除錯 Scrapy 支援 Python 內建的 breakpoint() 進行互動除錯:
1 2 breakpoint ()yield BookItem(**item)
在 breakpoint 暫停後,可以在終端機中直接測試:
1 2 3 BookItem(**item) BookItem(**item, **{"unknown" : "value" }) BookItem(title="Alice in wonderland" )
解耦爬取與解析:Page Object Pattern 隨著專案規模變大,維護性成為關鍵問題。網頁結構可能隨時改變,若把 CSS selector 散落在各個 Spider 方法中,日後維護的成本會大幅提高。
Page Object Pattern 的核心概念是:
Spider 只負責爬取 (導覽頁面、發出請求)
Page Object 只負責解析 (定義 CSS selector、回傳結構化資料)
這樣當頁面結構改變時,只需修改對應的 Page Object,不需動到 Spider 邏輯。
安裝 web-poet 與 scrapy-poet 1 pip install web-poet scrapy-poet
建立 page_objects.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 from web_poet import ItemWebPage, WebPagefrom books_to_scrape.items import BookItemclass BookPage (ItemWebPage ): def to_item (self ): item = { "title" : self .css(".product_main h1 ::text" ).get(), "price" : self .css(".product_main .price_color ::text" ).get(), "url" : self .url, } availability = self .css(".product_main .availability ::text" ).getall() item["availability" ] = "" .join(availability).strip() return BookItem(**item)class HomePage (WebPage ): @property def category_urls (self ): return self .css(".nav-list ul li a ::attr(href)" ).getall()class BookCategoryPage (WebPage ): @property def book_urls (self ): return self .css(".product_pod a ::attr(href)" ).getall() @property def category_name (self ): return self .css(".page-header h1 ::text" ).get() @property def next_page_url (self ): return self .css(".pager .next a ::attr(href)" ).get()
設定 Middleware(settings.py) 1 2 3 4 5 6 DOWNLOADER_MIDDLEWARES = { 'scrapy_poet.InjectionMiddleware' : 543 , } SPIDER_MIDDLEWARES = { "scrapy_poet.RetryMiddleware" : 275 , }
scrapy-poet 會在看到 page: BookPage 這樣的型別標注時,自動將對應的 Page Object 注入進來。
重構後的 books.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import scrapyfrom books_to_scrape.page_objects import HomePage, BookCategoryPage, BookPageclass BooksSpider (scrapy.Spider): name = "books" allowed_domains = ["books.toscrape.com" ] start_urls = ['http://books.toscrape.com/' ] def parse (self, response, page: HomePage ): for url in page.category_urls: yield response.follow(url, callback=self .parse_category) def parse_category (self, response, page: BookCategoryPage ): for url in page.book_urls: yield response.follow( url, callback=self .parse_book, cb_kwargs={"category_name" : page.category_name}, ) if next_page_url := page.next_page_url: yield response.follow( next_page_url, callback=self .parse_category, ) def parse_book (self, response, page: BookPage, category_name ): item = page.to_item() item.category_name = category_name yield item
對比第一版,Spider 的每個方法變得非常乾淨:它只負責「去哪裡」,不再關心「怎麼解析」。
監控爬蟲:Spidermon 當專案規模成長到擁有多隻 Spider 時,你需要能夠:
排程定期執行
查看目前與歷史執行狀況
搜尋擷取的 items、logs 與 stats
方便除錯
視覺化效能監控
Spidermon 提供客製化監控設定,並支援透過 Slack、Telegram、Discord、Email 發送通知。
安裝
設定 books.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class BooksSpider (scrapy.Spider): name = "books" allowed_domains = ["books.toscrape.com" ] start_urls = ['http://books.toscrape.com/' ] custom_settings = { "SPIDERMON_ENABLED" : True , "EXTENSIONS" : { "spidermon.contrib.scrapy.extensions.Spidermon" : 500 , }, "SPIDERMON_SPIDER_CLOSE_MONITORS" : ( "spidermon.contrib.scrapy.monitors.SpiderCloseMonitorSuite" ), "SPIDERMON_MIN_ITEMS" : 10 , "SPIDERMON_MAX_ERRORS" : 1 , "SPIDERMON_MAX_WARNINGS" : 1000 , "SPIDERMON_ADD_FIELD_COVERAGE" : True , } ...
監控報告截圖
資料驗證:JSON Schema 為了確保爬取到的資料符合預期格式,可以使用 JSON Schema 進行驗證。
優點:
可為每個 Item 設定客製化驗證規則
使用標準 JSON Schema 格式定義資料結構
缺點:
與 dataclass 或 attrs 函式庫不相容(需擇一使用)
安裝 1 pip install jsonschema scrapy-jsonschema
定義 BookSchemaItem(items.py) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 from scrapy_jsonschema.item import JsonSchemaItemclass BookSchemaItem (JsonSchemaItem ): jsonschema = { "$schema" : "http://json-schema.org/draft-07/schema#" , "title" : "Book" , "description" : "A Book item extracted from books.toscrape.com" , "type" : "object" , "properties" : { "url" : { "description" : "Book's URL" , "type" : "string" , "pattern" : "^https?://[\\S]+$" }, "category_name" : { "description" : "Name of the category which the book belongs to" , "type" : "string" }, "title" : { "description" : "Book's title" , "type" : "string" }, "price" : { "description" : "Book's price" , "minimum" : 0 , "type" : "number" }, "availability" : { "description" : "Book's availability" , "type" : "string" } }, "required" : ["url" ] }
更新 page_objects.py 1 2 3 4 5 6 from books_to_scrape.items import BookItem, BookSchemaItemreturn BookItem(**item)return BookSchemaItem(**item)
更新 books.py 1 2 3 4 def parse_book (self, response, page: BookPage, category_name ): item = page.to_item() item['category_name' ] = category_name yield item
?? 因為 JSON Schema 的 Item 不支援 dataclass 的 attribute 方式存取,改用 item['category_name']。
處理動態頁面:scrapy-playwright Scrapy 預設使用 HTTP 請求爬取頁面,但有些網站的內容是透過 JavaScript 動態渲染的(如 SPA、使用 AJAX 載入的資料)。對於這類網站,需要整合瀏覽器自動化工具。
scrapy-playwright 讓 Scrapy 能驅動 Playwright 瀏覽器引擎來處理動態頁面。
安裝 1 2 3 4 scrapy genspider quotes quotes.toscrape.com pip install scrapy-playwright playwright installsudo playwright install-deps
設定 Spider(quotes.py) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import scrapyclass QuotesSpider (scrapy.Spider): name = 'quotes' allowed_domains = ['quotes.toscrape.com' ] custom_settings = { "DOWNLOAD_HANDLERS" : { "http" : "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler" , "https" : "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler" , }, "TWISTED_REACTOR" : "twisted.internet.asyncioreactor.AsyncioSelectorReactor" , } def start_requests (self ): yield scrapy.Request( "http://quotes.toscrape.com/js" , callback=self .parse, meta={"playwright" : True }, ) def parse (self, response ): for quote in response.css(".quote .text ::text" ).getall(): yield {"quote" : quote}
透過在 meta 中加入 "playwright": True,Scrapy 會改用 Playwright 瀏覽器載入頁面,等待 JavaScript 執行完畢後再進行解析。
實用輔助函式庫 以下幾個函式庫在解析爬取資料時非常方便:
number-parser:解析文字數字1 2 3 4 5 from number_parser import parseprint (parse("One two three go!" )) print (parse_number("twenty three" )) print (parse_ordinal("seventy fifth" ))
price-parser:解析價格字串1 2 3 4 from price_parser import Priceprint (Price.fromstring("22,90 €" ))
支援多種貨幣格式與千分位符號,是處理電商資料的利器。
dateparser:解析各種日期格式1 2 3 4 import dateparser dateparser.parse("Fri, 12 Dec 2014 10:55:50" )
dateparser 支援超過 200 種語言的日期格式,包含相對時間(如「3 days ago」)。
extruct:提取頁面結構化資料1 2 3 4 import requests, extruct page = requests.get("https://tw.pycon.org/2022/en-us" )print (extruct.extract(page.text))
extruct 可從網頁的 HTML 中提取嵌入的結構化資料,如 JSON-LD 、Microdata 、OpenGraph 、RDFa 等標準格式。
結語 本文從最基礎的專案建立出發,逐步介紹了如何擴展 Scrapy 爬蟲:
善用 tree 掌握專案目錄結構
Scrapy 基本流程 :Seed URL → Crawling → Extraction → 儲存
yield、follow、callback、cb_kwargs 等核心機制
Walrus Operator (:=) 簡化程式碼
Dataclass 定義結構化 Item
Page Object Pattern(scrapy-poet) 解耦爬取與解析,提升維護性
Spidermon 監控多隻 Spider 的執行狀態
JSON Schema 驗證資料格式
scrapy-playwright 處理 JavaScript 動態渲染頁面
輔助函式庫 快速處理數字、價格、日期等特殊格式
完整程式碼:GitHub - milanochuang/scrape_tutorial
除錯筆記 問題一:HTTPS handler 錯誤 1 ERROR: Loading "scrapy.core.downloader.handlers.http.HTTPDownloadHandler" for scheme "https"
問題二:缺少 attrs 模組 1 ModuleNotFoundError: No module named
解決方法: 安裝指定版本的 Twisted
1 pip install Twisted==21.7.0
參考資料