iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0

寫爬蟲最花時間的不是抓不到。抓不到你會看到 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                                          # 沒有任何例外

big5 在哪裡停住

明確指定 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 自己守(比對日期)和習慣(先看完整回應)。


上一篇
Day 6 - 被交易所擋了幾百次,還能接著跑的爬蟲
下一篇
除權息那天的跳空,不是真的跌
系列文
台股日頻量化交易:開發一條從資料、特徵、模型到每日下單清單的產線8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言