25 KiB
m3u8-download.user.js 구현 계획
1. 목표
Turbo Download Manager(v3.m3) 확장 프로그램의 m3u8 탐지·병합 로직을 참고하여, Tampermonkey/Violentmonkey 등에서 실행 가능한 싱글 파일 UserScript(m3u8-download.user.js)를 구현한다.
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 기반 (SGet → MGet → MSGet → FGet → NFGet → SNGet 클래스 계층) |
| 저장 | downloads/file.js |
IndexedDB에 chunk 저장. disk-write-offset로 각 세그먼트의 바이트 위치 추적 |
| 복호화 | downloads/file.js |
crypto.subtle AES-CBC로 각 세그먼트 복호화 |
| 병합 | downloads/file.js |
ReadableStream + Response → Blob → URL.createObjectURL → chrome.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_xmlhttpRequest의 referer 옵션 |
| iframe 내부 요청 탐지 | content_scripts + all_frames: true |
@all-frames true 필요 |
iframe별 독립 후킹 + unsafeWindow 처리 |
| 크로스 오리진 iframe | webRequest로 전역 가로챔 |
❌ unsafeWindow 접근 불가 |
크로스 오리진 iframe은 탐지 불가 (브라우저 보안 정책) |
3.2 단일 파일 구조
m3u8-download.user.js
├── // ==UserScript== header block
├── m3u8-parser 라이브러리 인라인 삽입 (minified)
├── 유틸리티 함수
│ ├── resolveUri(root, rel) // 상대 URI → 절대 URI
│ ├── decryptSegment(key, data) // AES-CBC 복호화
│ └── guessFilename(url, mime) // URL에서 파일명 추출
├── m3u8 탐지 모듈
│ ├── xhrIntercept() // XMLHttpRequest.open 패치
│ ├── fetchIntercept() // fetch() 패치 (옵션)
│ └── domMonitor() // <video>/<audio> source 추적
├── m3u8 처리 모듈
│ ├── parseM3U8(url) // 플레이리스트 파싱
│ ├── resolveMasterPlaylist(...) // 마스터 플레이리스트 처리
│ ├── fetchSegments(...) // 세그먼트 다운로드
│ └── mergeAndDownload(...) // 병합 + 다운로드
├── UI 모듈
│ ├── createPanel() // 페이지 상단 UI 패널
│ ├── showStatus(...) // 진행률 표시
│ └── showPlaylistSelector(...) // 해상도 선택 (마스터 플레이리스트)
└── 초기화 코드
├── 탐지 모듈 활성화
└── UI 패널 생성/숨김 토글
3.3 파일 크기 예상
| 구성 요소 | 예상 크기 |
|---|---|
| UserScript header | ~1KB |
| m3u8-parser (minified) | ~30-40KB |
| 탐지 모듈 | ~5KB |
| 파싱/다운로드/병합 로직 | ~10KB |
| UI 모듈 | ~5KB |
| 로깅 유틸리티 | ~1KB |
| 총계 | ~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가지 병행)
-
XMLHttpRequest 오버라이드 (주력)
window.XMLHttpRequest.prototype.open/send패치- URL에
.m3u8포함 또는application/x-mpegURLContent-Type 응답 시 감지 setRequestHeader도 패칭하여 요청 헤더 수집 (Referer 등)- 탐지된 URL을 Set에 수집 (중복 제거)
- iframe 대응:
@all-frames true로 각 iframe 컨텍스트에도 동일 후킹 적용 @grant가none이 아닌 경우unsafeWindow.XMLHttpRequest.prototype대상
-
fetch 오버라이드 (보조)
window.fetch패치- 요청 URL 또는 응답 Content-Type으로 m3u8 감지
response.clone()으로 원본 소비 방지- iframe 대응:
@all-frames true로 각 iframe 컨텍스트에도 동일 후킹 적용 @grant가none이 아닌 경우unsafeWindow.fetch대상
-
DOM 모니터링 (보조)
<video>,<audio>요소의canplay이벤트 리스너MediaSourceAPI 사용 시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 헤더 수집 한계
fetch의init.headers로 명시적 헤더만 가시적- 브라우저가 자동 추가하는 기본 헤더(User-Agent, Referer, Cookie 등)는 JS 레벨에서 보이지 않음
- 실제 전송 헤더까지 확인하려면 확장 프로그램(
webRequest.onBeforeSendHeaders) 또는 외부 도구(mitmproxy) 필요 - 대응:
GM_xmlhttpRequest의referer옵션으로 수동 전달. 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 라이선스
- Turbo DM의
data/add/m3u8-parser.js(~39KB)에서 인라인 삽입
5.2 통합 방식
UserScript 내부에 minified 버전의 m3u8-parser를 직접 삽입:
// ==UserScript==
// ...header...
// ==/UserScript==
/* m3u8-parser v4.4.0 - Apache-2.0 */
// [minified source code here]
(function() {
'use strict';
// main script logic
})();
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 인라인 포함)