/** * index.js - Popup logic for the Goon Plugin. * Runs inside the popup; scans the current window's tabs for videos * and stores the results for the viewer to consume. */ const STORAGE_KEY = 'goon_videos'; const TAGGED_KEY = 'goon_tagged'; // Set to true to enable verbose console output in the popup. const DEBUG = false; function log(...args) { if (DEBUG) console.log('[goon]', ...args); } function warn(...args) { if (DEBUG) console.warn('[goon]', ...args); } // UI references const findBtn = document.getElementById('find-btn'); const clearBtn = document.getElementById('clear-btn'); const openBtn = document.getElementById('open-btn'); const statusEl = document.getElementById('status'); const listEl = document.getElementById('video-list'); const btnRow = document.getElementById('btn-row'); listEl.style.wordBreak = 'break-word'; listEl.style.whiteSpace = 'pre-wrap'; /* ======================================================================== */ /* UI helpers */ /* ======================================================================== */ function setStatus(msg) { statusEl.textContent = msg; log(msg); } /** Load the current video list and tags from storage. */ function loadVideos() { return chrome.storage.local.get([STORAGE_KEY, TAGGED_KEY]).then(data => ({ videos: data[STORAGE_KEY] || [], tags: data[TAGGED_KEY] || [], })); } /** Render the video list UI from stored video entries. */ function renderList(videos) { listEl.innerHTML = ''; if (!videos || videos.length === 0) { listEl.style.display = 'none'; return; } listEl.style.display = 'block'; for (const video of videos) { const link = document.createElement('a'); link.className = 'video-item'; link.href = video.src || video.url || ''; link.textContent = video.label ? `${video.label}: ${link.href}` : link.href; link.target = '_blank'; listEl.appendChild(link); } // Copy all URLs button const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn'; copyBtn.textContent = 'Copy All URLs'; copyBtn.addEventListener('click', () => { const urls = videos.map(v => v.src || v.url); navigator.clipboard.writeText(urls.join('\n')).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy All URLs'; }, 1500); }); }); listEl.appendChild(copyBtn); } /** Show the main button row and render saved videos. */ function showActions() { btnRow.style.display = 'flex'; loadVideos().then(({ videos, tags }) => { if (videos.length > 0) { const items = videos.map((video, i) => ({ src: video.src || video.url || '', label: tags[i], })); renderList(items); setStatus(`Using ${videos.length} video(s) from last search.`); } else { setStatus(''); } }); } /* ======================================================================== */ /* HLS deep probe - runs in target page context via executeScript */ /* ======================================================================== */ /** * Deep probe a page for HLS manifest URLs. * Walks known player objects, video elements, inline scripts, config globals, * and interceptor-collected data. * Returns an array of { url, player } objects. */ function m3u8DeepProbeFn() { const manifests = []; const seen = new Set(); function walk(obj, path, depth) { if (depth > 5) return; if (typeof obj === 'string') { const match = obj.match(/https?:\/\/[^"'\s`)]+\.m3u8[^"'\s`)]*/gi); if (match) { for (const u of match) { if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: path }); } } } return; } if (!obj || typeof obj !== 'object') return; for (const k of Object.keys(obj)) { walk(obj[k], `${path}.${k}`, depth + 1); } } // Strategy A: Known player globals (shallow + deep) const playerGlobals = [ 'hls', 'Hls', 'videojs', 'Video', 'flowplayer', 'jwplayer', '__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer', 'fhplayer', '__pj', 'playerInstance', 'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance', ]; for (const name of playerGlobals) { const obj = window[name]; if (!obj || typeof obj === 'function') continue; for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl']) { if (typeof obj[key] === 'string' && obj[key].includes('.m3u8')) { if (!seen.has(obj[key])) { seen.add(obj[key]); manifests.push({ url: obj[key], player: `${name}.${key}` }); } } } walk(obj, name, 0); } // Strategy B: Video element HLS instances // B1: Use Hls.cast(video) — the official hls.js API to get the bound instance if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') { for (const v of document.querySelectorAll('video')) { try { const hlsInst = Hls.cast(v); if (hlsInst && hlsInst.url) { if (!seen.has(hlsInst.url)) { seen.add(hlsInst.url); manifests.push({ url: hlsInst.url, player: 'Hls.cast' }); } } } catch { /* ignore */ } } } // B2: Check interceptor-set manifest annotation on video elements for (const v of document.querySelectorAll('video')) { if (v._goon_hls_manifest && !seen.has(v._goon_hls_manifest)) { seen.add(v._goon_hls_manifest); manifests.push({ url: v._goon_hls_manifest, player: 'video._goon_hls_manifest' }); } } // B3: Fallback — walk video element keys looking for HLS-related objects for (const v of document.querySelectorAll('video')) { for (const key of Object.keys(v)) { if (key.toLowerCase().includes('hls') || key === 'player') { const hlsObj = v[key]; if (hlsObj && typeof hlsObj === 'object') { for (const subKey of Object.keys(hlsObj)) { if (typeof hlsObj[subKey] === 'string' && hlsObj[subKey].includes('.m3u8')) { if (!seen.has(hlsObj[subKey])) { seen.add(hlsObj[subKey]); manifests.push({ url: hlsObj[subKey], player: `video.${key}.${subKey}` }); } } } walk(hlsObj, `video.${key}`, 0); } } } } // Strategy C: Inline scripts for (const script of document.querySelectorAll('script:not([src])')) { const text = script.textContent || ''; const matches = text.match(/https?:\/\/[^"'\s]+.(m3u8|hlsvod)[^"'\s]*/gi); if (matches) { for (const u of matches) { if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: 'inline-script' }); } } } } // Strategy D: Config globals const configKeys = ['__dataLayer', '__INITIAL_STATE__', 'config', '__NEXT_DATA__', '__PRELOADED_STATE__', 'appData', 'pageData', '__data']; for (const name of configKeys) { const obj = window[name]; if (obj && typeof obj === 'object') walk(obj, name, 0); } // Strategy E: Interceptor-collected manifests — the most reliable source. // The main-world interceptors (injected at document_start) capture .m3u8 URLs // from XHR, fetch, and Hls.prototype.loadSource/attachMedia calls, and store // them in window.__goon_plugin_data.manifests[]. This was the original // Strategy 3/6 on master that was accidentally dropped during refactoring. if (window.__goon_plugin_data && Array.isArray(window.__goon_plugin_data.manifests)) { for (const url of window.__goon_plugin_data.manifests) { if (url && typeof url === 'string' && !seen.has(url)) { seen.add(url); manifests.push({ url, player: '__goon_plugin_data' }); } } } return manifests; } /* ======================================================================== */ /* Video scraper - runs in target page context via executeScript */ /* ======================================================================== */ /** * Scrape visible video elements from the current frame. * Returns at most one video entry (the largest visible one). * @param {Array} hlsManifests - manifests found by the deep probe * @returns {{ videos: Array, videoCount: number, hlsManifest: string|null }} */ function scrapeFn(hlsManifests) { const allVideos = Array.from(document.querySelectorAll('video')) .filter(v => { const style = window.getComputedStyle(v); return style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0; }) .map(v => { const area = v.clientWidth * v.clientHeight; const rawSrc = v.currentSrc || v.src || ''; const poster = v.poster || v.getAttribute('data-poster') || null; let hlsManifest = null; let mediaType = 'video'; let src = rawSrc; // Priority 1: Direct .m3u8 source (rare but possible) if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) { hlsManifest = rawSrc; mediaType = 'hls'; } // Priority 2: Interceptor annotation — Hls.attachMedia() patched by interceptors else if (v._goon_hls_manifest) { hlsManifest = v._goon_hls_manifest; mediaType = 'hls'; src = hlsManifest; } // Priority 3: Hls.cast(video) — official hls.js API (works when Hls is loaded) else if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') { try { const hlsInst = Hls.cast(v); if (hlsInst && hlsInst.url) { hlsManifest = hlsInst.url; mediaType = 'hls'; src = hlsManifest; } } catch { /* ignore */ } } // Priority 4: Direct HTTP source (non-blob) if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) { mediaType = 'video'; } // Priority 5: Deep probe fallback — use manifests found by scanning page globals/scripts // and interceptor-collected manifests. This is the most reliable fallback because // interceptors catch HLS activity at the network layer regardless of player type. if (!hlsManifest && hlsManifests && hlsManifests.length > 0) { // Prefer interceptor-collected manifests over script-scrapped ones const interceptManifest = hlsManifests.find(m => m.player === '__goon_plugin_data') || hlsManifests.find(m => m.player === 'content-script-intercept') || hlsManifests.find(m => m.player !== 'inline-script') || hlsManifests[0]; hlsManifest = interceptManifest.url || interceptManifest; mediaType = 'hls'; src = hlsManifest; } return { src, poster, area, width: v.clientWidth, height: v.clientHeight, mediaType, hlsManifest }; }); allVideos.sort((a, b) => b.area - a.area); // Filter: only keep entries with a real playable source const playable = allVideos.filter(v => { const srcOk = v.src && (v.src.startsWith('http://') || v.src.startsWith('https://')); const hlsOk = v.hlsManifest && (v.hlsManifest.startsWith('http://') || v.hlsManifest.startsWith('https://')); return srcOk || hlsOk; }); // Dedup: if multiple videos share the same HLS manifest, keep only the largest const seen = new Map(); const unique = []; for (const v of playable) { const key = (v.hlsManifest || v.src || '') + '||' + (v.poster || ''); if (!seen.has(key)) { seen.set(key, v); unique.push(v); } } const largest = unique.length > 0 ? unique[0] : null; return { videos: largest ? [largest] : [], videoCount: largest ? 1 : 0, hlsManifest: largest?.hlsManifest || null, }; } /* ======================================================================== */ /* Scan pipeline */ /* ======================================================================== */ /** URLs that should not be scanned. */ const SYSTEM_PREFIXES = ['chrome://', 'chrome-extension://', 'edge://', 'about:', 'view-source:']; /** Filter out system/browser pages from the tab list. */ function filterValidTabs(tabs) { return tabs.filter(t => t.url && !SYSTEM_PREFIXES.some(prefix => t.url.startsWith(prefix)) ); } /** * Probe all frames in a tab for HLS manifests. * Runs in the MAIN world so strategies that depend on page globals work: * - window.__goon_plugin_data.manifests (set by main-world interceptors) * - window.Hls / Hls.cast (page's hls.js global) * - player globals (PHPlayer, flowplayer, etc.) * - config globals (__NEXT_DATA__, appData, etc.) * * The default isolated world cannot see these — only DOM properties like * video._goon_hls_manifest cross the boundary. * @param {number} tabId * @returns {Promise} array of { url, player } or empty on error */ async function probeHlsManifests(tabId) { try { const results = await chrome.scripting.executeScript({ target: { tabId, allFrames: true, world: 'MAIN' }, func: m3u8DeepProbeFn, }); return results.flatMap(r => r.result || []); } catch { return []; } } /** * Aggregate and deduplicate HLS manifests across frames for one tab. * Prefers manifests from known players/interceptors over inline-script scraping. * @param {Array} allManifests * @returns {Array} */ function dedupManifests(allManifests) { const seenUrls = new Set(); const unique = allManifests.filter(m => { if (seenUrls.has(m.url)) return false; seenUrls.add(m.url); return true; }); // Sort: prefer interceptor-collected > player globals > config > inline-script const playerRank = { '__goon_plugin_data': 0, 'content-script-intercept': 0, 'video._goon_hls_manifest': 1, 'Hls.cast': 1, 'inline-script': 99, }; unique.sort((a, b) => { const aRank = playerRank[a.player] ?? 50; const bRank = playerRank[b.player] ?? 50; return aRank - bRank; }); return unique; } /** * Query the content script for poster URLs, intercepted manifests, and video metadata. * @param {number} tabId * @returns {Promise} content script response or null on error/timeout */ async function queryContentScript(tabId) { try { return await chrome.tabs.sendMessage(tabId, { type: 'GOON_GET_VIDEO_DATA' }); } catch { return null; } } /** * Scrape video elements from ALL frames in a tab. * On sites like Pornhub, the video player lives inside an iframe — main-frame-only * scraping sees zero