昨天把 Notion 的重訓文字完整爬出來,到今天為止,該拿的資料大致都到手了。
原本的計畫很單純:設計一套 schema,把跑步、重訓、體重、課表裝進去,之後儀表板就有東西可讀。
但把它們攤在同一張桌上的時候,第一個發現是:這些資料並不在同一個階段。
而且真正難搞的只有一種。
| 來源 | 現在是什麼形態 | 誰處理的 |
|---|---|---|
| Garmin 活動摘要 | JSON,12 個欄位 | Day 3 |
| FIT 逐秒軌跡 | JSON,6 個欄位 × 1685 點 | Day 4–5 |
| 體重 | 讀出來是 dict,寫進檔案後變回文字 | Day 6 |
| 訓練日誌 | 純文字,從沒被解析過 | 今天 |
| 教練課表 | 圖片 | Day 13 |
四件事有五列,是因為跑步佔了兩個來源:Garmin 給的活動摘要,和 FIT 檔裡的逐秒軌跡。
雖然格式都不一樣,但重要的是它們離「機器可讀」有多遠。
Garmin 那兩個不用做什麼—API 回來就是 JSON,FIT 解析完也是 JSON。但要注意體重資料,明明讀出來是乾淨的 dict,包含重量、單位、阻抗,結果為了讓自己看得懂,寫進 markdown 表格,但今天要把它接回程式,就得解析回來。
面對這種狀況,很容易想做的是「設計一套夠通用的 schema,把所有資料都塞進去」。
我試了一下,但放棄了。
因為重訓紀錄的完整結構長這樣—動作、組數、次數、重量、備註,而且每一欄都有自己的變形寫法:
啞鈴胸推斜板|12;12🚀,8🔥🔥🔥,6+6|20;35 降20
要把這串完整拆開,得處理分號、逗號、加號、emoji、還有「降」這種中文動詞。可以做,但要花不少時間。
停下來問了自己一個問題:儀表板的內容需要顯示到多細?
答案是不用很細。這個系列的目標是馬拉松,重訓是支線。儀表板上「本週訓練」那一欄,只需要顯示「這天練了什麼」—不需要第三組推了幾下。
所以 schema 定成四個欄位:
@dataclass
class Session:
date: str
kind: str # 'run' | 'lift'
summary: str # 標題裡日期之後的整串文字
moves: int # 動作數(重訓才有意義)
schema 對齊的是目標,不是資料本身的複雜度。
而這個決定有個意外的好處:完全不用碰速記語法,也不用碰備註。前三個欄位全在標題那一行,第四個 moves 只要數表格有幾列。
## 2026-08-19 肩+二頭+三頭(含胸/腹輔助)
## 2026-08-11 跑步(中山區田徑場 間歇:1000m×2 + 600m×2)
那串 12;12🚀,8🔥🔥🔥,6+6 就可以被簡化。解析器只要認得標題,就結束了:
_HEADING = re.compile(r"^##\s+(\d{4}-\d{2}-\d{2})\s+(.+?)\s*$")
def parse_all() -> list[Session]:
sessions = []
for md in sorted(LOGS_DIR.glob("2026-*.md")):
if "annual" in md.name: # 年度回顧不是訓練紀錄
continue
text = md.read_text(encoding="utf-8")
# 依標題切段,保留內文以便數動作數
parts = re.split(r"^(##\s+\d{4}-\d{2}-\d{2}\s+.+?)$", text, flags=re.M)
for i in range(1, len(parts) - 1, 2):
m = _HEADING.match(parts[i].strip())
if not m:
continue
date, summary = m.group(1), m.group(2)
sessions.append(Session(
date=date,
kind="run" if summary.startswith("跑步") else "lift",
summary=summary,
moves=0 if summary.startswith("跑步") else _count_moves(parts[i + 1]),
))
sessions.sort(key=lambda s: s.date, reverse=True)
return sessions
跑完的結果:
解析出 40 筆訓練紀錄(跑步 13、重訓 27),涵蓋 39 天
日期範圍:2026-06-01 ~ 2026-08-23
40 筆。
(本文統計的資料截止日是 2026-08-23,紀錄每天都在長,之後重跑數字會再增加。)
解析零失敗,看起來很順利。直到拿這些資料去回答一個問題:
腿推有沒有進步?
把原始紀錄調出來:
06-02 「180」
06-10 「180→225」
06-16 「180→225」
06-30 「90」
07-11 「135」
07-20 「135→225→270」
用最直覺的做法—取當天最大的數字當工作重量—趨勢長這樣:
180 → 225 → 225 → 90 → 135 → 270
六月底掉了一半,七月中又衝上史上最高。
這條曲線畫到儀表板上會很好看,但它會被讀成一件根本沒發生的事。
問題出在 90。第一時間我以為是紀錄不完整—那天只記了暖身重量,工作組忘了寫。
然後我翻開那天的原始表格:
| 腿推 (Leg Press) | 12,12,12 | 90 | 因下背不適今日減量;180/225 為歷次紀錄 |
下背拉傷。
不是紀錄不完整,也不是退步。是真的推了 90,而且是刻意的。同一天的槓鈴深蹲備註也寫著「下背不舒服→維持輕重量工作組×3;60 未上(背不適)」—這正好是另一條鋸齒 40 → 60 → 60 → 40 → 60 裡那個 40。
再往後翻,整條曲線每一段都有交代。7/11 回到 135,備註寫「上次減量 90,今回到 135」—但同一天的深蹲只做了 3 下就收,理由寫得很直白:「腰還是不太行」。要到 7/20 才真的敢衝上史上最高的 270。
而七月的回顧檔裡,這件事早就結案了:
腰(深蹲):6/30、7/11 兩次下背不適 → 自己 7/20 回報已恢復。
那條看起來莫名其妙的鋸齒,其實是一次拉傷、兩次減量、一次回歸的完整過程。
所以這條曲線不是沒有意義。它的意義非常清楚—只是解析的方向需要確認。
$ .venv/bin/python parse_logs.py --json dashboard/public/sessions.json
解析出 40 筆訓練紀錄(跑步 13、重訓 27),涵蓋 39 天
解析出 2 筆體重紀錄
已寫出 40 筆 → dashboard/public/sessions.json
已寫出 2 筆 → dashboard/public/weights.json
兩份 JSON,前端可以直接 fetch。體重只有兩筆,因為那台體重機得有人站上去才有數字—這件事明天會再提到。
跑步和重訓終於在同一個資料結構裡了—這正是儀表板「本週訓練」那一欄需要的材料。
體重那份沒花什麼力氣。weight.md 是 weight_sync.py 自動追加的,欄位固定、沒有速記語法,一條正則就解完了。
今天就到 JSON 為止,接上畫面會在 Day 10 來處理。
四個來源裡有三個能變成 JSON 了,但每一種都要自己開終端機打指令。
Garmin 要手動同步、日誌要手動重跑解析、體重還得先站上去。只要有一天忘記,儀表板上的數字就是舊的—而且不會有任何提示。
明天要來處理排程,讓這些事情自己發生。