iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Modern Web

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

Day 7|重訓紀錄的演進:從 Google Keep 到 Notion,寫給自己看的紀錄

  • 分享至 

  • xImage
  •  

昨天的體重機資料取得,方式雖然曲折,不過至少格式固定—13 位元組,知道換算方式使用上就沒什麼問題。今天要處理的資料則是比較需要多加轉換:一套自己發明、還在演化中的重訓速記語法,紀錄在 Notion 頁面。

Day 2 時提及過這套語法的樣子:

啞鈴胸推斜板|12;12🚀,8🔥🔥🔥,6+6|35 降20

自己可以輕易理解,但是對機器來說就是一串未知的符號。但今天還不急著解析這串符號—明天才會來處理。今天要先解決一個更前面的問題:怎麼把這些字從 Notion 裡完整地拿出來。

聽起來應該很簡單,實際上卡了不少時間。


為什麼從 Keep 搬到 Notion

最早的版本是 Google Keep,一則筆記記一天。優點是紀錄方便、沒有什麼成本;缺點在累積到三個月後爆發—想比較「臥推這個動作有沒有進步」,只能一則一則往上滑,自己做比較。

https://ithelp.ithome.com.tw/upload/images/20260823/20183598pmf2P1Lijj.jpg

搬到 Notion 的理由除了方便記錄,更是它有結構。同一頁裡用日期當標題分段,每天一段,新的加在最上面。這樣至少「哪一天練了什麼」是可以被定位的。

但 Notion 有個特性在讀取時比較麻煩:它的內容不是純文字,有點像是一棵樹。


Notion 的資料其實是巢狀積木

在 Notion 頁面上看到的每一行,背後都是一個 block。段落是 block、清單項目是 block、toggle 也是 block,而且 block 底下還可以有 block

實際寫紀錄時,這個特性用得很自然:年份包月份、月份包日期、日期底下才是當天的動作清單。折疊起來很整齊,展開才看得到內容。

https://ithelp.ithome.com.tw/upload/images/20260823/20183598iBND2ak2hn.jpg

問題是官方 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 再要一次。頁面小的時候不會發現,等紀錄累積起來才會突然發現-怎麼少了幾天。


自己遇到的一個小坑:Shift + Enter 換行

上面那段程式碼裡有一行,是踩過之後才加上去的:

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 的重訓文字,各有各的麻煩,也各有各的解法。

但明天把它們攤在同一張桌上才發現,這四種資料根本不在同一個階段—有的一獲得就是結構化的,有的結構化過又退化了,有的從來沒被整理過,還有一種到現在連文字都不是。

明天要試著把重訓紀錄真的解析成結構化資料啦~解析是有成功,但拿去比較「這個動作有沒有進步」的時候,數字對不上,明天再來揭曉。


上一篇
Day 6|Web Bluetooth 讀體重機:底層看得到,但 API 呢?
下一篇
Day 8|把訓練紀錄變成 JSON:解得出來,也要比得出來
系列文
教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言