handle race condition iframe edge case
This commit is contained in:
parent
c0c69aa0c3
commit
fa82e382d2
4 changed files with 274 additions and 32 deletions
75
src/detection/artplayer-hook.ts
Normal file
75
src/detection/artplayer-hook.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { log } from '../logger';
|
||||
|
||||
function isVttUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return lower.includes('.vtt');
|
||||
}
|
||||
|
||||
export function hookArtplayer(onDetected: (url: string, headers?: Record<string, string>) => void): void {
|
||||
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
|
||||
|
||||
const getReferer = (src: string): string => {
|
||||
try {
|
||||
const srcObj = new URL(src);
|
||||
if (srcObj.origin !== location.origin) {
|
||||
return location.origin + '/';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return location.href;
|
||||
};
|
||||
|
||||
const extractSubtitleUrl = (config: any): void => {
|
||||
if (!config) return;
|
||||
|
||||
const subtitle = config.subtitle;
|
||||
if (subtitle && typeof subtitle === 'object') {
|
||||
const url = subtitle.url;
|
||||
if (url && typeof url === 'string' && isVttUrl(url)) {
|
||||
log.info(`Artplayer hook: found subtitle url: ${url}`);
|
||||
const referer = getReferer(url);
|
||||
onDetected(url, { Referer: referer });
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitle && Array.isArray(subtitle)) {
|
||||
for (const sub of subtitle) {
|
||||
if (sub && typeof sub === 'object' && sub.url && typeof sub.url === 'string' && isVttUrl(sub.url)) {
|
||||
log.info(`Artplayer hook: found subtitle url (array): ${sub.url}`);
|
||||
const referer = getReferer(sub.url);
|
||||
onDetected(sub.url, { Referer: referer });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (target.Artplayer) {
|
||||
const Original = target.Artplayer;
|
||||
target.Artplayer = class extends Original {
|
||||
constructor(options: any) {
|
||||
extractSubtitleUrl(options);
|
||||
super(options);
|
||||
}
|
||||
};
|
||||
log.info('Artplayer hook: intercepted Artplayer constructor');
|
||||
} else {
|
||||
let hookedValue: any = undefined;
|
||||
Object.defineProperty(target, 'Artplayer', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
set(value: any) {
|
||||
const Hooked = class extends value {
|
||||
constructor(options: any) {
|
||||
extractSubtitleUrl(options);
|
||||
super(options);
|
||||
}
|
||||
};
|
||||
hookedValue = Hooked;
|
||||
log.info('Artplayer hook: intercepted Artplayer constructor (via setter)');
|
||||
},
|
||||
get() {
|
||||
return hookedValue;
|
||||
},
|
||||
});
|
||||
log.info('Artplayer hook: property setter installed, waiting for CDN script');
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,15 @@ export function monitorDOM(onDetected: (url: string, headers?: Record<string, st
|
|||
return ctxReferer;
|
||||
};
|
||||
|
||||
const checkTrack = (track: HTMLTrackElement) => {
|
||||
const src = track.getAttribute('src') || track.src || '';
|
||||
if (src && isVttUrl(src)) {
|
||||
log.info(`DOM monitor: found vtt in <track> src: ${src}`);
|
||||
const referer = getReferer(src);
|
||||
onDetected(src, referer ? { Referer: referer } : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const checkSource = (el: HTMLMediaElement) => {
|
||||
const src = el.currentSrc || el.src || '';
|
||||
if (src && isTargetUrl(src)) {
|
||||
|
|
@ -45,12 +54,16 @@ export function monitorDOM(onDetected: (url: string, headers?: Record<string, st
|
|||
|
||||
if (el.children) {
|
||||
for (let i = 0; i < el.children.length; i++) {
|
||||
const source = el.children[i] as HTMLSourceElement;
|
||||
const sourceSrc = source.src || '';
|
||||
if (sourceSrc && isTargetUrl(sourceSrc)) {
|
||||
log.info(`DOM monitor: found in <source> src: ${sourceSrc}`);
|
||||
const referer = getReferer(sourceSrc);
|
||||
onDetected(sourceSrc, referer ? { Referer: referer } : undefined);
|
||||
const child = el.children[i];
|
||||
if (child.tagName === 'TRACK') {
|
||||
checkTrack(child as HTMLTrackElement);
|
||||
} else {
|
||||
const sourceSrc = (child as HTMLSourceElement).src || '';
|
||||
if (sourceSrc && isTargetUrl(sourceSrc)) {
|
||||
log.info(`DOM monitor: found in <source> src: ${sourceSrc}`);
|
||||
const referer = getReferer(sourceSrc);
|
||||
onDetected(sourceSrc, referer ? { Referer: referer } : undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +84,9 @@ export function monitorDOM(onDetected: (url: string, headers?: Record<string, st
|
|||
if (el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
|
||||
checkSource(el as HTMLMediaElement);
|
||||
}
|
||||
if (el.tagName === 'TRACK') {
|
||||
checkTrack(el as HTMLTrackElement);
|
||||
}
|
||||
if (el.tagName === 'SOURCE' && el.getAttribute('src')) {
|
||||
const src = el.getAttribute('src') || '';
|
||||
if (isM3U8Url(src)) {
|
||||
|
|
@ -85,4 +101,10 @@ export function monitorDOM(onDetected: (url: string, headers?: Record<string, st
|
|||
});
|
||||
|
||||
observer.observe(doc, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
|
||||
|
||||
// Scan existing <track> elements that may have been added before observer started
|
||||
const existingTracks = doc.querySelectorAll('track');
|
||||
for (let i = 0; i < existingTracks.length; i++) {
|
||||
checkTrack(existingTracks[i] as HTMLTrackElement);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
165
src/detection/iframe-fetch.ts
Normal file
165
src/detection/iframe-fetch.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { log } from '../logger';
|
||||
|
||||
function isVttUrl(url: string): boolean {
|
||||
return url.toLowerCase().includes('.vtt');
|
||||
}
|
||||
|
||||
function isM3U8Url(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return lower.includes('.m3u8') || lower.includes('.m3u') ||
|
||||
lower.includes('application/x-mpegurl') || lower.includes('application/vnd.apple.mpegurl');
|
||||
}
|
||||
|
||||
function extractUrlsFromHTML(html: string, iframeUrl: string): { vtt: string[]; m3u8: string[] } {
|
||||
const vtt: string[] = [];
|
||||
const m3u8: string[] = [];
|
||||
|
||||
// Artplayer subtitle.url: 'https://...'
|
||||
const subtitleRegex = /subtitle\s*:\s*\{[^}]*url\s*:\s*['"]([^'"]+)['"]/g;
|
||||
let match;
|
||||
while ((match = subtitleRegex.exec(html)) !== null) {
|
||||
const url = match[1].trim();
|
||||
if (isVttUrl(url) && !vtt.includes(url)) {
|
||||
const resolved = url.startsWith('http') ? url : new URL(url, iframeUrl).href;
|
||||
vtt.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
// Artplayer subtitle array: { url: '...' }
|
||||
const subtitleArrayRegex = /\{[^}]*url\s*:\s*['"]([^'"]+\.vtt)['"]/g;
|
||||
while ((match = subtitleArrayRegex.exec(html)) !== null) {
|
||||
const url = match[1].trim();
|
||||
if (!vtt.includes(url)) {
|
||||
const resolved = url.startsWith('http') ? url : new URL(url, iframeUrl).href;
|
||||
vtt.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
// Artplayer url: '...index.m3u8' (video URL)
|
||||
const videoRegex = /(?:url|videoUrl)\s*:\s*['"]([^'"]+)['"]/g;
|
||||
while ((match = videoRegex.exec(html)) !== null) {
|
||||
const url = match[1].trim();
|
||||
if (isM3U8Url(url) && !m3u8.includes(url)) {
|
||||
const resolved = url.startsWith('http') ? url : new URL(url, iframeUrl).href;
|
||||
m3u8.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
// <source src="...m3u8"> or <source src="...vtt">
|
||||
const sourceRegex = /<source[^>]*src\s*=\s*['"]([^'"]+)['"]/gi;
|
||||
while ((match = sourceRegex.exec(html)) !== null) {
|
||||
const url = match[1].trim();
|
||||
if (isVttUrl(url) && !vtt.includes(url)) {
|
||||
const resolved = url.startsWith('http') ? url : new URL(url, iframeUrl).href;
|
||||
vtt.push(resolved);
|
||||
} else if (isM3U8Url(url) && !m3u8.includes(url)) {
|
||||
const resolved = url.startsWith('http') ? url : new URL(url, iframeUrl).href;
|
||||
m3u8.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
// <track src="...vtt">
|
||||
const trackRegex = /<track[^>]*src\s*=\s*['"]([^'"]+)['"]/gi;
|
||||
while ((match = trackRegex.exec(html)) !== null) {
|
||||
const url = match[1].trim();
|
||||
if (isVttUrl(url) && !vtt.includes(url)) {
|
||||
const resolved = url.startsWith('http') ? url : new URL(url, iframeUrl).href;
|
||||
vtt.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return { vtt, m3u8 };
|
||||
}
|
||||
|
||||
const fetchCache = new Map<string, boolean>();
|
||||
|
||||
function processIframe(
|
||||
iframeUrl: string,
|
||||
onDetected: (url: string, headers?: Record<string, string>) => void
|
||||
): void {
|
||||
if (fetchCache.has(iframeUrl)) return;
|
||||
fetchCache.set(iframeUrl, true);
|
||||
|
||||
log.info(`iframe fetch: fetching cross-origin ${iframeUrl}`);
|
||||
|
||||
(GM_xmlhttpRequest as any)({
|
||||
method: 'GET',
|
||||
url: iframeUrl,
|
||||
responseType: 'text',
|
||||
headers: {
|
||||
'Referer': location.origin + '/',
|
||||
'Origin': location.origin,
|
||||
},
|
||||
onload: (response: any) => {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const html = response.responseText || response.response || '';
|
||||
const { vtt, m3u8 } = extractUrlsFromHTML(html, iframeUrl);
|
||||
|
||||
const headers = { Referer: location.origin + '/' };
|
||||
|
||||
for (const url of vtt) {
|
||||
log.info(`iframe fetch: found VTT ${url}`);
|
||||
onDetected(url, headers);
|
||||
}
|
||||
for (const url of m3u8) {
|
||||
log.info(`iframe fetch: found M3U8 ${url}`);
|
||||
onDetected(url, headers);
|
||||
}
|
||||
} else {
|
||||
log.warn(`iframe fetch: HTTP ${response.status} for ${iframeUrl}`);
|
||||
}
|
||||
},
|
||||
onerror: (error: any) => {
|
||||
log.warn(`iframe fetch: error for ${iframeUrl}: ${error.error}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function hookIframeHTML(
|
||||
onDetected: (url: string, headers?: Record<string, string>) => void
|
||||
): void {
|
||||
const currentOrigin = location.origin;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType !== 1 || (node as HTMLElement).tagName !== 'IFRAME') continue;
|
||||
const iframe = node as HTMLIFrameElement;
|
||||
const src = iframe.getAttribute('src');
|
||||
if (!src) continue;
|
||||
|
||||
let iframeUrl: string;
|
||||
try {
|
||||
iframeUrl = new URL(src, location.href).href;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip same-origin iframes — Tampermonkey @all-frames handles them
|
||||
if (iframeUrl.startsWith(currentOrigin)) continue;
|
||||
|
||||
processIframe(iframeUrl, onDetected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document, { childList: true, subtree: true });
|
||||
log.info('iframe fetch: MutationObserver installed (cross-origin only)');
|
||||
|
||||
// Scan existing cross-origin iframes
|
||||
const existingIframes = document.querySelectorAll('iframe');
|
||||
for (let i = 0; i < existingIframes.length; i++) {
|
||||
const iframe = existingIframes[i] as HTMLIFrameElement;
|
||||
const src = iframe.getAttribute('src');
|
||||
if (!src) continue;
|
||||
let iframeUrl: string;
|
||||
try {
|
||||
iframeUrl = new URL(src, location.href).href;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (iframeUrl.startsWith(currentOrigin)) continue;
|
||||
|
||||
processIframe(iframeUrl, onDetected);
|
||||
}
|
||||
}
|
||||
32
src/index.ts
32
src/index.ts
|
|
@ -3,6 +3,8 @@ import { normalizeReferer } from './utils/uri';
|
|||
import { interceptXHR } from './detection/xhr-intercept';
|
||||
import { interceptFetch } from './detection/fetch-intercept';
|
||||
import { monitorDOM } from './detection/dom-monitor';
|
||||
import { hookArtplayer } from './detection/artplayer-hook';
|
||||
import { hookIframeHTML } from './detection/iframe-fetch';
|
||||
import { parseM3U8, ParsedPlaylist, SegmentInfo } from './parser/m3u8-parse';
|
||||
import { fetchSegments } from './downloader/segment-fetch';
|
||||
import { mergeAndDownload } from './downloader/merge';
|
||||
|
|
@ -80,7 +82,7 @@ function addStreamToUI(url: string, parsed?: ParsedPlaylist): void {
|
|||
const encrypted = parsed && !parsed.isMaster ? parsed.segments.some(s => s.key !== null) : 'unknown';
|
||||
item.innerHTML = `
|
||||
<div class="m3u8-dl-stream-url">${url}</div>
|
||||
<div class="m3u8-dl-stream-meta">Segments: ${segmentCount} | Encrypted: ${encrypted ? 'AES-128' : 'No'}</div>
|
||||
<div class="m3u8-dl-stream-meta">Video | Segments: ${segmentCount} | Encrypted: ${encrypted ? 'AES-128' : 'No'}</div>
|
||||
<div class="m3u8-dl-stream-actions">
|
||||
<button class="m3u8-dl-download-btn" data-url="${url}">Download</button>
|
||||
</div>
|
||||
|
|
@ -288,6 +290,8 @@ if (typeof window === 'undefined') {
|
|||
interceptXHR(onStreamDetected);
|
||||
interceptFetch(onStreamDetected);
|
||||
monitorDOM(onStreamDetected);
|
||||
hookArtplayer(onStreamDetected);
|
||||
hookIframeHTML(onStreamDetected);
|
||||
|
||||
if (isTopFrame()) {
|
||||
window.addEventListener('message', (e: MessageEvent) => {
|
||||
|
|
@ -325,30 +329,6 @@ if (typeof window === 'undefined') {
|
|||
}
|
||||
});
|
||||
|
||||
const iframeObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType === 1 && (node as HTMLElement).tagName === 'IFRAME') {
|
||||
const iframe = node as HTMLIFrameElement;
|
||||
try {
|
||||
const iframeWin = iframe.contentWindow;
|
||||
if (iframeWin && iframeWin.location.origin === window.location.origin) {
|
||||
log.info(`iframe observer: injecting hooks into same-origin iframe`);
|
||||
interceptXHR.call(null, (url: string) => {
|
||||
onStreamDetected(url);
|
||||
});
|
||||
interceptFetch.call(null, (url: string) => {
|
||||
onStreamDetected(url);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log.info(`iframe observer: cross-origin iframe, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
iframeObserver.observe(document, { childList: true, subtree: true });
|
||||
// iframe detection is handled by hookIframeHTML (fetches iframe HTML to extract URLs)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue