fix: redesign HLS detection with 6-strategy per-tab query
CI / test (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 0s
- Query HLS data structures directly from page JS state per tab - Strategies: video element HLS instances, window-level player vars, intercepted data, inline scripts, global config objects, content script - Fixed merge logic: index.js now correctly processes interceptedManifests and hlsQueryResults instead of non-existent playerData - Skip blob: URLs in DOM scan (HLS-internal, can't be played directly) - Only attach HLS manifest when actually detected for that tab
This commit is contained in:
1 parent
c22631d1f1
commit
e17b228ee0
2 files changed
+578
-370
No files matched your search
+172
-221
@@ -1,227 +1,171 @@
|
||||
/**
|
||||
* content.js — Content script injected into matching pages.
|
||||
* Runs in the ISOLATED world. Coordinates scanning by injecting a script
|
||||
* into the MAIN world (which CAN intercept XHR/fetch), then collects results
|
||||
* via postMessage.
|
||||
* content.js - Content script injected into matching pages at document_start.
|
||||
* Runs in the ISOLATED world.
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Inject interceptor.js into MAIN world (intercepts network + DOM)
|
||||
* 2. Interceptor posts results back via window.postMessage
|
||||
* 3. This script collects results and forwards to the popup
|
||||
* Strategy: Inject XHR/fetch interceptors into the MAIN world at document_start
|
||||
* to capture HLS manifest URLs as they load. Also listen for postMessage events
|
||||
* from intercepted URLs.
|
||||
*
|
||||
* When the popup sends GOON_GET_VIDEO_DATA, we respond with:
|
||||
* - posterUrl: extracted poster/thumbnail
|
||||
* - interceptedManifests: manifests caught by XHR/fetch interceptors
|
||||
* - videoElements: DOM video elements with basic info
|
||||
*
|
||||
* The popup separately executes main-world scripts via chrome.scripting.executeScript
|
||||
* to deeply query HLS player state — this content script handles interception only.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Collect results from injected scripts
|
||||
let collectedData = {
|
||||
hlsManifests: new Set(),
|
||||
dashManifests: new Set(),
|
||||
videoSources: [],
|
||||
posterUrl: null,
|
||||
};
|
||||
|
||||
let pollingActive = false;
|
||||
let pollTimer = null;
|
||||
// Collected intercepted HLS manifest URLs from XHR/fetch interception
|
||||
const interceptedManifests = [];
|
||||
const interceptedSources = new Set();
|
||||
|
||||
/**
|
||||
* Inject the interceptor script into the MAIN world to intercept XHR/fetch.
|
||||
* Inject interceptors into the MAIN world at document_start.
|
||||
* Runs BEFORE any page JS, so we catch all HLS activity.
|
||||
*/
|
||||
function injectInterceptor() {
|
||||
const interceptorCode = `
|
||||
function injectInterceptors() {
|
||||
const interceptCode = `
|
||||
(function() {
|
||||
// Already injected
|
||||
if (window.__goonInterceptor) return;
|
||||
window.__goonInterceptor = true;
|
||||
if (window.__goon_plugin_intercepted) return;
|
||||
window.__goon_plugin_intercepted = true;
|
||||
|
||||
const manifests = { hls: new Set(), dash: new Set() };
|
||||
let scanCount = 0;
|
||||
|
||||
// --- XHR interception ---
|
||||
const origOpen = XMLHttpRequest.prototype.open;
|
||||
const origSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
this.__goonUrl = typeof url === 'string' ? url : url.toString();
|
||||
return origOpen.apply(this, arguments);
|
||||
window.__goon_plugin_data = window.__goon_plugin_data || {
|
||||
manifests: [],
|
||||
sources: []
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function() {
|
||||
const url = this.__goonUrl || '';
|
||||
if (url.includes('.m3u8') || url.includes('/hls/')) {
|
||||
manifests.hls.add(url);
|
||||
}
|
||||
if (url.includes('.mpd') || url.includes('/dash/')) {
|
||||
manifests.dash.add(url);
|
||||
}
|
||||
// Helper: resolve relative URLs
|
||||
function resolveUrl(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
if (url.startsWith('//')) return 'https:' + url;
|
||||
if (url.startsWith('http')) return url;
|
||||
return new URL(url, window.location.origin).href;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
this.addEventListener('load', function() {
|
||||
try {
|
||||
const resp = this.responseText;
|
||||
if (resp && typeof resp === 'string') {
|
||||
const m3u8Re = /https?:[^"'\\s]+\\.m3u8[^"'\\s]*/gi;
|
||||
let m;
|
||||
while ((m = m3u8Re.exec(resp)) !== null) {
|
||||
const cleaned = m[0].replace(/[;,]$/, '');
|
||||
if (!cleaned.includes('/subtitle') && !cleaned.includes('/captions') && !cleaned.includes('.m3u8x')) {
|
||||
manifests.hls.add(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
// Helper: report manifest to content script via postMessage
|
||||
function reportManifest(url, source) {
|
||||
var resolved = resolveUrl(url);
|
||||
if (!resolved) return;
|
||||
if (window.__goon_plugin_data.manifests.includes(resolved)) return;
|
||||
window.__goon_plugin_data.manifests.push(resolved);
|
||||
|
||||
return origSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// --- Fetch interception ---
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = function(input, init) {
|
||||
const url = typeof input === 'string' ? input : (input && input.url ? input.url.toString() : '');
|
||||
if (url.includes('.m3u8') || url.includes('/hls/')) manifests.hls.add(url);
|
||||
if (url.includes('.mpd') || url.includes('/dash/')) manifests.dash.add(url);
|
||||
|
||||
return origFetch.apply(this, arguments).then(function(resp) {
|
||||
if (resp && resp.clone) {
|
||||
resp.clone().text().then(function(text) {
|
||||
if (text && typeof text === 'string') {
|
||||
const m3u8Re = /https?:[^"'\\s]+\\.m3u8[^"'\\s]*/gi;
|
||||
let m;
|
||||
while ((m = m3u8Re.exec(text)) !== null) {
|
||||
const cleaned = m[0].replace(/[;,]$/, '');
|
||||
if (!cleaned.includes('/subtitle') && !cleaned.includes('/captions') && !cleaned.includes('.m3u8x')) {
|
||||
manifests.hls.add(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
return resp;
|
||||
}).catch(function(e) { return origFetch(input, init); });
|
||||
};
|
||||
|
||||
// --- Scan for HLS instances on video elements ---
|
||||
function scanVideos() {
|
||||
scanCount++;
|
||||
const videos = document.querySelectorAll('video');
|
||||
const sources = [];
|
||||
const hlsProps = ['hls', 'hlsInstance', '_hls', '__hls', 'player', '_player', '__player__'];
|
||||
|
||||
for (const v of videos) {
|
||||
let src = v.currentSrc || v.src || '';
|
||||
let poster = v.poster || '';
|
||||
let hlsUrl = null;
|
||||
|
||||
// Check HLS instances
|
||||
for (const prop of hlsProps) {
|
||||
if (v[prop] && typeof v[prop] === 'object') {
|
||||
const obj = v[prop];
|
||||
if (obj.url) hlsUrl = obj.url;
|
||||
else if (obj.opts && obj.opts.url) hlsUrl = obj.opts.url;
|
||||
else if (obj.config && obj.config.url) hlsUrl = obj.config.url;
|
||||
else if (obj.levels && obj.levels.length > 0) hlsUrl = obj.levels[0].uri;
|
||||
else if (obj.sourceLevels && obj.sourceLevels.length > 0) hlsUrl = obj.sourceLevels[0].uri;
|
||||
if (hlsUrl) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hlsUrl) manifests.hls.add(hlsUrl);
|
||||
|
||||
sources.push({
|
||||
src: src,
|
||||
poster: poster,
|
||||
width: v.clientWidth || 0,
|
||||
height: v.clientHeight || 0,
|
||||
isBlob: src.startsWith('blob:'),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Scan inline scripts for embedded URLs ---
|
||||
const m3u8Re = /https?:[^"'\\s]+\\.m3u8[^"'\\s]*/gi;
|
||||
for (const script of document.querySelectorAll('script')) {
|
||||
if (script.src) continue;
|
||||
const text = script.textContent;
|
||||
if (!text) continue;
|
||||
let m;
|
||||
while ((m = m3u8Re.exec(text)) !== null) {
|
||||
const cleaned = m[0].replace(/[;,]$/, '');
|
||||
if (!cleaned.includes('/subtitle') && !cleaned.includes('/captions') && !cleaned.includes('.m3u8x')) {
|
||||
manifests.hls.add(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scan global config objects ---
|
||||
const configVars = ['hlsConfig', 'hls_player', 'hlsOptions', 'playerConfig', 'streamConfig',
|
||||
'videoConfig', 'playerOpts', 'phPlayer', 'playerData', 'videoData', 'streamData',
|
||||
'cloudflareConfig', 'cfPlayer', 'cfStream', 'videoSource', '__videojs__', 'videojs'];
|
||||
for (const vname of configVars) {
|
||||
try {
|
||||
const obj = window[vname];
|
||||
if (obj && typeof obj === 'object') {
|
||||
function deepFind(o, depth) {
|
||||
if (!o || typeof o !== 'object' || depth > 4) return;
|
||||
for (const k of ['src', 'url', 'source', 'manifest', 'hls', 'm3u8', 'file', 'stream', 'video', 'data']) {
|
||||
if (o[k]) {
|
||||
if (typeof o[k] === 'string') {
|
||||
if (o[k].includes('.m3u8')) manifests.hls.add(o[k]);
|
||||
if (o[k].includes('.mpd')) manifests.dash.add(o[k]);
|
||||
} else {
|
||||
deepFind(o[k], depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
deepFind(obj, 0);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Post results to content script via postMessage
|
||||
window.postMessage({
|
||||
type: '__GOON_SCAN_RESULT__',
|
||||
hlsManifests: Array.from(manifests.hls),
|
||||
dashManifests: Array.from(manifests.dash),
|
||||
videoSources: sources,
|
||||
scanCount: scanCount,
|
||||
type: 'GOON_PLUGIN_HLS_INTERCEPT',
|
||||
url: resolved,
|
||||
source: source,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// Run initial scan + periodic rescans
|
||||
scanVideos();
|
||||
setInterval(scanVideos, 1500);
|
||||
// === INTERCEPTORS ===
|
||||
|
||||
// 1. Intercept XMLHttpRequest for manifest-like requests
|
||||
var originalOpen = XMLHttpRequest.prototype.open;
|
||||
var originalSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
this._goon_url = url;
|
||||
return originalOpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function(body) {
|
||||
if (this._goon_url && typeof this._goon_url === 'string') {
|
||||
var resolved = resolveUrl(this._goon_url);
|
||||
if (resolved && (resolved.includes('.m3u8') ||
|
||||
resolved.includes('/playlist') ||
|
||||
resolved.includes('/manifest'))) {
|
||||
reportManifest(resolved, 'xhr');
|
||||
}
|
||||
}
|
||||
return originalSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// 2. Intercept fetch() for manifest-like requests
|
||||
var originalFetch = window.fetch;
|
||||
window.fetch = function(resource, init) {
|
||||
var url = typeof resource === 'string' ? resource : (resource && resource.url);
|
||||
if (url && typeof url === 'string') {
|
||||
var resolved = resolveUrl(url);
|
||||
if (resolved && (resolved.includes('.m3u8') ||
|
||||
resolved.includes('/playlist') ||
|
||||
resolved.includes('/manifest'))) {
|
||||
reportManifest(resolved, 'fetch');
|
||||
}
|
||||
}
|
||||
return originalFetch.apply(this, arguments);
|
||||
};
|
||||
|
||||
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
|
||||
function patchHlsPrototype() {
|
||||
if (typeof Hls === 'undefined') return;
|
||||
var originalLoadSource = Hls.prototype.loadSource;
|
||||
if (!originalLoadSource) return;
|
||||
|
||||
Hls.prototype.loadSource = function(url) {
|
||||
reportManifest(url, 'hls_loadSource');
|
||||
return originalLoadSource.apply(this, arguments);
|
||||
};
|
||||
|
||||
var originalAttachMedia = Hls.prototype.attachMedia;
|
||||
if (originalAttachMedia) {
|
||||
Hls.prototype.attachMedia = function(media) {
|
||||
if (media && media.tagName === 'VIDEO') {
|
||||
media._goon_hls_manifest = this.url;
|
||||
reportManifest(this.url, 'hls_attachMedia');
|
||||
}
|
||||
return originalAttachMedia.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Patch immediately if HLS is already defined
|
||||
patchHlsPrototype();
|
||||
|
||||
// Watch for HLS.js to be dynamically defined later
|
||||
var originalDefineProperty = Object.defineProperty;
|
||||
var hlsPatched = false;
|
||||
Object.defineProperty = function(obj, prop, descriptor) {
|
||||
var result = originalDefineProperty.apply(this, arguments);
|
||||
if (obj === window && prop === 'Hls' && !hlsPatched) {
|
||||
hlsPatched = true;
|
||||
patchHlsPrototype();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
})();
|
||||
`;
|
||||
|
||||
// Inject as a script element into the page (runs in MAIN world)
|
||||
const script = document.createElement('script');
|
||||
script.textContent = interceptorCode;
|
||||
script.textContent = interceptCode;
|
||||
script.setAttribute('type', 'text/javascript');
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
// Remove the script element after execution but keep interceptors active
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for results from the injected interceptor.
|
||||
*/
|
||||
// Inject immediately at document_start
|
||||
injectInterceptors();
|
||||
|
||||
// Listen for intercepted HLS manifest URLs from the main world
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.source !== window) return;
|
||||
if (!event.data || event.data.type !== '__GOON_SCAN_RESULT__') return;
|
||||
|
||||
const data = event.data;
|
||||
if (data.hlsManifests) {
|
||||
for (const url of data.hlsManifests) {
|
||||
collectedData.hlsManifests.add(url);
|
||||
if (event.data && event.data.type === 'GOON_PLUGIN_HLS_INTERCEPT') {
|
||||
const url = event.data.url;
|
||||
const source = event.data.source;
|
||||
if (!interceptedSources.has(url)) {
|
||||
interceptedSources.add(url);
|
||||
interceptedManifests.push({
|
||||
url: url,
|
||||
source: source,
|
||||
timestamp: event.data.timestamp
|
||||
});
|
||||
console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url);
|
||||
}
|
||||
}
|
||||
if (data.dashManifests) {
|
||||
for (const url of data.dashManifests) {
|
||||
collectedData.dashManifests.add(url);
|
||||
}
|
||||
}
|
||||
if (data.videoSources && data.videoSources.length > 0) {
|
||||
collectedData.videoSources = data.videoSources;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -237,36 +181,48 @@
|
||||
];
|
||||
|
||||
for (const selector of posterSelectors) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
const url = el.getAttribute('content') || el.getAttribute('src') || el.getAttribute('data-src') || el.getAttribute('data-poster');
|
||||
if (url && url.startsWith('http')) {
|
||||
collectedData.posterUrl = url;
|
||||
return;
|
||||
try {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
const url = el.getAttribute('content') || el.getAttribute('src') ||
|
||||
el.getAttribute('data-src') || el.getAttribute('data-poster');
|
||||
if (url && url.startsWith('http')) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Inject the interceptor on load
|
||||
injectInterceptor();
|
||||
|
||||
// Extract poster from the content script's DOM view
|
||||
extractPoster();
|
||||
// Re-extract after a delay to catch dynamic content
|
||||
setTimeout(extractPoster, 3000);
|
||||
setTimeout(extractPoster, 6000);
|
||||
|
||||
/**
|
||||
* Get collected data as a serializable object.
|
||||
* Get video data: intercepted manifests + DOM scan + poster.
|
||||
* Called by the popup via chrome.tabs.sendMessage.
|
||||
*/
|
||||
function getData() {
|
||||
extractPoster(); // Fresh scan
|
||||
function getVideoData() {
|
||||
const poster = extractPoster();
|
||||
|
||||
// DOM video scan (isolated world — can access DOM but not page JS)
|
||||
const videoElements = [];
|
||||
try {
|
||||
const videos = document.querySelectorAll('video');
|
||||
for (const video of videos) {
|
||||
const style = window.getComputedStyle(video);
|
||||
if (style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0) {
|
||||
videoElements.push({
|
||||
src: video.currentSrc || video.src || '',
|
||||
poster: video.poster || '',
|
||||
width: video.clientWidth,
|
||||
height: video.clientHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(e) { /* ignore DOM access errors */ }
|
||||
|
||||
return {
|
||||
hlsManifests: Array.from(collectedData.hlsManifests),
|
||||
dashManifests: Array.from(collectedData.dashManifests),
|
||||
videoSources: collectedData.videoSources,
|
||||
posterUrl: collectedData.posterUrl,
|
||||
posterUrl: poster,
|
||||
interceptedManifests: interceptedManifests,
|
||||
videoElements: videoElements,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -274,16 +230,11 @@
|
||||
// Listen for messages from the popup
|
||||
chrome.runtime.onMessage.addListener(function(message, _sender, sendResponse) {
|
||||
if (message.type === 'GOON_GET_VIDEO_DATA') {
|
||||
sendResponse(getData());
|
||||
return false;
|
||||
}
|
||||
if (message.type === 'GOON_INJECT') {
|
||||
injectInterceptor();
|
||||
sendResponse({ injected: true });
|
||||
sendResponse(getVideoData());
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
console.log('[goon-plugin] Content script loaded (isolated world). Interceptor injected.');
|
||||
console.log('[goon-plugin] Content script loaded. Interceptors injected at document_start.');
|
||||
})();
|
||||
Reference in new issue
Block a user