Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 710ce264f8 | |||
| ffd23120fa | |||
| c11708281e | |||
| a8f025e1b5 | |||
| b56be38431 | |||
| 0729e4f84d | |||
| 99d6953f03 | |||
| 8250b42714 | |||
| 81c9cc1b8a | |||
| 8bb4b8d350 |
9 changed files with 134 additions and 44 deletions
|
|
@ -13,8 +13,6 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name M3U8 HLS Downloader
|
// @name M3U8 HLS Downloader
|
||||||
// @namespace http://tampermonkey.net/
|
// @namespace http://tampermonkey.net/
|
||||||
// @version 1.0.0
|
// @version 1.0.8
|
||||||
// @description 자동 탐지 및 다운로드: HLS(m3u8) 스트림의 세그먼트를 병합하여 단일 파일로 저장
|
// @description 자동 탐지 및 다운로드: HLS(m3u8) 스트림의 세그먼트를 병합하여 단일 파일로 저장. 활성화된 동안 모든 트래픽을 모니터링하고 접근하기 때문에, 사용하지 않을때는 비활성화 필요.
|
||||||
// @author kyush
|
// @author kyush
|
||||||
// @match *://*/*
|
// @match *://*/*
|
||||||
// @grant GM_xmlhttpRequest
|
// @grant GM_xmlhttpRequest
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "m3u8-monkey-script",
|
"name": "m3u8-monkey-script",
|
||||||
"version": "1.0.0",
|
"version": "1.0.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,11 @@ function processIframe(
|
||||||
log.info(`iframe fetch: found VTT ${url}`);
|
log.info(`iframe fetch: found VTT ${url}`);
|
||||||
onDetected(url, headers);
|
onDetected(url, headers);
|
||||||
}
|
}
|
||||||
|
// Skip m3u8 URLs from iframe-fetch — the iframe's own XHR intercept will detect them
|
||||||
|
// with the correct referer from the iframe context. iframe-fetch only has access to
|
||||||
|
// the top frame's origin as referer, which causes 403 errors from Cloudflare.
|
||||||
for (const url of m3u8) {
|
for (const url of m3u8) {
|
||||||
log.info(`iframe fetch: found M3U8 ${url}`);
|
log.info(`iframe fetch: found M3U8 ${url}, skipping (iframe XHR intercept will handle)`);
|
||||||
onDetected(url, headers);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.warn(`iframe fetch: HTTP ${response.status} for ${iframeUrl}`);
|
log.warn(`iframe fetch: HTTP ${response.status} for ${iframeUrl}`);
|
||||||
|
|
|
||||||
|
|
@ -54,20 +54,13 @@ export async function mergeAndDownload(
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||||
const merged = new Uint8Array(totalLength);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
merged.set(chunk, offset);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(`mergeAndDownload: merged ${chunks.length} chunks, total=${totalLength} bytes`);
|
log.info(`mergeAndDownload: merged ${chunks.length} chunks, total=${totalLength} bytes`);
|
||||||
|
|
||||||
const ext = segments.length > 0 && segments[0].uri.toLowerCase().includes('.m4s') ? '.mp4' : '.ts';
|
const ext = segments.length > 0 && segments[0].uri.toLowerCase().includes('.m4s') ? '.mp4' : '.ts';
|
||||||
const filename = guessFilename(m3u8Url) + (guessFilename(m3u8Url).includes('.') ? '' : ext);
|
const filename = guessFilename(m3u8Url) + (guessFilename(m3u8Url).includes('.') ? '' : ext);
|
||||||
|
|
||||||
const mimeType = ext === '.mp4' ? 'video/mp4' : 'video/mp2t';
|
const mimeType = ext === '.mp4' ? 'video/mp4' : 'video/mp2t';
|
||||||
const blob = new Blob([merged], { type: mimeType });
|
const blob = new Blob(chunks, { type: mimeType });
|
||||||
downloadBlob(blob, filename);
|
downloadBlob(blob, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
97
src/index.ts
97
src/index.ts
|
|
@ -1,5 +1,5 @@
|
||||||
import { log } from './logger';
|
import { log } from './logger';
|
||||||
import { normalizeReferer } from './utils/uri';
|
import { normalizeReferer, resolveUri } from './utils/uri';
|
||||||
import { interceptXHR } from './detection/xhr-intercept';
|
import { interceptXHR } from './detection/xhr-intercept';
|
||||||
import { interceptFetch } from './detection/fetch-intercept';
|
import { interceptFetch } from './detection/fetch-intercept';
|
||||||
import { monitorDOM } from './detection/dom-monitor';
|
import { monitorDOM } from './detection/dom-monitor';
|
||||||
|
|
@ -57,7 +57,15 @@ function addStreamToUI(url: string, parsed?: ParsedPlaylist): void {
|
||||||
if (!streamsContainer) return;
|
if (!streamsContainer) return;
|
||||||
|
|
||||||
const existing = streamsContainer.querySelector(`[data-url="${url}"]`);
|
const existing = streamsContainer.querySelector(`[data-url="${url}"]`);
|
||||||
if (existing) return;
|
if (existing) {
|
||||||
|
const metaEl = existing.querySelector('.m3u8-dl-stream-meta');
|
||||||
|
if (metaEl && parsed && !parsed.isMaster) {
|
||||||
|
const segmentCount = parsed.segments.length;
|
||||||
|
const encrypted = parsed.segments.some(s => s.key !== null);
|
||||||
|
metaEl.textContent = `Video | Segments: ${segmentCount} | Encrypted: ${encrypted ? 'AES-128' : 'No'}`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const emptyMsg = streamsContainer.querySelector('.m3u8-dl-empty');
|
const emptyMsg = streamsContainer.querySelector('.m3u8-dl-empty');
|
||||||
if (emptyMsg) emptyMsg.remove();
|
if (emptyMsg) emptyMsg.remove();
|
||||||
|
|
@ -79,10 +87,10 @@ function addStreamToUI(url: string, parsed?: ParsedPlaylist): void {
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
const segmentCount = parsed && !parsed.isMaster ? parsed.segments.length : '--';
|
const segmentCount = parsed && !parsed.isMaster ? parsed.segments.length : '--';
|
||||||
const encrypted = parsed && !parsed.isMaster ? parsed.segments.some(s => s.key !== null) : 'unknown';
|
const encrypted = parsed && !parsed.isMaster ? parsed.segments.some(s => s.key !== null) : false;
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="m3u8-dl-stream-url">${url}</div>
|
<div class="m3u8-dl-stream-url">${url}</div>
|
||||||
<div class="m3u8-dl-stream-meta">Video | Segments: ${segmentCount} | Encrypted: ${encrypted ? 'AES-128' : 'No'}</div>
|
<div class="m3u8-dl-stream-meta">Video | Segments: ${segmentCount} | Encrypted: ${segmentCount === '--' ? '--' : (encrypted ? 'AES-128' : 'No')}</div>
|
||||||
<div class="m3u8-dl-stream-actions">
|
<div class="m3u8-dl-stream-actions">
|
||||||
<button class="m3u8-dl-download-btn" data-url="${url}">Download</button>
|
<button class="m3u8-dl-download-btn" data-url="${url}">Download</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -244,13 +252,16 @@ async function downloadVttStream(url: string, entry: { url: string; type: 'vtt';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStreamDetected(url: string, headers?: Record<string, string>): void {
|
function onStreamDetected(url: string, headers?: Record<string, string>): void {
|
||||||
if (detectedStreams.has(url)) return;
|
const base = headers?.Referer || location.href;
|
||||||
|
const resolvedUrl = resolveUri(base, url);
|
||||||
|
|
||||||
const type = isVttUrl(url) ? 'vtt' : 'm3u8';
|
if (detectedStreams.has(resolvedUrl)) return;
|
||||||
log.info(`onStreamDetected: ${url} type=${type} headers=${JSON.stringify(headers)}`);
|
|
||||||
detectedStreams.set(url, { url, type, headers });
|
|
||||||
|
|
||||||
notifyTopFrame(url, headers);
|
const type = isVttUrl(resolvedUrl) ? 'vtt' : 'm3u8';
|
||||||
|
log.info(`onStreamDetected: ${resolvedUrl} type=${type} headers=${JSON.stringify(headers)}`);
|
||||||
|
detectedStreams.set(resolvedUrl, { url: resolvedUrl, type, headers });
|
||||||
|
|
||||||
|
notifyTopFrame(resolvedUrl, headers);
|
||||||
|
|
||||||
if (isTopFrame() && !panel && type === 'm3u8') {
|
if (isTopFrame() && !panel && type === 'm3u8') {
|
||||||
panel = createPanel();
|
panel = createPanel();
|
||||||
|
|
@ -267,24 +278,79 @@ function onStreamDetected(url: string, headers?: Record<string, string>): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTopFrame() && panel) {
|
if (isTopFrame() && panel) {
|
||||||
addStreamToUI(url);
|
addStreamToUI(resolvedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTopFrame() && type === 'm3u8') {
|
if (isTopFrame() && type === 'm3u8' && !detectedStreams.get(resolvedUrl)?.parsed) {
|
||||||
parseM3U8(url, headers?.Referer, headers?.Origin).then(parsed => {
|
parseM3U8(resolvedUrl, headers?.Referer, headers?.Origin, false).then(parsed => {
|
||||||
detectedStreams.get(url)!.parsed = parsed;
|
detectedStreams.get(resolvedUrl)!.parsed = parsed;
|
||||||
if (panel) {
|
if (panel) {
|
||||||
addStreamToUI(url, parsed);
|
addStreamToUI(resolvedUrl, parsed);
|
||||||
}
|
}
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
log.error(`onStreamDetected: pre-parse failed for ${url}: ${e}`);
|
log.error(`onStreamDetected: pre-parse failed for ${resolvedUrl}: ${e}`);
|
||||||
});
|
});
|
||||||
|
} else if (!isTopFrame() && type === 'm3u8') {
|
||||||
|
log.info(`onStreamDetected: iframe detected ${resolvedUrl}, skipping parse (top frame will handle)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
log.error('Script not running in a browser context');
|
log.error('Script not running in a browser context');
|
||||||
} else {
|
} else {
|
||||||
|
// Anti-devtool: devtools-detector 우회
|
||||||
|
// Source: https://greasyfork.org/en/scripts/456011-%E5%8F%8D-devtools-detector-%E5%8F%8D%E8%B0%83%E8%AF%95/code
|
||||||
|
// Targets: https://github.com/AEPKILL/devtools-detector
|
||||||
|
// Mechanism: Hooks Object.prototype.launch setter to intercept devtoolsDetector.launch.
|
||||||
|
// When the detector sets its launch method (containing '_detectLoopDelay'), it is
|
||||||
|
// replaced with a no-op that deletes the prototype-level launch and restores the
|
||||||
|
// original, preventing the debugger loop from executing.
|
||||||
|
(function() {
|
||||||
|
const noop = function() {};
|
||||||
|
|
||||||
|
function disableLaunch(alt: () => void) {
|
||||||
|
log.info('anti-devtool: disabling devtoolsDetector.launch');
|
||||||
|
|
||||||
|
const map = new Map<any, any>();
|
||||||
|
|
||||||
|
const func = function() {
|
||||||
|
delete (Object as any).prototype.launch;
|
||||||
|
Object.getPrototypeOf(this).launch = alt;
|
||||||
|
this.launch();
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(Object.prototype, 'launch', {
|
||||||
|
set(f: any) {
|
||||||
|
const checked = funcChecked(f);
|
||||||
|
map.set(this, checked ? func : f);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return getVal(this);
|
||||||
|
|
||||||
|
function getVal(obj: any): any {
|
||||||
|
return obj ? (map.has(obj) ? map.get(obj) : getVal(Object.getPrototypeOf(obj))) : undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function funcChecked(f: any): boolean {
|
||||||
|
if (f && typeof f.toString === 'function') {
|
||||||
|
const str = f.toString();
|
||||||
|
if (typeof str === 'string' && str.includes('_detectLoopDelay')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disableLaunch(noop);
|
||||||
|
log.info('anti-devtool: devtools-detector launch hook installed');
|
||||||
|
})();
|
||||||
|
|
||||||
log.info(`Script initialized in ${isTopFrame() ? 'top frame' : `iframe (${location.origin})`}`);
|
log.info(`Script initialized in ${isTopFrame() ? 'top frame' : `iframe (${location.origin})`}`);
|
||||||
|
|
||||||
interceptXHR(onStreamDetected);
|
interceptXHR(onStreamDetected);
|
||||||
|
|
@ -301,6 +367,7 @@ if (typeof window === 'undefined') {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (referer) headers['Referer'] = referer;
|
if (referer) headers['Referer'] = referer;
|
||||||
if (origin) headers['Origin'] = origin;
|
if (origin) headers['Origin'] = origin;
|
||||||
|
log.info(`postMessage received: url=${e.data.url} referer=${referer} origin=${origin}`);
|
||||||
onStreamDetected(e.data.url as string, Object.keys(headers).length ? headers : undefined);
|
onStreamDetected(e.data.url as string, Object.keys(headers).length ? headers : undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
export const log = (() => {
|
export const log = (() => {
|
||||||
let targetConsole: Console;
|
|
||||||
let prefix: string;
|
let prefix: string;
|
||||||
|
|
||||||
try {
|
// Collect all reachable consoles: page, unsafeWindow, top, parent, native
|
||||||
const uW = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : null) as any;
|
const consoles: Console[] = [];
|
||||||
targetConsole = uW && uW.console ? uW.console : console;
|
const seen = new Set<any>();
|
||||||
} catch {
|
|
||||||
targetConsole = console;
|
function addConsole(c: any) {
|
||||||
|
if (c && c.log && consoles.length < 5 && !seen.has(c)) {
|
||||||
|
seen.add(c);
|
||||||
|
consoles.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try { addConsole(console); } catch { /* ignore */ }
|
||||||
|
try { addConsole((typeof unsafeWindow !== 'undefined' ? unsafeWindow : null)?.console); } catch { /* ignore */ }
|
||||||
|
try { addConsole(window.top?.console); } catch { /* ignore */ }
|
||||||
|
try { addConsole(window.parent?.console); } catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (consoles.length === 0) {
|
||||||
|
try { addConsole(console); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -16,9 +28,24 @@ export const log = (() => {
|
||||||
prefix = '[M3U8-DL]';
|
prefix = '[M3U8-DL]';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMsg(level: string, ...args: unknown[]): string {
|
||||||
|
return `${prefix} [${level}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function teeLog(level: string, method: string, ...args: unknown[]) {
|
||||||
|
const msg = formatMsg(level, ...args);
|
||||||
|
for (const c of consoles) {
|
||||||
|
try {
|
||||||
|
if (c[method]) {
|
||||||
|
(c[method] as any)(msg);
|
||||||
|
}
|
||||||
|
} catch { /* console may be broken */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
info: (...args: unknown[]) => targetConsole.log(`${prefix} [INFO]`, ...args),
|
info: (...args: unknown[]) => teeLog('INFO', 'log', ...args),
|
||||||
warn: (...args: unknown[]) => targetConsole.warn(`${prefix} [WARN]`, ...args),
|
warn: (...args: unknown[]) => teeLog('WARN', 'warn', ...args),
|
||||||
error: (...args: unknown[]) => targetConsole.error(`${prefix} [ERROR]`, ...args),
|
error: (...args: unknown[]) => teeLog('ERROR', 'error', ...args),
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export async function parseM3U8(
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = manifest.segments || [];
|
const segments = manifest.segments || [];
|
||||||
const keyCache = new Map<string, Uint8Array>();
|
const keyCache = new Map<string, Uint8Array | undefined>();
|
||||||
const resolvedSegments: SegmentInfo[] = [];
|
const resolvedSegments: SegmentInfo[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -122,13 +122,16 @@ export async function parseM3U8(
|
||||||
|
|
||||||
let keyBytes: Uint8Array | null = null;
|
let keyBytes: Uint8Array | null = null;
|
||||||
if (fetchKeys) {
|
if (fetchKeys) {
|
||||||
keyBytes = keyCache.get(keyUri);
|
const cached = keyCache.get(keyUri);
|
||||||
if (!keyBytes) {
|
if (cached !== undefined) {
|
||||||
|
keyBytes = cached;
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
keyBytes = await fetchKey(keyUri, referer, origin);
|
keyBytes = await fetchKey(keyUri, referer, origin);
|
||||||
keyCache.set(keyUri, keyBytes);
|
keyCache.set(keyUri, keyBytes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error(`parseM3U8: failed to fetch key ${keyUri}: ${e}`);
|
log.warn(`parseM3U8: failed to fetch key ${keyUri}: ${e}`);
|
||||||
|
keyCache.set(keyUri, undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export function guessFilename(url: string, mime?: string): string {
|
||||||
function extractTitleParts(): { name: string; volume: string } | null {
|
function extractTitleParts(): { name: string; volume: string } | null {
|
||||||
try {
|
try {
|
||||||
const title = document.title || '';
|
const title = document.title || '';
|
||||||
const match = title.match(/^(.+?)\s+😜\s+(\d+)\s+-\s+Anime\s+-\s+Linkkf/);
|
const match = title.match(/^(.+?)\s+😜\s+(\d+)\s+-\s+Ani\S+\s+-\s+Linkkf/);
|
||||||
if (match) {
|
if (match) {
|
||||||
return { name: match[1].trim(), volume: match[2].trim() };
|
return { name: match[1].trim(), volume: match[2].trim() };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue