AI & Data Dreamers練功房
寶箱片場規則
正片・課程手冊

第 2 章:換上真實爬蟲(只印出)— 骨架不變,換上 FinMind API

這一章是整套課程最重要的轉折。你會把第 1 章那個假任務換成「真的去抓台股股價」,並驗證一件事:Celery 的基礎設施,跟你的任務內容其實是分開的。


做完這一章,你會做到

  1. 先徒手呼叫一次 FinMind API,看懂它回傳的股價資料長什麼樣。
  2. 看懂一個真實爬蟲任務怎麼呼叫 FinMind API、把 JSON 變成表格。
  3. 不改 worker、不改 broker、也不重開 worker,只換一支 producer,就抓到 5 支真實股票的股價。
  4. 從對照中自己領悟:以後要換成任何工作,你要動的都只有「任務本體」這一小塊。

先搞懂:這一章「只換了一個東西」

第 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 專門處理。