你的 Scrapy spider 在 100 個測試頁面上跑得很順,結果一到 10,000 頁就整個炸掉。這通常不是爬取邏輯本身有問題,而是那些看起來像爬取 bug、其實是網路層在鬧的狀況。大家第一個想到的解法通常就是代理伺服器,而把代理塞進 request.meta,大概三十秒就能做完。
問題是,這個三十秒的解法,往往也就是很多入門教學的終點。它們通常只會教你 meta={"proxy": "http://IP:PORT"},最多再加一個 middleware 類別就收工。它們很少會講到:代理在爬取途中掛掉怎麼辦、憑證怎麼避免流進 Git 歷史、或者為什麼重試時可能又沿用同一個失敗代理。這篇指南要處理的,正是這些只有上線後才會真的撞到的問題:失敗即封閉(fail-closed)的設定、密鑰管理、重試時有意識地選擇代理、協定限制,以及可量化的成本取捨。
Scrapy 裡的代理中介軟體是什麼?
代理中介軟體是一段位在 Scrapy downloader 流程中的程式碼,它會在請求真的送出去之前,決定這個請求看起來是從哪個 IP 發出。Scrapy 內建的 HttpProxyMiddleware 會讀取 request.meta 裡的 proxy 鍵,必要時附上代理驗證資訊,然後把請求交給真正負責建立連線的下載處理器。自訂代理中介軟體並不是取代這個傳輸步驟,而是先在內建機制接手前,負責挑選要用哪個代理的「選擇器」。這個差異比表面看起來更重要,也正是大多數「我的自訂 middleware 沒作用」問題的根源。
為什麼代理伺服器對正式版 Scrapy 專案很重要
代理會改變網路路徑與對外顯示的來源 IP。這對合法的地區測試、分散授權的請求流量,以及隔離網路故障都很有幫助。但它不會自動授權你存取內容,也不能繞過速率限制,更不保證一定能成功。爬蟲到底需不需要代理,取決於目標網站、其條款、請求頻率,以及這個任務對穩定性的要求。
不是每種代理都一樣,而選錯等級本身就可能變成一場正式環境事故:
| 代理類型 | 可靠性 | 速度 | 被偵測風險 | 常見成本 |
|---|---|---|---|---|
| 免費公開代理 | 變動極大 | 變動 | 通常很高 | 不收費,但有明顯的安全與營運風險 |
| 資料中心代理 | 依供應商與目標而定 | 通常較快 | 依目標而定 | 常以 GB 或 IP 計價 |
| 家用代理 | 依供應商與目標而定 | 變動 | 依目標而定 | 常以 GB 計價 |
| ISP 代理 | 依供應商與目標而定 | 變動 | 依目標而定 | 依供應商而異 |
免費代理之所以要特別提醒,是因為實際風險很高。為期 30 個月的 Free Proxies Unmasked 研究 追蹤了 11 家供應商的 640,000 多個位址:其中 34.5% 至少曾經活躍一次,且有 16,923 個會竄改內容。這個結果足以對公開代理清單發出強烈的安全警告;但它並不是所有最新清單、付費代理池、目標網站或工作負載都適用的通用失敗率。
代理也不是萬能的反機器人偵測破解方案。像 Cloudflare 這類服務,會用 TLS 指紋、標頭一致性、JavaScript 執行、行為模式等數十種訊號來評分,而 IP 只是其中一個因素。即使你用了乾淨的家用代理,如果請求標頭前後不一致,還是沒有 cookie jar,一樣可能被標記。別在還沒理解這點之前,就把整個架構建立在「只要輪替 IP 就好」的假設上。
開始之前
- 難度: 中等
- 所需時間: 完整正式版設定約 30–45 分鐘,快速測試約 5 分鐘
- 你需要準備: Python 3.9+、已安裝 Scrapy(本文以 Scrapy 2.17.0 為測試版本,發佈於 2026 年 7 月)、透過
scrapy startproject建立好的 spider,以及至少一個代理端點(任何資料中心代理供應商提供的免費試用都適合測試)
步驟 1:用 Request Meta 參數測試代理
確認代理是否可用最快的方法,就是先完全繞過所有 middleware 架構,直接測試它。
Scrapy 內建的 HttpProxyMiddleware 會直接讀取 request.meta 裡的 proxy 鍵,並透過它路由請求。不需要改設定,也不需要自訂 middleware 類別,只要一個 keyword argument。
import scrapy
class ProxyTestSpider(scrapy.Spider):
name = "proxy_test"
start_urls = ["https://httpbin.org/ip"]
def start_requests(self):
for url in self.start_urls:
yield scrapy.Request(
url,
meta={"proxy": "http://203.0.113.10:8080"},
callback=self.parse,
)
def parse(self, response):
self.logger.info(response.text)
使用 scrapy runspider proxy_test.py 執行。若代理正常,httpbin.org/ip 會回傳代理伺服器的 IP,而不是你的 IP,這就代表成功。如果回應卡住,最後出現 TCP connection timed out,代表代理已失效或無法連線——而且老實說,這種情況比代理供應商宣稱的還常見。
這個方法很適合一次性 spider 或快速測試。但只要你不只一個 spider,它就會開始失控,因為你得在五個不同檔案裡硬編碼同一段代理字串。
步驟 2:建立自訂代理中介軟體
只要不只是單一 spider,就應該把代理邏輯集中管理。請在專案的 middlewares.py 裡建立一個 ProxyMiddleware 類別:
from scrapy.exceptions import NotConfigured
class ProxyMiddleware:
def __init__(self, proxy_url):
self.proxy_url = proxy_url
@classmethod
def from_crawler(cls, crawler):
proxy_url = crawler.settings.get("PROXY_URL")
if not proxy_url:
raise NotConfigured("PROXY_URL is required; refusing silent direct fallback")
return cls(proxy_url=proxy_url)
def process_request(self, request, spider):
if "proxy" not in request.meta:
request.meta["proxy"] = self.proxy_url
然後在 settings.py 註冊:
import os
PROXY_URL = os.environ.get("PROXY_URL")
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.ProxyMiddleware": 350,
}
請注意這裡用的是 from_crawler 類別方法,而不是單純的 __init__。這正是 Scrapy 建議用來讀取設定的方式,而我們在第 4 步談到密鑰時,也會再次用到這個 hook。這樣做的好處很直接:只要改一個設定,整個專案裡的 spider 都會一起切換到新的代理。不用再到處搜尋,把五個檔案裡過期的 IP 一個個改掉。
步驟 3:理解 middleware 執行順序,避免代理默默失效
這裡是幾乎所有教學都會用一句「把優先順序設成 350 就好,相信我」草草帶過的部分。Scrapy 的 downloader middleware 會依照固定順序執行,如果你不理解這件事,代理設定就會出現一些看起來跟代理完全無關的 bug。
請求端的 hook(process_request)會依 遞增 優先順序執行,也就是數字越小越先跑。回應端的 hook(process_response、process_exception)則會依 遞減 優先順序執行,也就是數字越大越先跑,再往回一路解開。
Request flow (ascending):
Spider → [100] RobotsTxt → [300] HttpAuth → [350] YOUR PROXY MIDDLEWARE
→ [500] UserAgent → [550] Retry → [700] Cookies → [750] HttpProxy
→ Downloader
Response flow (descending):
Downloader → [750] HttpProxy → [700] Cookies → [550] Retry
→ [500] UserAgent → [350] YOUR PROXY MIDDLEWARE → [300] HttpAuth
→ [100] RobotsTxt → Spider
以下是 Scrapy 內建 middleware 的最新預設優先順序表(已對照 2.17.0 驗證):
| Middleware | 預設優先順序 |
|---|---|
| RobotsTxtMiddleware | 100 |
| HttpAuthMiddleware | 300 |
| DownloadTimeoutMiddleware | 350 |
| DefaultHeadersMiddleware | 400 |
| UserAgentMiddleware | 500 |
| RetryMiddleware | 550 |
| RedirectMiddleware | 600 |
| CookiesMiddleware | 700 |
| HttpProxyMiddleware | 750 |
| DownloaderStats | 850 |
| HttpCacheMiddleware | 900 |
當 RetryMiddleware 排程一次重試時,它會複製失敗的請求——包含其 metadata——而這個新請求會重新進入 downloader middleware 鏈。selector 與優先順序 550 的數字關係,並不代表它一定會自動輪替代理。只有當 selector 程式碼辨識出這是重試請求,並且刻意覆寫複製過來的 meta["proxy"] 時,輪替才會發生。350 這個位置很適合放 selector,因為它會在內建的傳輸步驟 750 之前執行,但它本身並不是輪替機制。

常見的 middleware 排序錯誤
- 把你的 middleware 設成和
HttpProxyMiddleware同樣的優先順序(750): 這會造成競態條件,Scrapy 的 dict 順序(不是你的邏輯)會決定哪個 middleware 先處理請求。症狀:代理行為時好時壞,完全無法解釋。 - 在輪替 selector 中使用
setdefault()或if "proxy" not in request.meta: 重試時複製過來的請求會保留舊代理。症狀:每次重試都走同一條失敗路徑。解法:偵測重試(例如retry_times > 0),並明確替換掉 selector 管理的代理值。 - 完全停用
HttpProxyMiddleware: 有些教學會叫你把"scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": None,理由是「自訂 middleware 會處理好」。其實不是——你的自訂 middleware 只是 selector,不是傳輸層。把內建 middleware 關掉後,代理驗證標頭就不會被附上,請求會在沒有認證的情況下悄悄送出,甚至根本送不出去。
步驟 4:不要把代理憑證硬寫死
我看過幾乎所有排名靠前的 Scrapy 代理教學都會把 http://username:password@proxy.example.com:8080 直接寫進 Python 檔。這等於把憑證永久留在 Git 歷史裡,之後每次 clone、fork,甚至有人不小心 print() 出來,都可能被翻出來。
實際上有三種做法:
| 方法 | 安全性 | 彈性 | 適合情境 |
|---|---|---|---|
| 直接硬寫在 spider/settings.py | 差——密鑰留在 repo 裡 | 低 | 只適合快速本機測試 |
http_proxy 環境變數(Scrapy 原生) | 較好——不在程式碼中 | 低(單一代理) | CI/CD 流程、Docker |
.env 檔 + python-dotenv + from_crawler | 最佳——不在程式碼中,且可依環境切換 | 高(多代理、可輪替) | 正式版爬蟲 |
第三種方法最值得好好做。先安裝 python-dotenv,建立 .env 檔,並立刻把它加入 .gitignore——我是認真的,現在就做:
PROXY_USER=myuser
PROXY_PASSWORD=my$ecret!Pass
PROXY_HOST=proxy.example.com
PROXY_PORT=8080
接著在 settings.py 最上方載入:
from dotenv import load_dotenv
import os
load_dotenv()
PROXY_USER = os.getenv("PROXY_USER")
PROXY_PASSWORD = os.getenv("PROXY_PASSWORD")
PROXY_HOST = os.getenv("PROXY_HOST")
PROXY_PORT = os.getenv("PROXY_PORT")
然後在 middleware 裡用 from_crawler 安全地讀取這些值——這正是我在論壇上最常看到的困惑的解法:人們會問「如果每次執行 scrapy crawl 前都可能換憑證,要怎麼在執行前設定?」
import os
from urllib.parse import quote
class SecureProxyMiddleware:
def __init__(self, user, password, host, port):
self.user = quote(user, safe="")
self.password = quote(password, safe="")
self.host = host
self.port = port
@classmethod
def from_crawler(cls, crawler):
settings = crawler.settings
return cls(
user=settings.get("PROXY_USER"),
password=settings.get("PROXY_PASSWORD"),
host=settings.get("PROXY_HOST"),
port=settings.get("PROXY_PORT"),
)
def process_request(self, request, spider):
proxy_url = f"http://{self.user}:{self.password}@{self.host}:{self.port}"
request.meta["proxy"] = proxy_url
注意這裡對憑證做了 urllib.parse.quote()。如果你的密碼裡含有 @、: 或 /(密碼產生器很愛塞這些符號),不先做 percent-encoding,URL 解析就會壞掉。這只是一行修正,卻能幫你省下一小時的尷尬排錯時間,免得你一直追查「invalid proxy URL」這種其實跟代理本身無關的錯誤。
把憑證留在 log 外面,並用真實但假的樣本值測試 percent-encoding。HTTPS tunnel、SOCKS5,以及非拉丁字元憑證都有各自的處理邊界;不要在沒有針對性的整合測試前,就假設同一套驗證方式可以通用。

步驟 5:加入代理輪替
就算是一個不錯的代理,連續對同一網站發出 5,000 次請求,最後也很可能被標記。你需要一個代理池。
方案 A——自己寫。 其實非常簡單:
import random
class RotatingProxyMiddleware:
def __init__(self, proxy_pool):
self.proxy_pool = proxy_pool
@classmethod
def from_crawler(cls, crawler):
return cls(proxy_pool=crawler.settings.getlist("PROXY_POOL"))
def process_request(self, request, spider):
request.meta["proxy"] = random.choice(self.proxy_pool)
這種方式適合基本用途,但它完全不知道哪些代理真的還活著。你只是每次請求都在擲骰子。
方案 B——使用 scrapy-rotating-proxies。 這個第三方套件內建封鎖偵測與自動退避:
pip install scrapy-rotating-proxies
這裡我得老實說:根據 PyPI 上的最新版本頁面,其版本是 0.6.2,日期是 2019 年,而且專案標記為 Alpha。它不一定在現代 Scrapy 上壞掉,但「2019 年後沒有持續積極維護」並不等於「已經能穩穩撐住 2026 年的正式流量」。請固定版本、用你的真實目標站測試,且不要想當然地認為它能處理需要驗證的代理端點——它大致上做不到。
步驟 6:建立容錯型代理中介軟體(偵測失效代理)
這一段通常是競品教學完全省略的部分,但它正是 demo 與能撐過 6 小時無人看管爬取之間的差別。
| 情境 | 多數教學怎麼處理 | 這個 middleware 新增了什麼 |
|---|---|---|
| 代理回傳 407 | 通常不處理 | 將其視為代理驗證失敗 |
| 目標站回傳 403/429 | 經常混在一起 | 分開處理政策/速率回饋與代理健康狀態 |
| 代理逾時 | 通常不處理 | 可設定 timeout 閾值與健康分數衰減 |
| 所有代理都掛了 | 通常不處理 | 優雅退回或暫停爬取,並記錄警告 |
| 代理間歇性失靈 | 通常不處理 | 在重新加入池前加入冷卻期 |
import time
import random
from scrapy.exceptions import IgnoreRequest
class FaultTolerantProxyMiddleware:
MAX_FAILURES = 3
COOLDOWN_SECONDS = 300
def __init__(self, proxy_pool):
self.pool = {p: {"failures": 0, "banned_until": 0} for p in proxy_pool}
@classmethod
def from_crawler(cls, crawler):
return cls(proxy_pool=crawler.settings.getlist("PROXY_POOL"))
def _healthy_proxies(self):
now = time.time()
return [p for p, s in self.pool.items() if s["banned_until"] < now]
def process_request(self, request, spider):
healthy = self._healthy_proxies()
if not healthy:
spider.logger.warning("All proxies unhealthy — pausing crawl")
raise IgnoreRequest("No healthy proxies available")
request.meta["proxy"] = random.choice(healthy)
def process_response(self, request, response, spider):
proxy = request.meta.get("proxy")
if proxy and response.status == 407:
self._mark_failure(proxy)
return response
def process_exception(self, request, exception, spider):
proxy = request.meta.get("proxy")
if proxy:
self._mark_failure(proxy)
def _mark_failure(self, proxy):
state = self.pool[proxy]
state["failures"] += 1
if state["failures"] >= self.MAX_FAILURES:
state["banned_until"] = time.time() + self.COOLDOWN_SECONDS
state["failures"] = 0
根據實作經驗,有幾點要注意:不要把每個 403 都當成代理已死——RFC 9110 將 403 定義為「伺服器理解了請求,但拒絕處理」,這也可能只是你的標頭或 session 看起來可疑,跟代理本身無關。相對地,407 代表的是代理本身拒絕你的驗證,這是更強也更精確的訊號。把這些訊號混成同一桶,是很多人白白燒掉健康代理的原因。
正式環境請明確保留 Scrapy 的暫時性重試預設,讓審查者看得懂政策:
RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429]
RETRY_TIMES = 2
這些是 Scrapy 2.17 的預設值。不要一股腦把 403 或 407 加進去:403 是目標站拒絕請求,原因很多;407 是代理驗證問題,單純重試請求並不會解決。如果某個特定目標契約確實需要針對其他狀態碼重試,請把原因寫清楚,並單獨測試。

步驟 7:一種有成本意識的「先直連、再代理」模式
某些你已獲授權的目標,可能直接連線就能拿到有效內容,只有在發生文件化的暫時性回應後才需要改走代理。這樣可降低透過代理傳輸的流量,但不存在任何可合理套用的通用節省百分比,也沒有可直接移植的直連成功率。採用這種模式前,請先用自己的工作負載做量測。
請使用 Scrapy 公開的 get_retry_request() 輔助函式,並且只對你明確分類過的、目標專屬狀態碼進行升級處理。下面這個範例把 429 與 503 視為可重試的壓力訊號,刻意排除 403 與 407。
from scrapy.downloadermiddlewares.retry import get_retry_request
class CostAwareEscalationMiddleware:
def process_response(self, request, response, spider):
if response.status not in {429, 503}:
return response
retry = get_retry_request(
request,
spider=spider,
reason=f"proxy_escalation_{response.status}",
max_retry_times=2,
)
if retry is None:
return response
current_tier = request.meta.get("proxy_tier", "direct")
if current_tier == "direct":
retry.meta["proxy"] = spider.settings["DATACENTER_PROXY"]
retry.meta["proxy_tier"] = "datacenter"
elif current_tier == "datacenter":
retry.meta["proxy"] = spider.settings["RESIDENTIAL_PROXY"]
retry.meta["proxy_tier"] = "residential"
else:
return response
return retry
請記錄直連有效內容率、代理有效內容率、每筆成功記錄的流量、每次成功所需重試次數,以及額外延遲。只有在直連存取是被授權的、且系統在需要代理時會失敗即封閉,這種先直連的設計才算可接受。真正省下來的是可量測的代理流量減少,而不是憑空假設的比例。
什麼時候 DIY 代理管理不值得做
我想坦白說明,而不是假裝整篇文章都是多餘的:上面提到的所有做法都是真實且有用的工程方案。對很多專案來說——高流量爬取、自訂資料流、或任何需要完全掌控請求排程的場景——這就是正確做法。
但如果你的真正目標是「從這個頁面拿到結構化資料」,而不是「維運代理基礎設施」,那麼抽取 API 可能是更好的邊界。Thunderbit 目前的 POST /extract 文件 接受頁面 URL 與可選的 JSON Schema;當沒有提供 schema 時,服務可以根據頁面內容自動產生。這個端點也提供 none、basic、full 三種渲染模式,以及 timeout 與載入後等待控制。純提示詞抽取並不屬於目前支援的請求介面,所以正式整合時應以文件化的 schema 合約為基礎。這會把抽取介面收斂成單一請求,但並不代表它能對所有反機器人或 CAPTCHA 目標做出萬能保證。
| 因素 | 自建 Scrapy + 代理 | 以 API 為基礎的抽取(例如 Thunderbit) | |---|---|---|---| | 設定成本 | 高——middleware、輪替、重試邏輯都要自己做 | 低——一次 API 呼叫加上 schema 即可 | | 網路/渲染行為 | 由你設定 handler、代理、標頭與延遲 | 透過文件化的 API 選項控制 | | 維護 | selector、代理池健康度、目標變動都要自己負責 | schema 品質、驗證與整合行為由你負責 | | 成本模型 | 代理費 + 運算成本 + 工程時間 | 目前文件標示每頁 20 units(查核於 2026-08-10) | | 控制度 | 完整——自訂 pipeline、middleware 鏈 | 受限於 API 能力 | | 最適合 | 複雜爬取、高流量、自訂邏輯 | 定向抽取、原型驗證、資料補強 |
如果你主要是在擷取名單、產品資料或研究用的結構化頁面,不妨先用 Thunderbit Chrome Extension 或 API 做一個代表性試點,比較有效記錄數、延遲、units 與維護時間。如果你的專案需要自訂爬取圖與 pipeline 控制,那麼 Scrapy 仍然是更強的選擇。
延伸閱讀
提示與常見陷阱
- 提示: 在把代理指向真實目標之前,先用
httpbin.org/ip測試。這是確認路由是否正常、再往上疊加其他複雜度的最快方法。 - 陷阱: 把代理設在
request.headers而不是request.meta。這真的是一種很常見、看起來又很像只是打錯字的 bug——HttpProxyMiddleware只會讀meta["proxy"],用 header 設定不會報明顯錯誤,只會默默失敗。 - 陷阱: 以為 HTTP 和 HTTPS 代理的設定方式完全一樣。HTTP 代理 URL 轉送到 HTTPS 目的地,通常會透過相容的 handler 以 CONNECT tunnel 運作;但代理端點本身使用
https://scheme,則是另一種支持度較低的設定——不要把兩者混為一談。 - 提示: 如果你需要 SOCKS5 支援,請先確認 Scrapy 版本的下載處理器能力。Scrapy 2.17 的實驗性 Httpx handler 透過
httpx[socks]支援 SOCKS5,但它仍標記為實驗性——不要在沒有自己測試的情況下,把它當成正式依賴。
替代做法
除了 scrapy-rotating-proxies 之外,有些團隊會把所有代理邏輯交給供應商閘道處理——也就是只對外暴露單一代理 URL,由供應商在後台處理輪替、session stickiness 與地區定位。這樣能用較少的 middleware 程式碼換取較少的控制權,正式寫自己的代理池之前,值得先和自管方案比較成本。
結論
在 Scrapy 裡設定代理,只需要一行。但要讓這套設定撐過真實的正式爬取,則需要明確的失敗政策、受保護的憑證、經過測試的處理器邊界,以及在重試時會刻意覆寫複製代理 metadata 的 selector 程式碼。如果你只帶走兩個習慣,那就記住這兩件事:當必須使用代理時,要失敗即封閉;而且永遠不要把代理密碼提交到 Python 檔案裡。
常見問題
如何在 Scrapy 中設定帶驗證的自訂代理?
使用 protocol://username:password@host:port 的 URL 格式,但如果 username 和 password 含有特殊字元,請先用 urllib.parse.quote() 進行 percent-encoding。正式環境中,應透過 from_crawler 類別方法從環境變數讀取這些憑證,不要硬寫在程式碼裡。
Scrapy 的自訂代理 middleware 應該用什麼優先順序?
350 是常見的 selector 優先順序,因為它會在 HttpProxyMiddleware(750)之前執行。但這不代表它能保證輪替。只有當 selector 辨識出複製過來的重試請求,並覆寫先前的 meta["proxy"] 值時,重試才會拿到新的代理。
如何在 Scrapy 中自動處理失效代理?
建立一個 middleware,在 process_response 與 process_exception 中追蹤每個代理的失敗次數;當失敗超過門檻後,將其從可用池中移除,並在冷卻期後重新加入,而不是永久封鎖。
Scrapy 可以搭配 SOCKS5 代理使用嗎?
Scrapy 2.17 的實驗性 HttpxDownloadHandler 在安裝 httpx[socks] 時支援 SOCKS5。預設的 HTTP/1.1 handler 不支援 SOCKS 代理。請先固定 handler 與版本,並跑整合測試,再把這條路徑視為可上線。
「先直連、再代理」這種模式到底能省多少代理成本? 沒有通用比例。請量測:有授權的請求中直接拿到有效內容的比例、每個代理層級傳輸的流量、每筆成功記錄所需的重試次數,以及額外延遲。實際減少的代理流量就是你的節省;如果直連存取沒有授權,或內容無效,就不要使用這種模式。


