真實食物資料是怎麼一筆一筆蒐集回來的?
Day21 把 FoodItem 的欄位定好了,接下來是真正花時間的部分:把各品牌官網上「給人看」的營養標示,變成程式能用的結構化資料。這篇整理實際用到的蒐集方式(MOS、Subway、全家各用了不同的做法),以及過程中踩到的雷。
資料蒐集在做什麼
網頁上的營養標示是排好版給人看的,程式要用的話,得先把它「抽」出來,整理成固定格式(也就是 Day21 的 FoodItem)。這件事就叫資料蒐集,也常被叫做爬蟲。
傳統做法:指定「第幾個表格的第幾格」
傳統的做法就是「自己寫爬蟲」。先理解一件事:網頁其實是一份 HTML 文字檔,瀏覽器把它畫成漂亮的畫面給人看。爬蟲程式做的事情是:
用一個簡化的例子(示意):網頁背後的 HTML 長這樣:
<table class="nutrition">
<tr><td>熱量</td><td>433.9</td></tr>
<tr><td>蛋白質</td><td>17.8</td></tr>
</table>
程式就要踏穩地寫「去找 class 叫 nutrition 的表格、第 1 列的第 2 格」:
document.querySelector('.nutrition tr:nth-child(1) td:nth-child(2)').textContent // "433.9"
優點:免費、速度快、結果固定(同一頁每次抓到的值都一樣)、數字可以精確保留。
缺點有三個:
新做法:讓 AI 讀懂網頁的意思
Firecrawl 是一個雲端服務:你把網址交給它,它幫你把網頁抓下來(包括需要 JavaScript 才會顯示內容的頁面),再回傳整理好的結果。這個專案用到它的兩種模式:
實際傳給 Firecrawl 的請求長這樣(節錄自專案腳本):
// 模式一:只要乾淨的文字
{ url, formats: ['markdown'], onlyMainContent: true }
// 模式二:讓 AI 抽取,附上 prompt(要哪些分類)和 schema(回傳格式)
{ url, formats: ['json'], onlyMainContent: true, jsonOptions: { prompt: extractPrompt, schema: MULTI_ITEM_SCHEMA } }
它幫你省下的是「下載網頁、處理 JavaScript 載入」這一層,模式二還能省下「找位置」這一層。不過它也有代價:免費方案有次數限制(實測約每分鐘 11 次,超過就會收到 429 錯誤),所以腳本裡要每次請求隔幾秒、遇到錯誤自動重試。
三種做法對照看看:
| 做法 | 誰負責找資料的位置 | 優點 | 代價 |
|---|---|---|---|
| 傳統爬蟲(自己寫 selector 或正則) | 我們自己寫規則 | 免費、快、精確、結果固定 | 改版就壞、每站要重寫、動態頁面門檻高 |
| Firecrawl 轉 markdown,再自己正則解析 | 我們自己寫規則(但不用處理下載與 HTML) | 省下下載與前置處理,數字仍精確 | 還是要自己寫和維護正則 |
| Firecrawl AI 抽取 | AI 自己讀懂網頁 | 不用寫規則,這類表格混雜的頁面特別快 | 數字可能被四捨五入、每次結果不一定完全一致 |
官方 API:有時候不用爬網頁
有些網站的網頁背後,其實是呼叫一支 API 取得資料。如果能直接呼叫那支 API,就能拿到最乾淨的資料,連解析網頁這一步都省了。
整條蒐集流程長這樣
FoodItem 的格式專案裡對應的腳本在 backend/scripts/(ingest-food-data.mjs 處理 MOS 與 Subway,ingest-familymart-data.mjs 處理全家)。Firecrawl 省掉的是「解析 HTML」這一層,不是整條流程,還是需要一支可以重複執行的腳本,把上面五步串起來。
方式一:Firecrawl 轉 markdown,再用正則表達式解析(MOS)
MOS 每個商品都有一頁獨立的詳細頁,版面統一。所以流程是:用 Firecrawl 把頁面轉成 markdown,再用正則表達式從文字裡抓出「熱量、蛋白質、脂肪」的數字(節錄):
const caloriesMatch = markdown.match(/熱量\s*\|\s*([\d.]+)/);
這段正則的意思是:找到「熱量」兩個字,後面接一道表格的豎線,再抓出後面的數字。這個方式的好處是數字保留原始小數位、同樣輸入結果一樣,所以資料的 confidence 標為 high。也因為解析是純函式,比再打一次 AI API 更快、更省錢、也更容易寫測試。
方式二:Firecrawl AI 抽取(Subway)
實測發現它的語意判斷是可信的:抓到沒有營養資料的頁面時,AI 會老實回填 0,不會亂猜一個數字。Subway 的官網是一頁裡混了多個表格,用正則表達式解析太脆弱,所以改用 Firecrawl 的 JSON schema 抽取模式,先把欄位結構鎖死,避免 AI 每次自己取的欄位名稱都不一樣(實測過一次叫 food_items、一次叫 items)。代價是 AI 抽取會把小數點四捨五入(例如 6.3 變 6),精確度比正則解析低一點,所以這類資料的 confidence 標為 medium。
方式三:直接呼叫官方 REST API(全家)
全家的「食在購安心」網站,表面上是要互動搜尋的網頁,但用瀏覽器的開發者工具攔截網路請求後,發現背後其實是一支沒有防護的官方 API,可以直接用程式呼叫,連 Firecrawl 都不用。這是這次蒐集裡最意外的發現,也讓全家一次拿到 1750 筆資料,整個資料庫因此來到 1915 筆(MOS 133 筆、Subway 32 筆、全家 1750 筆)。
真實踩過的雷
soup.aspx 其實是咖啡系列頁面,真正的濃湯混在別的分類裡;全家把「小菜、滷味、湯品」全部併在同一類。這種坑踩了兩次,變成一個通則:拿到新資料來源時,一定要核對頁面實際內容,不能只信網址或分類標籤
Claude Code 在這裡幫了什麼
這幾支腳本是在 Claude Code 的協助下,邊寫邊除錯完成的。分工上,需要判斷的部分留給自己(找資料規律、決定用哪種蒐集方式、設計驗證規則),重複性高的腳本骨架與除錯交給 AI 加速。
這篇的重點是:選蒐集方式,先看資料藏在哪裡。有整合頁面就讓 AI 抽取、網頁背後有 API 就直接呼叫。資料蒐集回來之後,下一篇 Day23 要處理的是:資料要放在哪裡?