init
This commit is contained in:
commit
e1e6bd8407
4 changed files with 625 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
external/
|
||||
dist/
|
||||
nogit/
|
||||
1
AGENTS.md
Normal file
1
AGENTS.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Powershell 명령어를 통한 파일 읽기와 수정, 쓰기에서는 반드시 utf-8 인코딩을 명시적으로 지정해야 합니다. 그렇지 않으면 인코딩이 망가집니다.
|
||||
13
package.json
Normal file
13
package.json
Normal 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"
|
||||
}
|
||||
607
plans/implementation-plan.md
Normal file
607
plans/implementation-plan.md
Normal 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 인라인 포함)
|
||||
Loading…
Add table
Add a link
Reference in a new issue