先給到目前為止的實績:
完成的請求 70,630 筆 21 個資料來源
寫進 parquet 2,620 萬列
原始回應落地 47,241 個檔,0.82 GB
網路錯誤 158 次
行程被中斷 7 次(含一次機器休眠十七小時)
最後留在失敗清單裡的 0 筆
158 次錯誤、7 次中斷,最後 0 筆失敗、0 個資料洞。這篇講靠什麼撐住。
實際遇到的:

HTTP 520 82 次 上櫃,來源不明的錯誤
HTTP 502 33 次 MOPS,白天特別多
連線被對方切斷 26 次
DNS 解析失敗 10 次 這其實是我這邊的網路
讀取逾時 7 次
TWSE 實測大約每五秒三次請求就會開始擋。所以預設每次請求間隔 3 到 5 秒,帶隨機抖動,失敗時指數退避重試(8、16、32、64 秒),最多五次:
def get(self, url, *, cache_key, params=None, ...):
hit = self.cached(cache_key)
if hit is not None:
return hit # 快取命中,不發請求
for attempt in range(config.MAX_RETRIES):
self._throttle(host) # 每個網域各自限速
try:
r = sess.request(method, url, params=params, timeout=config.TIMEOUT)
if r.status_code == 429 or r.status_code >= 500:
raise requests.HTTPError(f"HTTP {r.status_code}")
r.raise_for_status()
self._store(cache_key, r.content) # 先落地,再回傳
return r.content
except Exception as e:
sleep = config.BACKOFF_BASE * (2 ** attempt) + random.uniform(0, 3)
time.sleep(sleep)
self._sessions.pop(host, None) # 被擋時換一條連線
指數退避不是為了禮貌,是因為對方擋你的時候,你越快重試它擋得越久。
至於 DNS 那十次,是我這邊的網路抖了一下。爬蟲分不出「對方擋我」和「我斷網」,也不用分——重試邏輯對兩者都有效。
每個網路回應,在解析之前先 gzip 存進 data/raw/。
這件事我原本以為只是為了「parser 改了可以重跑」。實際用起來,它救了三次:
0.00 的 bug,改完離線重解析 4,869 天,六分鐘、零次請求第三件事值得多講。TWSE 的每日收盤行情 API 一次回傳十張表,我的 parser 只用了報價那一張,另外九張直接丟掉。等到要做擇時的市場廣度指標時才發現,那些資料早就在硬碟上了:
「113年01月02日 價格指數(臺灣證券交易所)」 56 列
「報酬指數(臺灣證券交易所)」 47 列
「113年01月02日 大盤統計資訊」 17 列
「漲跌證券數合計」 5 列
「113年01月02日 每日收盤行情(全部)」 1,222 列 ← 只用了這張
用同一個 cache key 重讀一遍,5,390 個交易日在兩分鐘內跑完,速度是 0.0 秒/天,一次網路請求都沒發。
代價是 0.82 GB 的磁碟。相對於「重抓一次要幾小時而且可能被擋」,非常划算。
state/progress.db 是一個 SQLite,記錄每個 (來源, 日期或期別) 的狀態:
CREATE TABLE progress (
dataset TEXT NOT NULL, -- 'twse_daily'
key TEXT NOT NULL, -- '2024-01-02' 或 '2330:2024Q1'
status TEXT NOT NULL, -- done | empty | failed
rows INTEGER DEFAULT 0,
note TEXT,
updated TEXT NOT NULL,
PRIMARY KEY (dataset, key)
);
empty 跟 done 一樣不會重抓。這點很重要:如果把「沒有資料」當成「還沒抓」,每次重跑都會再去問交易所同樣的問題。
目前累積 3,979 筆 empty,來源大致是:
假日、休市、該來源當年還沒開始提供 3,008 筆
該期別本來就沒有資料 971 筆
現金流量表 780(那家公司那季沒申報)
月營收 154(早年沒有「外國公司」這個類別)
同一個 key 重複寫不會產生重複列,新抓到的覆蓋舊的。
這不只是為了容錯。交易所會事後更正數字——某天的收盤價過幾天可能被修正。冪等寫入讓「重抓一次」永遠是安全的操作,不用先煩惱要不要刪舊資料。
前面講過了。補一點:--delay-scale 2.0 可以整體放慢一倍,MOPS 在台股交易時間內特別容易 502,那時候用得上。
上面四個機制,我一開始只有三個是對的。
原本的寫法:抓完一天 → 資料丟進 ParquetWriter 的緩衝區 → 進度記成 done。
問題是 ParquetWriter.add() 只把資料放進記憶體緩衝區,真正寫檔要等累積夠多(我設 20 萬列,上市日線大約 180 天)才 flush 一次。
所以中間有一段時間,進度說「抓過了」,但檔案裡還沒有。

正常結束沒問題,with 區塊離開會 flush。Ctrl-C 也沒問題,例外往上拋一樣會 flush。
被 SIGKILL 或機器斷電就有問題了。那批資料消失,進度卻留著 done。重跑時爬蟲看到 done 就跳過,那幾天永遠不會再被抓。
而且它不報錯。你要主動比對「進度說完成幾天」和「檔案裡有幾天」才會發現。
抓到一半順手查了一下:
state 說 twse_daily 完成 2,418 天
parquet 裡實際有 2,282 天
差 136 天
那 136 天正卡在緩衝區。如果那個當下機器被強制關掉,這 136 天就永久跳過了,而我只會在幾個月後某次分析裡看到一段莫名其妙的空白。
把順序倒過來,並且把這個不變式交給 ParquetWriter 自己守:
def flush(self):
...
written = self._merge_into(path, new) # 先把資料寫進 parquet
self._commit_pending() # 寫成功了,才把這批 key 記成 done
return written
def add(self, df, key=None):
self._buf.append(df)
if key is not None:
self._pending.append((key, "done", len(df), ""))
if self._buf_rows >= FLUSH_ROWS or len(self._pending) >= self._mark_batch:
self.flush()
每 30 天 checkpoint 一次:先 flush() 把資料寫進檔案,成功了才把這批進度記進去。順序不能顛倒。
反過來的殘留(資料寫了但進度沒記)是安全的:重跑會重抓那幾天,然後被冪等寫入覆蓋掉。寧可重做,不可漏做。
現在 crawler/sources/ 底下沒有任何一支自己呼叫 state.mark(..., "done"),以後新增的來源自動受保護。
批次大小 30 天是折衷:當機最多損失 30 天的工作(約兩分鐘),代價是分區檔要多重寫幾次。
修完之後我加了一項檢查進 crawler check,專門抓這類洞:
【進度與檔案一致性】
twse_daily 進度 done 5051|檔案 5051|OK
tpex_daily 進度 done 4563|檔案 4563|OK
twse_inst 進度 done 3491|檔案 3491|OK
tpex_inst 進度 done 2852|檔案 2852|OK
twse_valuation 進度 done 4875|檔案 4875|OK
mops_revenue 進度 done 199|檔案 199|OK
不一致時它會印出缺哪幾天,以及補抓的指令。
這種檢查的價值不在於「現在有沒有問題」,在於下次我改壞了會知道。爬蟲最危險的失敗模式不是崩潰,是安靜地少抓——網站改版後 parser 抓到空表,流程照樣跑完,資料就少了一塊而且沒有任何跡象。