Multi-Media Creator

無胥 Starknows

  • 多媒體創作者
  • 世界觀設定、小說創作、多媒體編劇
  • 直播輔助工具、網頁應用、AI 應用服務

不透過 YouTube Data API 側錄直播聊天室留言

本篇文章由 Opus 5.5 根據作者的程式碼撰寫。

測試日期:2026-09-26。這個做法依賴 YouTube 網頁內部的介面,格式可能隨時改變,以下內容只代表撰寫當時的狀況。

請將本文內容當作研究用途,或用於自製的小工具。這不是 YouTube 提供給第三方的公開介面,不建議用在商業服務或大規模收集資料。

前言

為了做聊天室留言的離線分析,需要把直播聊天室的留言原樣錄下來。這篇整理的是其中「輸入直播 ID,就開始側錄留言」的核心做法。

思路跟之前「你推多久沒開台了」相同:不呼叫 Data API,而是讀取 YouTube 網頁本身載入的資料。

技術分享 - 你推多久沒開台了?

官方 API 的做法

用 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。

1. 解析影片 ID

使用者可能貼上各種格式的網址,先統一成 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 以外的網域。

2. 從聊天室網頁取得初始參數

聊天室的彈出視窗網址是:

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,這時會直接報錯

3. 輪詢聊天室端點

拿到參數後,用 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 兩個部分。

4. 解析留言

回應的結構大致是:

continuationContents
  └ liveChatContinuation
      ├ actions[]         這一批的事件
      └ continuations[]   下一次要用的 token 與等待時間

actions 裡的新留言放在 addChatItemAction.item 底下,依類型分成不同的 renderer:

renderer內容
liveChatTextMessageRenderer一般留言
liveChatPaidMessageRendererSuper 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:某位作者的留言全部被刪除

實際的工具把刪除事件另外記成一筆紀錄,不回頭修改已經寫入的留言,保留「當時看到什麼」的原始順序。

5. 下一個 token 與輪詢間隔

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 的負擔跟一般觀眾開著聊天室差不多。

6. 去重與寫檔

同一則留言有可能在相鄰兩批回應中重複出現,所以用留言 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,避免中斷時留下寫到一半的檔案。

7. 主迴圈與錯誤處理

把上面的步驟組起來:

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 與配額時,這個做法或許可以參考。如果有更好的做法,也歡迎交流。