m3u8-monkey-script/plans/implementation-plan.md
2026-06-06 18:27:03 +09:00

27 KiB

m3u8-download.user.js 구현 계획

1. 목표

Turbo Download Manager(v3.m3) 확장 프로그램의 m3u8 탐지·병합 로직을 참고하여, Tampermonkey/Violentmonkey 등에서 실행 가능한 UserScript를 구현한다.

  • 개발 시: src/ 폴더 아래 모듈화된 구조로 작성하여 유지보수성 확보
  • 빌드 시: esbuild로 단일 파일로 번들링 후, 파일 상단에 UserScript 메타데이터 주입
  • 출력: dist/m3u8-download.user.js (minify 안 된 가독성 있는 번들 파일)

2. 참고 소스 분석 요약

2.1 Turbo Download Manager의 m3u8 처리 흐름

단계 파일 설명
탐지 worker.js (webRequest API) + inject.js (content script) chrome.webRequest.onBeforeRequest.m3u8 XHR/미디어 요청 가로챔. <video>/<audio>canplay 이벤트로도 수집
파싱 data/add/index.js + data/add/m3u8-parser.js m3u8Parser.Parser로 playlist 파싱. 마스터 플레이리스트는 해상도 선택 후 재파싱. .ts 세그먼트 URI 추출 + 상대 경로 절대화
키 획득 data/add/index.js #EXT-X-KEY 지시문에서 key URI를 fetch. AES-128 검증 (16바이트). IV 처리. 중복 키 캐싱
다운로드 downloads/downloads.js + downloads/get.js 세그먼트를 순차적으로 다운로드. Multi-thread Range 기반 (SGetMGetMSGetFGetNFGetSNGet 클래스 계층)
저장 downloads/file.js IndexedDB에 chunk 저장. disk-write-offset로 각 세그먼트의 바이트 위치 추적
복호화 downloads/file.js crypto.subtle AES-CBC로 각 세그먼트 복호화
병합 downloads/file.js ReadableStream + ResponseBlobURL.createObjectURLchrome.downloads.download()

2.2 핵심 로직 추출

m3u8 파싱 (data/add/index.js:112-242):

1. fetch(m3u8_url) → text
2. m3u8Parser.Parser.push(content), parser.end()
3. parser.manifest.playlists 존재? → 마스터 플레이리스트 → 해상도 선택 → 재귀 파싱
4. parser.manifest.segments 존재? → 미디어 플레이리스트 → segment.uri 배열 추출
5. 상대 URI 절대화: path(root, rel) 함수
6. 중복 segment 제거
7. 각 segment의 key.uri fetch → ArrayBuffer → AES-128 검증 → 캐싱

세그먼트 다운로드·병합 (downloads/downloads.js:48-272 + downloads/file.js):

1. 세그먼트 URL 배열 순차 반복
2. 각 세그먼트 fetch → ArrayBuffer → IndexedDB에 offset 저장
3. 마지막 세그먼트 완료 시:
   - File.stream() → ReadableStream 생성
   - 암호화 있으면: 각 세그먼트 분할 → crypto.subtle.decrypt(AES-CBC)
   - Response(blob) → URL.createObjectURL → 다운로드

3. UserScript 구현 아키텍처

3.1 제약 조건 (확장 프로그램 vs UserScript)

기능 확장 프로그램 UserScript (Tampermonkey) 대응 방안
네트워크 요청 가로채기 chrome.webRequest API 사용 불가 XMLHttpRequest/fetch 오버라이드 + MutationObserver
m3u8 플레이리스트 fetch 동일 도메인 fetch CORS 제한 가능 GM_xmlhttpRequest 사용
암호화 키 fetch 동일 도메인 fetch CORS 제한 가능 GM_xmlhttpRequest 사용
세그먼트 다운로드 fetch + Range headers CORS 제한 가능 GM_xmlhttpRequest 사용
대용량 저장 IndexedDB 메모리 한계 메모리 기반 (Uint8Array). 매우 큰 파일은 경고
파일 저장 chrome.downloads.download() 사용 불가 <a> 요소로 Blob URL 다운로드
백그라운드 처리 Service Worker + Offscreen Doc 사용 불가 메인 스레드 + async/await
Referrer 주입 chrome.declarativeNetRequest 사용 불가 GM_xmlhttpRequestreferer 옵션
iframe 내부 요청 탐지 content_scripts + all_frames: true @all-frames true 필요 iframe별 독립 후킹 + unsafeWindow 처리
크로스 오리진 iframe webRequest로 전역 가로챔 unsafeWindow 접근 불가 크로스 오리진 iframe은 탐지 불가 (브라우저 보안 정책)

3.2 프로젝트 디렉토리 구조

m3u8-monkey-script/
├── package.json
├── esbuild.config.mjs          # esbuild 빌드 설정
├── metadata.user.js            # UserScript 메타데이터 블록 (banner로 주입)
├── src/
│   ├── index.ts                # 진입점: 초기화, 모듈 조립
│   ├── logger.ts               # 로깅 유틸리티 (unsafeWindow.console 기반)
│   ├── utils/
│   │   ├── uri.ts              # resolveUri(root, rel) 등 URI 유틸
│   │   ├── crypto.ts           # decryptSegment(key, data) AES-CBC 복호화
│   │   └── filename.ts         # guessFilename(url, mime) 파일명 추출
│   ├── detection/
│   │   ├── xhr-intercept.ts    # XMLHttpRequest.open/send 패치
│   │   ├── fetch-intercept.ts  # fetch() 패치
│   │   └── dom-monitor.ts      # <video>/<audio> source 추적
│   ├── parser/
│   │   ├── m3u8-parse.ts       # parseM3U8(url), resolveMasterPlaylist
│   │   └── key-fetch.ts        # 암호화 키 fetch + 검증 + 캐싱
│   ├── downloader/
│   │   ├── segment-fetch.ts    # fetchSegments(...) 세그먼트 다운로드
│   │   └── merge.ts            # mergeAndDownload(...) 병합 + 다운로드
│   └── ui/
│       ├── panel.ts            # createPanel() 페이지 상단 UI 패널
│       ├── progress.ts         # showStatus(...) 진행률 표시
│       └── selector.ts         # showPlaylistSelector(...) 해상도 선택
├── dist/
│   └── m3u8-download.user.js   # 빌드 결과 (banner + 번들 코드)
└── plans/
    └── implementation-plan.md

빌드 흐름:

  1. src/index.ts를 entry point로 esbuild 실행
  2. --bundle로 모든 모듈 + 의존성(m3u8-parser)를 단일 파일로 번들
  3. --bannermetadata.user.js의 메타데이터 블록을 파일 상단에 주입
  4. --format=iife로 Tampermonkey 호환 IIFE 생성
  5. minify 미적용 (--minify 생략)하여 가독성 유지
  6. 결과물 dist/m3u8-download.user.js 출력

3.3 파일 크기 예상

구성 요소 예상 크기
UserScript header (banner) ~1KB
m3u8-parser (bundled) ~30-40KB
탐지 모듈 ~5KB
파싱/다운로드/병합 로직 ~10KB
UI 모듈 ~5KB
로깅 유틸리티 ~1KB
총계 (dist) ~52-62KB

3.4 로깅 전략

Tampermonkey는 @grant가 선언되면 스크립트를 샌드박스에서 실행합니다. 샌드박스 내의 console.log는 페이지의 DevTools Console에 표시되지 않으므로, 디버깅이 거의 불가능합니다. 로컬 테스트로는 샌드박스 격리, iframe 컨텍스트 분리, CORS 등 실제 런타임 환경에서 발생하는 문제를 파악할 수 없으므로, 모든 로직에 페이지 메인 콘솔을 통한 로그가 포함되어야 합니다.

핵심 규칙:

  • 샌드박스 컨텍스트에서는 unsafeWindow.console을 사용하여 로그 출력
  • @all-frames true로 iframe에서도 실행되므로, iframe 로그는 [iframe] 접두사로 구분
  • 각 Phase의 주요 진입점, 분기점, 에러 발생 시 로그 필수
  • 로그 레벨(INFO, WARN, ERROR) 구분 + [M3U8-DL] 접두사로 필터링 용이
// 로깅 유틸리티
const log = (() => {
  const targetConsole = unsafeWindow && unsafeWindow.console ? unsafeWindow.console : console;
  const prefix = window.top === window ? '[M3U8-DL]' : `[M3U8-DL:iframe:${location.origin}]`;
  return {
    info:  (...args) => targetConsole.log(`${prefix} [INFO]`, ...args),
    warn:  (...args) => targetConsole.warn(`${prefix} [WARN]`, ...args),
    error: (...args) => targetConsole.error(`${prefix} [ERROR]`, ...args),
  };
})();

로그 필수 위치:

모듈 로그 위치 내용
초기화 스크립트 진입 시 실행 프레임 정보, unsafeWindow 가용성
XHR 후킹 open 호출 시 (m3u8 URL일 때) URL, method
XHR 후킹 load 이벤트 시 (m3u8 응답) status, Content-Type, body 길이
fetch 후킹 호출 시 (m3u8 URL일 때) URL, init.headers
fetch 후킹 응답 시 (m3u8 Content-Type) URL, Content-Type
파싱 시작 parseM3U8() 진입 m3u8 URL
파싱 완료 segment/key 추출 후 세그먼트 수, 암호화 여부
마스터 플레이리스트 해상도 선택 UI 표시 시 이용 가능한 스트림 목록
키 fetch 각 키 요청 전/후 key URI, 크기, 검증 결과
세그먼트 다운로드 각 세그먼트 시작/완료 인덱스, URL, 크기, 진행률
세그먼트 다운로드 에러 발생 시 URL, 에러 메시지, 재시도 횟수
복호화 각 세그먼트 복호화 전/후 인덱스, 키 정보, 결과 크기
병합 최종 Blob 생성 시 총 크기, 세그먼트 수
다운로드 파일 저장 시작/완료 파일명, 크기
에러 모든 catch 블록 에러 메시지, 스택, 현재 상태

4. 상세 구현 계획

Phase 1: m3u8 탐지

목표: 페이지 및 모든 iframe에서 m3u8 URL을 자동으로 탐지

1.1 iframe 컨텍스트 문제

각 iframe은 독립된 window를 가지므로, 메인 페이지에 후킹을 걸어도 iframe 내부의 fetch/XHR 요청은 잡히지 않습니다. Tampermonkey에서 iframe까지 스크립트를 주입하려면 다음이 필요합니다:

// @match        *://*/*
// @run-at       document-start
// @all-frames   true

주의사항:

  • @grant none 사용 시: 스크립트가 페이지 컨텍스트에서 직접 실행 → window가 곧 실제 페이지 window → 후킹이 바로 적용됨
  • @grant를 다른 값(GM_xmlhttpRequest 등) 사용 시: 스크립트가 샌드박스에 격리 → unsafeWindow를 후킹 대상으로 사용해야 함
  • 크로스 오리진 iframe(src가 다른 도메인): unsafeWindow 접근 불가 → 탐지 불가 (브라우저 보안 정책)
  • about:blank / srcdoc / blob URL iframe: @match로 안 잡힘 → 메인 프레임에서 MutationObserver로 iframe 생성 감지 후 스크립트 인젝션 필요

1.2 구현 방식 (3가지 병행)

  1. XMLHttpRequest 오버라이드 (주력)

    • window.XMLHttpRequest.prototype.open / send 패치
    • URL에 .m3u8 포함 또는 application/x-mpegURL Content-Type 응답 시 감지
    • setRequestHeader도 패칭하여 요청 헤더 수집 (Referer 등)
    • 탐지된 URL을 Set에 수집 (중복 제거)
    • iframe 대응: @all-frames true로 각 iframe 컨텍스트에도 동일 후킹 적용
    • @grantnone이 아닌 경우 unsafeWindow.XMLHttpRequest.prototype 대상
  2. fetch 오버라이드 (보조)

    • window.fetch 패치
    • 요청 URL 또는 응답 Content-Type으로 m3u8 감지
    • response.clone()으로 원본 소비 방지
    • iframe 대응: @all-frames true로 각 iframe 컨텍스트에도 동일 후킹 적용
    • @grantnone이 아닌 경우 unsafeWindow.fetch 대상
  3. DOM 모니터링 (보조)

    • <video>, <audio> 요소의 canplay 이벤트 리스너
    • MediaSource API 사용 시 sourcebuffer 추가 모니터링 (옵션)
    • <source> 태그의 src 속성 확인
    • iframe 대응: @all-frames true로 각 iframe의 DOM도 모니터링

1.3 동적 iframe 대응 (MutationObserver)

about:blank, srcdoc, blob URL 등 @match로 잡히지 않는 동적 iframe은 메인 프레임에서 감지:

// 메인 프레임에서만 실행 (window.top === window)
const iframeObserver = new MutationObserver(mutations => {
  for (const m of mutations) {
    for (const node of m.addedNodes) {
      if (node.tagName === 'IFRAME') {
        // iframe이 same-origin일 경우 contentWindow에 후킹 스크립트 인젝션
        try {
          const iframeWin = node.contentWindow;
          if (iframeWin && iframeWin.location.origin === window.location.origin) {
            injectHooks(iframeWin);
          }
        } catch (e) {
          // 크로스 오리진 iframe — 접근 불가, 무시
        }
      }
    }
  }
});
iframeObserver.observe(document, { childList: true, subtree: true });

1.4 헤더 수집 한계

  • fetchinit.headers로 명시적 헤더만 가시적
  • 브라우저가 자동 추가하는 기본 헤더(User-Agent, Referer, Cookie 등)는 JS 레벨에서 보이지 않음
  • 실제 전송 헤더까지 확인하려면 확장 프로그램(webRequest.onBeforeSendHeaders) 또는 외부 도구(mitmproxy) 필요
  • 대응: GM_xmlhttpRequestreferer 옵션으로 수동 전달. XHR 후킹 시 setRequestHeader를 통해 명시된 헤더 수집

1.5 수집된 URL 처리

  • detectedM3U8s: Set<string>에 저장 (메인 프레임 기준)
  • iframe에서 탐지된 URL은 window.postMessage 또는 top을 통해 메인 프레임으로 전달
  • 중복 URL은 무시
  • 탐지 시 UI 패널에 표시 (탐지된 프레임 정보 포함)

Phase 2: m3u8 파싱

목표: m3u8 플레이리스트를 파싱하여 세그먼트 목록과 암호화 키 추출

흐름:

parseM3U8(m3u8Url)
  │
  ├─ GM_xmlhttpRequest로 m3u8Url fetch (text)
  │
  ├─ m3u8Parser.Parser 생성
  │  parser.push(content)
  │  parser.end()
  │
  ├─ parser.manifest.playlists 존재?
  │   ├─ Yes → 마스터 플레이리스트
  │   │         각 playlist의 RESOLUTION 속성 추출
  │   │         사용자에게 해상도 선택 UI 표시
  │   │         선택된 playlist의 URI를 재귀 파싱
  │   │
  │   └─ No → 미디어 플레이리스트 진행
  │
  └─ parser.manifest.segments 존재?
      ├─ segment.uri 배열 추출
      ├─ 상대 URI 절대화 (resolveUri)
      ├─ 중복 세그먼트 제거
      │
      └─ 각 segment.key 처리:
          ├─ key.uri 존재?
          │   ├─ Yes → GM_xmlhttpRequest로 key fetch (ArrayBuffer)
          │   │        key.value = Uint8Array(buffer)
          │   │        key.length === 16 검증
          │   │        key.method === 'AES-128' 검증
          │   │        key.iv 처리 (없으면 0-filled 16바이트)
          │   │        중복 key 캐싱 (uri 기반 Map)
          │   │
          │   └─ No → key 없음 (암호화 미사용)
          │
          └─ 결과: { segments: string[], keys: Key[] } 반환

resolveUri 함수:

function resolveUri(root, rel) {
  if (rel.startsWith('http://') || rel.startsWith('https://')) return rel;
  const parts = root.split('/');
  const relParts = rel.split('/').filter(Boolean);
  const idx = parts.indexOf(relParts[0]);
  if (idx === -1) {
    parts.pop();
  } else {
    parts = parts.slice(0, idx);
  }
  parts.push(rel);
  return parts.join('/');
}

Phase 3: 세그먼트 다운로드

목표: 모든 .ts 세그먼트를 순차적으로 다운로드

핵심: GM_xmlhttpRequest를 사용하여 CORS 제한 우회

fetchSegments(segments, referrer)
  │
  ├─ progress = { current: 0, total: segments.length }
  │
  ├─ for each segmentUrl in segments:
  │   │
  │   ├─ GM_xmlhttpRequest({
  │   │     method: 'GET',
  │   │     url: segmentUrl,
  │   │     responseType: 'arraybuffer',
  │   │     referer: referrer,
  │   │     onload: (response) => {
  │   │       buffers.push(response.detail?.response || response.response)
  │   │       progress.current++
  │   │       updateUI(progress)
  │   │     }
  │   │   })
  │   │
  │   └─ await 완료
  │
  └─ 반환: ArrayBuffer[] (세그먼트별 데이터)

성능 고려사항:

  • 세그먼트가 많을 경우 (100+), 병렬 다운로드 고려 (동시 3-5개)
  • Promise pool 패턴으로 동시 요청 수 제한
  • 진행률 UI实时更新

Phase 4: 복호화 및 병합

목표: 다운로드된 세그먼트를 복호화하여 단일 Blob으로 병합

mergeAndDownload(buffers, keys, filename)
  │
  ├─ encrypted = keys.length > 0
  │
  ├─ if encrypted:
  │   │
  │   ├─ segmentOffsets 계산:
  │   │   offsets[0] = 0
  │   │   offsets[i] = offsets[i-1] + buffers[i-1].byteLength
  │   │
  │   ├─ decryptedChunks = []
  │   ├─ for i = 0 to buffers.length - 1:
  │   │   │
  │   │   ├─ key = keys[i] 또는 가장 최근 key
  │   │   │     (m3u8에서 key는 여러 세그먼트에 공유됨)
  │   │   │
  │   │   ├─ importedKey = await crypto.subtle.importKey(
  │   │   │     'raw',
  │   │   │     key.value,
  │   │   │     { name: 'AES-CBC', length: 128 },
  │   │   │     false,
  │   │   │     ['decrypt']
  │   │   │   )
  │   │   │
  │   │   ├─ iv = key.iv ? new Uint8Array(key.iv).buffer
  │   │   │            : new ArrayBuffer(16)
  │   │   │
  │   │   ├─ decrypted = await crypto.subtle.decrypt(
  │   │   │     { name: 'AES-CBC', iv },
  │   │   │     importedKey,
  │   │   │     buffers[i]
  │   │   │   )
  │   │   │
  │   │   └─ decryptedChunks.push(new Uint8Array(decrypted))
  │   │
  │   └─ chunks = decryptedChunks
  │
  ├─ else:
  │   └─ chunks = buffers.map(b => new Uint8Array(b))
  │
  ├─ totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
  │
  ├─ merged = new Uint8Array(totalLength)
  ├─ offset = 0
  ├─ for chunk of chunks:
  │     merged.set(chunk, offset)
  │     offset += chunk.length
  │
  ├─ blob = new Blob([merged], { type: 'video/mp2t' })
  │
  └─ downloadBlob(blob, filename)

downloadBlob 함수:

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename || 'download.ts';
  document.body.appendChild(a);
  a.click();
  setTimeout(() => {
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }, 1000);
}

Phase 5: UI

목표: 사용자에게 탐지된 m3u8 스트림과 다운로드 진행 상황을 표시

UI 구성:

┌─────────────────────────────────────────────┐
│  🍵 M3U8 Downloader        [설정] [숨기기]  │
├─────────────────────────────────────────────┤
│  탐지된 스트림:                              │
│  ┌─────────────────────────────────────────┐│
│  │ 🔗 https://example.com/playlist.m3u8    ││
│  │    세그먼트: 120 | 암호화: AES-128       ││
│  │    [다운로드] [취소]                     ││
│  └─────────────────────────────────────────┘│
│                                              │
│  진행 상황:                                  │
│  [████████████████░░░░░░░░] 65% (78/120)     │
│  속도: 2.4 MB/s | 남은 시간: 12초            │
└─────────────────────────────────────────────┘

기능:

  • 탐지된 m3u8 URL 목록 표시
  • 각 스트림의 세그먼트 수, 암호화 여부 표시
  • 다운로드 버튼 클릭 시 처리 시작
  • 진행률 바 + 속도 + 남은 시간 표시
  • 마스터 플레이리스트일 경우 해상도 선택 모달
  • 다운로드 완료 시 알림
  • 패널 숨기기/보이기 토글

Phase 6: Referrer 처리

목표: 암호화 키와 세그먼트 fetch 시 올바른 Referrer 헤더 전달

GM_xmlhttpRequest({
  method: 'GET',
  url: targetUrl,
  responseType: 'arraybuffer',
  referer: document.location.href,  // 현재 페이지 URL
  headers: {
    'Origin': new URL(document.location.href).origin
  },
  onload: callback
});

5. m3u8-parser 통합

5.1 라이브러리 선택

  • videojs/m3u8-parser (v4.4.0)
  • Turbo Download Manager에서 사용 중인 동일한 버전
  • Apache-2.0 라이선스
  • npm 패키지로 설치하여 번들러에 위임

5.2 통합 방식

npm 의존성으로 설치하면 esbuild가 자동으로 번들에 포함:

pnpm add m3u8-parser@4.4.0

src/parser/m3u8-parse.ts에서 import:

import * as m3u8Parser from 'm3u8-parser';

function parseM3U8(url: string) {
  const parser = new m3u8Parser.Parser();
  // ...
}

esbuild가 번들링 시 m3u8-parser의 모든 의존성(mp4box 등)을 자동으로 포함하여 단일 파일로 생성.


6. 구현 단계 (작업 순서)

순서 작업 의존성
1 UserScript header 작성 + 프로젝트 구조 설정 -
2 m3u8-parser 인라인 삽입 + 동작 확인 1
2.1 로깅 유틸리티 구현 (unsafeWindow.console 기반) 1
3 m3u8 탐지 모듈 (XHR/fetch 오버라이드 + DOM 모니터링) 2
3.1 iframe 대응: @all-frames + unsafeWindow 후킹 + 동적 iframe MutationObserver 3
4 m3u8 파싱 모듈 (parseM3U8, resolveUri, 마스터 플레이리스트 처리) 2, 3
5 암호화 키 fetch + 검증 모듈 4
6 세그먼트 다운로드 모듈 (GM_xmlhttpRequest, 병렬 처리) 4
7 복호화 + 병합 모듈 (AES-CBC, Blob 생성) 5, 6
8 UI 패널 (탐지 목록, 진행률, 해상도 선택) 3-7
9 Referrer 처리 + 에러 처리 +边界 케이스 3-8
10 테스트 + 디버깅 + 최적화 1-9

7. 에러 처리 및边界 케이스

상황 대응
CORS로 인해 m3u8 fetch 실패 GM_xmlhttpRequest로 재시도
마스터 플레이리스트 없이 segment 없음 사용자에게 오류 메시지
AES-128以外的 암호화 방식 경고 + 다운로드 불가 메시지
키 fetch 실패 해당 세그먼트 스킵 또는 전체 실패
세그먼트 다운로드 실패 재시도 (최대 3회) 후 스킵
복호화 실패 (IV 불일치 등) 오류 로그 + 해당 세그먼트 스킵
세그먼트 수非常多 (1000+) 메모리 경고 + 병렬 다운로드 조정
DASH (.mpd) 플레이리스트 미지원 메시지 (별도 구현 필요)
fMP4 세그먼트 (.m4s) 확장자 .mp4로 저장 제안
크로스 오리진 iframe unsafeWindow 접근 불가 → 탐지 불가 (브라우저 보안 정책)
about:blank / srcdoc iframe MutationObserver로 감지 후 same-origin일 때만 후킹 인젝션
unsafeWindow가 정의되지 않음 Tampermonkey 버전 확인. GM 샌드박스 정책 변경 가능성
iframe에서 탐지된 URL 전달 실패 window.postMessage 또는 top.detectedM3U8s 직접 접근

8. iframe 대응 전략 (보강)

8.1 @all-frames true의 동작

Tampermonkey는 @all-frames true 설정 시, @match 패턴과 일치하는 모든 iframe에도 스크립트를 주입합니다. 각 iframe은 독립된 실행 컨텍스트를 가지므로, 후킹 코드도 각 iframe에서 개별적으로 실행됩니다.

8.2 unsafeWindow를 통한 후킹

@grant GM_xmlhttpRequest를 사용하면 스크립트는 샌드박스에서 실행됩니다. 이 경우 window는 Tampermonkey의 샌드박스 window이며, 실제 페이지의 window는 unsafeWindow를 통해 접근합니다:

(function() {
  const target = unsafeWindow || window;
  const origFetch = target.fetch;
  target.fetch = function(...args) {
    // 후킹 로직
    return origFetch.apply(this, args);
  };
})();

8.3 탐지된 URL의 중앙 집중

각 iframe에서 탐지된 m3u8 URL은 메인 프레임(UI 패널이 있는 곳)으로 전달해야 합니다:

// 각 프레임에서 탐지 시
function onM3U8Detected(url) {
  if (window.top !== window) {
    // iframe 내부: 메인 프레임으로 전달
    try {
      if (window.top._m3u8Detected) {
        window.top._m3u8Detected(url);
      }
    } catch (e) {
      // 크로스 오리진 — 무시
    }
  } else {
    // 메인 프레임: 직접 처리
    addDetectedUrl(url);
  }
}

8.4 UI 패널 위치

UI 패널은 메인 프레임(window.top === window)에서만 생성됩니다. iframe에서는 UI를 생성하지 않고 탐지된 URL만 메인 프레임으로 전달합니다.


9. GM 메타데이터

// ==UserScript==
// @name         M3U8 HLS Downloader
// @namespace    https://github.com/.../m3u8-monkey-script
// @version      1.0.0
// @description  자동 탐지 및 다운로드: HLS(m3u8) 스트림의 세그먼트를 병합하여 단일 파일로 저장
// @author       ...
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @run-at       document-start
// @all-frames   true
// ==/UserScript==

필수 GM API:

  • GM_xmlhttpRequest: CORS 우회 네트워크 요청 (m3u8 fetch, 키 fetch, 세그먼트 다운로드)
  • unsafeWindow: 샌드박스 격리 시 실제 페이지 window에 접근 (후킹 대상)

핵심 설정:

  • @run-at document-start: 페이지 로드 초기에 인터셉트 설치. 플레이어 초기화 전에 후킹이 걸려야 첫 m3u8 요청까지 탐지
  • @match *://*/*: 모든 페이지에서 작동
  • @all-frames true: 모든 iframe에도 스크립트 주입. 각 iframe은 독립된 window를 가지므로 개별 후킹 필요
  • @grant unsafeWindow: GM_xmlhttpRequest 사용 시 스크립트가 샌드박스에 격리되므로, window 대신 unsafeWindow를 후킹 대상으로 사용해야 함

@grant 전략:

  • @grant none일 경우: 스크립트가 페이지 컨텍스트에서 직접 실행 → window가 곧 실제 페이지 window → 후킹이 바로 적용됨. 하지만 GM_xmlhttpRequest 사용 불가
  • @grant GM_xmlhttpRequest일 경우: 샌드박스 격리 → unsafeWindow를 통해 실제 페이지의 window.fetch, XMLHttpRequest를 후킹해야 함
  • 결정: GM_xmlhttpRequest가 필수이므로 unsafeWindow 함께 선언. 후킹 시 unsafeWindow 대상

10. 출력 파일

  • 파일명: m3u8-download.user.js
  • 위치: 프로젝트 루트 (C:\Users\SemteulGaram\Sync\Workspace\m3u8-monkey-script\m3u8-download.user.js)
  • 형식: 단일 파일, Self-contained (m3u8-parser 인라인 포함)