iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0

先給到目前為止的實績:

完成的請求      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 改了可以重跑」。實際用起來,它救了三次:

  1. 上櫃日線把「無成交」寫成 0.00 的 bug,改完離線重解析 4,869 天,六分鐘、零次請求
  2. 現金流量表 Q4 表頭格式不同導致全被丟掉,1,234 筆從快取重解析
  3. 大盤指數、產業指數、報酬指數、漲跌家數——這些一直躺在已經抓過的回應裡

第三件事值得多講。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)
);

emptydone 一樣不會重抓。這點很重要:如果把「沒有資料」當成「還沒抓」,每次重跑都會再去問交易所同樣的問題。

目前累積 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 抓到空表,流程照樣跑完,資料就少了一塊而且沒有任何跡象。


上一篇
Day 5 - 台股資料到處都有,為什麼我還要自己爬
下一篇
Day 7 - 它不會報錯,只會給你錯的東西
系列文
台股日頻量化交易:開發一條從資料、特徵、模型到每日下單清單的產線8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言