숨어 있는 강의 영상의 '진짜 주소'를 낚아채는 Chrome 확장 — videokeeper에 입구를 달다

TL;DR

  • videokeeper 본체에 강의 영상을 공급하는 입구 역할의 Chrome 확장을 만들었다
  • 영상의 진짜 주소는 페이지 소스에 없다 → Manifest V3 webRequest.m3u8/.mpd 요청을 감시해 낚아챈다
  • HLS는 마스터·변형 플레이리스트가 섞여 잡힌다 → 파일명 패턴으로 변형을 걸러내 마스터만 남긴다
  • 인증 영상은 쿠키가 필요하다 → 페이지 도메인과 CDN 도메인이 달라서 둘 다 모아 Netscape 쿠키 파일로 변환한다
  • 제목 추출은 executeScript 동적 주입 대신 manifest 등록 content script가 호환성이 좋았다

들어가며

지난 글에서 videokeeper를 하루 만에 MVP로 배포하고 13일간 키운 이야기를 했다. URL 하나로 영상을 받아 자막을 뽑고 AI로 요약해 검색 가능한 아카이브로 만드는 도구다. 그런데 그 글에서 잠깐 흘린 욕구가 하나 있었다 — “유료 강의도 마찬가지다. 수강 기간이 끝나면 다시 볼 수 없다.”

문제는, videokeeper가 일을 시작하려면 그 입구에 “받아야 할 강의 영상”이 먼저 들어와야 한다는 것이다. YouTube야 URL만 붙여넣으면 yt-dlp가 알아서 받지만, 로그인해서 보는 강의 영상은 URL이 눈에 보이지도 않는다.

그래서 작은 보조 도구를 하나 더 만들었다. 브라우저에서 강의 영상의 진짜 주소와 인증 정보를 모아 videokeeper에 넘겨주는 Chrome 확장이다. 이 글은 그 얇은 부품을 만들면서 부딪힌 문제들을 다룬다.

참고: 특정 교육 플랫폼을 겨냥한 도구가 아니라, 본인이 정당하게 수강 중인 강의를 개인 학습 목적으로 오프라인 보관하기 위한 개인 프로젝트다. 어떤 사이트를 대상으로 했는지는 의도적으로 밝히지 않는다.


전체 그림 — 확장은 “입구”만 담당한다

[브라우저 확장]          [videokeeper 본체]
 영상 URL 감지            영상 다운로드
 쿠키 수집        ──▶     STT → 요약 → 태깅
 제목 추출                로컬 학습 아카이브
 (이 글의 범위)           (지난 글의 범위)

videokeeper는 영상을 받아 STT·요약·태깅까지 해주는 본체이고, 확장은 그 앞단에서 “지금 보고 있는 강의의 진짜 영상 주소와, 그걸 받는 데 필요한 인증 정보를 모아 videokeeper에 넘기는” 한 가지 일만 한다. 다운로드도, 후처리도 모두 본체의 몫이다. 그래서 확장 자체는 의도적으로 얇게 만들었다 — 전체 코드가 manifest 포함 7개 파일, 핵심 로직은 200줄 남짓이다.


문제 1 — 영상의 “진짜 주소”는 숨어 있다

요즘 강의 사이트는 영상을 통째로 하나의 mp4 파일로 주지 않는다. 대신 HLS(.m3u8)나 DASH(.mpd) 같은 스트리밍 포맷을 쓴다. 영상을 수 초 단위 조각으로 쪼개고, 그 목록을 담은 플레이리스트 파일을 브라우저가 받아서 이어 붙여 재생하는 방식이다.

그래서 페이지 소스를 아무리 뒤져도 “영상 주소”가 안 보인다. 영상은 페이지가 로드된 뒤 네트워크 요청으로 따로 받아오니까.

해결책은 브라우저가 보내는 네트워크 요청을 엿보는 것이다. Manifest V3의 webRequest API로 모든 요청을 감시하다가, .m3u8 / .mpd로 끝나는 URL이 보이면 낚아챈다. 감지한 URL은 탭별로 chrome.storage.session에 저장하고, 확장 아이콘 배지에 개수를 띄워 “이 페이지에서 N개 잡혔다”를 바로 알 수 있게 했다.

const STREAMING_PATTERNS = [/\.m3u8(\?|$)/i, /\.mpd(\?|$)/i];

chrome.webRequest.onBeforeRequest.addListener(
  (details) => {
    const { url, tabId } = details;
    if (tabId < 0) return;
    if (!STREAMING_PATTERNS.some((p) => p.test(url))) return;
    // ... 감지한 URL을 탭별로 저장하고 배지에 개수 표시
  },
  { urls: ["<all_urls>"] }
);

그런데 m3u8이 여러 개 잡힌다

HLS는 보통 두 단계 구조다.

  1. 마스터 플레이리스트(master playlist) — “화질별로 이런 옵션들이 있어요” 하는 목차
  2. 미디어/변형 플레이리스트(variant playlist) — 특정 화질의 실제 조각 목록 (chunklist, index-v1, media-0 같은 이름)

받아야 할 건 마스터 플레이리스트 하나다. 이걸 yt-dlp 같은 도구에 넘기면 알아서 최적 화질을 골라 받으니까. 그런데 감지 로직은 변형 플레이리스트까지 다 잡아버린다. 그래서 파일명 패턴으로 변형을 걸러낸다.

const VARIANT_PATTERNS = [
  /index-v\d/i,
  /chunklist/i,
  /media-\d/i,
  /stream_\d/i,
  /level\d/i,
];

const filename = new URL(url).pathname.split("/").pop();
if (VARIANT_PATTERNS.some((p) => p.test(filename))) return; // 변형은 건너뜀

이렇게 하면 팝업에는 받을 가치가 있는 마스터 URL만 깔끔하게 남는다.


문제 2 — 인증된 영상은 쿠키가 있어야 받힌다

수강 중인 강의 영상은 아무나 받을 수 없다. 로그인 세션(쿠키)이 있어야 CDN(Content Delivery Network)이 영상 조각을 내어준다. 그래서 다운로드 요청을 보낼 때 현재 브라우저의 쿠키를 함께 수집해서 넘겨야 한다.

여기에 함정이 하나 있었다. 강의 페이지의 도메인과, 실제 영상이 올라가 있는 CDN 도메인이 다른 경우가 많다는 것이다. 페이지 쿠키만 보내면 정작 영상 서버는 “넌 누구냐”며 거절한다. 그래서 사이트 쿠키 + CDN 쿠키를 둘 다 모은다.

async function collectCookies(tabUrl, m3u8Url) {
  const siteCookies = await chrome.cookies.getAll({ url: tabUrl });

  let cdnCookies = [];
  const cdnOrigin = new URL(m3u8Url).origin;
  if (cdnOrigin !== new URL(tabUrl).origin) {
    cdnCookies = await chrome.cookies.getAll({ url: m3u8Url }); // CDN 도메인 쿠키도 수집
  }
  // ... domain|path|name 키로 중복 제거 후 병합
}

수집한 쿠키는 yt-dlp가 읽을 수 있는 Netscape 쿠키 파일 포맷으로 변환해서 백엔드에 넘긴다. httpOnly 쿠키는 #HttpOnly_ 접두사를 붙이고, 도메인·서브도메인 허용 여부·경로·secure 플래그·만료·이름·값을 탭으로 구분된 한 줄로 직렬화하는 식이다.

function toNetscapeCookieFile(cookies) {
  const lines = ["# Netscape HTTP Cookie File"];
  for (const c of cookies) {
    const domain = c.httpOnly ? `#HttpOnly_${c.domain}` : c.domain;
    const subdomains = c.domain.startsWith(".") ? "TRUE" : "FALSE";
    const secure = c.secure ? "TRUE" : "FALSE";
    const expiry = c.expirationDate ? Math.floor(c.expirationDate) : 0;
    lines.push(
      `${domain}\t${subdomains}\t${c.path}\t${secure}\t${expiry}\t${c.name}\t${c.value}`
    );
  }
  return lines.join("\n") + "\n";
}

문제 3 — 영상 제목은 어떻게 알아낼까

파일을 261669.mp4 같은 숫자 ID로 저장하면 나중에 찾을 수가 없다. **“몇 강 무슨 내용”**이라는, 사람이 읽을 수 있는 제목이 필요하다.

제목은 페이지 DOM 안에 있다. “지금 재생 중”으로 강조된 강의 목록 항목을 찾으면 된다. 그런데 처음엔 chrome.scripting.executeScript로 페이지에 코드를 주입해 제목을 뽑으려 했는데, 일부 사이트에서는 이 방식이 잘 동작하지 않았다.

배운 점: executeScript 동적 주입 대신, manifest에 미리 등록한 content script 방식이 호환성이 좋았다. 페이지가 로드될 때부터 함께 들어가 있으니 DOM 접근이 안정적이었다.

그래서 content script가 페이지에 상주하다가, 팝업이 “제목 알려줘” 메시지를 보내면 셀렉터로 찾아 응답하는 구조로 바꿨다.

// content.js — 페이지에 상주하며 제목 요청에 응답
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message.type === "getTitle") {
    const selectors = getTitleSelectors(window.location.hostname);
    for (const sel of selectors) {
      const el = document.querySelector(sel);
      if (el && el.textContent.trim().length > 3) {
        sendResponse({ title: el.textContent.trim() });
        return;
      }
    }
    sendResponse({ title: "" });
  }
});

사이트별 설정은 한 곳에 모으기

사이트마다 “재생 중인 강의 제목”을 가리키는 CSS 셀렉터가 다르다. 새 사이트를 지원할 때마다 코드 여기저기를 고치고 싶지 않아서, 설정을 sites.js 한 파일에 모았다.

const SITE_CONFIGS = [
  {
    name: "사이트명",
    hostPattern: "도메인 키워드",   // hostname.includes()로 매칭
    source: "저장 디렉토리명",
    titleSelectors: ["재생 중인 강의 제목 CSS 셀렉터"],
  },
];

새 강의 플랫폼을 추가할 때 이 배열에 항목 하나만 넣으면 된다. 사이트별 셀렉터가 안 맞아도, 범용 셀렉터([aria-current], [class*="playing"] [class*="title"] 등)로 폴백하도록 해서 어느 정도는 자동으로 잡히게 했다.


videokeeper에 넘기는 페이로드

확장이 최종적으로 videokeeper의 capture API로 보내는 요청은 이렇게 생겼다.

{
  "url": "https://...master.m3u8?...",
  "cookies": "# Netscape HTTP Cookie File\n...",
  "title": "Ch01-02. 강의 제목",
  "page_url": "https://.../classroom/...",
  "source": "플랫폼명",
  "source_id": "강의 ID"
}

sourcesource_id를 함께 넘기는 이유는 videokeeper가 파일을 다운로드폴더/플랫폼명/강의ID/제목.mp4 구조로 정리해 저장하게 하기 위해서다. 폴더가 정돈돼 있어야 본체가 뒤이어 STT·요약·태깅한 결과물과 묶어 관리하기 편하다.

확장은 얇게 만들었지만, videokeeper와 맞물리는 지점에서 만난 함정도 두 가지 메모해둔다.

  • 제목이 덮어써지는 문제: yt-dlp가 m3u8을 받으면 파일명을 master로 인식해서, 애써 추출한 강의 제목을 날려버린다. 본체 쪽에서 다운로드 시 원래 제목을 따로 보존하도록 처리가 필요했다.
  • CORS와 인증 쿠키: videokeeper가 지난 글에서 다룬 Cloudflare Access 뒤에 있어서, 확장이 credentials: "include"로 인증 쿠키를 실어 보내야 했고, 서버 쪽도 CORS_ALLOW_CREDENTIALS = True가 켜져 있어야 했다.

작은 UX 디테일 — URL이 구분돼 보이게

처음엔 팝업에 감지된 URL을 한 줄로 줄여서(로 말줄임) 보여줬다. 그런데 강의 영상 URL은 경로가 워낙 길고 비슷해서, 두 개가 나란히 있으면 어느 게 어느 건지 구분이 안 됐다. 둘 다 cdn.../realtime/… 까지만 보이고 나머지가 잘렸으니까.

그래서 말줄임을 버리고 전체 경로를 줄바꿈해서 다 보여주되, 호스트네임은 회색·경로는 검은색으로 색을 나눠 강조하고 고정폭(monospace) 폰트를 적용했다. 사소하지만, 매일 쓰는 도구라 이런 차이가 체감이 크다.


마치며

확장이 하는 일은 영상 주소 감지 → 쿠키 수집 → 제목 추출 → videokeeper 전송이 전부다. 의도적으로 얇게 만든 보조 도구다. 하지만 이 입구가 있어야 지난 글에서 만든 본체가 받아 온 영상을 STT로 스크립트화하고, 요약하고, 태깅해서 비로소 “검색 가능한 학습 아카이브”가 된다. 작은 부품이지만 전체 시스템의 시작점인 셈이다.

요금제 걱정 없이 오프라인에서 강의를 보고, 들었던 내용을 텍스트로 검색하고, 태그로 주제를 넘나드는 — 그런 학습 환경을 videokeeper로 직접 만들어가는 중이다.


  • 기술 스택: Chrome Extension Manifest V3 · webRequest · cookies API · vanilla JS
  • 연결 시스템: videokeeper 본체 개발기 (Django · Celery · yt-dlp · K3s)

개인 학습 목적의 토이 프로젝트이며, 정당하게 수강 중인 콘텐츠를 오프라인 보관하는 용도다. 저작권과 각 서비스 이용약관을 준수하는 선에서 사용해야 한다.