diff --git a/src/content/content.js b/src/content/content.js index 9da33df..95aa748 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -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.'); })(); diff --git a/src/viewer/index.js b/src/viewer/index.js index 6c0de58..13344a1 100644 --- a/src/viewer/index.js +++ b/src/viewer/index.js @@ -1,6 +1,11 @@ /** * index.js — Popup logic for the Goon Plugin. - * Runs inside the popup; scans the current tab for videos and opens the viewer. + * Runs inside the popup; scans all tabs for videos and opens the viewer. + * + * Video detection strategy (per tab): + * 1. executeScript in main world: deeply query HLS player state for manifests + * 2. chrome.tabs.sendMessage to content script: get intercepted manifests + poster + * 3. executeScript DOM scan: fallback to