Multi-Media Creator
無胥 Starknows
- 多媒體創作者
- 世界觀設定、小說創作、多媒體編劇
- 直播輔助工具、網頁應用、AI 應用服務
Multi-Media Creator
無胥 Starknows
本篇文章由 Opus 5.5 根據作者的程式碼撰寫。
測試日期:2026-09-26。這個做法依賴 YouTube 網頁內部的介面,格式可能隨時改變,以下內容只代表撰寫當時的狀況。
請將本文內容當作研究用途,或用於自製的小工具。這不是 YouTube 提供給第三方的公開介面,不建議用在商業服務或大規模收集資料。
為了做聊天室留言的離線分析,需要把直播聊天室的留言原樣錄下來。這篇整理的是其中「輸入直播 ID,就開始側錄留言」的核心做法。
思路跟之前「你推多久沒開台了」相同:不呼叫 Data API,而是讀取 YouTube 網頁本身載入的資料。
用 YouTube Data API 取得聊天室留言,大致需要:
1. 建立 Google Cloud 專案並申請 API key
2. 用 videos.list 取得直播的 activeLiveChatId
3. 用 liveChatMessages.list 持續輪詢新留言
依官方配額表(2026-09 查閱),liveChatMessages.list 每次呼叫 1 單位,專案預設每天 10,000 單位。如果每 5 秒輪詢一次,一小時是 720 次,大約可以錄 13 小時。
只錄一兩場直播的話,官方 API 可能就足夠了,也比較穩定。這篇的做法主要省掉的是建立專案、管理 key 和配額的步驟,代價是介面沒有保證,隨時可能需要跟著 YouTube 改版調整。
| Data API | 網頁內部介面(本篇) | |
|---|---|---|
| 需要 API key | 需要,自己申請 | 不需要,使用網頁版的公開 key |
| 配額 | 每天 10,000 單位 | 沒有專案配額 |
| 穩定性 | 有文件、有版本 | 沒有文件,改版可能失效 |
| 資料內容 | 官方定義的欄位 | 網頁前端收到的原始資料 |
1. 從輸入的網址或 ID 解析出影片 ID 2. 載入聊天室彈出視窗的網頁,從 HTML 取出三個值:API key、client 版本、第一個 continuation token 3. 用 continuation token 呼叫聊天室的 InnerTube 端點,取得一批留言 4. 解析回應裡的留言 5. 從回應取出下一個 continuation token 和建議的等待時間,回到第 3 步 6. 用留言 ID 去重後寫入檔案
以下依序說明。程式碼是從實際的工具簡化而來,使用 Node.js 20 內建的 fetch。
使用者可能貼上各種格式的網址,先統一成 11 碼左右的影片 ID:
const ID_PATTERN = /^[A-Za-z0-9_-]{6,20}$/
export const parseVideoId = (input: string): string => {
const value = input.trim()
if (ID_PATTERN.test(value)) return value
const url = new URL(value.includes('://') ? value : `https://${value}`)
const host = url.hostname.toLowerCase().replace(/^www\./, '')
// https://youtu.be/<id>
if (host === 'youtu.be') return url.pathname.split('/').filter(Boolean)[0]
// https://www.youtube.com/watch?v=<id>
const queryId = url.searchParams.get('v')
if (queryId) return queryId
// https://www.youtube.com/live/<id>、/embed/<id>、/shorts/<id>
const [kind, id] = url.pathname.split('/').filter(Boolean)
if (['live', 'embed', 'shorts'].includes(kind) && id) return id
throw new Error('請提供 YouTube 直播網址或影片 ID。')
}實際的工具會再用 ID_PATTERN 檢查每個分支的結果,並拒絕 youtube.com 以外的網域。
聊天室的彈出視窗網址是:
https://www.youtube.com/live_chat?is_popout=1&v=<影片ID>這個頁面的 HTML 裡嵌著網頁前端啟動時需要的設定,其中有三個值是後續呼叫需要的:
| 值 | 在 HTML 裡的樣子 | 用途 |
|---|---|---|
| API key | "INNERTUBE_API_KEY":"..." | 網頁版前端共用的公開 key |
| client 版本 | "INNERTUBE_CONTEXT_CLIENT_VERSION":"..." | 請求時告訴伺服器前端的版本 |
| continuation | "continuation":"...","clickTrackingParams": | 第一次取得留言用的 token |
這裡的 API key 不是自己申請的 key,而是每個打開這個頁面的人都會拿到的同一把,不會扣任何人的專案配額。
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
// HTML 裡是 JSON 字串,可能含有 \u0026 這類跳脫字元,要還原
const decodeJsonString = (value: string): string => JSON.parse(`"${value}"`)
export const fetchBootstrap = async (videoId: string) => {
const url = new URL('https://www.youtube.com/live_chat')
url.searchParams.set('is_popout', '1')
url.searchParams.set('v', videoId)
const response = await fetch(url, { headers: { 'User-Agent': USER_AGENT } })
if (!response.ok) throw new Error(`無法載入聊天室頁面(HTTP ${response.status})`)
const html = await response.text()
const apiKey = html.match(/"INNERTUBE_API_KEY":"((?:\\.|[^"\\])*)"/)?.[1]
const clientVersion =
html.match(/"INNERTUBE_CONTEXT_CLIENT_VERSION":"((?:\\.|[^"\\])*)"/)?.[1] ??
html.match(/"clientVersion":"((?:\\.|[^"\\])*)"/)?.[1]
const continuation =
html.match(/"continuation":"((?:\\.|[^"\\])*)","clickTrackingParams":/)?.[1]
if (!apiKey || !clientVersion || !continuation) {
throw new Error('頁面沒有可用的聊天室資訊;請確認直播中且聊天室已啟用。')
}
return {
apiKey: decodeJsonString(apiKey),
clientVersion: decodeJsonString(clientVersion),
continuation: decodeJsonString(continuation),
}
}幾個注意點:
- 要帶一般瀏覽器的 User-Agent,否則可能拿到內容不同的頁面
- 正規表達式 (?:\\.|[^"\\])* 是為了正確處理字串裡的跳脫字元,而不是遇到第一個 \" 就截斷
- continuation 的正規表達式依賴它後面緊接著 clickTrackingParams 這個欄位順序,是整個流程裡最容易因改版失效的地方
- 直播沒有在播、或聊天室被關閉時,頁面上沒有 continuation,這時會直接報錯
拿到參數後,用 POST 呼叫網頁前端取得留言時用的同一個端點:
export const fetchLiveChatBatch = async (
bootstrap: { apiKey: string, clientVersion: string },
continuation: string,
) => {
const url = new URL('https://www.youtube.com/youtubei/v1/live_chat/get_live_chat')
url.searchParams.set('key', bootstrap.apiKey)
url.searchParams.set('prettyPrint', 'false')
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
body: JSON.stringify({
context: { client: { clientName: 'WEB', clientVersion: bootstrap.clientVersion } },
continuation,
}),
})
if (!response.ok) throw new Error(`聊天室輪詢失敗(HTTP ${response.status})`)
return response.json()
}請求本體只需要 context.client 和 continuation 兩個部分。
回應的結構大致是:
continuationContents
└ liveChatContinuation
├ actions[] 這一批的事件
└ continuations[] 下一次要用的 token 與等待時間actions 裡的新留言放在 addChatItemAction.item 底下,依類型分成不同的 renderer:
| renderer | 內容 |
|---|---|
liveChatTextMessageRenderer | 一般留言 |
liveChatPaidMessageRenderer | Super Chat |
liveChatMembershipItemRenderer | 會員加入或會員里程碑 |
每個 renderer 裡常用的欄位:
- id:留言 ID,用來去重
- message:留言內容
- authorName:顯示名稱
- authorExternalChannelId:作者的頻道 ID
- timestampUsec:送出時間,單位是微秒
- authorBadges:身分徽章(頻道主、管理員、會員、已驗證)
留言內容不是單純的字串,而是 runs 陣列,文字和表情符號分開存放,需要自己組回來:
const textFromRuns = (message: any): string => {
if (!message) return ''
if (typeof message.simpleText === 'string') return message.simpleText
return (message.runs ?? [])
.map((run: any) => {
if (typeof run.text === 'string') return run.text
// 表情符號:優先用 :shortcut: 形式,其次搜尋詞,最後才用 emojiId
const emoji = run.emoji
if (!emoji) return ''
return emoji.shortcuts?.[0]
?? (emoji.searchTerms?.[0] ? `:${emoji.searchTerms[0]}:` : undefined)
?? emoji.emojiId
?? '[emoji]'
})
.join('')
.trim()
}timestampUsec 是微秒的字串,數字超過 JavaScript 安全整數的範圍,轉換時先用 BigInt 除以 1000:
const toIso = (usec: string) => new Date(Number(BigInt(usec) / 1000n)).toISOString()另外,actions 裡也會有刪除事件:
- markChatItemAsDeletedAction、removeChatItemAction:單則留言被刪除,targetItemId 是被刪的留言 ID
- markChatItemsByAuthorAsDeletedAction:某位作者的留言全部被刪除
實際的工具把刪除事件另外記成一筆紀錄,不回頭修改已經寫入的留言,保留「當時看到什麼」的原始順序。
continuations 陣列裡會有其中一種資料:
| 欄位 | 情況 |
|---|---|
invalidationContinuationData | 直播中,通常是這一種 |
timedContinuationData | 直播中,依固定時間輪詢 |
liveChatReplayContinuationData | 直播存檔的聊天室重播 |
三種都有 continuation(下一次請求要用的 token)和 timeoutMs(伺服器建議的等待時間):
const nextContinuation = (liveChatContinuation: any) => {
for (const entry of liveChatContinuation.continuations ?? []) {
const data = entry.invalidationContinuationData
?? entry.timedContinuationData
?? entry.liveChatReplayContinuationData
if (!data) continue
const timeout = Number(data.timeoutMs)
return {
continuation: data.continuation ?? null,
// 限制在 0.25–30 秒之間,避免異常值讓程式狂打或卡住
pollAfterMs: Number.isFinite(timeout) ? Math.min(30_000, Math.max(250, timeout)) : 1_000,
}
}
return { continuation: null, pollAfterMs: 1_000 }
}每次都要用新拿到的 token 取代舊的;沒有拿到下一個 token 時,通常代表直播已經結束。
撰寫時實測一場新聞直播,拿到的是 invalidationContinuationData,timeoutMs 是 10000,也就是建議每 10 秒輪詢一次。照著伺服器給的間隔輪詢,對 YouTube 的負擔跟一般觀眾開著聊天室差不多。
同一則留言有可能在相鄰兩批回應中重複出現,所以用留言 ID 去重:
const seen = new Set<string>()
for (const message of batch.messages) {
if (seen.has(message.id)) continue
seen.add(message.id)
await file.appendFile(`${JSON.stringify(message)}\n`, 'utf8')
}檔案格式用 JSON Lines(一行一筆 JSON):
- 只需要附加寫入,錄到一半中斷,前面的資料也不會壞掉 - 之後處理時可以逐行讀取,不需要一次載入整個檔案
實際的工具另外有一個 session.json 記錄直播 ID、開始與結束時間、留言數。這個檔案會反覆覆寫,所以先寫到暫存檔再 rename,避免中斷時留下寫到一半的檔案。
把上面的步驟組起來:
const run = async (input: string, signal: AbortSignal) => {
const videoId = parseVideoId(input)
let bootstrap = await fetchBootstrap(videoId)
let continuation = bootstrap.continuation
let failures = 0
while (!signal.aborted) {
try {
const payload = await fetchLiveChatBatch(bootstrap, continuation)
const live = payload?.continuationContents?.liveChatContinuation
if (!live) throw new Error('回應沒有 liveChatContinuation,直播可能已結束')
await saveMessages(live.actions ?? []) // 第 4、6 步
const next = nextContinuation(live) // 第 5 步
if (!next.continuation) break // 直播結束
continuation = next.continuation
failures = 0
await sleep(next.pollAfterMs, signal)
} catch (error) {
if (signal.aborted) break
failures += 1
// 連續失敗 3 次,token 可能已經失效,重新從網頁取得
if (failures >= 3) {
bootstrap = await fetchBootstrap(videoId)
continuation = bootstrap.continuation
failures = 0
}
// 指數退避,最多等 30 秒
await sleep(Math.min(30_000, 1_000 * 2 ** failures), signal)
}
}
}幾個設計上的考量:
- 暫時性的網路錯誤不應該讓側錄停止,所以用退避重試
- 連續失敗時,可能是 continuation token 過期,重新載入聊天室網頁取得新的 token
- sleep 可以被 AbortSignal 中斷,按下 Ctrl+C 時會立刻跳出等待,並在 finally 裡把結束時間寫進 session.json
聊天室留言是觀眾的資料,實際的工具採取了以下預設:
- 保存留言文字,因為這是分析的對象
- 不保存頻道 ID、頭像網址、Super Chat 金額
- 作者用 HMAC 產生假名(例如 viewer_a1b2c3d4e5f6),金鑰每次側錄隨機產生,同一場裡同一個人的假名相同,跨場無法對應
- 預設不保存顯示名稱,需要時才用參數開啟
import { createHmac, randomUUID } from 'node:crypto'
const sessionKey = randomUUID() // 每次側錄重新產生,不寫入檔案
const pseudonym = (channelId: string) =>
`viewer_${createHmac('sha256', sessionKey).update(channelId).digest('hex').slice(0, 12)}`另外,錄下來的資料不進版控,也建議只側錄自己有權管理或研究的聊天室,並限制資料的存取與保留時間。
- 這不是公開 API,YouTube 改版時,HTML 裡的欄位名稱、順序或回應結構都可能改變,需要跟著調整 - 目前只能用在公開直播;私人或會員限定的直播無法使用 - 直播沒有在播、或聊天室被關閉時,第 2 步就會失敗 - 基本上只能取得開始側錄之後的留言,無法回溯更早的內容 - 本篇沒有測試直播存檔的聊天室重播 - 請當作研究用途或自製小工具使用;使用前也建議先確認 YouTube 的服務條款與自己的使用情境
整個流程的核心其實只有兩個請求:一次載入聊天室網頁取得參數,之後重複呼叫同一個端點並更換 token。比較花時間的部分是解析回應的結構,以及處理 token 失效、直播結束等狀況。
如果只是要錄少數幾場直播,官方的 Data API 可能是更穩定的選擇;想做研究或自製小工具、又不想管理 API key 與配額時,這個做法或許可以參考。如果有更好的做法,也歡迎交流。