This commit is contained in:
Kyush 2026-06-06 18:12:49 +09:00
commit e1e6bd8407
4 changed files with 625 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
external/
dist/
nogit/

1
AGENTS.md Normal file
View file

@ -0,0 +1 @@
Powershell 명령어를 통한 파일 읽기와 수정, 쓰기에서는 반드시 utf-8 인코딩을 명시적으로 지정해야 합니다. 그렇지 않으면 인코딩이 망가집니다.

13
package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "m3u8-monkey-script",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "MIT",
"packageManager": "pnpm@10.33.0"
}

View file

@ -0,0 +1,607 @@
# 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]` 접두사로 필터링 용이
```javascript
// 로깅 유틸리티
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까지 스크립트를 주입하려면 다음이 필요합니다:
```javascript
// @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 컨텍스트에도 동일 후킹 적용
- `@grant``none`이 아닌 경우 `unsafeWindow.XMLHttpRequest.prototype` 대상
2. **fetch 오버라이드** (보조)
- `window.fetch` 패치
- 요청 URL 또는 응답 Content-Type으로 m3u8 감지
- `response.clone()`으로 원본 소비 방지
- **iframe 대응:** `@all-frames true`로 각 iframe 컨텍스트에도 동일 후킹 적용
- `@grant``none`이 아닌 경우 `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은 메인 프레임에서 감지:
```javascript
// 메인 프레임에서만 실행 (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 함수:**
```javascript
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 함수:**
```javascript
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 헤더 전달
```javascript
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를 직접 삽입:
```javascript
// ==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`를 통해 접근합니다:
```javascript
(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 패널이 있는 곳)으로 전달해야 합니다:
```javascript
// 각 프레임에서 탐지 시
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 메타데이터
```javascript
// ==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 인라인 포함)