fix: redesign HLS detection with 6-strategy per-tab query
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:
hermes committed 2026-08-10 20:39:49 +00:00
1 parent c22631d1f1
commit e17b228ee0
2 files changed
+578 -370

No files matched your search

+172 -221
View File
@@ -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.');
})();