feat: Strategy 8 — enhance phncdn.com network fallback detection

- Log ALL phncdn.com requests (not just .m3u8) to chrome.storage.local
- Reconstruct manifest URLs from segment URL patterns (/videos/{id}/playlist.m3u8)
- Add GOON_GET_PHNCDN_REQUESTS message handler for popup consumption
- Add queryPhncdnRequests() consumer in viewer/index.js
- Integrate reconstructed manifests into scan pipeline (step 1.6)
This commit is contained in:
kfj001 committed 2026-08-16 16:17:29 -07:00
1 parent a1f4d2241b
commit 877feec731
2 files changed
+547 -58

No files matched your search

+163 -7
View File
@@ -4,22 +4,178 @@
* Uses Manifest V3 module-type service worker.
*/
// Fired when the extension is first installed / updated / Chrome starts.
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
console.log('[goon-plugin] Extension installed');
} else if (details.reason === 'update') {
console.log(`[goon-plugin] Extension updated from ${details.previousVersion}`);
// === HLS manifest tracking via chrome.webRequest ===
// Intercepts network requests at the browser level — no world/timing issues.
// Catches .m3u8 manifest requests from ANY player (HLS.js, Video.js, custom).
//
// IMPORTANT: MV3 service workers are ephemeral — they are killed when idle and
// restarted on demand. In-memory state (Map, Set, etc.) is LOST on kill.
// Therefore, we persist captured manifests to chrome.storage.local so they
// survive service worker restarts.
function isHlsManifest(url) {
const u = url.toLowerCase();
// Exclude media segments
if (u.includes('.ts') || u.includes('.mp4') || u.includes('.webm') ||
u.includes('.aac') || u.includes('.m4s')) return false;
return u.includes('.m3u8') || u.includes('.hlsvod') ||
(u.includes('/hls/') && u.includes('.m3u')) ||
u.includes('/playlist.m3u') || u.includes('/manifest');
}
// === STRATEGY 8: Network-level fallback — log ALL phncdn.com and video-related requests ===
// Pornhub's es6player generates HLS manifests in-memory with zero .m3u8 network requests.
// However, the player still fetches video segments from phncdn.com. By logging all phncdn
// requests we can: (a) confirm HLS activity via segment patterns, (b) reconstruct manifest
// URLs from segment paths, (c) detect CDN response headers that reveal playlist endpoints.
const PHNCDN_HOSTS = ['phncdn.com', 'pornhubpremium.com', 'cdn.pornhub.com'];
const VIDEO_PATH_PATTERNS = ['/hls/', '/dash/', '/video/', '/segment', '/media/', '/stream'];
function isVideoRequest(url) {
const u = url.toLowerCase();
// Already a known video/manifest extension
if (u.includes('.m3u8') || u.includes('.mpd') || u.includes('.hlsvod') ||
u.includes('.ts') || u.includes('.m4s') || u.includes('.mp4')) return true;
// Path-based detection for in-memory playlists
return VIDEO_PATH_PATTERNS.some(p => u.includes(p));
}
// Persist all video requests per tab (survives SW restarts)
async function persistVideoRequest(tabId, url) {
const key = 'vr_requests_' + tabId;
try {
const stored = await chrome.storage.local.get(key);
const arr = stored[key] || [];
// Avoid duplicates — phncdn segments come in rapid succession
if (!arr.some(r => r === url)) {
arr.push(url);
// Keep last 200 requests per tab (enough to detect segment patterns)
if (arr.length > 200) arr.splice(0, arr.length - 200);
await chrome.storage.local.set({ [key]: arr });
}
} catch(e) {
console.warn('[goon-bg] Failed to persist video request:', e && e.message ? e.message : e);
}
}
// Reconstruct manifest URL from segment URLs
// Segment: https://phncdn.com/videos/123456/seg-0-v1-a1.ts
// Manifest: https://phncdn.com/videos/123456/playlist.m3u8
function reconstructManifestsFromSegments(segments) {
const seen = new Set();
const manifests = [];
for (const url of segments) {
if (!url.includes('/videos/') || !url.includes('/seg-')) continue;
try {
const parsed = new URL(url);
const parts = parsed.pathname.split('/');
// Find pattern: .../videos/{videoId}/seg-{n}...
const vidIdx = parts.findIndex(p => p === 'videos');
if (vidIdx === -1 || vidIdx + 2 >= parts.length) continue;
const videoId = parts[vidIdx + 1];
// Build manifest URL: same origin, /videos/{videoId}/playlist.m3u8
const manifestUrl = `${parsed.protocol}//${parsed.host}/videos/${videoId}/playlist.m3u8`;
if (!seen.has(manifestUrl)) {
seen.add(manifestUrl);
manifests.push(manifestUrl);
}
} catch { /* skip malformed */ }
}
return manifests;
}
chrome.webRequest.onBeforeRequest.addListener(
async (details) => {
if (details.tabId < 0) return;
const u = details.url.toLowerCase();
const isPhncdn = PHNCDN_HOSTS.some(host => details.url.includes(host));
const isVideo = isVideoRequest(details.url);
// Log ALL phncdn.com requests regardless of path
if (isPhncdn) {
console.log('[goon-bg] webRequest: phncdn request in tab', details.tabId, ':', details.url.substring(0, 180));
await persistVideoRequest(details.tabId, details.url);
}
// Log video-like requests from any host
if (isVideo && !isPhncdn) {
console.log('[goon-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 150));
}
// Also capture HLS manifest URLs (original logic)
if (!isHlsManifest(details.url)) return;
console.log('[goon-bg] webRequest: HLS manifest in tab', details.tabId, ':', details.url.substring(0, 120));
const key = 'wr_manifests_' + details.tabId;
try {
const stored = await chrome.storage.local.get(key);
const arr = stored[key] || [];
if (!arr.includes(details.url)) {
arr.push(details.url);
await chrome.storage.local.set({ [key]: arr });
}
} catch(e) {
console.warn('[goon-bg] Failed to persist webRequest manifest:', e && e.message ? e.message : e);
}
},
{ urls: ['<all_urls>'] }
);
// Clean up stale entries when tabs are closed
chrome.tabs.onRemoved.addListener(async (tabId) => {
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
try {
await chrome.storage.local.remove(keys);
} catch { /* ignore */ }
});
// Listen for messages from popup or content scripts.
// === Message handlers for popup/data consumers ===
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
switch (message.type) {
case 'PING':
sendResponse({ pong: true, timestamp: Date.now() });
return;
case 'GOON_GET_WEBREQUEST_MANIFESTS': {
(async () => {
const tabId = message.tabId;
const key = 'wr_manifests_' + tabId;
try {
const stored = await chrome.storage.local.get(key);
sendResponse({ manifests: stored[key] || [] });
} catch(e) {
sendResponse({ manifests: [] });
}
})();
return true;
}
case 'GOON_GET_PHNCDN_REQUESTS': {
// Strategy 8: Return all phncdn.com requests for a tab, including reconstructed manifests
(async () => {
const tabId = message.tabId;
const [segKey] = ['vr_requests_' + tabId];
try {
const stored = await chrome.storage.local.get(segKey);
const segments = stored[segKey] || [];
// Also reconstruct manifest URLs from segment patterns
const reconstructed = reconstructManifestsFromSegments(segments);
sendResponse({
segments: segments,
reconstructedManifests: reconstructed,
totalRequests: segments.length,
});
} catch(e) {
sendResponse({ segments: [], reconstructedManifests: [], totalRequests: 0 });
}
})();
return true;
}
default:
console.warn('[goon-plugin] Unknown message type:', message.type);
}
+384 -51
View File
@@ -8,11 +8,25 @@ const STORAGE_KEY = 'goon_videos';
const TAGGED_KEY = 'goon_tagged';
// Set to true to enable verbose console output in the popup.
const DEBUG = false;
const DEBUG = true; // TEMP: enabled for debugging — revert to false when done
function log(...args) { if (DEBUG) console.log('[goon]', ...args); }
function warn(...args) { if (DEBUG) console.warn('[goon]', ...args); }
/** Log a message to the active tab's page console (visible to the user in DevTools). */
async function logToPage(msg) {
if (!DEBUG) return;
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) return;
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (m) => { console.log('[goon-page]', m); },
args: [msg],
});
} catch { /* ignore — tab may have navigated away */ }
}
// UI references
const findBtn = document.getElementById('find-btn');
const clearBtn = document.getElementById('clear-btn');
@@ -104,6 +118,12 @@ function m3u8DeepProbeFn() {
const manifests = [];
const seen = new Set();
// DEBUG: Log probe activity to console
function probeLog(...args) {
try { console.log('[goon-probe]', ...args); } catch(e) {}
}
probeLog('Deep probe started on:', window.location.href, 'World:', typeof chrome !== 'undefined' ? 'ISOLATED' : 'MAIN');
// Helpers available in the MAIN world context
function resolveUrl(url) {
if (!url) return null;
@@ -145,33 +165,8 @@ function m3u8DeepProbeFn() {
} catch { /* avoid circular ref errors */ }
}
// 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',
'bitgravity', 'bgPlayer', 'FG', 'Fhls',
];
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', 'hls_url', 'mp4_url']) {
if (typeof obj[key] === 'string' && isManifestUrl(obj[key])) {
const resolved = resolveUrl(obj[key]);
if (resolved && !seen.has(resolved)) { seen.add(resolved); manifests.push({ url: resolved, player: `${name}.${key}` }); }
}
}
walk(obj, name, 0);
}
// Strategy B: Video element HLS instances
// B1: Scan video elements for HLS instance references stored by players.
// NOTE: Hls.cast() in hls.js is a TypeScript type-cast utility (identity function),
// NOT a reverse-lookup. It returns its input unchanged — Hls.cast(video) just
// returns the <video> element with no .url property. Real players store the
// HLS instance directly on the video element as a custom property.
// Only check non-standard properties (skip built-in <video> attributes).
// Standard <video> element attributes — skip these when scanning for custom
// HLS player properties. Define here so it's available for Strategy A2 onward.
const STANDARD_VIDEO_ATTRS = new Set([
'src', 'srcObject', 'crossOrigin', 'preload', 'autoplay', 'muted',
'defaultMuted', 'loop', 'controls', 'controlsList', 'disableRemotePlayback',
@@ -183,63 +178,205 @@ function m3u8DeepProbeFn() {
'classList', 'style', 'attributes', 'dataset', 'innerHTML', 'textContent',
'outerHTML', 'outerText', 'slot', 'accessKey', 'draggable', 'hidden',
'lang', 'spellcheck', 'tabIndex', 'title', 'translate',
'width', 'height', 'poster', 'preload', 'referrerPolicy',
'width', 'height', 'poster', 'referrerPolicy',
]);
// 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',
'bitgravity', 'bgPlayer', 'FG', 'Fhls',
];
let strategyACount = 0;
for (const name of playerGlobals) {
const obj = window[name];
if (!obj || typeof obj === 'function') continue;
probeLog('Strategy A: found global', name, '(not function)');
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl', 'hls_url', 'mp4_url']) {
if (typeof obj[key] === 'string' && isManifestUrl(obj[key])) {
const resolved = resolveUrl(obj[key]);
if (resolved && !seen.has(resolved)) { seen.add(resolved); manifests.push({ url: resolved, player: `${name}.${key}` }); strategyACount++; }
}
}
walk(obj, name, 0);
}
probeLog('Strategy A (player globals): found', strategyACount, 'new manifests');
// Strategy A2: Hls instances — iterate all video elements and check if any
// HLS player instance (created via new Hls()) is bound to them. Even if our
// attachMedia patch missed the call (timing race), the player object still
// exists on the video element with a .url property.
let strategyA2Count = 0;
try {
for (const v of document.querySelectorAll('video')) {
// Check all custom properties on the video for HLS-like instances
for (const key of Object.keys(v)) {
if (STANDARD_VIDEO_ATTRS.has(key)) continue;
const obj = v[key];
if (!obj || typeof obj !== 'object') continue;
// HLS instances have .url, .media, .config, .networkControls, etc.
if (typeof obj.url === 'string' && isManifestUrl(obj.url)) {
const resolved = resolveUrl(obj.url);
if (resolved && !seen.has(resolved)) {
seen.add(resolved);
manifests.push({ url: resolved, player: `video.${key}.url (Hls instance)` });
strategyA2Count++;
probeLog('Strategy A2: found Hls instance on video.', key, '- url:', obj.url.substring(0, 100));
}
}
}
}
} catch(e) { /* ignore */ }
probeLog('Strategy A2 (Hls instances on video): found', strategyA2Count, 'new manifests');
// Strategy B: Video element HLS instances
// B1: Scan video elements for HLS instance references stored by players.
// NOTE: Hls.cast() in hls.js is a TypeScript type-cast utility (identity function),
// NOT a reverse-lookup. It returns its input unchanged — Hls.cast(video) just
// returns the <video> element with no .url property. Real players store the
// HLS instance directly on the video element as a custom property.
// Only check non-standard properties (skip built-in <video> attributes).
const allVideos = document.querySelectorAll('video');
probeLog('Strategy B: found', allVideos.length, '<video> elements');
let strategyBCount = 0;
for (const v of allVideos) {
for (const key of Object.keys(v)) {
if (STANDARD_VIDEO_ATTRS.has(key)) continue;
const obj = v[key];
if (!obj || typeof obj !== 'object') continue;
// Check for common HLS instance patterns on video elements
if (typeof obj === 'object' && typeof obj.url === 'string' && isManifestUrl(obj.url)) {
probeLog('Strategy B1: found HLS instance on video.', key, '- url:', obj.url.substring(0, 100));
const resolved = resolveUrl(obj.url);
if (resolved && !seen.has(resolved)) { seen.add(resolved); manifests.push({ url: resolved, player: `video.${key}.url` }); }
if (resolved && !seen.has(resolved)) { seen.add(resolved); manifests.push({ url: resolved, player: `video.${key}.url` }); strategyBCount++; }
}
// Walk deep into player objects attached to video elements
walk(obj, `video.${key}`, 0);
}
}
probeLog('Strategy B1 (video custom props): found', strategyBCount, 'new manifests');
// B2: Check interceptor-set manifest annotation on video elements
let strategyB2Count = 0;
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' });
strategyB2Count++;
probeLog('Strategy B2: found _goon_hls_manifest on video:', v._goon_hls_manifest.substring(0, 100));
}
}
probeLog('Strategy B2 (interceptor annotation): found', strategyB2Count, 'new manifests');
// Strategy C: Inline scripts
let strategyCCount = 0;
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' }); }
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: 'inline-script' }); strategyCCount++; }
}
}
}
probeLog('Strategy C (inline scripts): found', strategyCCount, 'new manifests');
// Strategy D: Config globals
const configKeys = ['__dataLayer', '__INITIAL_STATE__', 'config', '__NEXT_DATA__', '__PRELOADED_STATE__', 'appData', 'pageData', '__data'];
let preDCount = manifests.length;
for (const name of configKeys) {
const obj = window[name];
if (obj && typeof obj === 'object') walk(obj, name, 0);
if (obj && typeof obj === 'object') {
probeLog('Strategy D: walking config global', name);
walk(obj, name, 0);
}
}
probeLog('Strategy D (config globals): found', manifests.length - preDCount, 'new manifests');
// 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.
// For dynamic playlists, the interceptors also poll Hls instances via Hls.cast()
// and extract internal state (levels, url) — these URLs may not end in .m3u8.
let preECount = manifests.length;
if (window.__goon_plugin_data && Array.isArray(window.__goon_plugin_data.manifests)) {
probeLog('Strategy E: __goon_plugin_data has', window.__goon_plugin_data.manifests.length, 'interceptor 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' });
}
}
} else {
probeLog('Strategy E: __goon_plugin_data missing or has no manifests array', {
hasData: !!window.__goon_plugin_data,
hasArray: window.__goon_plugin_data ? Array.isArray(window.__goon_plugin_data.manifests) : false,
});
}
probeLog('Strategy E (interceptor data): found', manifests.length - preECount, 'new manifests');
// Strategy F: Dynamic playlist fallback — scan video elements for Hls-like
// instances and extract ANY URL, not just .m3u8. Sites like Pornhub use
// HLS.js dynamic playlists where the "manifest" is generated in-memory,
// so the URL might be a blob:, CDN endpoint, or data URI.
let strategyFCount = 0;
try {
for (const v of document.querySelectorAll('video')) {
for (const key of Object.keys(v)) {
if (STANDARD_VIDEO_ATTRS.has(key)) continue;
const obj = v[key];
if (!obj || typeof obj !== 'object') continue;
// Look for Hls-like objects: have .url, .levels, .attachMedia, etc.
const isHlsLike = (typeof obj.url === 'string' && obj.url.length > 5) ||
(obj.levels && Array.isArray(obj.levels)) ||
(typeof obj.attachMedia === 'function') ||
(typeof obj.loadSource === 'function');
if (!isHlsLike) continue;
// Extract URL without isManifestUrl filter
if (typeof obj.url === 'string' && obj.url.length > 5 && !seen.has(obj.url)) {
const resolved = resolveUrl(obj.url) || obj.url;
// Skip obvious media segments
const u = resolved.toLowerCase();
if (u.includes('.ts') || u.includes('.m4s') || u.includes('.aac')) continue;
seen.add(resolved);
manifests.push({ url: resolved, player: `video.${key}.url (dynamic HLS)` });
strategyFCount++;
probeLog('Strategy F: found dynamic HLS URL on video.', key, ':', resolved.substring(0, 150));
}
// Extract level URIs from levels array
if (obj.levels && Array.isArray(obj.levels)) {
for (let li = 0; li < obj.levels.length; li++) {
const lvl = obj.levels[li];
if (lvl && lvl.uri && !seen.has(lvl.uri)) {
const resolved = resolveUrl(lvl.uri) || lvl.uri;
seen.add(resolved);
manifests.push({ url: resolved, player: `video.${key}.levels[${li}].uri (dynamic)` });
strategyFCount++;
probeLog('Strategy F: found level URI:', resolved.substring(0, 120));
}
// Also check details.uri (populated after playlist load)
if (lvl && lvl.details && lvl.details.uri && !seen.has(lvl.details.uri)) {
const resolved = resolveUrl(lvl.details.uri) || lvl.details.uri;
seen.add(resolved);
manifests.push({ url: resolved, player: `video.${key}.levels[${li}].details.uri (dynamic)` });
strategyFCount++;
probeLog('Strategy F: found level details.uri:', resolved.substring(0, 120));
}
}
}
}
}
} catch(e) { /* ignore */ }
probeLog('Strategy F (dynamic HLS fallback): found', strategyFCount, 'new manifests');
probeLog('Deep probe complete. Total unique manifests found:', manifests.length);
if (manifests.length > 0) {
probeLog('Manifest summary:', JSON.stringify(manifests.map(m => ({ url: m.url.substring(0, 100), player: m.player }))));
}
return manifests;
}
@@ -387,14 +524,21 @@ function filterValidTabs(tabs) {
*/
async function probeHlsManifests(tabId) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId, allFrames: true, world: 'MAIN' },
log('probeHlsManifests: executing deep probe on tab', tabId);
const raw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
func: m3u8DeepProbeFn,
});
// When allFrames is true, destroyed frames may return null entries.
// Filter them out before accessing .result to avoid TypeError.
return results.filter(r => r && typeof r === 'object').flatMap(r => r.result || []);
} catch {
// When allFrames is true, destroyed / sandboxed frames may return null entries.
// Guard: ensure raw is an array, then filter to objects with a defined .result.
const manifests = Array.isArray(raw)
? raw.filter(r => r != null && typeof r === 'object' && 'result' in r)
.flatMap(r => Array.isArray(r.result) ? r.result : [])
: [];
log('probeHlsManifests: tab', tabId, 'returned', manifests.length, 'manifests from', Array.isArray(raw) ? raw.length : '?', 'frame results');
return manifests;
} catch(e) {
warn('probeHlsManifests: tab', tabId, 'error:', e && e.message ? e.message : e);
return [];
}
}
@@ -440,6 +584,44 @@ async function queryContentScript(tabId) {
}
}
/**
* Query the service worker for webRequest-captured HLS manifests.
* The service worker listens to chrome.webRequest.onBeforeRequest and
* records all .m3u8 / HLS manifest URLs per tab. This works at the
* network level — no world or timing issues.
* @param {number} tabId
* @returns {Promise<Array>} array of manifest URLs
*/
async function queryWebRequestManifests(tabId) {
try {
const resp = await chrome.runtime.sendMessage({
type: 'GOON_GET_WEBREQUEST_MANIFESTS',
tabId: tabId,
});
return resp?.manifests || [];
} catch {
return [];
}
}
/**
* Query the service worker for all phncdn.com requests captured by Strategy 8.
* Returns segments + reconstructed manifest URLs from segment patterns.
* @param {number} tabId
* @returns {Promise<{segments: Array, reconstructedManifests: Array, totalRequests: number}>}
*/
async function queryPhncdnRequests(tabId) {
try {
const resp = await chrome.runtime.sendMessage({
type: 'GOON_GET_PHNCDN_REQUESTS',
tabId: tabId,
});
return resp || { segments: [], reconstructedManifests: [], totalRequests: 0 };
} catch {
return { segments: [], reconstructedManifests: [], totalRequests: 0 };
}
}
/**
* Scrape video elements from ALL frames in a tab.
* On sites like Pornhub, the video player lives inside an iframe — main-frame-only
@@ -457,20 +639,21 @@ async function queryContentScript(tabId) {
*/
async function scrapeTab(tabId, manifests) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId, allFrames: true, world: 'MAIN' },
const raw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
func: scrapeFn,
args: [manifests],
});
// When allFrames is true, destroyed frames may return null entries.
// Filter them out before accessing .result to avoid TypeError.
const valid = results
.filter(r => r && typeof r === 'object')
// When allFrames is true, destroyed / sandboxed frames may return null entries.
// Guard: ensure raw is an array, then filter to objects with a defined .result.
const frameResults = Array.isArray(raw)
? raw.filter(r => r != null && typeof r === 'object' && 'result' in r)
.map(r => r.result)
.filter(r => r && r.videos && r.videos.length > 0);
: [];
const valid = frameResults.filter(r => r && r.videos && r.videos.length > 0);
if (valid.length === 0) {
const firstValid = results.find(r => r && typeof r === 'object');
return firstValid?.result || null;
const firstWithResult = frameResults.find(r => r != null);
return firstWithResult || null;
}
valid.sort((a, b) => {
const aArea = a.videos[0]?.area || 0;
@@ -478,8 +661,8 @@ async function scrapeTab(tabId, manifests) {
return bArea - aArea;
});
return valid[0];
} catch {
warn('skipping tab', tabId);
} catch(e) {
warn('scrapeTab: tab', tabId, 'error:', e && e.message ? e.message : e);
return null;
}
}
@@ -561,18 +744,70 @@ function makeVideoEntry(video, tabTitle, posterFromContent) {
* @returns {Promise<{ items: Array, scanned: number, skipped: number }>}
*/
async function scanVideos(tabsToScan) {
log('scanVideos: starting scan of', tabsToScan.length, 'tabs');
await logToPage(`scanVideos: starting scan of ${tabsToScan.length} tabs`);
// Step 1: Probe for HLS manifests in all frames
const manifestPromises = tabsToScan.map(t => probeHlsManifests(t.id));
const allManifestResults = await Promise.all(manifestPromises);
// Build per-tab manifest map (deduped)
const tabManifestMap = new Map();
let totalManifests = 0;
for (let i = 0; i < tabsToScan.length; i++) {
const unique = dedupManifests(allManifestResults[i]);
if (unique.length > 0) {
const tabUrl = tabsToScan[i].url ? tabsToScan[i].url.substring(0, 80) : '(unknown)';
log('scanVideos step1: tab', tabsToScan[i].id, '(' + tabUrl + ') has', unique.length, 'unique manifests');
totalManifests += unique.length;
tabManifestMap.set(tabsToScan[i].id, unique);
}
await logToPage(`step1 (probe): found ${totalManifests} manifests across ${tabsToScan.length} tabs`);
// Step 1.5: Query service worker for webRequest-captured HLS manifests.
// This catches manifests at the network level — no world or timing issues.
// Works even when the player loads before our interceptors are active.
const webRequestPromises = tabsToScan.map(t => queryWebRequestManifests(t.id));
const webRequestResults = await Promise.all(webRequestPromises);
let totalWebRequestManifests = 0;
for (let i = 0; i < tabsToScan.length; i++) {
const urls = webRequestResults[i];
const existing = tabManifestMap.get(tabsToScan[i].id) || [];
for (const url of urls) {
if (!existing.some(m => m.url === url)) {
existing.push({ url, player: 'webRequest' });
}
}
tabManifestMap.set(tabsToScan[i].id, existing);
const newCount = urls.length;
totalWebRequestManifests += newCount;
if (newCount > 0) {
log('scanVideos webRequest: tab', tabsToScan[i].id, 'found', newCount, 'manifests via network interception');
}
}
await logToPage(`step1.5 (webRequest): found ${totalWebRequestManifests} manifests via network interception`);
// Step 1.6: Strategy 8 — Query phncdn.com requests and reconstruct manifest URLs.
// When the player uses in-memory dynamic HLS (zero .m3u8 network requests), we can
// still detect it by recognizing segment URL patterns from phncdn.com and deriving
// the parent manifest URL: /videos/{id}/playlist.m3u8
const phncdnPromises = tabsToScan.map(t => queryPhncdnRequests(t.id));
const phncdnResults = await Promise.all(phncdnPromises);
let totalPhncdnManifests = 0;
for (let i = 0; i < tabsToScan.length; i++) {
const reconstructed = phncdnResults[i]?.reconstructedManifests || [];
if (reconstructed.length > 0) {
log('scanVideos phncdn: tab', tabsToScan[i].id, 'found', reconstructed.length, 'reconstructed manifests from segment URLs');
}
totalPhncdnManifests += reconstructed.length;
const existing = tabManifestMap.get(tabsToScan[i].id) || [];
for (const url of reconstructed) {
if (!existing.some(m => m.url === url)) {
existing.push({ url, player: 'phncdn_reconstructed' });
}
}
tabManifestMap.set(tabsToScan[i].id, existing);
}
await logToPage(`step1.6 (phncdn): found ${totalPhncdnManifests} reconstructed manifests from segment URLs`);
// Step 2: Scrape video elements (all frames) AND query content scripts (in parallel)
const scrapePromises = tabsToScan.map(t =>
@@ -584,6 +819,24 @@ async function scanVideos(tabsToScan) {
Promise.all(contentPromises),
]);
// Log scrape + content script results for debugging
let totalScrapedVideos = 0;
let totalInterceptedManifests = 0;
for (let i = 0; i < tabsToScan.length; i++) {
const tabUrl = tabsToScan[i].url ? tabsToScan[i].url.substring(0, 80) : '(unknown)';
const scrape = scrapeResults[i];
const content = contentResults[i];
const scrapeVideos = scrape ? (scrape.videos ? scrape.videos.length : 0) : 0;
const intercepts = content ? (content.interceptedManifests ? content.interceptedManifests.length : 0) : 0;
totalScrapedVideos += scrapeVideos;
totalInterceptedManifests += intercepts;
log('scanVideos step2: tab', tabsToScan[i].id, '(' + tabUrl + '):',
'scrape=' + (scrape ? (scrape.videos ? scrape.videos.length + ' videos' : 'no videos') : 'null'),
'content=' + (content ? ('poster=' + !!content.posterUrl + ', intercepts=' + intercepts + ', videoEls=' + (content.videoElements ? content.videoElements.length : 0)) : 'null'),
);
}
await logToPage(`step2 (scrape+content): ${totalScrapedVideos} scraped videos, ${totalInterceptedManifests} intercepted manifests`);
// Step 3: Merge content-script data into tab results
// - Enrich video entries with poster URLs from the content script
// - Merge content-script intercepted manifests into the deep probe results
@@ -622,6 +875,7 @@ async function scanVideos(tabsToScan) {
const poster = contentData?.posterUrl || null;
const filteredManifests = filterManifests(tabManifests);
if (filteredManifests.length > 0) {
log('scanVideos fallback: tab', tab.id, 'no visible videos but found', filteredManifests.length, 'manifests — creating fallback entries');
const fallbackVideos = filteredManifests.map(m => ({
src: m.url,
poster: poster,
@@ -633,6 +887,7 @@ async function scanVideos(tabsToScan) {
result: { videos: fallbackVideos },
};
}
log('scanVideos: tab', tab.id, 'has no videos and no manifests — skipping');
return null;
}
@@ -640,7 +895,7 @@ async function scanVideos(tabsToScan) {
tabTitle: tab.title || 'Unknown',
result,
};
}).filter(g => g.result && g.result.videos?.length > 0);
}).filter(g => g != null && g.result && g.result.videos?.length > 0);
// Step 4: Dedup across tabs
const seen = new Map();
@@ -657,6 +912,11 @@ async function scanVideos(tabsToScan) {
const items = [...seen.values()];
const skipped = scrapeResults.filter(r => r === null).length;
log('scanVideos complete: scanned=', tabResults.length, 'skipped=', skipped, 'unique items=', items.length);
if (items.length > 0) {
log('Final items:', JSON.stringify(items.map(v => ({ src: (v.src || '').substring(0, 80), mediaType: v.mediaType, tab: v.tab }))));
}
await logToPage(`scan complete: ${items.length} unique items from ${tabResults.length} tabs (skipped: ${skipped})`);
return { items, scanned: tabResults.length, skipped };
}
@@ -700,6 +960,79 @@ findBtn.addEventListener('click', async () => {
const msg = `Found ${items.length} video(s) across ${scanned} tab(s).`;
setStatus(skipped > 0 ? `${msg} ${skipped} tab(s) skipped (idle).` : msg);
btnRow.style.display = 'flex';
// If no videos found, run diagnostics in the active tab
if (items.length === 0) {
const activeTab = validTabs.find(t => t.active) || validTabs[0];
if (activeTab) {
try {
await chrome.scripting.executeScript({
target: { tabId: activeTab.id },
func: () => {
const diag = [];
// Check interceptor data
if (window.__goon_plugin_data) {
diag.push(`__goon_plugin_data.manifests: ${window.__goon_plugin_data.manifests.length} URLs`);
if (window.__goon_plugin_data.manifests.length > 0) {
diag.push(` First: ${window.__goon_plugin_data.manifests[0].substring(0, 120)}`);
}
} else {
diag.push('__goon_plugin_data: NOT FOUND — interceptors may not have loaded');
}
// Check Hls global
if (typeof Hls !== 'undefined') {
diag.push(`Hls global: defined`);
if (typeof Hls.cast === 'function') {
diag.push(`Hls.cast: available`);
}
} else {
diag.push(`Hls global: NOT DEFINED`);
}
// Check video elements
const videos = document.querySelectorAll('video');
diag.push(`Video elements: ${videos.length}`);
for (let vi = 0; vi < videos.length; vi++) {
const v = videos[vi];
const style = window.getComputedStyle(v);
const isVisible = style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
const src = (v.currentSrc || v.src || '').substring(0, 120);
diag.push(` Video[${vi}]: src="${src}", visible=${isVisible}, _goon_hls_manifest=${v._goon_hls_manifest || 'null'}`);
// Check custom properties
const customProps = [];
for (const key of Object.keys(v)) {
if (typeof v[key] === 'object' && v[key] && typeof v[key].url === 'string') {
customProps.push(`${key}.url="${v[key].url.substring(0, 80)}"`);
}
}
if (customProps.length > 0) {
diag.push(` Custom props with .url: ${customProps.join(', ')}`);
}
}
// Check known player globals
const playerNames = ['hls', 'Hls', 'player', 'videoPlayer', 'mainPlayer', 'PHPlayer', '__phPlayer', '__ph', 'fhplayer', '__pj', 'bitgravity', 'bgPlayer', 'FG', 'Fhls'];
for (const name of playerNames) {
if (window[name] && typeof window[name] !== 'function') {
const obj = window[name];
const urlVal = obj.url || obj._url || obj.src || obj.manifestUrl || obj.hlsUrl;
if (typeof urlVal === 'string' && urlVal) {
diag.push(`Global ${name}.url-like: "${urlVal.substring(0, 120)}"`);
} else {
diag.push(`Global ${name}: exists but no obvious URL property. Keys: ${Object.keys(obj).slice(0, 20).join(', ')}`);
}
}
}
console.group('[goon-diag] HLS diagnostics');
for (const line of diag) {
console.log(line);
}
console.groupEnd();
},
});
} catch (e) {
console.warn('[goon-diag] Diagnostic failed:', e.message);
}
}
}
} catch (e) {
setStatus(`Scan failed: ${e.message}`);
warn('executeScript failed:', e);