跳至主要內容
技術

從 PDF / CSV 到 JSON:政府開放資料的 ETL 實戰

從 PDF / CSV 到 JSON:政府開放資料的 ETL 實戰
打造 TaxMap-TW:用 Astro 做台灣所得地圖 第 5 / 10 篇

本篇是「打造 TaxMap-TW:用 Astro 做台灣所得地圖」系列的第 5 / 10 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。

TaxMap-TW 最讓我意外的不是地圖、不是色階、是資料清理。

我以為「政府開放資料」就是去 data.gov.tw 抓 CSV → 解析 → 完成。實際做才發現:

  • 民國年 vs 西元年命名混淆
  • 同一個 dataset 不同年度 schema 不一樣
  • BOM、引號規則跨年漂移
  • 表格裡藏著「合計」「其他」要過濾
  • 村里名有罕用字 mojibake 跨資料集對不起來
  • 早期年度只有 PDF,沒 CSV

每一條單看都不是技術難題,但全部疊起來,地圖本身兩個晚上就會動了,光把這份資料弄乾淨我花了快一週。

這篇整理我在處理財政部所得稅 CSV 時踩到的 pattern,這些 pattern 在不少政府開放資料上都能類推(至少 CSV / 下載式 dataset 這一類)。

陷阱 1:民國年 vs 西元年

財政部的所得稅 CSV 檔名長這樣:

111_165-9.csv
112_165-9.csv
113_165-9.csv

111112 是民國年。但這個民國年指的是「所得發生年」,不是「發布年」或「申報年」。

舉例:

檔名民國年所得發生申報初步核定正式核定
111_165-9.csv11120222023/52024/62025/4
112_165-9.csv11220232024/52025/62026/4/30
113_165-9.csv11320242025/5預估 2026/6-7預估 2027/4

所以「2024 年的資料」是模糊的:

  • 「2024 年發布的」= 民國 111(income year 2022)的核定版?還是民國 112 的初步核定版?
  • 「income year 2024」= 民國 113 = 還沒發布

寫程式時要明確:所有公開介面都用西元年(income year),內部存民國年只用在組 URL

const ROC_OFFSET = 1911;
const rocToCe = (rocYear: number) => rocYear + ROC_OFFSET;
const fiaCsvUrl = (rocYear: number) =>
  `https://www.fia.gov.tw/WEB/fia/ias/ias${rocYear}/${rocYear}_165-9.csv`;

這樣 user-facing 永遠是「2022 年所得」,build script 才用 111 組 URL。

陷阱 2:Schema 跨年漂移

抓下 11 年的 CSV(民國 101–111)後做了第一個 sanity check:每個檔頭幾個 bytes:

101: e9 84 89 (鄉鎮市區)
105: ef bb bf e9 84 89 (BOM + 鄉鎮市區)
106: 22 e9 84 89 (引號 + 鄉鎮市區)
110: 22 ef bb bf e7 b8 a3 (引號 + BOM + 縣市別)
111: 22 ef bb bf e7 b8 a3 (引號 + BOM + 縣市別)

三件事跨年變了:

  1. BOM:民國 101–104 沒 BOM,民國 105 起有 BOM
  2. 引號:民國 101–105 欄位無引號,民國 106 起加上雙引號
  3. 欄位名:民國 101–110 的第一欄叫「鄉鎮市區」,民國 111 改叫「縣市別」(但實際資料一樣,都是「臺北市松山區」這種拼接字串)

實際資料一致只是 header label 換名,所以解析時不能用「欄位名匹配」這種精準方法。用欄位順序、忽略 BOM、忽略引號變化

import Papa from 'papaparse';

const csv = await readFile(csvPath, 'utf-8');
// 跨年安全:strip BOM 若存在
const trimmed = csv.charCodeAt(0) === 0xfeff ? csv.slice(1) : csv;

const parsed = Papa.parse(trimmed, {
  header: false,  // ← 不要用 header: true,跨年欄位名不一樣
  skipEmptyLines: true,
  transform: v => v.trim(),
});

// 直接用 row[0], row[1] 索引取值
for (const row of parsed.data) {
  const cityTownship = row[0];  // 「臺北市松山區」
  const village = row[1];        // 「中華里」
  const mean = Number(row[4]);   // 平均所得
}

關鍵點:在這個 dataset 裡,欄位順序比欄位名穩定。政府資料的 schema 命名隨時可能改,但欄位順序通常不太動(因為改順序會破壞既有使用者)。

不過「信任欄位順序」不是免費的午餐,它有自己的失效情境。欄位名匹配遇到改名會直接報錯、逼你正視;位置索引遇到中間插一欄、刪一欄、或某年悄悄把兩欄對調,row[4] 還是讀得出一個數字,只是默默讀錯——這種靜默取錯比 header 對不上更危險,因為它不會炸,會一路把錯的值算進統計。我自己跨年漂移那段就是最好的反例:欄位名才剛被證明會跨年改,憑什麼相信順序就一定不改?

所以我的折衷是「位置索引 + 輕量驗證」:解析時照樣用 row[0]row[1] 取值,但每個檔開頭加一道 sanity check,確認欄位數和關鍵欄的型別跟我預期的一致,對不上就讓 build 直接 fail,而不是默默產出錯誤資料。

const EXPECTED_COLS = 10; // 這個 dataset 固定 10 欄
for (const row of parsed.data) {
  // 欄位數變了 → 大概率插欄/刪欄/重排,立刻停下來重新核對 schema
  if (row.length !== EXPECTED_COLS) {
    throw new Error(`欄位數異常:預期 ${EXPECTED_COLS},實際 ${row.length}`);
  }
  // 該是數字的欄位卻不是數字 → 順序可能跑掉了
  if (Number.isNaN(Number(row[4]))) {
    throw new Error(`row[4] 應為平均所得數字,實際拿到「${row[4]}」`);
  }
}

位置索引的賭注是「順序穩定」,驗證就是替這個賭注買的保險:賭對了幾乎零成本,賭錯了它會在第一個檔就尖叫,而不是讓你三個月後才發現某年的平均所得整欄錯位。

陷阱 3:「合計」「其他」要過濾

逐列檢視資料後發現:

"臺北市松山區","中崙里","1439","2452035","1704","817","356","1902","5899.26","346.20"
"臺北市松山區","自強里","3029","4355839","1438","666","285","1629","3517.94","244.63"
...
"臺北市松山區","其他","451","340002","754","498","268","1016","818.87","108.62"
"臺北市松山區","合計","69082","98796332","1430","729","310","1639","4770.51","333.57"
"臺北市大安區","和平里","...","...","...","...","...","...","...","..."

每個鄉鎮市區的最後兩列是 其他合計這兩列不是村里

  • 「其他」:該鄉鎮中歸不到任何里的納稅單位
  • 「合計」:該鄉鎮的小計(所有里 + 其他的加總)

如果不過濾,會被當成「2 個額外的村里」算進統計,總數就會錯。每個檔 ~387 個合計 + ~406 個其他要過濾掉。

const SKIP_VILLAGES = new Set(['合計', '其他']);

for (const row of parsed.data.slice(1)) {  // skip header row
  const village = row[1];
  if (SKIP_VILLAGES.has(village)) continue;
  // 真正的村里
}

過濾掉後民國 111 剩 7,748 個村里。這是 FIA 這份資料過濾後可對應到的村里數,與內政部公布的全台村里口徑量級一致(官方數字本身會逐年微調,不是固定值),不是什麼「官方公布的全台村里總數」——別把這份稅務資料的計數直接當成戶政權威數字。

陷阱 4:拼接字串 split

第一欄是「臺北市松山區」這種拼接字串。要拆成「縣市」+ 「鄉鎮市區」兩欄。

我本來以為要寫個正則或字典 lookup。實際看資料:

南投縣、嘉義市、嘉義縣、基隆市、宜蘭縣、屏東縣、
彰化縣、新北市、新竹市、新竹縣、桃園市、澎湖縣、
臺中市、臺北市、臺南市、臺東縣、花蓮縣、苗栗縣、
連江縣、金門縣、雲林縣、高雄市

全部 22 個縣市都是 3 個中文字(最後一字為「市」或「縣」)。

function splitCityTownship(combined: string) {
  return {
    city: combined.slice(0, 3),
    township: combined.slice(3),
  };
}

3 行解決。但要老實說:slice(0, 3) 能成立,純粹是「現在這 22 個縣市剛好都是 3 個字」這個巧合。它不是定律——萬一哪天行政區改名、或這份資料混進別的縣市格式,這刀就會切歪,而且一樣是默默切歪。所以我把這個巧合當「可利用但要驗證」的規律,配一張白名單 assert 當安全網:

const KNOWN_CITIES = new Set([
  '臺北市', '新北市', '桃園市', '臺中市', '臺南市', '高雄市',
  '基隆市', '新竹市', '嘉義市', '新竹縣', '苗栗縣', '彰化縣',
  '南投縣', '雲林縣', '嘉義縣', '屏東縣', '宜蘭縣', '花蓮縣',
  '臺東縣', '澎湖縣', '金門縣', '連江縣',
]); // 22 個

function splitCityTownship(combined: string) {
  const city = combined.slice(0, 3);
  if (!KNOWN_CITIES.has(city)) {
    throw new Error(`切出非預期縣市「${city}」,原字串:${combined}`);
  }
  return { city, township: combined.slice(3) };
}

規律性是政府資料的隱藏資產,多看幾個 row 比寫 fancy 解析器有用;但「規律」和「保證」是兩件事,能利用就順手加一個 assert 把假設釘死,免得哪天規律破了你還蒙在鼓裡。

陷阱 5:跨資料集 JOIN 的罕用字 mojibake

最痛苦的是這個。

我要把 FIA 的「縣市|鄉鎮|村里」資料,跟 NLSC 國土測繪中心的村里界 GeoJSON(用 VILLCODE 索引)對起來。

理論上「臺北市松山區中華里」在兩邊都該存在。實際做 JOIN:

// 7,747 個 NLSC codified villages
// 7,721 matched FIA
// 26 不 match

99.66% 命中率。剩下 26 個是這種:

嘉義市|西區|磚𥕢里     ← FIA 用「𥕢」
嘉義市|西區|磚磘里     ← NLSC 用「磘」

臺南市|安南區|塩埕里  ← FIA 用「塩」
臺南市|安南區|鹽埕里  ← NLSC 用「鹽」(標準字)

兩個資料集對同一個村里用了不同的「異體字」或「PUA private use area」字。

實際上 kiang/salary 維護一份手動對照表來處理這些,但對我這個「在地圖上塗色」的 MVP 來說 99.66% 已經很夠。

不過這個「夠」是看用途的,不是通則。我這裡每個村里只是地圖上一塊獨立色塊,缺 26 個就是 26 塊顯示「無資料」,不影響其他村里——這種場景 0.34% 缺漏可以接受。但如果今天是要把全台所得加總、算各縣市佔比、判斷「某村里有沒有達到某門檻」這類資格判定、或要拿去做學術統計,那 0.34% 就不能放著:缺的不是隨機 26 個,而是系統性地漏掉用罕用字/異體字的偏鄉里,等於你的缺漏剛好集中在某一類地區,會讓加總和分佈悄悄偏掉。用途越接近「精確數字」,這 0.34% 越不可妥協。

處理方式:

const villages: Record<string, VillageMeta> = {};
for (const feature of geojson.features) {
  const compositeKey = `${COUNTYNAME}|${TOWNNAME}|${VILLNAME}`;
  const hasFiaMatch = fiaStats.has(compositeKey);
  villages[VILLCODE] = {
    code: VILLCODE,
    // ...
    hasStats: hasFiaMatch,
  };
}

把「有沒有對應 FIA 資料」存進 master JSON,前端遇到沒對應的村里就顯示「目前無資料」。

在「顯示用」的場景下,我寧可承認資料的不完美、誠實標記缺漏,也不硬塞模糊匹配演算法去猜——猜錯一個村里的所得,比老實說「無資料」傷害更大。但這句話別當成「永遠別追 100%」的鐵則:當缺的那 0.34% 會被拿去加總、判定資格或做統計時,該補的對照表還是得補(或至少把缺漏清單攤開讓人知道),而不是用「承認不完美」當不處理的藉口。

陷阱 6:早期年度只有 PDF

研究 agent 一開始給我的 URL pattern 是:

https://www.fia.gov.tw/WEB/fia/ias/ias{民國年}/{民國年}_165-9.csv

說「民國 88 (1999) 到民國 112 (2023) 都有」。實測卻發現:

ROC 101-111 (2012-2022): HTTP 200 ✓
ROC 88-100 (1999-2011):  HTTP 404 ✗
ROC 112 (2023):          HTTP 404 ✗

實際上 CSV 只覆蓋 11 年。早期年度官方只有 PDF;2023 年(民國 112)核定版剛在 2026/4/30 公布但 CSV 還沒上架。

這直接砍掉了「1999-2023 共 25 年」這個 scope。我把專案調整為 MVP 只做 2012-2022。

後來我學乖了:政府資料的「公開」常常分好幾種介面(CSV、XLSX、HTML、PDF、API),而且不同年度開放的介面還不一樣。研究 agent 給的 description 寫得再篤定,規劃前我都先 curl 一輪把每年實際拿不拿得到檔案掃過,再決定 scope,省得做到一半才發現有四年根本沒 CSV。

政府開放資料 ETL 的 6 條通用 pattern

整理我這次學到的政府開放資料 ETL 通則:

  1. 公開介面用西元年 — 民國年只在組 URL 時用
  2. 不信任 header — 用欄位順序、處理 BOM 與引號變化
  3. 掃描異常列 — 「合計」「其他」「小計」要過濾
  4. 規律性是寶藏 — 多看幾個 row 通常有 dirty 但穩定的 pattern
  5. 承認資料不完美(看用途) — 顯示用場景,99% 命中 + 標記 1% missing 比強塞演算法可靠;但要拿去加總、判定資格或做統計時,那 1%(且常系統性漏掉偏鄉罕用字)就得補齊
  6. 規劃前先 curl — 不同年度公開不同介面,description 不可信

如果這六條只能記一條,我會記第 2 條的延伸版:別相信任何人(包括研究 agent、包括 description、包括上一年的自己)對這份資料長相的描述,自己 curl 下來、自己印出前幾個 byte、自己用 assert 把假設釘住。這次大半的坑,本質都是「我以為它長這樣,它其實長那樣」。

也得老實承認:不是所有鍋都甩給上游。回頭看,這些坑有一半是我自己一開始沒先替整條管線立一份「資料契約」——把「應該幾欄、哪欄是數字、村里數量級、哪些值要過濾」這些預期先寫成 schema validation 擺在最前面,後面任何一年違約就立刻 fail。我是踩了三四個坑之後才補上這層驗證的,要是一開始就建,至少能少跑幾趟「產出怪數字 → 回頭 debug」的冤枉路。我也沒法保證這套打法在每一份台灣政府開放資料上都成立——我的樣本就 FIA 這一份(API、XML、奇怪編碼的資料各有各的故事)——但至少 CSV / 下載式 dataset 這一類,照這六條走能少踩很多坑。

系列其他文章

資料清乾淨之後,接下來就是「怎麼把這些村里排名、怎麼畫成地圖」,沿著「清理 → 排名 → 視覺化 → 復盤」的脈絡:

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →