iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
ChatGPT & Codex

我的音樂不該被平台綁架:30 天用 ChatGPT × Codex 打造跨平台 Music Recap系列 第 2

Day 2|把我的 YouTube Music 紀錄挖出來:Google Takeout 到底給了什麼?

  • 分享至 

  • xImage
  •  

昨天決定了這個系列的方向:把散落在不同平台的音樂紀錄整理到一起,建立自己的 Music Recap。

我主要使用 YouTube Music,所以第一個要接進來的來源,就是自己的觀看紀錄。

不過,拿到匯出檔案,只是開始。接下來還要知道:裡面有哪些資料?哪些活動來自 YouTube Music?缺少的欄位怎麼處理?轉換過程有沒有漏掉紀錄?

今天完成了第一版 YouTubeMusicImporter,把 Google Takeout 的 HTML 轉成統一事件格式,再產生匯入摘要、逐筆處理紀錄與基礎活動統計。

測試方面,完成了 35 項自動化測試,以及 30,000 筆合成活動的大量資料驗證。本文也會把「先前從真實資料觀察到的結果」與「今天實際測試的結果」分開說明。

一、Google Takeout 裡,音樂紀錄長什麼樣子?

先前探索的檔案位於:

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 出現幾筆、哪個月份活動最多、哪些時段比較常出現紀錄,都能從目前的事件資料計算。至於真實收聽分鐘數,就繼續保留未知。

三、從 HTML 裡找出 YouTube Music 活動

第一版使用 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 矛盾,或資訊不足以確認,就留下原因,等待檢查。

四、把來源資料轉成 ListeningEvent

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 版型,仍然需要實際輸入才能確認相容性。


上一篇
Day 1|把散落各平台的音樂紀錄,變成自己的 Music Recap
下一篇
Day 3 | 拿 15,403 筆真實 YouTube Music 紀錄驗收,哪些資料真的能拿來做 Recap?
系列文
我的音樂不該被平台綁架:30 天用 ChatGPT × Codex 打造跨平台 Music Recap5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
lin1015
iT邦新手 5 級 ‧ 2026-09-16 17:40:29

把「不知道播放多久」和「播放零秒」分開,這個細節很容易被忽略。好奇之後產生 Music Recap 時,會不會同時顯示資料覆蓋率或可信程度?不然漂亮的排行可能會讓人忘記原始資料其實有缺口。

我要留言

立即登入留言