유튜브 자막 확보 — 플레이어 요청 가로채기

만든 것17분조회 –

유튜브 영상에 원문 자막과 번역 자막을 같이 띄워 주는 브라우저 확장을 개인적으로 만들고 있습니다. 화면 아래에 두 줄을 겹쳐 보여주는 게 전부라 겉으로는 단순한데, 번역을 하려면 먼저 그 영상의 자막 텍스트를 손에 넣어야 합니다.

유튜브 자막은 timedtext 라는 API 가 내려주는데, 플레이어가 재생을 시작하면서 이 API 를 부르고 받아온 걸 화면에 뿌리는 구조라, 확장도 같은 URL 을 부르면 될 것처럼 보입니다.

그런데 확장 프로그램이 페이지 안에서 도는 위치는 두 군데로 갈립니다. 확장 전용 컨텍스트(content script)에서는 페이지의 자바스크립트 객체에 직접 손댈 수 없고, 페이지와 같은 컨텍스트(MAIN world)로 들어가야 페이지가 쓰는 fetch 나 XMLHttpRequest 를 그대로 볼 수 있습니다.

일단 확장에서 timedtext 를 직접 부르는 것부터 시작했고, 결국 자막을 가져오는 방법을 세 번 갈아엎었습니다. 셋 다 실제로 붙여 봤고, 지금은 하나를 주 경로로 쓰고 나머지는 폴백으로 남겨 뒀습니다.

1세대 — timedtext 직접 호출 (차단됨)

유튜브 자막은 timedtext API 로 받으니 확장에서 그 URL 을 직접 부르면 될 것 같았습니다.

그런데 안 됩니다. 유튜브가 이 API 에 세션 토큰(pot, Proof of Origin Token)을 요구하도록 바꿔서 확장이 만든 요청에는 그게 없기 때문입니다. 그렇다고 4xx 가 떨어지지는 않고, 이렇게 옵니다.

> GET /api/timedtext?v=<videoId>&lang=en

< HTTP/1.1 200 OK
< content-length: 0

200 OK 인데 본문이 0바이트라, 상태 코드만 보고 성공으로 처리하면 자막이 빈 채로 조용히 넘어갑니다.

2세대 — 자막 패널 DOM 스크래핑 (느림)

유튜브가 화면에 보여주는 자막 패널에서 텍스트를 읽어 오는 방법으로, 이건 확실히 동작은 합니다.

문제는 속도와 화면이라서, 긴 영상에서는 패널이 뜨는 데 24초까지 걸렸고 설명란을 펼치는 과정에서 화면이 움직이는 데다 언어를 바꾸는 로직도 따로 필요해서, 옆에서 보면 꽤 부산스럽습니다.

3세대 — 플레이어 요청 가로채기 (현재)

플레이어 자신이 보내는 timedtext 요청은 항상 성공합니다. 세션 토큰이 그 요청에는 들어 있기 때문이고, 그렇다면 확장이 요청을 만들지 말고 플레이어가 보내는 요청의 응답을 읽기만 하면 됩니다.

유튜브 플레이어timedtext 요청 (성공)yt-main.jsMAIN world: fetch·XHR 래핑youtube.js이중자막·번역응답만 읽음postMessage
확장은 요청을 안 만들고 응답만 읽음

yt-main.js 를 페이지 컨텍스트(MAIN world)에 넣어 fetch 를 감쌉니다. 원래 함수를 들고 있다가 그대로 부르고, 응답이 timedtext 면 복사본을 읽어 확장으로 넘깁니다.

const origFetch = window.fetch

window.fetch = async (...args) => {
  const res = await origFetch(...args)          // 플레이어 요청은 그대로 통과
  // fetch 는 문자열도 Request 객체도 받는다. 후자면 .url 을 봐야 한다
  const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url ?? '')

  if (url.includes('/api/timedtext')) {
    res.clone().text().then((body) => {          // 복사본만 읽는다
      window.postMessage({ type: 'YT_SUBTITLE', body }, '*')
    })
  }
  return res
}

res.clone() 이 핵심인데, 응답 본문은 한 번만 읽을 수 있어서 원본을 읽어 버리면 플레이어가 자막을 못 받으니, 복사본을 읽어야 플레이어 쪽은 아무 일도 없었던 것처럼 돌아갑니다.

자막이 꺼져 있으면 플레이어 API 로 잠깐 켰다가 받고 원상복구합니다.

응답 1건이 영상 전체 자막이라 3.5시간짜리 영상도 1~2초에 확보되고, DOM 을 건드리지 않으니 화면도 안 움직입니다.

가로채기에서 걸린 것들

먼저 걸린 건 래핑 대상이라, 요즘 웹이면 당연히 fetch 겠거니 하고 그것만 감쌌는데, 유튜브 플레이어는 자막을 XHR 로 받고 있어서 아무것도 안 잡혔습니다. XHR 은 open 에서 URL 을 기억해 두고 load 에서 본문을 읽는 식으로 따로 감싸야 합니다.

const origOpen = XMLHttpRequest.prototype.open

XMLHttpRequest.prototype.open = function (method, url, ...rest) {
  if (String(url).includes('/api/timedtext')) {
    this.addEventListener('load', () => {
      window.postMessage({ type: 'YT_SUBTITLE', body: this.responseText }, '*')
    })
  }
  return origOpen.call(this, method, url, ...rest)
}

둘 다 감싸야 놓치지 않습니다.

리스너를 붙이기 전에 지나간 요청도 문제라서, 확장이 로드되기 전에 플레이어가 이미 자막을 받아 버리면 그 응답은 그냥 사라지니, 페이지 쪽에 버퍼를 두고 확장이 준비되면 재전송하게 했습니다.

자막 목록(tracklist)이 빈 배열인데 실제로는 자막이 나오는 영상도 있습니다. 목록을 믿고 자막이 없다고 판단하면 이런 영상에서 아무것도 못 하니 playerResponse 에서 자막 정보를 직접 구성해서 우회합니다.

마지막으로 폴백 체인을 남겨 뒀고, 가로채기가 실패하면 직접 baseUrl 호출로 그것도 안 되면 패널 스크래핑, 그다음 실시간 미러 순으로 내려갑니다. 실시간 미러는 화면에 뜨는 자막을 그때그때 읽어 옮기는 방식이고, 유튜브가 언제 또 바꿀지 모르니 한 방법에만 걸어 두지 않았습니다.

직접 만들기에서 플레이어 읽기로

1세대는 유튜브가 세션 토큰을 요구하도록 바꾸면서 막혔고, 2세대는 동작은 했지만 화면이 움직이고 느려서 쓰기 나빴습니다. 3세대에서 방향을 바꿔 자막을 직접 받아 내는 대신 이미 자막을 처리하고 있는 플레이어에서 그 결과를 읽어 오는 쪽으로 갔습니다.

남의 서비스 위에 얹는 확장에서는 이쪽이 훨씬 오래갑니다. 내가 만든 요청이라면 그게 유효한지를 계속 따라가야 하지만, 플레이어가 만든 요청을 읽기만 하면 인증이든 파라미터든 유튜브가 알아서 맞춰 줍니다.

  1. 불러오는 중