iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Modern Web

教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard系列 第 8

Day 8|把訓練紀錄變成 JSON:解得出來,也要比得出來

  • 分享至 

  • xImage
  •  

昨天把 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 要對齊什麼

面對這種狀況,很容易想做的是「設計一套夠通用的 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 要手動同步、日誌要手動重跑解析、體重還得先站上去。只要有一天忘記,儀表板上的數字就是舊的—而且不會有任何提示。

明天要來處理排程,讓這些事情自己發生。


上一篇
Day 7|重訓紀錄的演進:從 Google Keep 到 Notion,寫給自己看的紀錄
下一篇
Day 9|排程:讓資料自己更新,以及為什麼只跑在自己電腦上
系列文
教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言