第 2 章:換上真實爬蟲(只印出)— 骨架不變,換上 FinMind API
這一章是整套課程最重要的轉折。你會把第 1 章那個假任務換成「真的去抓台股股價」,並驗證一件事:Celery 的基礎設施,跟你的任務內容其實是分開的。
做完這一章,你會做到
- 先徒手呼叫一次 FinMind API,看懂它回傳的股價資料長什麼樣。
- 看懂一個真實爬蟲任務怎麼呼叫 FinMind API、把 JSON 變成表格。
- 不改 worker、不改 broker、也不重開 worker,只換一支 producer,就抓到 5 支真實股票的股價。
- 從對照中自己領悟:以後要換成任何工作,你要動的都只有「任務本體」這一小塊。
先搞懂:這一章「只換了一個東西」
第 1 章的 crawler(x) 是假的(印字 + 睡覺)。這一章的 crawler_finmind_print(stock_id) 是真的——它去打 FinMind 的 API 抓某支股票的歷史股價,然後印出來。
除此之外,其他全部一樣。 你可以照這張表自己核對:
| 元件 | 第 1 章 | 第 2 章 | 有變嗎? |
|---|---|---|---|
worker.py |
沿用 | 沿用 | ❌ 沒變 |
| worker 啟動指令 | celery -A crawler.worker worker |
一模一樣 | ❌ 沒變 |
| broker(RabbitMQ) | 沿用 | 沿用 | ❌ 沒變 |
| 任務內容 | 假的 crawler |
真的 crawler_finmind_print |
✅ 只有這裡變 |
| producer | producer.py |
producer_crawler_finmind_print.py |
✅ 換一支 |
跑完實作後,回來對照這張表,你就會懂:Celery 是一套「跟工作內容無關」的派送與執行基礎設施。
為什麼 worker 不用重開?因為第 1 章就講過,
worker.py的include一開始就掛了crawler.tasks_crawler_finmind。所以你啟動的那個 worker 老早就認得crawler_finmind_print了,你只是第一次真的去用它。
這一章會用到的檔案
| 檔案 | 角色 | 說明 |
|---|---|---|
crawler/tasks_crawler_finmind.py |
任務定義 | 裡面有 crawler_finmind_print(本章)和 crawler_finmind(第 5 章) |
crawler/producer_crawler_finmind_print.py |
生產者 | 派送 5 支股票的 print 版任務 |
crawler/worker.py |
不動 | 沿用第 1 章 |
熱身:先徒手打一次 FinMind API(不經 Celery)
在把爬蟲包進 Celery 之前,先直接用一段 inline Python 打一次 API。這一步完全不需要 Docker、不需要 worker,目的是讓你先確認兩件事:API 能通、回傳的資料你看得懂。
uv run python -c "
import requests
import pandas as pd
url = 'https://api.finmindtrade.com/api/v4/data'
parameter = {
'dataset': 'TaiwanStockPrice',
'data_id': '2330',
'start_date': '2025-01-01',
'end_date': '2025-06-17',
}
resp = requests.get(url, params=parameter)
data = resp.json()
if resp.status_code == 200:
df = pd.DataFrame(data['data'])
print(f'抓到 {len(df)} 筆資料')
print(df.head())
else:
print(data['msg'])
"
✅ 預期:
抓到 XXX 筆資料
date stock_id Trading_Volume ... close spread Trading_turnover
0 2025-01-02 2330 34587892 ... 1040.0 15.0 35810803350
1 2025-01-03 2330 28374881 ... 1050.0 10.0 29720832960
...
看到台積電(2330)的股價 DataFrame,代表 FinMind API 能通、pandas 正常。
看懂回傳的欄位
| 欄位 | 說明 |
|---|---|
date |
日期 |
stock_id |
股票代碼 |
Trading_Volume |
成交量(股) |
open |
開盤價 |
max / min |
最高價 / 最低價 |
close |
收盤價 |
spread |
漲跌 |
Trading_turnover |
成交金額 |
💡 想換一支試試?把
data_id改成'0050'(元大台灣 50)再跑一次。記住你剛剛做的三個動作:
requests.get發請求 →resp.json()解析 →pd.DataFrame變表格。等一下的 Celery 任務做的就是一模一樣的事——只是從「你手動跑」變成「worker 收到任務時自動跑」。
一行一行讀懂 crawler_finmind_print
import pandas as pd
import requests
from crawler.worker import app
@app.task()
def crawler_finmind_print(stock_id):
# ① FinMind API 的網址
url = "https://api.finmindtrade.com/api/v4/data"
# ② 你要問 API 的四個問題,包成一個 dict
parameter = {
"dataset": "TaiwanStockPrice", # 我要哪種資料:台股日線
"data_id": stock_id, # 哪一支股票:例如 2330
"start_date": "2024-01-01", # 從哪天開始
"end_date": "2025-06-17", # 到哪天結束
}
# ③ 真正發出 HTTP GET 請求
resp = requests.get(url, params=parameter)
# ④ 把回傳的 JSON 轉成 Python dict
data = resp.json()
# ⑤ HTTP 200 代表成功
if resp.status_code == 200:
df = pd.DataFrame(data["data"]) # data["data"] 是 list of dict,直接變表格
print(df) # 只印出,不寫 DB
else:
print(data["msg"]) # 失敗就印錯誤訊息,方便你排查
有沒有發現?這就是你剛剛在熱身段落徒手做的事,只是多了兩個差異:股票代碼變成參數 stock_id(誰派任務誰決定抓哪支),以及最上面多了一行 @app.task()(把它變成可派送的任務)。
一段一段看:
- ①② 是在「準備要問什麼」。你可以想成填一張借書單:dataset 是「哪個書庫」、data_id 是「哪本書」、start/end 是「借閱期間」。
- ③
requests.get(url, params=parameter)是整段唯一真正跟外界互動的一行。它會組出像https://.../data?dataset=TaiwanStockPrice&data_id=2330&...這樣的網址並發出去。注意:程式跑到這行會「等」網路回應,這段時間 CPU 其實在發呆。 記住這個特性,第 9 章調併發時會用到。 - ④
resp.json():FinMind 回的是 JSON 文字,這行把它變成 Python dict,結構大概是{"msg": "...", "status": 200, "data": [ {...}, {...} ]}。 - ⑤ 成功就把
data["data"](一堆「每天一筆」的 dict)丟給pd.DataFrame變成表格印出;失敗就走else印出data["msg"]。
小訣竅:如果你想看清楚 API 到底回什麼,暫時在
resp.json()後面加一行print(data),先看原始結構,再看它變成 DataFrame 的樣子。
一行一行讀懂 producer
from crawler.tasks_crawler_finmind import crawler_finmind_print
# 一次派送 5 支股票的爬蟲任務
for stock_id in ["2330", "0050", "2317", "0056", "00713"]:
print(stock_id)
crawler_finmind_print.delay(stock_id=stock_id) # 非同步派送
- 結構跟第 1 章一模一樣(
for + .delay()),只有兩點不同:import 的是crawler_finmind_print、送出去的是真實股票代碼。 - 這 5 支是台灣人最熟的標的:2330 台積電、0050 元大台灣 50、2317 鴻海、0056 高股息、00713 元大台灣高息低波。
一步一步跟著做
一樣建議三個分頁:T1 worker、T2 producer、瀏覽器開 Flower。
Step 1:確認基礎設施還在跑(剛做完第 1 章就可略過)
docker compose -f docker-compose-local.yml ps
沒在跑就重新起:
docker compose -f docker-compose-local.yml up -d rabbitmq flower mysql phpmyadmin
✅ rabbitmq、flower 至少要在跑。
Step 2:啟動 worker(跟第 1 章一模一樣,故意不改)
uv run python -m celery -A crawler.worker worker --loglevel=info
✅ 一樣看到
ready.,而且[tasks]裡有crawler.tasks_crawler_finmind.crawler_finmind_print。這時你可以自己問一句:我改 worker 了嗎? 沒有。
Step 3:派送 print 版任務
在 T2:
uv run crawler/producer_crawler_finmind_print.py
T2(producer)你會看到——秒跑完:
2330
0050
2317
0056
00713
T1(worker)你會看到——印出 5 張真實股價表(順序可能交錯,因為併發):
date stock_id Trading_Volume ... open max min close ...
0 2024-01-02 2330 21610948 ... 593.0 596.0 590.0 593.0
1 2024-01-03 2330 ...
...
[N rows x M columns]
✅ worker 畫面出現真實股價的表格(有 date、open、close 等欄位)就過關。如果印出的是一行錯誤訊息,看下面的排錯表。
Step 4:到 Flower 看這 5 個任務
瀏覽器開 http://localhost:5555 → Tasks 頁籤。
✅ 預期:看到 5 筆 crawler.tasks_crawler_finmind.crawler_finmind_print,狀態都是 SUCCESS,而且每筆的 args/kwargs 欄位可以看到當初送的股票代碼。
Step 5:回頭對照「什麼都沒改」
跑完後,回到上面那張對照表,一格一格自己核對:worker 沒改、指令沒改、broker 沒改,你只換了 producer 和任務內容。這一步就是這一章的重點,值得你多停留一分鐘去體會。
檢查你是不是真的看到了
| # | 你應該看到 | 它證明了什麼 |
|---|---|---|
| 1 | 徒手 inline 呼叫 API 看到 DataFrame | API 能通、你看得懂回傳結構 |
| 2 | worker [tasks] 仍列出 finmind 任務 |
worker 早就認得,不用重開 |
| 3 | worker 印出 5 張真實股價表 | 爬蟲三部曲成功 |
| 4 | 5 支股票是併發抓的 | 沿用第 1 章的併發能力 |
| 5 | Flower 出現 5 個 crawler_finmind_print |
任務確實經過 Celery |
想再深入一點
- 這個任務是「I/O 密集」的典型:整段最花時間的是
requests.get等網路回應,CPU 幾乎在發呆。這種任務適合開「遠高於核心數」的併發(第 9 章會教 gevent)。 - FinMind 免費版有請求上限:沒登入時每小時請求數有限,抓太多會被擋。本章 5 支剛好;要大量抓可以去 FinMind 註冊拿 API token。
- 為什麼要先做一個「只印出」的版本? 這是刻意的安排:先確認「任務能派送、worker 能收到、API 能呼叫、資料能解析」這條流程通了,第 5 章再加上寫資料庫。這樣萬一出錯,你能分辨是 Celery 的問題還是資料庫的問題。
卡住了?常見錯誤這樣排
| 你遇到的狀況 | 原因 | 怎麼解 |
|---|---|---|
| worker 印出一行錯誤、不是表格 | API 回非 200(限流、參數錯) | 暫時加 print(data) 看原始回傳的 msg |
| 印出空的 DataFrame | 股票代碼錯,或那段期間沒資料 | 換一個確定有資料的代碼(如 2330)測試 |
requests / pandas 找不到 |
套件沒裝 | 回根目錄 uv sync |
| 抓幾支就被擋 | FinMind 免費限流 | 減少數量,或註冊拿 token |
想一想(確認你懂了)
先自己想過再看答案。
Q1:從第 1 章到這一章,你總共改了哪幾個檔案?哪些完全沒動?
只改了兩個地方:換一支 producer(producer_crawler_finmind_print.py)、送出的任務換成 crawler_finmind_print。完全沒動的是:worker.py、worker 的啟動指令、broker(RabbitMQ)。這正是這一章要你體會的——Celery 的基礎設施跟任務內容是分開的。
Q2:crawler_finmind_print 大部分時間花在「算」還是「等」?
花在「等」。整段最慢的是 requests.get 等 FinMind 回應,這段時間 CPU 幾乎在發呆。這種「大部分時間在等」的任務叫 I/O 密集,跟「大部分時間在算」的 CPU 密集相反。記住這個判斷,第 9 章調 concurrency 時,I/O 密集的任務可以開遠高於核心數的併發。
Q3:為什麼要特地做一個「只印出、不寫資料庫」的版本,而不是一步到位?
為了把問題切開、方便除錯。先確認「任務能派送、worker 能收到、API 能呼叫、資料能解析」這條流程通了,第 5 章再加上寫資料庫。這樣萬一出錯,你能立刻分辨是 Celery 的問題還是資料庫的問題,而不是一堆東西攪在一起找不到原因。
Q4:熱身段落的徒手呼叫,跟透過 Celery 派送,差在哪?
做的事一模一樣(requests → json → DataFrame),差在「誰執行、能不能平行」。徒手版是你的終端機同步執行、一次一支;Celery 版是 worker 併發執行、5 支同時抓,而且之後可以排程自動跑、可以多開 worker 加速。這就是「把一段程式變成任務」換來的能力。
換你試試看
練習 1:換成你有興趣的股票
把 producer 的股票清單改成你自己想看的 3 支(例如 ["2412", "2603", "3008"]),重跑一次。
uv run crawler/producer_crawler_finmind_print.py
你會在 worker 看到這 3 支的股價表。這讓你確認:要換抓什麼,只要改 producer 的清單,任務邏輯和 worker 都不用動。
練習 2:看清楚 API 到底回什麼
在 tasks_crawler_finmind.py 的 resp.json() 後面暫時加一行:
print(data.keys())
重開 worker 再跑一次 producer。你會在 worker 看到類似 dict_keys(['msg', 'status', 'data'])。這讓你搞懂 FinMind 的回傳結構:外層是一個 dict,真正的股價在 data["data"] 裡,這也解釋了為什麼程式是寫 pd.DataFrame(data["data"]) 而不是 pd.DataFrame(data)。看完記得把這行刪掉。
練習 3:改日期範圍,感受資料量變化
把 parameter 裡的 start_date 改成 "2025-06-01"(範圍變很短),重跑。你會看到印出的 DataFrame 列數明顯變少。這讓你理解:start_date/end_date 直接決定 API 回傳多少筆資料,抓一整年跟抓兩週差很多——之後要控制抓取量、避免打爆 API,就是調這兩個參數。
這一章你學到了
- 爬蟲三部曲:
requests.get→resp.json()→pd.DataFrame——徒手跑過一次,再包進 Celery。 - Celery 的基礎設施和任務內容是分開的,換工作只需換任務本體。
- print 版讓你先確認流程通,把「資料落地」留到後面。
下一章要做什麼
現在任務會抓真實資料了。下一章先不急著存 DB,而是學怎麼把不同類型的股票(上市 twse / 上櫃 tpex)分流到不同佇列,交給不同 worker 專門處理。

送件鴿飛出去了