寫爬蟲最花時間的不是抓不到。抓不到你會看到 traceback,然後去修。
真正麻煩的是抓到了,而且看起來完全正常。程式跑完、DataFrame 結構正確、欄位齊全、數字合理——只是內容是錯的。
這幾天我踩到四種,形狀都不一樣。整理成一份分類。
公開資訊觀測站的月營收頁面,HTML 開頭這樣寫:
<meta http-equiv="Content-Type" content="text/html; charset=big5">
照著做:
>>> raw = gzip.open("data/raw/mops_revenue/sii/113_01_0.html.gz", "rb").read()
>>> raw.decode("big5")
UnicodeDecodeError: 'big5' codec can't decode byte 0xf9
in position 182411: illegal multibyte sequence
檔案 445,412 bytes,斷在第 182,411 個。才走了 41%。
那個 0xf9 是什麼字?
>>> raw[182371:182411].decode("cp950")
' align=center>2353</td><td align=left>宏'
>>> raw[182411:182413].decode("cp950")
'碁'
**2353,宏碁。**卡在「碁」上。
big5 沒有這個字,它在 CP950 才有。CP950 是 big5 的超集:
碁 → big5 沒有,cp950 有
銹 → big5 沒有,cp950 有
裏 → big5 沒有,cp950 有
墻 → big5 沒有,cp950 有
都是常用字,出現在公司名稱裡的機率不低。
用 big5 解碼,解析器讀到 41% 就停住。而且它不會拋錯給你看——把原始 bytes 直接丟給 pandas.read_html,它會照著 meta 宣告的 big5 去解,撞到壞位元組就停在那裡,然後把讀到的部分解析完、回傳一張看起來完全正常的表格:
>>> dfs = pd.read_html(io.BytesIO(raw)) # 讓 pandas 自己處理編碼
>>> len(dfs)
31 # 沒有任何例外

明確指定 cp950 970 家
把 bytes 丟給 pandas 自己猜 395 家
漏掉 575 家 = 59.3%
而且漏掉的不是隨機的六成。MOPS 的月營收表是按產業分成好幾張 HTML table 串起來的,截斷是把整張表切掉:

完整 讀得到 漏掉
電子零組件業 94 0 94
光電業 64 0 64
建材營造 53 0 53
金融保險業 32 0 32
航運業 27 0 27
...
半導體業 85 85 0
生技醫療業 47 47 0
家數十家以上的產業,有 15 個一家都不剩。
拿這份資料做研究,你的世界裡沒有光電、沒有航運、沒有金融、沒有建材。你會算出「台股的產業輪動」,而那個結論建立在一半產業不存在的前提上。半導體、生技、電機、紡織則完好無損——它們剛好排在「碁」前面。
順帶一提,同一個站不同端點的行為還不一樣:
月營收 宣告 big5 實際只有 cp950 解得開
財報 沒有宣告 實際是 utf-8
現金流量表 沒有宣告 實際是 utf-8
修法只差一個字,重點是要知道該改:
def parse(body: bytes, ...):
# 關鍵:cp950 而非 meta 宣告的 big5
html = body.decode("cp950", errors="replace")
errors="replace" 而不是 "ignore"。這兩個的差別值得講清楚,因為它們是兩種不同的失敗:
big5 + errors='ignore' → 970 家(不截斷),但「宏碁」變成「宏」
big5 + errors='replace' → 970 家(不截斷),「宏碁」變成「宏��」
ignore 不會漏掉公司,它只是把解不開的位元組默默丟掉,於是你的股票對照表多一家叫「宏」的公司,而且你不會發現。replace 至少留下 � 這個看得見的記號。
上櫃的每日收盤行情,某幾天長這樣:
2011-11-08 8077 收盤 6.98 成交量 1000
2011-11-09 8077 收盤 0.00 成交量 0 ← 沒有成交
2011-11-10 8077 收盤 0.00 成交量 0 ← 沒有成交
2011-11-14 8077 收盤 NaN 成交量 0 ← 同樣沒有成交
2011-11-15 8077 收盤 6.91 成交量 1000
同一支股票、同一個月,「當日無成交」有兩種寫法。我一開始以為是年代差異,翻原始回應才發現它們並存:
交易所原始回應(8077,2011 年 11 月)
11/09 "0.00" 11/14 "--"
11/10 "0.00" 11/16 "--"
11/11 "0.00" 11/17 "--"
我的清洗規則認得 --:
_NULL_TOKENS = {"", "-", "--", "---", "n/a", "N/A", "不適用", "無", "nan", "None"}
def to_num(series):
s = series.astype("string")
s = s.str.replace(",", "", regex=False)
s = s.mask(s.isin(_NULL_TOKENS)) # 這裡攔掉 '--'
return pd.to_numeric(s, errors="coerce")
0.00 通過每一條規則。它是一個完全合法的數字。
於是「這天沒人交易」變成「這天股價是 0 元」。還原股價照樣把它乘上調整係數,跨過這天的任何報酬計算就是 ±inf 或 −100%。
規模:112,356 列,佔全部日線的 1.29%,全部集中在上櫃 2008 到 2011 年。
這一種跟第一種一樣不會報錯,但更難防。第一種只要你明確呼叫 raw.decode("big5"),Python 會丟 UnicodeDecodeError 給你看;只有在把 bytes 交給 pandas、或加了 errors="ignore" 的時候才會靜默。這一種連那條路都沒有——0.00 在任何一層都是合法輸入,從頭到尾沒有異常訊號,直到你去算報酬,才會看到一堆 inf。
這是我覺得最陰險的一種。
上櫃的個股本益比查詢,可以帶日期參數。我想抓 2007 年開始的歷史(頁面自己寫「本資訊自民國96年1月起開始提供」)。

我請求的日期 伺服器回應的「資料日期」
96/01/02 → 115/08/27 不是我要的
96/06/29 → 115/08/27 不是我要的
99/01/04 → 115/08/27 不是我要的
100/01/03 → 100/01/03 一致
113/01/02 → 113/01/02 一致
115/08/25 → 115/08/25 一致
超出可查範圍時,它不報錯、不回空表,直接把「今天」的資料給你。
如果我沒有比對回應裡的「資料日期」,2007 到 2010 那四年會被塞進四年份的今日快照。而那份資料在任何檢查裡都會通過:欄位齊全、數字合理、家數正常、也不會有 NaN。你甚至可以拿它跑出漂亮的回測,因為那四年「每一天」的本益比都一模一樣。
防法只能是硬檢查:
want = f"{day.year - 1911}/{day.month:02d}/{day.day:02d}"
stamp = next((l for l in lines[:5] if "資料日期" in l), "")
if want not in stamp:
return pd.DataFrame() # 不是我要的那天,當成沒有資料
加上這道防線之後,真正的邊界跑出來了:2011-01-03,不是頁面自稱的民國 96 年。
順帶一提,同一支端點還無視我傳的 charset=UTF-8,實際回 CP950——第一種和第三種同時發生。
前三種是「拿到錯的」。第四種反過來:你手上一直有,只是不知道。
我的爬蟲抓上市日線,用的是 TWSE 的每日收盤行情 API。parser 從回應裡找出報價那張表,解析,寫檔,完成。跑了 5,051 個交易日。
後來要做擇時的市場廣度指標,我想找「每天有多少檔上漲、多少檔下跌」。查了半天怎麼抓,最後打開一個已經存下來的原始回應——

「113年01月02日 價格指數(臺灣證券交易所)」 56 列
「價格指數(跨市場)」 41 列
「價格指數(臺灣指數公司)」 35 列
「報酬指數(臺灣證券交易所)」 47 列
「報酬指數(跨市場)」 42 列
「報酬指數(臺灣指數公司)」 34 列
「113年01月02日 大盤統計資訊」 17 列
「漲跌證券數合計」 5 列
「113年01月02日 每日收盤行情(全部)」 1,222 列 ← 我只讀了這張
一次請求回九張表。發行量加權股價指數、加權報酬指數(含息)、各產業指數、漲跌家數、分類成交統計——全都在裡面,而且已經在我硬碟上躺了 5,051 天。
因為原始回應一律 gzip 落地,我用同一個 cache key 重讀一遍就把它們抽出來了:
大盤指數 5,390/5,390(100%)|0.0 秒/天
tw_index 617,372 列 356 種指數(加權指數、報酬指數、各產業指數)
market_breadth 7,356 列 漲跌家數
market_turnover 93,993 列 分類成交統計
兩分鐘,零次網路請求。
有兩個限制要講清楚。第一,TWSE 是逐步把這些表加進回應的——大盤統計從 2006 就在,指數表 2009 才出現,漲跌家數要到 2011 年 8 月。所以市場廣度的可用區間是 2011-08 起,不是 20 年。第二,2009 年以前的加權指數只能另外抓,我用 FMTQIK(每日市場成交資訊,一次回一個月)補了 271 個月、1,226 列,那部分是有發請求的。
這一種沒有「防法」,只有習慣:每接一個新來源,先把完整回應印出來看一遍,不要只找你當下要的那個欄位。我沒做這件事,代價是整個專案一直沒有大盤指數可用,Day 3 的擇時只好拿 0050 當代理。
| 症狀 | 會不會讓程式停下來 | 你怎麼發現 | |
|---|---|---|---|
| 一 | 宣告錯的編碼 | 明確 decode("big5") 會;交給 pandas 或用 errors="ignore" 就不會 |
比對家數 |
| 二 | 用合法值表示缺失 | 不會 | 算報酬時看到 inf |
| 三 | 超範圍時回傳別的資料 | 不會 | 比對回應裡的日期 |
| 四 | 你沒發現它給了什麼 | 不會 | 打開原始回應看 |
**四種在實務上的常見寫法裡,沒有一種會讓程式停下來。**第一種是唯一有機會拋例外的,但那要你剛好用了會拋例外的寫法——而 pd.read_html(io.BytesIO(raw)) 這種最省事的寫法,正好不會。
所以我對「資料品質檢查」的想法變了。一開始我以為那是抓完之後的收尾工作,跑一次確認沒事就好。現在我把它當成跟 parser 同等重要的東西——因為 parser 只負責把資料變成 DataFrame,而它沒有辦法知道自己拿到的是不是對的。
目前 crawler check 裡的檢查,每一項都對應到上面某一種:
月營收連續性 逐月列出家數,突然掉三成就是 parser 出事 ← 第一種
無法解釋的跳空 單日報酬 > 50% 且沒有除權息/減資/分割 ← 第二種
進度與檔案一致性 進度說抓過、檔案裡沒有 ← 中斷造成的洞
第三種和第四種目前沒有通用的檢查,只能靠 parser 自己守(比對日期)和習慣(先看完整回應)。