如何維護爬蟲專案?用更有效率更方便的方法來寫爬蟲!

Hao-Yun Chuang | Nov 4, 2022
本文改寫自同名演講投影片,介紹如何使用 Scrapy 建立、優化並維護一個網頁爬蟲專案。

目錄

  1. 顯示目錄結構的 tree 指令
  2. 使用 Scrapy 建立爬蟲專案
  3. 核心詞彙定義
  4. 爬蟲流程 (一):基本資料擷取
  5. 爬蟲流程 (二):分類導覽與翻頁
  6. 優化爬蟲:定義 Item
  7. 解耦爬取與解析:Page Object Pattern
  8. 監控爬蟲:Spidermon
  9. 資料驗證:JSON Schema
  10. 處理動態頁面:scrapy-playwright
  11. 實用輔助函式庫
  12. 除錯筆記

Tree 指令

在開始爬蟲專案之前,先介紹一個實用的小工具 — tree,它能以樹狀結構視覺化顯示目錄下所有的檔案與資料夾,讓你對專案架構一目了然。

Tree 指令輸出範例

安裝方式

macOS(使用 Homebrew):

1
brew install tree

Windows:
Windows 使用者可參考此教學將 tree 加入 Git Bash:Add tree to Git Bash on Windows 10

使用方式

安裝完成後,切換到任意目錄下執行:

1
tree

輸出範例(以 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
# macOS / Linux
source scrapy-tutorial/bin/activate

# Windows
scrapy-tutorial\Scripts\activate.bat

接著 clone 範例程式碼並安裝依賴:

1
2
3
git clone https://github.com/milanochuang/scrape_tutorial.git
cd 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/milanochuang/Documents/code/spider_tutorial/books_to_scrape

You can start your first spider with:
cd books_to_scrape
scrapy genspider example example.com
1
cd books_to_scrape

Scrapy 預設的專案結構如下:

Scrapy 專案結構

建立 Spider

books.toscrape.com 為爬取目標,使用 genspider 指令建立 Spider:

1
scrapy genspider books books.toscrape.com
1
Created spider 'books' using template 'basic'

建立完成後,執行 tree 查看完整專案結構:

1
tree

Scrapy 專案完整結構

此時可以用你喜歡的編輯器打開 books_to_scrape 目錄(外層),並在同一目錄下開啟終端機。

編寫第一個 Spider

打開 spiders/books.py,預設內容如下:

1
2
3
4
5
6
7
8
9
10
# -*- coding: utf-8 -*-
import scrapy

class 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 scrapy

class 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 與 Generator

yield 是 Python 中建立 generator(生成器)的關鍵字。與 return 不同,yield 不會立刻結束函式,而是暫停執行並回傳一個值,等到下次被呼叫時再從上次暫停的地方繼續。

1
2
3
power = (i**2 for i in range(100000))
print(power) # <generator object <genexpr> at 0x7f8ddb35ec10>
print(type(power)) # generator

在 Scrapy 中,yield 一個 Request 物件代表「請 Scrapy 接著去爬這個網址」,yield 一個 Item 代表「這是我解析出來的資料,請存起來」。

response.followcallback

  • 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 欄位的資料有問題:

data.json 問題

.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

再次執行,資料即可正確輸出:

data.json 正確輸出

爬蟲流程(二):分類導覽與翻頁

第一個版本只爬取首頁上的書籍,但 books.toscrape.com 有許多分類,每個分類也可能有多頁。我們來升級爬蟲:

目標流程:

  1. 從 Seed URL 出發
  2. 遍歷側邊欄的所有分類連結
  3. 進入各分類頁面,爬取所有書籍
  4. 處理分頁(Next Page)
  5. 儲存資料至本地

修改 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)")

# 使用 Walrus Operator
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_kwargs

cb_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 scrapy

class 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 定義方式:

  1. scrapy.Item(Scrapy 原生)
  2. Python dataclass(推薦)
  3. attrs 函式庫

使用 Python dataclass 定義 Item

打開 items.py,清除預設內容,改成以下定義:

1
2
3
4
5
6
7
8
9
10
from dataclasses import dataclass
from 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 BookItem

# 將原本的
yield item
# 改為
yield 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, WebPage
from books_to_scrape.items import BookItem

# 書籍詳情頁
class 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 scrapy
from books_to_scrape.page_objects import HomePage, BookCategoryPage, BookPage

class 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 發送通知。

安裝

1
pip install spidermon

設定 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,
}
...

監控報告截圖

Spidermon 監控報告 1

Spidermon 監控報告 2

資料驗證:JSON Schema

為了確保爬取到的資料符合預期格式,可以使用 JSON Schema 進行驗證。

優點:

  • 可為每個 Item 設定客製化驗證規則
  • 使用標準 JSON Schema 格式定義資料結構

缺點:

  • dataclassattrs 函式庫不相容(需擇一使用)

安裝

1
pip install jsonschema scrapy-jsonschema

定義 BookSchemaItemitems.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 JsonSchemaItem

class 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, BookSchemaItem

# 將
return 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 # 注意:需用 dict 方式存取
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 install
sudo 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 scrapy

class 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}, # 告訴 Scrapy 使用 Playwright 處理此請求
)

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 parse

print(parse("One two three go!")) # '1 2 3 go!'
print(parse_number("twenty three")) # 23
print(parse_ordinal("seventy fifth")) # 75

price-parser:解析價格字串

1
2
3
4
from price_parser import Price

print(Price.fromstring("22,90 €"))
# Price(amount=Decimal('22.90'), currency='€')

支援多種貨幣格式與千分位符號,是處理電商資料的利器。

dateparser:解析各種日期格式

1
2
3
4
import dateparser

dateparser.parse("Fri, 12 Dec 2014 10:55:50")
# datetime.datetime(2014, 12, 12, 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-LDMicrodataOpenGraphRDFa 等標準格式。

結語

本文從最基礎的專案建立出發,逐步介紹了如何擴展 Scrapy 爬蟲:

  1. 善用 tree 掌握專案目錄結構
  2. Scrapy 基本流程:Seed URL → Crawling → Extraction → 儲存
  3. yieldfollowcallbackcb_kwargs 等核心機制
  4. Walrus Operator (:=) 簡化程式碼
  5. Dataclass 定義結構化 Item
  6. Page Object Pattern(scrapy-poet) 解耦爬取與解析,提升維護性
  7. Spidermon 監控多隻 Spider 的執行狀態
  8. JSON Schema 驗證資料格式
  9. scrapy-playwright 處理 JavaScript 動態渲染頁面
  10. 輔助函式庫 快速處理數字、價格、日期等特殊格式

完整程式碼:GitHub - milanochuang/scrape_tutorial

除錯筆記

問題一:HTTPS handler 錯誤

1
ERROR: Loading "scrapy.core.downloader.handlers.http.HTTPDownloadHandler" for scheme "https"

問題二:缺少 attrs 模組

1
ModuleNotFoundError: No module named 'attrs'

解決方法: 安裝指定版本的 Twisted

1
pip install Twisted==21.7.0

參考資料


如何維護爬蟲專案?用更有效率更方便的方法來寫爬蟲!
https://milanochuang.github.io/2026/04/04/如何維護爬蟲專案?用更有效率更方便的方法來寫爬蟲!/
作者
Hao-Yun Chuang (Milan)
發布於
2026年4月4日
許可協議