handle race condition iframe edge case

This commit is contained in:
Kyush 2026-06-06 22:33:50 +09:00
commit fa82e382d2
4 changed files with 274 additions and 32 deletions

View 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');
}
}

View file

@ -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);
}
}

View 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);
}
}

View file

@ -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)
}
}