昨天的體重機資料取得,方式雖然曲折,不過至少格式固定—13 位元組,知道換算方式使用上就沒什麼問題。今天要處理的資料則是比較需要多加轉換:一套自己發明、還在演化中的重訓速記語法,紀錄在 Notion 頁面。
Day 2 時提及過這套語法的樣子:
啞鈴胸推斜板|12;12🚀,8🔥🔥🔥,6+6|35 降20
自己可以輕易理解,但是對機器來說就是一串未知的符號。但今天還不急著解析這串符號—明天才會來處理。今天要先解決一個更前面的問題:怎麼把這些字從 Notion 裡完整地拿出來。
聽起來應該很簡單,實際上卡了不少時間。
最早的版本是 Google Keep,一則筆記記一天。優點是紀錄方便、沒有什麼成本;缺點在累積到三個月後爆發—想比較「臥推這個動作有沒有進步」,只能一則一則往上滑,自己做比較。

搬到 Notion 的理由除了方便記錄,更是它有結構。同一頁裡用日期當標題分段,每天一段,新的加在最上面。這樣至少「哪一天練了什麼」是可以被定位的。
但 Notion 有個特性在讀取時比較麻煩:它的內容不是純文字,有點像是一棵樹。
在 Notion 頁面上看到的每一行,背後都是一個 block。段落是 block、清單項目是 block、toggle 也是 block,而且 block 底下還可以有 block。
實際寫紀錄時,這個特性用得很自然:年份包月份、月份包日期、日期底下才是當天的動作清單。折疊起來很整齊,展開才看得到內容。

問題是官方 API 讀資料時,一次只給你一層。要拿到完整內容,得自己遞迴往下爬:
def _blocks_text(notion, block_id, depth=0):
out = []
cursor = None
while True:
resp = notion.blocks.children.list(block_id, start_cursor=cursor)
for b in resp["results"]:
bt = b["type"]
rich = (b.get(bt) or {}).get("rich_text")
if rich:
txt = "".join(t["plain_text"] for t in rich)
out.append(" " * depth + txt) # 用縮排記住原本的層級
if b.get("has_children"):
out.extend(_blocks_text(notion, b["id"], depth + 1))
if not resp.get("has_more"):
break
cursor = resp["next_cursor"]
return out
此外有兩個要注意的點:
第一,層級要記錄清楚。 爬到的文字如果直接串成一串,樹狀結構就消失了。所以每往下一層就多縮排兩格—把層級關係翻譯成縮排,後面才有辦法判斷「這個動作屬於哪一天」。
第二,要處理分頁。 Notion 的 API 一次不會給你全部的 block,超過就要拿 next_cursor 再要一次。頁面小的時候不會發現,等紀錄累積起來才會突然發現-怎麼少了幾天。
上面那段程式碼裡有一行,是踩過之後才加上去的:
txt = "".join(t["plain_text"] for t in rich).replace("\n", " ")
在 Notion 裡按 Shift + Enter 會在同一個 block 內換行—看起來像兩行,實際上還是同一個 block。而這個換行符號會原封不動出現在 API 回傳的文字裡。
結果就是:一個 block 被縮排標記成第三層,但它內部的第二行前面什麼縮排都沒有。後面依縮排判斷歸屬時,那半行就會被判成不屬於任何一天,直接被丟掉。
紀錄裡少了東西,而且不會有任何錯誤訊息。 這種靜默的資料遺失最麻煩—要等到某天回頭看某次訓練,才發現有幾組不見了。
解法很簡單,就是上面那個 .replace("\n", " "):把 block 內部的換行併成同一行。
爬完之後拿到的是一大串帶縮排的文字。接下來要把它切成一天一段。
判斷標題的規則是「這一行以日期開頭」,兩種寫法都接受:
_DATE_RE = re.compile(r"^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}/\d{1,2})\b")
2026-07-20 可以,7/20 也可以—因為實際寫紀錄時兩種都寫過,讓程式兩種都能辨識。
真正的關鍵在切段的判斷:縮排比日期淺的,就不算這一天的內容
這條規則的好處是它同時處理了好幾種寫法。可以是平鋪的 ## 2026-07-20 底下接同層的 bullet;也可以是日期是 toggle、動作藏在底下更深一層;年 → 月 → 日三層巢狀—因為外層的「2026 年」「7 月」這種標題縮排比日期標題還淺,會自動被跳過,不會污染上一天的內容。
一條規則通吃三種寫法,不用為了每種格式各寫一套解析。
現在拿到的是「一天一段的純文字」。這還不是結構化資料—12;12🚀,8🔥🔥🔥,6+6 這串仍然是一串迷之符號,之後才會被拆成動作、重量、組數、次數。
不過已經足夠餵給教練用了。Telegram bot 上的 /workout 指令做的就是這件事:抓最新一天的段落,整段交出去。
而等到結構化做完之後,這些文字最後會變成儀表板上「本週課表達成」那一欄裡,跑步與重訓混排的清單—每一列都得先經過今天這道爬取程序。
上面那些都還算好處理—巢狀、分頁、換行,知道有這回事就能處理掉。
比較麻煩的是資料本身。看這一列:
後側飛鳥機械|12,12,12|26
26的單位是什麼?26 公斤還是 26 磅?
紀錄裡沒寫。而它沒寫的原因很合理:健身房裡磅制和公斤制的器材本來就混在一起,通常只會記錄數字,看自己有沒有進度而已。
所以這台後側飛鳥是磅、旁邊的腿伸機械是公斤、三頭下拉 W 槓是公斤、腿推又是磅—釐清單位能讓紀錄更有依據。
這種坑算是從以前累積到現在,但因為要工程化這些資料所以要再確認的資訊。
解法很土炮:一筆一筆確認「這台是磅還是公斤」,整理成一張對照表。往後程式看到沒寫單位的數字就查表,缺的那塊資訊補回來了。
這也是為什麼要趁還記得的時候做。單位這件事,紀錄裡永遠找不回來,但確認好單位之後就一勞永逸了~
從 Day 3 到今天,一直在做同一件事:把散在各處的資料弄回自己手上。Garmin 的逐秒紀錄、體重機的 13 bytes、Notion 的重訓文字,各有各的麻煩,也各有各的解法。
但明天把它們攤在同一張桌上才發現,這四種資料根本不在同一個階段—有的一獲得就是結構化的,有的結構化過又退化了,有的從來沒被整理過,還有一種到現在連文字都不是。
明天要試著把重訓紀錄真的解析成結構化資料啦~解析是有成功,但拿去比較「這個動作有沒有進步」的時候,數字對不上,明天再來揭曉。