diff --git a/manifest.json b/manifest.json index e1525be..2e177e1 100644 --- a/manifest.json +++ b/manifest.json @@ -27,7 +27,8 @@ { "matches": [""], "js": ["content.js"], - "run_at": "document_idle" + "run_at": "document_start", + "all_frames": true } ], "icons": { diff --git a/src/content/content.js b/src/content/content.js index 32e0232..9da33df 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -1,19 +1,289 @@ /** * content.js — Content script injected into matching pages. - * Communicates with the background service worker via chrome.runtime. - * Use cautiously: runs in the page's DOM context, so isolate from page globals. + * 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. + * + * 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 */ (function () { 'use strict'; - console.log('[goon-plugin] Content script loaded'); + // Collect results from injected scripts + let collectedData = { + hlsManifests: new Set(), + dashManifests: new Set(), + videoSources: [], + posterUrl: null, + }; - // Example: inject a small element into the page. - // Uncomment and customize as needed. - // - // const badge = document.createElement('div'); - // badge.textContent = 'Goon Plugin Active'; - // badge.style.cssText = 'position:fixed;bottom:8px;right:8px;padding:4px 8px;...'; - // document.body.appendChild(badge); + let pollingActive = false; + let pollTimer = null; + + /** + * Inject the interceptor script into the MAIN world to intercept XHR/fetch. + */ + function injectInterceptor() { + const interceptorCode = ` + (function() { + // Already injected + if (window.__goonInterceptor) return; + window.__goonInterceptor = 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); + }; + + 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); + } + + 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) {} + }); + + 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, + }, '*'); + } + + // Run initial scan + periodic rescans + scanVideos(); + setInterval(scanVideos, 1500); + })(); + `; + + // Inject as a script element into the page (runs in MAIN world) + const script = document.createElement('script'); + script.textContent = interceptorCode; + script.setAttribute('type', 'text/javascript'); + (document.head || document.documentElement).appendChild(script); + script.parentNode.removeChild(script); + } + + /** + * Listen for results from the injected interceptor. + */ + 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 (data.dashManifests) { + for (const url of data.dashManifests) { + collectedData.dashManifests.add(url); + } + } + if (data.videoSources && data.videoSources.length > 0) { + collectedData.videoSources = data.videoSources; + } + }); + + /** + * Extract poster URL from the page (content script CAN access DOM). + */ + function extractPoster() { + const posterSelectors = [ + 'meta[property="og:image"]', + 'meta[name="twitter:image"]', + 'meta[itemprop="image"]', + 'img.poster, img.thumbnail, .video-poster img, .thumbnail img, .preview img', + '[data-poster]', + ]; + + 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; + } + } + } + } + + // 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. + */ + function getData() { + extractPoster(); // Fresh scan + return { + hlsManifests: Array.from(collectedData.hlsManifests), + dashManifests: Array.from(collectedData.dashManifests), + videoSources: collectedData.videoSources, + posterUrl: collectedData.posterUrl, + timestamp: Date.now(), + }; + } + + // 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 }); + return false; + } + return false; + }); + + console.log('[goon-plugin] Content script loaded (isolated world). Interceptor injected.'); })(); diff --git a/src/viewer/index.js b/src/viewer/index.js index fa5ef80..6c0de58 100644 --- a/src/viewer/index.js +++ b/src/viewer/index.js @@ -112,47 +112,6 @@ findBtn.addEventListener('click', async () => { const scrapeFn = () => { const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster']; - /** Extract HLS manifest URLs from page using 3 strategies. */ - function extractHlsManifest() { - // Strategy 1: