昨天決定了這個系列的方向:把散落在不同平台的音樂紀錄整理到一起,建立自己的 Music Recap。
我主要使用 YouTube Music,所以第一個要接進來的來源,就是自己的觀看紀錄。
不過,拿到匯出檔案,只是開始。接下來還要知道:裡面有哪些資料?哪些活動來自 YouTube Music?缺少的欄位怎麼處理?轉換過程有沒有漏掉紀錄?
今天完成了第一版 YouTubeMusicImporter,把 Google Takeout 的 HTML 轉成統一事件格式,再產生匯入摘要、逐筆處理紀錄與基礎活動統計。
測試方面,完成了 35 項自動化測試,以及 30,000 筆合成活動的大量資料驗證。本文也會把「先前從真實資料觀察到的結果」與「今天實際測試的結果」分開說明。
先前探索的檔案位於:
Takeout/YouTube 和 YouTube Music/觀看記錄/watch-history.html
從路徑就能看出來,YouTube 和 YouTube Music 的資料放在同一個服務分類底下。
專案前一輪留下的初步觀察是:
| 項目 | 初步觀察 |
|---|---|
| 總觀看活動 | 26,900 筆 |
| YouTube Music 活動 | 15,403 筆 |
| 一般 YouTube 活動 | 11,497 筆 |
| 音樂活動中的不同 Video ID | 1,197 個 |
音樂紀錄的日期範圍是 2026 年 1 月 31 日到 9 月 15 日。
這些數字來自先前的真實檔案探索。本次開發環境沒有那份私人原始 HTML,因此今天完成的新匯入器,尚未重新驗證這 15,403 筆紀錄。接下來介紹的測試數字,都來自明確標示的合成資料。
先前的觀察,則幫助我定出第一版匯入器需要保留的資訊:活動時間、顯示標題、頻道名稱、來源 URL 和 Video ID。
還有一個會直接影響 Recap 設計的缺口:這份觀看紀錄沒有提供每筆實際播放秒數。
假設紀錄裡出現一首歌,能不能直接算成「我完整聽了一次」?
目前這份資料沒有提供足夠資訊,讓我做出這個判斷。
它可以告訴我某個時間出現了一筆活動,但沒有告訴我當時聽了多久。因此,匯入器沿用 Day 1 的規則:
{
"played_ms": null,
"duration_kind": "unknown"
}
null 表示不知道;0 則表示來源明確記錄了零秒。
這兩種狀態必須分開。否則,當所有播放時長都是未知,程式可能算出一個漂亮的「0 分鐘」,卻傳達了錯誤的意思。
同樣地,即使之後查得到歌曲完整長度,也不能拿它直接填入 played_ms。歌曲長度是作品資訊,實際播放時間是某次活動的資訊,中間還差著使用者究竟聽了多少。
所以,今天的統計先使用「活動次數」這個名稱。
某個 Video ID 出現幾筆、哪個月份活動最多、哪些時段比較常出現紀錄,都能從目前的事件資料計算。至於真實收聽分鐘數,就繼續保留未知。
第一版使用 Python 標準函式庫的 HTMLParser 讀取 HTML。
這個介面可以接收分段輸入,在讀到標籤與文字時呼叫對應的方法,也能處理 HTML 字元參照。例如,HTML 裡的 & 會還原成顯示文字中的 &。
我把整個轉換流程整理成:
watch-history.html
→ 逐張讀取活動卡片
→ 判斷來源
→ 取得影片連結、標題、頻道與時間
→ 檢查 Video ID 與時間格式
→ 建立 ListeningEvent
→ 處理頻道缺值與衝突
→ 輸出事件、摘要與逐筆稽核
程式以 HTML 裡的 outer-cell 作為活動卡片邊界,再從卡片的 header 與主要內容區抽取資料。
來源判斷優先使用卡片 header。標記為 YouTube Music 的資料進入音樂匯入流程;標記為 YouTube 的資料則列為一般觀看活動。
這裡不會用標題猜來源。
例如,一般 YouTube 影片的標題叫做「YouTube Music 使用教學」,它仍然是一筆一般 YouTube 活動,不會因為標題裡有關鍵字就被放進音樂統計。
沒有 header 時,程式接受合法的 music.youtube.com 影片 URL 作為補充證據,並記錄判斷依據。如果來源標記與 URL 矛盾,或資訊不足以確認,就留下原因,等待檢查。
Day 1 已經建立了 ListeningEvent,今天就是讓匯入器真正產生這種格式。
主要欄位的對應方式如下:
| 統一欄位 | YouTube Music 的處理方式 |
|---|---|
source |
固定為 youtube_music |
source_track_id |
來源 URL 中的 Video ID |
source_url |
保留來源 URL |
raw_title |
保留顯示標題 |
raw_channel |
保留來源顯示的頻道,缺少時為 null |
occurred_at |
解析來源時間,再統一為 UTC |
raw_artist |
保留 null |
played_ms |
保留 null |
duration_kind |
固定為 unknown |
其中,raw_artist 沒有直接使用頻道名稱。
因為目前拿到的欄位是「頻道」,而後續要做的歌手排行需要「歌手身份」。兩者之間仍然需要辨識流程,不能在匯入時就把這個問題略過。
Video ID 也有類似的限制。它可以用來辨識來源影片,但同一首歌可能有 MV、現場演出或其他版本,所以「不同 Video ID 數」也不能直接改名叫「不同歌曲數」。
先把原始資料保留下來,後面建立歌曲與歌手辨識時,才有足夠資訊可以回頭查核。
時間看起來只是字串,真正處理時卻有不少細節。
例如:
2026年9月15日 下午6:52:27 GMT+08:00
轉成 UTC 後是:
2026-09-15T10:52:27+00:00
兩者代表同一個時間點。
目前的做法是:事件儲存採 UTC,製作日期、月份和時段統計時,預設轉成 +08:00。
這樣才能正確處理跨日與跨月。例如:
來源時間:2026-08-31T16:10:00Z
統計時間:2026-09-01T00:10:00+08:00
在 +08:00 的統計裡,這筆活動應該放進 9 月 1 日,而不是 8 月 31 日。
另一個問題是來源時間沒有時區。
程式預設會拒絕這種紀錄,並留下原因。只有明確指定 --assume-timezone,才會替缺少時區的時間套用假設,而且會記錄哪些資料使用了這個假設。
兩個參數的用途也因此分開:
--timezone
控制統計使用的時差。
--assume-timezone
明確指定缺少來源時區時,要使用哪個時差。
統計時區不會偷偷變成來源時區,執行程式的電腦設定也不會替資料做決定。
先前探索發現,部分活動缺少正常的頻道名稱,但相同 Video ID 的其他紀錄可能有可用資訊。
今天把這件事整理成一個明確規則:
同一個 Video ID 只有一種可用頻道名稱時,才替缺值紀錄補上頻道。
以下是合成例子:
紀錄 A:
Video ID = DEMO0000001
頻道 = null
紀錄 B:
Video ID = DEMO0000001
頻道 = 示範樂團 - Topic
紀錄 A 可以參考紀錄 B,但補值後仍保留原始欄位:
{
"raw_channel": null,
"resolved_channel": "示範樂團 - Topic",
"metadata_status": "enriched"
}
raw_channel 記錄來源原本給了什麼,resolved_channel 記錄目前整理出的可用名稱。
另外,metadata_evidence 會保存補值方法、提供證據的事件 ID,以及支持該名稱的紀錄數。之後發現問題時,可以沿著證據回頭查。
如果相同 Video ID 出現兩種頻道名稱,程式會標記 conflict,缺值那筆不會任意選擇其中一種。
這種情況可能涉及頻道更名,也可能有其他原因。第一版先保留候選與衝突,等取得更多資訊後再處理。
某些頻道欄位也可能只顯示網址。程式會保留這段原始內容,但不把它直接當作可用的頻道名稱。
另一個需要先處理的問題,是重複匯入。
相同檔案跑兩次,統計不應因此變成兩倍。但同一份檔案裡如果有兩張完全相同的卡片,是否就能確定其中一張該刪掉?
目前沒有足夠資訊直接下結論。
因此,第一版保留每張有效來源卡片,以時間、Video ID、原始 URL、標題、頻道與合成標記建立內容指紋。完全相同的內容,再加上出現序號。
這樣,相同檔案重跑會產生相同的一組事件 ID,可以交給既有的去重函式處理。來源檔案本身的相同卡片則繼續保留,並另外統計疑似重複數量。
這項設計目前保證的是相同輸入的可重現性。
不同日期匯出的檔案,標題、頻道或其他內容可能改變,因此跨匯出批次的事件合併,仍然是後續工作。
在包含 Day 2 程式的專案根目錄,先安裝套件,再執行合成範例:
python -m pip install -e .
music-recap import-youtube samples/watch-history.synthetic.html --output-dir outputs/day02-demo
執行後會產生:
outputs/day02-demo/
├─ listening_events.json
├─ import_report.json
└─ import_audit.json
listening_events.json 存放統一事件,可以交給原本的摘要指令:
music-recap outputs/day02-demo/listening_events.json
import_report.json 包含匯入數量、缺值、頻道衝突、疑似重複,以及日期和時段分布。
import_audit.json 則逐張記錄卡片的處理結果。成功匯入的資料會對應到事件 ID,無法接受的資料則留下原因。
摘要與稽核也會保存原始檔案的 SHA-256,方便確認某次結果對應的是哪一份輸入。
這次的合成 HTML 刻意放入不同情況,包括一般 YouTube、缺頻道、頻道衝突、相同卡片、無法解析的時間與來源不足的資料。
實際結果是:
來源卡片:12 張
成功匯入 YouTube Music:8 筆
略過一般 YouTube:2 筆
拒絕:1 筆
來源待確認:1 筆
數量可以核對:
12 = 8 + 2 + 1 + 1
這比只印出「成功匯入 8 筆」更有用,因為剩下的資料也都有明確去向。
8 筆匯入事件涵蓋 4 個 Video ID,其中 1 筆成功補回頻道,2 筆頻道仍未解決。
播放時長則全部未知,因此:
{
"event_count": 8,
"known_duration_event_count": 0,
"unknown_duration_event_count": 8,
"known_played_ms": 0,
"duration_coverage": 0.0,
"true_total_listening_ms": null
}
這裡的 known_played_ms = 0,只是已知時長的加總為零。目前沒有任何已知時長,真實總收聽時間仍是 null。
由於合成範例刻意包含問題資料,報告狀態會是 partial。
加上 --strict 時,程式仍然保存結果與稽核,但會回傳 exit code 1,讓後續腳本知道這次有資料需要檢查。
今天執行完整測試的結果:
python -m unittest discover -s tests -v
Ran 35 tests
OK
測試涵蓋原有的未知時長、零秒與去重規則,也包含這次新增的時間解析、來源判斷、頻道補值與命令列匯入。
例如,午夜與正午不能轉錯;月份分布必須依統計時区計算;一般 YouTube 的影片不能只因為標題像歌曲就混入;頻道補值必須保留原始值與證據;相同檔案重跑也必須得到一致結果。
命令列測試會實際啟動子程序,寫出三份 JSON,再用既有摘要指令讀回事件,而不只測試單一函式的回傳值。
另外,也執行了大量資料驗證:
python scripts/verify_youtube_scale.py
這個腳本會自行產生 30,000 張合成卡片,實測結果如下:
總活動卡片:30,000 張
匯入 YouTube Music:20,000 筆
略過一般 YouTube:10,000 筆
不同 Video ID:128 個
成功補回頻道:128 筆
拒絕與來源待確認:0 筆
來源數量核對一致,完整事件寫出 JSON 再讀回後,也逐筆相等。
本輪的本機驗證環境是 Linux、Python 3.13.5。提交到 GitHub 後,CI 的 Windows/Ubuntu × Python 3.11/3.13 四組環境,也都完成套件安裝與測試,結果全部通過。
這些測試確認了目前涵蓋的行為。真實私人檔案、其他語系或不同的 Takeout 版型,仍然需要實際輸入才能確認相容性。
把「不知道播放多久」和「播放零秒」分開,這個細節很容易被忽略。好奇之後產生 Music Recap 時,會不會同時顯示資料覆蓋率或可信程度?不然漂亮的排行可能會讓人忘記原始資料其實有缺口。