Scrapy 是 Python 最成熟的爬蟲框架:6.5 萬顆星、BSD-3 授權、2010 年活躍至今。它把「抓取、解析、去重、節流、匯出」拆成可組合的零件,適合要抓上千頁而非三、五頁的場合。本文用 Scrapy 2.19.0 從零實跑一遍,所有指令與數字都是實際執行結果。
一句話結論:什麼時候該用 Scrapy
| 情境 | 建議工具 | 原因 |
|---|---|---|
| 抓 1~3 頁 API 或 HTML | requests + parsel | 開一個框架反而多學一層 |
| 同一個站要抓數百到數萬頁 | Scrapy | 內建佇列、去重、重試、併發控制、節流、匯出格式 |
| 頁面內容靠 JavaScript 渲染 | Playwright/Selenium(或 scrapy-playwright) | Scrapy 預設只拿到 HTML |
| 要餵 LLM 的網頁轉 Markdown | Crawl4AI/Firecrawl | 直接輸出 LLM 友善格式,省一層清洗 |
| 需要登入、複雜互動、滑動載入 | Playwright | 瀏覽器自動化的主場 |
換句話說:Scrapy 解的是「規模」與「工程化」,不是「怎麼拿到第一個 HTML」。當你開始需要斷點續爬、去重、限速、把資料寫進資料庫、每天跑一次,Scrapy 的價值才會出現。
專案現況(2026-11-04 查證)
| 項目 | 內容 |
|---|---|
| 最新版本 | Scrapy 2.19.0(本次實測安裝版本) |
| GitHub | scrapy/scrapy,65,221 stars、12,094 forks |
| 授權 | BSD-3-Clause |
| 首個 commit | 2010-02-22(十多年的老專案,生態完整) |
| 官方文件 | docs.scrapy.org(本文引用的頁面都回 200) |
安裝:用虛擬環境,不要裝進系統 Python
python3 -m venv /tmp/scrapyenv
source /tmp/scrapyenv/bin/activate
pip install --upgrade pip
pip install scrapy
scrapy version --verbose
本次在 Python 3.12.3 上安裝,scrapy version --verbose 的實際輸出:
Scrapy : 2.19.0
lxml : 6.1.3
libxml2 : 2.14.6
cssselect : 1.6.0
parsel : 1.12.1
w3lib : 2.5.0
Twisted : 26.10.0
Python : 3.12.3
pyOpenSSL : 26.4.0
cryptography : 50.0.2
兩個實務提醒:
- Python 版本別亂跳。 Scrapy 的併發靠 Twisted,Twisted 對新版 Python 的支援通常落後幾季。本次測試環境是 3.12;若手上是更新的版本而安裝失敗,先降 Python 而不是硬上。
scrapy version --verbose比scrapy version有用。 出事時(SSL、lxml 解析、Twisted 事件迴圈)需要的是整份依賴清單,不是一個版本號。
建立專案:2.19 的模板長得跟舊教學不一樣
scrapy startproject quotebot
cd quotebot
產生的結構(find 實際輸出):
quotebot/quotebot/__init__.py
quotebot/quotebot/items.py
quotebot/quotebot/middlewares.py
quotebot/quotebot/pipelines.py
quotebot/quotebot/settings.py
quotebot/scrapy.cfg
兩個與舊文章不同的地方,值得先知道:
items.py的模板已經是@dataclass,不再是scrapy.Item的子類。scrapy.Item支援item["field"] = ...的字典式賦值,dataclass 不支援——這一點會在下面變成一個真實的錯誤。settings.py多了ADDONS = {}。這是 Scrapy 2.12 之後新增的 addons 機制,用來掛官方或第三方的擴充(例如scrapy.addons.AutoThrottleAddon),寫法比直接改EXTENSIONS/DOWNLOADER_MIDDLEWARES乾淨。
settings.py 模板預設已經幫你打開兩個「正確預設」:
ROBOTSTXT_OBEY = True
CONCURRENT_REQUESTS_PER_DOMAIN = 1
DOWNLOAD_DELAY = 1
FEED_EXPORT_ENCODING = "utf-8"
這代表預設就是「每網域每秒 1 個請求、遵守 robots.txt」。想加速要自己改,這個預設方向是對的。
第一支 spider:抓分頁、產生 item
以 Scrapy 官方教學用的 quotes.toscrape.com 為目標(刻意設計給爬蟲練習的站),spiders/quotes.py:
import scrapy
from quotebot.items import QuoteItem
class QuotesSpider(scrapy.Spider):
name = "quotes"
allowed_domains = ["quotes.toscrape.com"]
start_urls = ["https://quotes.toscrape.com/"]
custom_settings = {
"DOWNLOAD_DELAY": 0.5,
"AUTOTHROTTLE_ENABLED": True,
"AUTOTHROTTLE_START_DELAY": 0.5,
"AUTOTHROTTLE_MAX_DELAY": 5.0,
"AUTOTHROTTLE_TARGET_CONCURRENCY": 2.0,
"CONCURRENT_REQUESTS_PER_DOMAIN": 2,
}
def parse(self, response):
for box in response.css("div.quote"):
item = QuoteItem(
text=" ".join(box.css("span.text::text").get("").split()),
author=box.css("small.author::text").get(""),
tags=box.css("div.tags a.tag::text").getall(),
url=response.url,
)
item.word_count = len(item.text.split())
yield item
next_page = response.css("li.next a::attr(href)").get()
if next_page:
yield response.follow(next_page, callback=self.parse)
對應的 items.py:
from dataclasses import dataclass, field
@dataclass
class QuoteItem:
text: str = ""
author: str = ""
tags: list[str] = field(default_factory=list)
url: str = ""
word_count: int = 0
執行的實際結果(scrapy crawl quotes -O quotes.json):
{'downloader/response_count': 11,
'downloader/response_status_count/200': 10,
'downloader/response_status_count/404': 1,
'elapsed_time_seconds': 5.992566949920729,
'item_scraped_count': 100,
'items_per_minute': 1200.0,
'memusage/max': 80510976,
'request_depth_max': 9,
'robotstxt/response_status_count/404': 1,
'finish_reason': 'finished'}
換成人話:
- 10 頁、100 則引言、5.99 秒跑完,
request_depth_max: 9代表分頁是「一層一層往下」的鏈狀結構,不是一頁列出所有連結。 - 那 1 個 404 是 Scrapy 自己去抓
robots.txt的結果——這個站沒有 robots.txt。404 不等於被禁止:依 robots 規範,沒有 robots.txt 就視為「全部允許」,所以抓取照常進行(robotstxt/response_status_count/404就是這條紀錄)。 - 記憶體峰值約 80.5 MB(
memusage/max: 80510976),對長時間排程很重要——這是「一個 Scrapy 行程的常駐成本」,不是每頁成本。
Selector 速查:這六個寫法等於八成的爬蟲
Scrapy 的 Selector 底層是 parsel,同時支援 CSS 與 XPath。實測 scrapy shell "https://quotes.toscrape.com/" -c "print(len(response.css('div.quote')), response.css('small.author::text').getall()[:3])" 回傳:
10 ['Albert Einstein', 'J.K. Rowling', 'Albert Einstein']
也就是這一頁 10 則、作者抓得到。常用寫法:
| 目的 | 寫法 | 備註 |
|---|---|---|
| 取文字 | response.css("h1::text").get() | 只取第一個 |
| 取全部文字 | response.css("a.tag::text").getall() | 回傳 list |
| 取屬性 | response.css("a::attr(href)").get() | 圖片改 ::attr(src) |
| XPath 取上層 | response.xpath("//div[@class='quote']/span/text()").get() | 結構複雜時比 CSS 好用 |
| 用父節點反查 | response.xpath("//small[@class='author']/parent::*/text()") | 常見於「標籤在隔壁」的版面 |
| 正規表達式 | response.css("span.text::text").re(r"\d+") | 從文字裡撈數字最快 |
三個實務建議:
- 先開
scrapy shell再寫 selector。 在 shell 裡打view(response)可以看 Scrapy 實際收到的 HTML(不是瀏覽器看到的),能立刻判斷「抓不到」是選擇器錯還是 JavaScript 沒渲染。 - 不要用絕對路徑。
div.quote > span.text這種相對寫法比body > div:nth-child(3) > ...耐用一個量級。 get()沒命中回None,要給預設值。 上面程式碼用.get("")就是避免後面len(None.split())直接爆掉。
Item Pipeline:去重、正規化、收尾統計
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem
class QuotebotPipeline:
def __init__(self):
self.seen = set()
self.kept = 0
self.dropped = 0
self.tag_counter = {}
def process_item(self, item, spider):
adapter = ItemAdapter(item)
key = (adapter["author"], adapter["text"][:40])
if key in self.seen:
self.dropped += 1
raise DropItem(f"duplicate: {key}")
self.seen.add(key)
adapter["tags"] = sorted({t.lower().strip() for t in adapter["tags"]})
for t in adapter["tags"]:
self.tag_counter[t] = self.tag_counter.get(t, 0) + 1
self.kept += 1
return item
def close_spider(self, spider):
top = sorted(self.tag_counter.items(), key=lambda kv: -kv[1])[:5]
spider.logger.info("PIPELINE kept=%d dropped=%d top_tags=%s",
self.kept, self.dropped, top)
在 settings.py 啟用:
ITEM_PIPELINES = {
"quotebot.pipelines.QuotebotPipeline": 300,
}
實跑的 log 尾巴:
[quotes] INFO: PIPELINE kept=100 dropped=0 top_tags=[('love', 14), ('inspirational', 13), ('life', 13), ('humor', 12), ('books', 11)]
[scrapy.extensions.feedexport] INFO: Stored json feed (100 items) in: quotes.json
Pipeline 的價值不在「清洗」這一步,而在它是唯一保證「每一筆 item 都經過」的位置:去重、欄位補齊、寫資料庫、打 API 都在這裡,比散在 spider 裡可靠。
禮貌與法規:ROBOTSTXT_OBEY 到底做什麼
很多人以為打開 ROBOTSTXT_OBEY 就「安全」。實際行為值得用一個可重現的實驗看清楚——本機起一個測試站,robots.txt 寫 Disallow: /private/,首頁同時有 /public/ok.html 與 /private/secret.html 兩個連結:
User-agent: *
Disallow: /private/
跑完之後的統計與 log:
2026-11-04 ... [rbt_demo] INFO: PARSED http://127.0.0.1:8899/ links=2
2026-11-04 ... [rbt_demo] INFO: GOT http://127.0.0.1:8899/public/ok.html title=public ok
2026-11-04 ... [scrapy.downloadermiddlewares.robotstxt] DEBUG: Forbidden by robots.txt: <GET http://127.0.0.1:8899/private/secret.html>
{'downloader/exception_type_count/scrapy.exceptions.IgnoreRequest': 1,
'robotstxt/forbidden': 1}
三個結論:
ROBOTSTXT_OBEY是「請求層」的過濾。被擋的那個請求根本沒發出去(記成IgnoreRequest),不是抓回來才丟掉。- 連結照樣被解析。spider 還是看到兩個連結、還是呼叫了
response.follow,只是其中一個被下載器中介層攔掉。所以「遵守 robots」不會讓你的解析邏輯變乾淨,只是不發出請求。 - 要驗證有沒有真的擋,看統計不要看 log 等級。
robotstxt/forbidden這個計數器是唯一可靠的證據;把LOG_LEVEL設成INFO時那句 DEBUG 訊息根本不會出現。
與 robots 相關但常被混淆的幾件事:
| 機制 | 作用 | 常見誤解 |
|---|---|---|
robots.txt | 站方聲明的抓取範圍(自願性規範) | 不是法律,也不等於授權 |
ROBOTSTXT_OBEY = True | Scrapy 遵守上面那份聲明 | 打開它不等於合法,也不等於對方同意 |
DOWNLOAD_DELAY / AutoThrottle | 控制請求速率 | 設了就不會被 ban?不一定,雲端 IP 常直接封 |
| 網站 ToS | 合約層面的限制 | robots 允許 ≠ ToS 允許 |
倫理與法律面的完整版(含 Cloudflare Bot Management、指紋辨識、以及台灣常見的個資問題)另文處理,這裡只留一句:抓取前先確認「對方有沒有提供 API」,有 API 就別爬 HTML,這是成本最低也最不容易出事的選擇。
匯出資料:-O 與 -o 差一個字母,行為完全不同
# 覆寫(overwrite):開發時用,每次跑都清空重寫
scrapy crawl quotes -O quotes.json
# 附加(append):累積歷史時用
scrapy crawl quotes -o quotes-$(date +%F).jsonl
實測 CSV 匯出(-O quotes.csv)的檔案開頭與行數:
text,author,tags,url,word_count
"“The world as we have created it is a process of our thinking...",Albert Einstein,"change,deep-thoughts,thinking,world",https://quotes.toscrape.com/,21
"“It is our choices, Harry, that show what we truly are, far more than our abilities.”",J.K. Rowling,"abilities,choices",https://quotes.toscrape.com/,16
101 quotes.csv # 100 筆資料 + 1 行表頭
幾個格式選擇的實務建議:
- 要進資料庫或餵程式:用
jsonl(JSON Lines)。 一行一筆,可以串流處理;json是整包陣列,百萬筆時記憶體會炸。 - 要給人看:
csv。 但注意 utf-8 在 Excel 會亂碼,前面加 BOM 或改用xlsx外掛。 - 欄位順序由 item 的定義決定(dataclass 的宣告順序),不是由 spider 的賦值順序決定。
上線:cron 或 systemd timer
每日排程最簡單的長相(把 log 與產物分開放,失敗才有東西可查):
cd /srv/quotebot && /srv/venv/bin/scrapy crawl quotes \
-O /srv/data/quotes-$(date +%F).jsonl \
-s LOG_FILE=/srv/logs/quotes-$(date +%F).log
30 1 * * * cd /srv/quotebot && /srv/venv/bin/scrapy crawl quotes -O /srv/data/quotes-$(date +\%F).jsonl
四個上線才會遇到的問題:
- cron 裡的
%要跳脫(\%F),否則 cron 會把它當換行。 - 一定要用絕對路徑:cron 的 PATH 與工作目錄跟你手動跑不同,
scrapy直接寫會找不到。 - 不要把
-O指向同一個檔名又併發跑兩個行程,會互相截斷;用「日期+時段」命名。 - 監控「有沒有抓完」而不是「有沒有跑」。 Scrapy 有
finish_reason(finished/closespider_*/shutdown),結束碼 0 只代表「沒爆炸」,不代表抓完整。真要監控,就在close_spider裡把crawler.stats.get_stats()寫進一個 JSON,再比對item_scraped_count是否落在預期區間。
五個實際踩到的坑
| 坑 | 症狀 | 解法 |
|---|---|---|
| 2.19 的 dataclass item 不能用字典賦值 | TypeError: 'QuoteItem' object does not support item assignment,item_scraped_count 為 0 | 用 QuoteItem(text=..., author=...) 建構,之後賦值用 item.word_count = ...。若沿用 scrapy.Item 舊寫法就會踩到 |
| robots.txt 404 被誤認為「被封鎖」 | 統計出現 robotstxt/response_status_count/404 | 404 依規範視為允許,屬正常;真正被擋是 robotstxt/forbidden |
-O 蓋掉昨天的資料 | 檔案永遠只有當天那批 | 開發用 -O,排程用 -o 加日期檔名,或改用 feed URI 帶時間變數 |
選擇器抓得到、view(response) 卻是空殼 | 瀏覽器看得到內容、Scrapy 拿到空 HTML | 內容是 JS 渲染的,需 scrapy-playwright 或改用 Playwright/Crawl4AI |
把 DOWNLOAD_DELAY 當成萬用護身符 | 併發 16 條同時打,還是被 WAF 封 | DOWNLOAD_DELAY 只管「每個 request 間隔」,要一起調 CONCURRENT_REQUESTS_PER_DOMAIN 與 AUTOTHROTTLE;再加 ROTATING_PROXY/USER_AGENT 才有意義 |
限制:這篇沒有做的事
- 沒有實測
scrapy-playwright。 文中提到它是 JS 頁面的解法,但本次測試站是純 HTML,沒有驗證套件版本與設定。 - 沒有實測分散式(Scrapy-Redis/ScrapydWeb)。 單機 5.99 秒抓 10 頁的規模用不到,硬寫只是在抄文件。
- 沒有做壓力測試。 「能抓多少頁」取決於目標站、延遲設定、網路與被封的機率,任何「每秒 N 頁」的通用數字都不可信。
- 數字都是單次執行結果。 5.99 秒那次含 0.5 秒下載延遲與 AutoThrottle 的動態調整,不是純解析效能。
quotes.toscrape.com是練習站。 真實網站會遇到登入、反爬、動態 token、圖形驗證碼,那些不在本文範圍。
站內延伸閱讀
- Python Playwright 爬蟲完整教學:安裝、定位器、等待機制與反偵測實戰——Scrapy 拿不到 JS 內容時的搭檔。
- Crawl4AI 完整教學 2026:8 萬星開源 LLM 友善爬蟲框架——目標是「餵給 LLM」時,比 Scrapy 少一層轉換。
- 網站防爬蟲實戰指南:從 robots.txt 到 Cloudflare Bot 管理——從防守方視角看本文的每一個設定,會更清楚為什麼會被擋。
原始來源
- Scrapy 官方文件:安裝、教學、Selector、Item Pipeline、AutoThrottle、Feed exports、Settings、實務建議
- 專案首頁:scrapy.org|github.com/scrapy/scrapy
