Compare commits

...
5 Commits
Author SHA1 Message Date
kfj001 001cf146ee Attempted fix at pornhub video
CI / test (pull_request) Failing after 10m39s
2026-08-16 23:00:47 -07:00
kfj001 b234c8dcae fix: HLS playback — CORS proxy for phncdn.com + diagnostic logging
Problem: Even after fixing the hls.js file naming (was 'hls.min.js', now
'hls.js'), videos don't play in the viewer. Likely root cause is CORS
blocking chrome-extension:// requests to phncdn.com CDN URLs that lack
CORS headers for extension origins.

Solution — two-layer fix:

1. Service worker proxy (background.js):
   - New GOON_PROXY_FETCH handler fetches any URL via native fetch()
     (no CORS restrictions in the service worker)
   - Returns base64-encoded response data to the viewer

2. Custom HLS loader (viewer/viewer.js):
   - HlsLoader implements hls.js Loader interface
   - For phncdn.com/pornhubcdn URLs: proxies via GOON_PROXY_FETCH
   - For all other URLs: uses native XHR (bypasses proxy overhead)
   - Config passed to hls.js only when manifest URL needs proxying

3. Diagnostics (viewer/viewer.js):
   - CORS check at page load for phncdn.com and per-video URL
   - Full storage dump of video entries in console
   - Manifest URL logging on each HLS load attempt
   - Proxy activation logging for debugging
2026-08-16 16:42:12 -07:00
kfj001 fa0a0d76cd fix: rename hls.min.js -> hls.js to match vite build output
The vite config outputs 'hls.js' but manifest.json and viewer.js
both referenced 'hls.min.js', causing chrome.runtime.getURL() to
return a 404 — triggering 'HLS player unavailable'.

- Update web_accessible_resources entry in manifest.json
- Update loadHlsJs() chrome.runtime.getURL reference
- Add debug log of manifest URL being loaded for diagnostics
2026-08-16 16:28:55 -07:00
kfj001 509948def1 feat: deep probe strategies G (non-enumerable/Symbol) and H (full window scan)
Strategy G — Independent non-enumerable + Symbol property detection on video elements.
Uses Object.getOwnPropertyNames/Symbols to catch properties hidden by enumerable:false or Symbol keys.
Walks nested objects for URL/levels/config signatures matching HLS instance heuristics.

Strategy H — Full window global enumeration with player-like key filtering.
Catches previously-unknown globals the player wrapper exposes internally.

Content script bridge — merge content-level intercepted manifests into
window.__goon_plugin_data so both interceptors and executeScript share findings.

Add phncdn_reconstructed to dedup priority ranking.
2026-08-16 16:22:07 -07:00
kfj001 877feec731 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)
2026-08-16 16:17:29 -07:00
11 changed files with 1123 additions and 272 deletions

No files matched your search

+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run *)", "Bash(npm run *)",
"Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)" "Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)",
"Bash(open -a \"Microsoft Edge\" --args --remote-debugging-port=9222 --profile-directory=Default)"
] ]
} }
} }
-160
View File
@@ -1,160 +0,0 @@
# Goon Plugin — HLS QA Report
## Summary
**Scope**: Comprehensive QA verification of HLS (HTTP Live Streaming) support in the Goon Plugin Chrome extension, including detection, playback, tracking, interceptors, deep probe strategies, and edge cases.
**Result**: 83/83 tests passing (39 existing + 44 new HLS-specific QA tests). 1 bug found and documented. 2 enhancement recommendations.
---
## Test Coverage
### New Test Suite: `hls_qa.test.mjs` (44 tests)
#### Fragmented Playback Tracking (5 tests)
- Segment loaded/failed counting via FRAG_LOADED, FRAG_BUFFERED, ERROR events
- Buffer health tracking (BUFFER_APPENDED, BUFFER_FLUSHED, BUFFER_RESET)
- Quality level switch tracking (LEVEL_SWITCHED, LEVEL_UPDATED)
- FPS drop tracking (FPS_DROP, FPS_MAX_LEVEL_DROP)
- Video element lifecycle events (autoplay, muted, loop, playsInline)
**Verdict**: PASS — All HLS.js event handlers properly wired to panel state machine.
#### Manifest Fallback Behavior (5 tests)
- hlsManifest field used when present
- src falls back when hlsManifest missing AND src looks like .m3u8
- Non-.m3u8 src NOT treated as HLS manifest (mediaType=hls but src=.mp4)
- blob: URLs not treated as HLS
- data: URLs handled gracefully
**Verdict**: PASS — Fallback chain works correctly: hlsManifest > src (if .m3u8) > native playback. Warning logged when HLS entry has no valid manifest.
#### Interceptor Detection (8 tests)
- XHR .m3u8 URL detection
- fetch .m3u8 URL detection
- Hls.prototype.loadSource interception
- Deduplication of same manifest URL
- Relative URL resolution to absolute
- Non-HLS URLs NOT intercepted
- /playlist and /manifest URL detection (even without .m3u8 extension)
- Idempotent double-injection prevention
**Verdict**: PASS — Interceptors detect all HLS request patterns, deduplicate, resolve relative URLs, and are safe against double injection.
#### Deep Probe Strategies (7 tests)
- Player global detection (hls, videojs, etc.)
- Inline script .m3u8 URL extraction
- Interceptor-collected manifest detection (__goon_plugin_data)
- Hls.cast(video) API support
- Interceptor-collected manifests preferred over inline-script (priority order)
- Config globals (__NEXT_DATA__) — BUG DOCUMENTED
- Config globals with top-level .m3u8 values — BUG DOCUMENTED
**Verdict**: PASS with BUG — 5 of 7 probes work correctly. 2 config-global tests document a walk() dead-code bug (see below).
#### Configuration and Resource Management (5 tests)
- Grid-optimized HLS config (maxBufferLength: 10s, capLevelToPlayerSize, maxBufferSize: 2MB, lowLatencyMode: false)
- CDN version pinned to @1.5.17 (not floating)
- HLS instance destruction on page unload
- Storage cleanup on viewer close
- CDN failure graceful handling
**Verdict**: PASS — Config values appropriate for grid-panel usage. Resource lifecycle is correct.
#### iframe and Multi-frame Scenarios (3 tests)
- HLS probe uses allFrames: true
- Video scraping uses allFrames: true
- content_scripts manifest has all_frames: true + run_at: document_start
**Verdict**: PASS — Iframe detection properly configured at all levels.
#### Edge Cases (6 tests)
- Empty HLS manifest URL (empty string) — falls back to native
- Null hlsManifest field — falls back to native
- Multiple HLS streams in same grid (3 unique instances)
- Oversized grid (3x3 with 1 video — 8 empty panels)
- Mixed HLS + blob source in same grid
- /manifest URLs without .m3u8 extension
**Verdict**: PASS — All edge cases handled gracefully without crashes.
---
## Bug Found
### BUG: walk() dead code in m3u8DeepProbeFn — config globals not fully scanned
**Severity**: MEDIUM
**Location**: `src/viewer/index.js`, lines 107-120
**Affected probe**: Strategy D (Config globals: __NEXT_DATA__, appData, __INITIAL_STATE__, etc.)
**Root Cause**:
```js
function walk(obj, path, depth) {
if (!obj || typeof obj !== 'object' || depth > 5) return; // GUARD
if (typeof obj === 'string') { // NEVER REACHED — dead code
const match = obj.match(/https?:\/\/.../gi);
...
}
for (const k of Object.keys(obj)) {
walk(obj[k], `${path}.${k}`, depth + 1);
}
}
```
The guard `typeof obj !== 'object'` returns early for string values. When `walk()` recurses into `__NEXT_DATA__.props.pageProps.streamUrl`, the string value is rejected by the guard before the regex match can run. The `typeof obj === 'string'` branch is dead code.
**Impact**: Config globals (Next.js, React SSR, etc.) that embed .m3u8 URLs deeply nested in JSON objects are NOT detected. Shallow player globals (hls.url, etc.) still work via Strategy A's direct key check.
**Fix**: Move the string check before the object guard:
```js
function walk(obj, path, depth) {
if (typeof obj === 'string') {
const match = obj.match(/https?:\/\/.../gi);
// ...
return;
}
if (!obj || typeof obj !== 'object' || depth > 5) return;
// ...
}
```
**Mitigation**: The interceptors (Strategy E) are the primary detection method and work correctly. This only affects pages that rely solely on config globals without active HLS players.
---
## Enhancement Recommendations
### 1. MediaSource MIME type validation for blob: URLs
blob: URLs in `src` are currently treated as direct MediaSource playback. There is no verification that the content type is actually HLS-compatible (`application/x-mpegURL`). If a page sets `mediaType: 'hls'` with a `blob:` src, the viewer warns about missing manifest but falls through to native playback. Consider adding `MediaSource.isTypeSupported()` checks for blob URLs that claim to be HLS.
### 2. HLS.js error recovery in grid panels
When `onError` fires for one panel, `recoverMediaError()` is called. If recovery fails, the panel shows a persistent error overlay. There is no retry after recovery — the panel stays broken. Consider adding a configurable retry count (e.g., 3 attempts with backoff) before marking the panel as permanently failed.
---
## Existing Test Suite
All 39 pre-existing tests continue to pass:
- 10 Schema validation tests
- 4 resolveSrc() tests
- 5 isHls() tests
- 3 resolveHlsManifest() tests
- 3 DOM setup tests
- 5 Data flow tests
- 9 Integration tests
- 5 Config/build integrity tests
---
## Files Changed
- `__tests__/hls_qa.test.mjs` — NEW: 44 HLS-specific QA tests (83 total tests in suite)
- `__tests__/run.mjs` — ADDED: import for hls_qa.test.mjs
## Build/Deploy Status
- 83/83 tests passing in 12s
- No linting errors in test files
- No changes to production source files
+3 -2
View File
@@ -6,7 +6,8 @@
"permissions": [ "permissions": [
"storage", "storage",
"tabs", "tabs",
"scripting" "scripting",
"webRequest"
], ],
"host_permissions": [ "host_permissions": [
"<all_urls>" "<all_urls>"
@@ -33,7 +34,7 @@
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["interceptors.js", "hls.min.js"], "resources": ["interceptors.js", "hlds.js"],
"matches": ["<all_urls>"] "matches": ["<all_urls>"]
} }
], ],
-9
View File
@@ -7,9 +7,6 @@
"": { "": {
"name": "goon-plugin", "name": "goon-plugin",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"hls.js": "^1.5.17"
},
"devDependencies": { "devDependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
@@ -2784,12 +2781,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hls.js": {
"version": "1.6.17",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.17.tgz",
"integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==",
"license": "Apache-2.0"
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+1 -1
View File
@@ -6,7 +6,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build && node scripts/copy-hlds.mjs",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "node __tests__/run.mjs", "test": "node __tests__/run.mjs",
+20
View File
@@ -0,0 +1,20 @@
// Copy the original ES5 hls.min.js source directly to dist/hlds.js.
// This avoids Vite/esbuild adding arrow functions (which Chrome flags as "ES module content"
// in extension URLs, preventing window.Hls from registering).
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const src = resolve(__dirname, '../src/inject/hls.min.js');
const dst = resolve(__dirname, '../dist/hlds.js');
let data = readFileSync(src, 'utf-8');
// Remove sourceMappingURL line (avoids Chrome trying to fetch it from an invalid chrome-extension:// URL)
data = data.replace(/[\r\n]+\/\/# sourceMappingURL=[^\s]*[\r\n]*/, '');
writeFileSync(dst, data);
console.log(`[copy-hlds] Copied hls.min.js -> dist/hlds.js (${data.length} bytes, pure ES5 source)`);
+219 -7
View File
@@ -4,22 +4,234 @@
* Uses Manifest V3 module-type service worker. * Uses Manifest V3 module-type service worker.
*/ */
// Fired when the extension is first installed / updated / Chrome starts. // === HLS manifest tracking via chrome.webRequest ===
chrome.runtime.onInstalled.addListener((details) => { // Intercepts network requests at the browser level — no world/timing issues.
if (details.reason === 'install') { // Catches .m3u8 manifest requests from ANY player (HLS.js, Video.js, custom).
console.log('[goon-plugin] Extension installed'); //
} else if (details.reason === 'update') { // IMPORTANT: MV3 service workers are ephemeral — they are killed when idle and
console.log(`[goon-plugin] Extension updated from ${details.previousVersion}`); // 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://ev-h.phncdn.com/hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1-v1-a1.ts?h=tmrD...&e=1786932752&f=1
// Manifest: https://ev-h.phncdn.com/hls/videos/202008/01/338337051/master.m3u8?h=tmrD...&e=1786932752&f=1
//
// Auth tokens (h, e, f or validfrom, validto, hash) are extracted from segment query params
// and reapplied to the reconstructed manifest URL. Without auth, CDN returns 403.
function reconstructManifestsFromSegments(segments) {
const seen = new Set();
const manifests = [];
for (const url of segments) {
// Only handle HLS segment URLs with the full path: /hls/videos/{year}/{subdir}/{vid}...
if (!url.includes('/hls/videos/') || !/\/seg-\d+/.test(url)) continue;
try {
const parsed = new URL(url);
const host = parsed.host.toLowerCase();
// Skip non-phncdn hosts — we only reconstruct for known video CDNs
if (!host.includes('phncdn')) continue;
const pathParts = parsed.pathname.split('/');
// Find the /hls/ marker and extract: hls/videos/{year}/{subdir}/{videoId}/{variant}/...
const hlsIdx = pathParts.findIndex(p => p === 'hls');
if (hlsIdx === -1 || hlsIdx + 5 >= pathParts.length) continue;
// Reconstruct with BOTH "hls" prefix AND variant directory included:
// segment: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1.ts
// manifest: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/master.m3u8
const year = pathParts[hlsIdx + 2]; // e.g. 2020
const subDir = pathParts[hlsIdx + 3]; // e.g. 01 (sub-category)
const videoId = pathParts[hlsIdx + 4]; // e.g. 338337051
const variant = pathParts[hlsIdx + 5]; // e.g. 720P_4000K_338337051.mp4
// Extract auth params from segment query string — different CDN variants use different keys:
// ev-h/validfrom/validto/hash vs hv-h/h/e/f
const authParts = [];
for (const [key, val] of parsed.searchParams) {
if (['h', 'e', 'f', 'validfrom', 'validto', 'hash'].includes(key)) {
authParts.push(`${key}=${val}`);
}
}
const authStr = authParts.length > 0 ? '?' + authParts.join('&') : '';
// Build manifest: include hls prefix AND variant directory
const manifestUrl = `${parsed.protocol}//${parsed.host}/${pathParts[hlsIdx]}/${pathParts[hlsIdx + 1]}/${year}/${subDir}/${videoId}/${variant}/master.m3u8${authStr}`;
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) => { chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
switch (message.type) { switch (message.type) {
case 'PING': case 'PING':
sendResponse({ pong: true, timestamp: Date.now() }); sendResponse({ pong: true, timestamp: Date.now() });
return; 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;
}
case 'GOON_PROXY_FETCH': {
// Proxy a fetch request through the service worker (bypasses CORS).
// Returns base64-encoded response data. Used by the viewer when
// extension-origin requests to video CDNs are blocked by CORS.
(async () => {
const url = message.url;
try {
const resp = await fetch(url);
if (!resp.ok) {
sendResponse({ ok: false, status: resp.status });
return;
}
const buffer = await resp.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Convert to base64 (btoa can only handle strings up to ~65k chars)
let binary = '';
const CHUNK_SIZE = 32768;
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
const chunk = bytes.subarray(i, i + CHUNK_SIZE);
binary += String.fromCharCode.apply(null, Array.from(chunk));
}
const base64 = btoa(binary);
sendResponse({ ok: true, data: base64, mimeType: resp.headers.get('content-type') || '' });
} catch(e) {
console.warn('[goon-bg] proxyFetch failed:', e && e.message ? e.message : e);
sendResponse({ ok: false, error: e && e.message ? e.message : String(e) });
}
})();
return true;
}
default: default:
console.warn('[goon-plugin] Unknown message type:', message.type); console.warn('[goon-plugin] Unknown message type:', message.type);
} }
+46 -4
View File
@@ -31,15 +31,24 @@
* extension-originated <script src="..."> as content scripts — not as * extension-originated <script src="..."> as content scripts — not as
* page inline code — so the page's CSP does not apply. * page inline code — so the page's CSP does not apply.
* *
* IMPORTANT: Remove the script element ONLY after the load event fires.
* Removing it synchronously can prevent the browser from starting the fetch,
* especially in Edge/Chrome with strict timing.
*
* Runs BEFORE any page JS (document_start), so we catch all HLS activity. * Runs BEFORE any page JS (document_start), so we catch all HLS activity.
*/ */
function injectInterceptors() { function injectInterceptors() {
const script = document.createElement('script'); const script = document.createElement('script');
script.src = chrome.runtime.getURL('interceptors.js'); script.src = chrome.runtime.getURL('interceptors.js');
script.setAttribute('type', 'text/javascript'); script.setAttribute('type', 'text/javascript');
script.onload = function () {
// Script loaded and executed — safe to remove the element now
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
(document.head || document.documentElement).appendChild(script); (document.head || document.documentElement).appendChild(script);
// Remove the script element after execution but keep interceptors active // Keep the element in the DOM — onload will remove it after execution
script.parentNode.removeChild(script);
} }
// Inject immediately at document_start // Inject immediately at document_start
@@ -59,6 +68,7 @@
timestamp: event.data.timestamp timestamp: event.data.timestamp
}); });
console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url); console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url);
console.log('[goon-plugin] Total intercepted manifests so far:', interceptedManifests.length);
} }
} }
}); });
@@ -96,14 +106,28 @@
*/ */
function getVideoData() { function getVideoData() {
const poster = extractPoster(); const poster = extractPoster();
console.log('[goon-content] getVideoData called. Poster:', poster);
console.log('[goon-content] Intercepted manifests count:', interceptedManifests.length);
if (interceptedManifests.length > 0) {
console.log('[goon-content] Manifest details:', JSON.stringify(interceptedManifests.map(m => ({ url: m.url, source: m.source }))));
}
// DOM video scan (isolated world — can access DOM but not page JS) // DOM video scan (isolated world — can access DOM but not page JS)
const videoElements = []; const videoElements = [];
try { try {
const videos = document.querySelectorAll('video'); const videos = document.querySelectorAll('video');
console.log('[goon-content] Found', videos.length, '<video> elements in DOM');
for (const video of videos) { for (const video of videos) {
const style = window.getComputedStyle(video); const style = window.getComputedStyle(video);
if (style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0) { const isVisible = style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0;
console.log('[goon-content] Video:', {
src: (video.currentSrc || video.src || '').substring(0, 120),
visible: isVisible,
dimensions: video.clientWidth + 'x' + video.clientHeight,
networkState: video.networkState,
readyState: video.readyState,
});
if (isVisible) {
// Extract buffered ranges as serializable arrays // Extract buffered ranges as serializable arrays
const bufferedRanges = []; const bufferedRanges = [];
if (video.buffered && video.buffered.length > 0) { if (video.buffered && video.buffered.length > 0) {
@@ -122,7 +146,25 @@
}); });
} }
} }
} catch { /* ignore DOM access errors */ } } catch(e) { /* ignore DOM access errors */
console.warn('[goon-content] DOM scan error:', e && e.message ? e.message : e);
}
console.log('[goon-content] Visible video elements:', videoElements.length);
// Expose interceptor data globally so the popup's deep probe can read it.
window.__goon_plugin_data = window.__goon_plugin_data || {
manifests: [],
sources: [],
_contentType: 'content-script',
};
// Merge our intercepted manifests into the shared data store (the interceptors
// write their own URLs here; we add content-script-level XHR intercepts).
for (const m of interceptedManifests) {
if (!window.__goon_plugin_data.manifests.includes(m.url)) {
window.__goon_plugin_data.manifests.push(m.url);
window.__goon_plugin_data.sources.push(m.source);
}
}
return { return {
posterUrl: poster, posterUrl: poster,
+621 -70
View File
@@ -8,11 +8,25 @@ const STORAGE_KEY = 'goon_videos';
const TAGGED_KEY = 'goon_tagged'; const TAGGED_KEY = 'goon_tagged';
// Set to true to enable verbose console output in the popup. // 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 log(...args) { if (DEBUG) console.log('[goon]', ...args); }
function warn(...args) { if (DEBUG) console.warn('[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 // UI references
const findBtn = document.getElementById('find-btn'); const findBtn = document.getElementById('find-btn');
const clearBtn = document.getElementById('clear-btn'); const clearBtn = document.getElementById('clear-btn');
@@ -104,6 +118,12 @@ function m3u8DeepProbeFn() {
const manifests = []; const manifests = [];
const seen = new Set(); 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 // Helpers available in the MAIN world context
function resolveUrl(url) { function resolveUrl(url) {
if (!url) return null; if (!url) return null;
@@ -145,33 +165,8 @@ function m3u8DeepProbeFn() {
} catch { /* avoid circular ref errors */ } } catch { /* avoid circular ref errors */ }
} }
// Strategy A: Known player globals (shallow + deep) // Standard <video> element attributes — skip these when scanning for custom
const playerGlobals = [ // HLS player properties. Define here so it's available for Strategy A2 onward.
'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).
const STANDARD_VIDEO_ATTRS = new Set([ const STANDARD_VIDEO_ATTRS = new Set([
'src', 'srcObject', 'crossOrigin', 'preload', 'autoplay', 'muted', 'src', 'srcObject', 'crossOrigin', 'preload', 'autoplay', 'muted',
'defaultMuted', 'loop', 'controls', 'controlsList', 'disableRemotePlayback', 'defaultMuted', 'loop', 'controls', 'controlsList', 'disableRemotePlayback',
@@ -183,63 +178,344 @@ function m3u8DeepProbeFn() {
'classList', 'style', 'attributes', 'dataset', 'innerHTML', 'textContent', 'classList', 'style', 'attributes', 'dataset', 'innerHTML', 'textContent',
'outerHTML', 'outerText', 'slot', 'accessKey', 'draggable', 'hidden', 'outerHTML', 'outerText', 'slot', 'accessKey', 'draggable', 'hidden',
'lang', 'spellcheck', 'tabIndex', 'title', 'translate', 'lang', 'spellcheck', 'tabIndex', 'title', 'translate',
'width', 'height', 'poster', 'preload', 'referrerPolicy', 'width', 'height', 'poster', 'referrerPolicy',
]); ]);
for (const v of document.querySelectorAll('video')) {
// 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)) { for (const key of Object.keys(v)) {
if (STANDARD_VIDEO_ATTRS.has(key)) continue; if (STANDARD_VIDEO_ATTRS.has(key)) continue;
const obj = v[key]; const obj = v[key];
if (!obj || typeof obj !== 'object') continue; if (!obj || typeof obj !== 'object') continue;
// Check for common HLS instance patterns on video elements // Check for common HLS instance patterns on video elements
if (typeof obj === 'object' && typeof obj.url === 'string' && isManifestUrl(obj.url)) { 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); 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 deep into player objects attached to video elements
walk(obj, `video.${key}`, 0); walk(obj, `video.${key}`, 0);
} }
} }
probeLog('Strategy B1 (video custom props): found', strategyBCount, 'new manifests');
// B2: Check interceptor-set manifest annotation on video elements // B2: Check interceptor-set manifest annotation on video elements
let strategyB2Count = 0;
for (const v of document.querySelectorAll('video')) { for (const v of document.querySelectorAll('video')) {
if (v._goon_hls_manifest && !seen.has(v._goon_hls_manifest)) { if (v._goon_hls_manifest && !seen.has(v._goon_hls_manifest)) {
seen.add(v._goon_hls_manifest); seen.add(v._goon_hls_manifest);
manifests.push({ url: v._goon_hls_manifest, player: 'video._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 // Strategy C: Inline scripts
let strategyCCount = 0;
for (const script of document.querySelectorAll('script:not([src])')) { for (const script of document.querySelectorAll('script:not([src])')) {
const text = script.textContent || ''; const text = script.textContent || '';
const matches = text.match(/https?:\/\/[^"'\s]+.(m3u8|hlsvod)[^"'\s]*/gi); const matches = text.match(/https?:\/\/[^"'\s]+.(m3u8|hlsvod)[^"'\s]*/gi);
if (matches) { if (matches) {
for (const u of 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 // Strategy D: Config globals
const configKeys = ['__dataLayer', '__INITIAL_STATE__', 'config', '__NEXT_DATA__', '__PRELOADED_STATE__', 'appData', 'pageData', '__data']; const configKeys = ['__dataLayer', '__INITIAL_STATE__', 'config', '__NEXT_DATA__', '__PRELOADED_STATE__', 'appData', 'pageData', '__data'];
let preDCount = manifests.length;
for (const name of configKeys) { for (const name of configKeys) {
const obj = window[name]; 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. // Strategy E: Interceptor-collected manifests — the most reliable source.
// The main-world interceptors (injected at document_start) capture .m3u8 URLs // The main-world interceptors (injected at document_start) capture .m3u8 URLs
// from XHR, fetch, and Hls.prototype.loadSource/attachMedia calls, and store // from XHR, fetch, and Hls.prototype.loadSource/attachMedia calls, and store
// them in window.__goon_plugin_data.manifests[]. This was the original // them in window.__goon_plugin_data.manifests[]. This was the original
// Strategy 3/6 on master that was accidentally dropped during refactoring. // 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)) { 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) { for (const url of window.__goon_plugin_data.manifests) {
if (url && typeof url === 'string' && !seen.has(url)) { if (url && typeof url === 'string' && !seen.has(url)) {
seen.add(url); seen.add(url);
manifests.push({ url, player: '__goon_plugin_data' }); 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');
// Strategy G: Non-enumerable + Symbol property detection on video elements.
// Object.keys() only returns enumerable string properties. Players like es6player
// hide instances via Object.defineProperty(enumerable:false) or Symbol keys.
// This runs in main-world via executeScript, so getOwnPropertyNames/Symbols work.
let strategyGCount = 0;
try {
for (const v of document.querySelectorAll('video')) {
// Non-enumerable string properties
const nonEnumProps = Object.getOwnPropertyNames(v);
for (const key of nonEnumProps) {
if (STANDARD_VIDEO_ATTRS.has(key)) continue;
try {
const obj = v[key];
if (!obj || typeof obj !== 'object') continue;
// Check if this object looks like an HLS instance (same heuristics as interceptors)
const isHlsLike = typeof obj.url === 'string' && obj.url.length > 5 ||
(obj.levels && Array.isArray(obj.levels)) ||
typeof obj.attachMedia === 'function' ||
typeof obj.loadSource === 'function' ||
typeof obj.getConfig === 'function' ||
typeof obj.detachMedia === 'function' ||
typeof obj.destroy === 'function' ||
(obj.networkControls && typeof obj.networkControls === 'object');
if (isHlsLike) {
probeLog('Strategy G: found HLS-like object on video via non-enumerable prop', key);
// Extract URL from non-enumerable property
if (typeof obj.url === 'string' && obj.url.length > 5) {
const resolved = resolveUrl(obj.url);
if (resolved && !seen.has(resolved)) {
seen.add(resolved);
manifests.push({ url: resolved, player: `video[G].${key}.url` });
strategyGCount++;
}
}
// Extract level URIs
if (obj.levels && Array.isArray(obj.levels)) {
for (let gi = 0; gi < obj.levels.length; gi++) {
const lvl = obj.levels[gi];
if (lvl && lvl.uri && !seen.has(lvl.uri)) {
const resolved = resolveUrl(lvl.uri) || lvl.uri;
seen.add(resolved);
manifests.push({ url: resolved, player: `video[G].${key}.levels[${gi}].uri` });
strategyGCount++;
}
}
}
} else {
// Also deep-walk nested objects for URL-like values (even without HLS signatures)
if (typeof obj === 'object' && obj !== null) {
walk(obj, `video[G].${key}`, 0);
}
}
} catch(e) { /* circular or inaccessible */ }
}
// Symbol properties
const symbolProps = Object.getOwnPropertySymbols(v);
for (const symKey of symbolProps) {
try {
const symObj = v[symKey];
if (!symObj || typeof symObj !== 'object') continue;
probeLog('Strategy G: video has Symbol prop', symKey.toString());
// Check HLS signatures on symbols too
if (typeof symObj.url === 'string' && symObj.url.length > 5) {
const resolved = resolveUrl(symObj.url);
if (resolved && !seen.has(resolved)) {
seen.add(resolved);
manifests.push({ url: resolved, player: `video[G].${symKey.toString()}.url` });
strategyGCount++;
}
}
if (symObj.levels && Array.isArray(symObj.levels)) {
for (let gi = 0; gi < symObj.levels.length; gi++) {
const lvl = symObj.levels[gi];
if (lvl && lvl.uri && !seen.has(lvl.uri)) {
const resolved = resolveUrl(lvl.uri) || lvl.uri;
seen.add(resolved);
manifests.push({ url: resolved, player: `video[G].${symKey.toString()}.levels[${gi}].uri` });
strategyGCount++;
}
}
}
// Deep-walk nested objects
walk(symObj, `video[G].${symKey.toString()}`, 0);
} catch(e) { /* inaccessible */ }
}
}
} catch(e) { /* ignore */ }
probeLog('Strategy G (non-enumerable/Symbol props): found', strategyGCount, 'new manifests');
// Strategy H: Full window global scan — enumerate ALL objects on window, not just
// known player names. Filter out built-ins and walk any object that looks like a
// player container (.url, .levels, .config with HLS keys, .media reference).
let strategyHCount = 0;
try {
const windowKeys = Object.keys(window);
for (const key of windowKeys) {
const val = window[key];
// Skip primitives
if (typeof val === 'function' || typeof val === 'string' ||
typeof val === 'number' || typeof val === 'boolean' ||
val === null || val === undefined) continue;
// Skip DOM elements directly on window
if (val && typeof val.nodeType === 'number') continue;
if (typeof val !== 'object') continue;
// Quick check: does this object have any player-like keys?
let hasPlayerKey = false;
try {
const subKeys = Object.keys(val).slice(0, 50); // limit scan depth for performance
hasPlayerKey = subKeys.some(k => {
const lk = k.toLowerCase();
return lk.includes('hls') || lk.includes('player') || lk.includes('video') ||
lk.includes('manifest') || lk.includes('source') || lk.includes('level') ||
lk.includes('playlist') || lk.includes('stream') || lk === 'url' ||
lk === 'config' || lk === 'media' || lk === 'levels' ||
lk === 'width' || lk === 'height' || lk === 'volume';
});
} catch { hasPlayerKey = false; }
if (!hasPlayerKey) continue;
probeLog('Strategy H: window global', key, 'has player-like keys');
// Deep-walk this object for HLS instances and manifest URLs
walk(val, `window[H].${key}`, 0);
if (typeof val.url === 'string' && isManifestUrl(val.url)) {
const resolved = resolveUrl(val.url);
if (resolved && !seen.has(resolved)) {
seen.add(resolved);
manifests.push({ url: resolved, player: `window[H].${key}.url` });
strategyHCount++;
}
}
}
} catch(e) { /* ignore */ }
probeLog('Strategy H (full window scan): found', strategyHCount, '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; return manifests;
} }
@@ -387,14 +663,21 @@ function filterValidTabs(tabs) {
*/ */
async function probeHlsManifests(tabId) { async function probeHlsManifests(tabId) {
try { try {
const results = await chrome.scripting.executeScript({ log('probeHlsManifests: executing deep probe on tab', tabId);
target: { tabId, allFrames: true, world: 'MAIN' }, const raw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
func: m3u8DeepProbeFn, func: m3u8DeepProbeFn,
}); });
// When allFrames is true, destroyed frames may return null entries. // When allFrames is true, destroyed / sandboxed frames may return null entries.
// Filter them out before accessing .result to avoid TypeError. // Guard: ensure raw is an array, then filter to objects with a defined .result.
return results.filter(r => r && typeof r === 'object').flatMap(r => r.result || []); const manifests = Array.isArray(raw)
} catch { ? 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 []; return [];
} }
} }
@@ -416,7 +699,9 @@ function dedupManifests(allManifests) {
const playerRank = { const playerRank = {
'__goon_plugin_data': 0, '__goon_plugin_data': 0,
'content-script-intercept': 0, 'content-script-intercept': 0,
'video._goon_hls_manifest': 1, 'webRequest': 1,
'phncdn_reconstructed': 2,
'video._goon_hls_manifest': 3,
'inline-script': 99, 'inline-script': 99,
}; };
unique.sort((a, b) => { unique.sort((a, b) => {
@@ -440,6 +725,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. * Scrape video elements from ALL frames in a tab.
* On sites like Pornhub, the video player lives inside an iframe — main-frame-only * On sites like Pornhub, the video player lives inside an iframe — main-frame-only
@@ -457,20 +780,21 @@ async function queryContentScript(tabId) {
*/ */
async function scrapeTab(tabId, manifests) { async function scrapeTab(tabId, manifests) {
try { try {
const results = await chrome.scripting.executeScript({ const raw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true, world: 'MAIN' }, target: { tabId, allFrames: true },
func: scrapeFn, func: scrapeFn,
args: [manifests], args: [manifests],
}); });
// When allFrames is true, destroyed frames may return null entries. // When allFrames is true, destroyed / sandboxed frames may return null entries.
// Filter them out before accessing .result to avoid TypeError. // Guard: ensure raw is an array, then filter to objects with a defined .result.
const valid = results const frameResults = Array.isArray(raw)
.filter(r => r && typeof r === 'object') ? raw.filter(r => r != null && typeof r === 'object' && 'result' in r)
.map(r => r.result) .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) { if (valid.length === 0) {
const firstValid = results.find(r => r && typeof r === 'object'); const firstWithResult = frameResults.find(r => r != null);
return firstValid?.result || null; return firstWithResult || null;
} }
valid.sort((a, b) => { valid.sort((a, b) => {
const aArea = a.videos[0]?.area || 0; const aArea = a.videos[0]?.area || 0;
@@ -478,8 +802,8 @@ async function scrapeTab(tabId, manifests) {
return bArea - aArea; return bArea - aArea;
}); });
return valid[0]; return valid[0];
} catch { } catch(e) {
warn('skipping tab', tabId); warn('scrapeTab: tab', tabId, 'error:', e && e.message ? e.message : e);
return null; return null;
} }
} }
@@ -505,29 +829,72 @@ function filterManifests(manifests) {
(u.includes('/playlist') && !u.endsWith('.ts')); (u.includes('/playlist') && !u.endsWith('.ts'));
}); });
// If we have actual manifests, use them directly // Reject fake reconstructed manifest paths.
if (actualManifests.length > 0) { // Real manifests are under /hls/videos/{year}/{subdir}/{videoId}/...
return actualManifests.map(m => ({ url: m.url })); // Fakes look like: phncdn.com/videos/{id}/playlist.m3u8 or .../master.m3u8 (no /hls/)
const valid = actualManifests.filter(m => {
const u = m.url.toLowerCase();
try {
const urlObj = new URL(u);
// phncdn URLs must have /hls/ in the path — fake reconstructed paths lack it
if (u.includes('phncdn') || u.includes('pornhubcdn')) {
if (!urlObj.pathname.includes('/hls/') && !u.includes('?h=')) {
return false; // Fake: phncdn URL without /hls/ or auth query param
}
}
} catch {}
return true;
});
// If we have actual (non-fake) manifests, use them directly
if (valid.length > 0) {
return valid.map(m => ({ url: m.url }));
} }
// No .m3u8 found — interceptors only captured .ts segments. // No .m3u8 found — interceptors only captured .ts segments.
// Reconstruct the manifest URL from segment URLs. // Reconstruct the parent manifest URL from segment paths.
// Segment URLs look like: /hls/videos/{video-id}/seg-{n}-v{ver}-a{aud}.ts // Segment: https://ev-h.phncdn.com/hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1-v1-a1.ts?h=tmrD...&e=1786932752&f=1
// Manifest URL would be: /hls/videos/{video-id}/playlist.m3u8 // Manifest: https://ev-h.phncdn.com/hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/master.m3u8?h=tmrD...&e=1786932752&f=1
//
// Auth tokens (h, e, f or validfrom, validto, hash) are extracted from segment query params
// and reapplied to the reconstructed manifest URL. This is critical because CDN domains
// require auth — a path-only URL will be rejected with 403.
const seen = new Set(); const seen = new Set();
const reconstructed = []; const reconstructed = [];
for (const m of manifests) { for (const m of manifests) {
const u = m.url.toLowerCase(); const u = m.url.toLowerCase();
if (!u.includes('/hls/') || !u.endsWith('.ts')) continue; if (!u.includes('/hls/videos/') || !/\/seg-\d+/.test(u)) continue;
try { try {
const url = new URL(m.url); const urlObj = new URL(m.url);
const pathParts = url.pathname.split('/'); // Skip non-phncdn hosts — we only reconstruct for known video CDNs
// Find the /hls/videos/ segment and the video-id directory const host = urlObj.host.toLowerCase();
if (!host.includes('phncdn')) continue;
const pathParts = urlObj.pathname.split('/');
// Find the /hls/ marker and extract: hls/videos/{year}/{subdir}/{videoId}/{variant}/...
const hlsIdx = pathParts.findIndex(p => p === 'hls'); const hlsIdx = pathParts.findIndex(p => p === 'hls');
if (hlsIdx === -1 || hlsIdx + 2 >= pathParts.length) continue; if (hlsIdx === -1 || hlsIdx + 5 >= pathParts.length) continue;
// Build manifest URL: origin + /hls/videos/{video-id}/playlist.m3u8 // Reconstruct with BOTH "hls" prefix AND variant directory included:
const manifestPath = `${pathParts[hlsIdx]}/${pathParts[hlsIdx + 1]}/${pathParts[hlsIdx + 2]}/playlist.m3u8`; // segment: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1.ts
const manifestUrl = `${url.protocol}//${url.host}${manifestPath}`; // manifest: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/master.m3u8
const year = pathParts[hlsIdx + 2]; // e.g. 2020
const subDir = pathParts[hlsIdx + 3]; // e.g. 01
const videoId = pathParts[hlsIdx + 4]; // e.g. 338337051
const variant = pathParts[hlsIdx + 5]; // e.g. 720P_4000K_338337051.mp4
// Extract auth params from segment query string — different CDN variants use different keys:
// ev-h/validfrom/validto/hash vs hv-h/h/e/f
const authParts = [];
for (const [key, val] of urlObj.searchParams) {
if (['h', 'e', 'f', 'validfrom', 'validto', 'hash'].includes(key)) {
authParts.push(`${key}=${val}`);
}
}
const authStr = authParts.length > 0 ? '?' + authParts.join('&') : '';
// Build manifest URL: include hls prefix AND variant directory
const manifestUrl = `${urlObj.protocol}//${urlObj.host}/${pathParts[hlsIdx]}/${pathParts[hlsIdx + 1]}/${year}/${subDir}/${videoId}/${variant}/master.m3u8${authStr}`;
if (!seen.has(manifestUrl)) { if (!seen.has(manifestUrl)) {
seen.add(manifestUrl); seen.add(manifestUrl);
reconstructed.push({ url: manifestUrl }); reconstructed.push({ url: manifestUrl });
@@ -537,8 +904,30 @@ function filterManifests(manifests) {
return reconstructed; return reconstructed;
} }
/**
* Validate that a manifest URL uses a real phncdn.com path structure.
* Rejects fake paths like /videos/{id}/playlist.m3u8 — real manifests use
* /hls/videos/{year}/{subdir}/{videoId}/master.m3u8 or similar /hls/ paths.
*/
function isValidManifestUrl(url) {
if (!url || typeof url !== 'string') return false;
// Real phncdn HLS manifest: must contain /hls/videos/ with sufficient path depth
const hlsIdx = url.indexOf('/hls/videos/');
if (hlsIdx === -1) {
// Not phncdn — could be a different CDN, allow it
if (!url.includes('phncdn') && !url.includes('pornhubcdn')) return true;
// phncdn without /hls/ prefix is invalid for manifests
return false;
}
// Check there are at least {year}/{subdir}/{videoId} after /hls/videos/
const after = url.substring(hlsIdx + '/hls/videos/'.length);
if (after.indexOf('/') === -1) return false; // need at least one more segment
return true;
}
/** /**
* Build a normalized video entry from a scraped video object. * Build a normalized video entry from a scraped video object.
* Validates manifest URLs to prevent storing fake paths like /videos/{id}/playlist.m3u8.
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video * @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video
* @param {string} tabTitle * @param {string} tabTitle
* @param {string|null} posterFromContent - poster URL from content script (OG image, etc.) * @param {string|null} posterFromContent - poster URL from content script (OG image, etc.)
@@ -551,7 +940,19 @@ function makeVideoEntry(video, tabTitle, posterFromContent) {
tab: tabTitle, tab: tabTitle,
mediaType: video.mediaType || 'video', mediaType: video.mediaType || 'video',
}; };
if (video.hlsManifest) entry.hlsManifest = video.hlsManifest; if (video.hlsManifest) {
// Reject fake phncdn manifest paths before storing
if (!isValidManifestUrl(video.hlsManifest)) {
console.warn('[goon] Rejecting fake manifest URL:', video.hlsManifest.substring(0, 120));
} else {
entry.hlsManifest = video.hlsManifest;
}
}
// Also validate src when it looks like a manifest URL (no hlsManifest set but src has .m3u8)
if (!entry.hlsManifest && entry.src?.includes('.m3u8') && !isValidManifestUrl(entry.src)) {
console.warn('[goon] Rejecting fake manifest URL from src:', entry.src.substring(0, 120));
// Don't treat it as HLS — leave mediaType as-is (likely 'video' or unset)
}
return entry; return entry;
} }
@@ -561,18 +962,70 @@ function makeVideoEntry(video, tabTitle, posterFromContent) {
* @returns {Promise<{ items: Array, scanned: number, skipped: number }>} * @returns {Promise<{ items: Array, scanned: number, skipped: number }>}
*/ */
async function scanVideos(tabsToScan) { 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 // Step 1: Probe for HLS manifests in all frames
const manifestPromises = tabsToScan.map(t => probeHlsManifests(t.id)); const manifestPromises = tabsToScan.map(t => probeHlsManifests(t.id));
const allManifestResults = await Promise.all(manifestPromises); const allManifestResults = await Promise.all(manifestPromises);
// Build per-tab manifest map (deduped) // Build per-tab manifest map (deduped)
const tabManifestMap = new Map(); const tabManifestMap = new Map();
let totalManifests = 0;
for (let i = 0; i < tabsToScan.length; i++) { for (let i = 0; i < tabsToScan.length; i++) {
const unique = dedupManifests(allManifestResults[i]); const unique = dedupManifests(allManifestResults[i]);
if (unique.length > 0) { const tabUrl = tabsToScan[i].url ? tabsToScan[i].url.substring(0, 80) : '(unknown)';
tabManifestMap.set(tabsToScan[i].id, unique); 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: /hls/videos/{id}/master.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) // Step 2: Scrape video elements (all frames) AND query content scripts (in parallel)
const scrapePromises = tabsToScan.map(t => const scrapePromises = tabsToScan.map(t =>
@@ -584,6 +1037,24 @@ async function scanVideos(tabsToScan) {
Promise.all(contentPromises), 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 // Step 3: Merge content-script data into tab results
// - Enrich video entries with poster URLs from the content script // - Enrich video entries with poster URLs from the content script
// - Merge content-script intercepted manifests into the deep probe results // - Merge content-script intercepted manifests into the deep probe results
@@ -622,6 +1093,7 @@ async function scanVideos(tabsToScan) {
const poster = contentData?.posterUrl || null; const poster = contentData?.posterUrl || null;
const filteredManifests = filterManifests(tabManifests); const filteredManifests = filterManifests(tabManifests);
if (filteredManifests.length > 0) { 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 => ({ const fallbackVideos = filteredManifests.map(m => ({
src: m.url, src: m.url,
poster: poster, poster: poster,
@@ -633,6 +1105,7 @@ async function scanVideos(tabsToScan) {
result: { videos: fallbackVideos }, result: { videos: fallbackVideos },
}; };
} }
log('scanVideos: tab', tab.id, 'has no videos and no manifests — skipping');
return null; return null;
} }
@@ -640,7 +1113,7 @@ async function scanVideos(tabsToScan) {
tabTitle: tab.title || 'Unknown', tabTitle: tab.title || 'Unknown',
result, 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 // Step 4: Dedup across tabs
const seen = new Map(); const seen = new Map();
@@ -657,6 +1130,11 @@ async function scanVideos(tabsToScan) {
const items = [...seen.values()]; const items = [...seen.values()];
const skipped = scrapeResults.filter(r => r === null).length; 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 }; return { items, scanned: tabResults.length, skipped };
} }
@@ -700,6 +1178,79 @@ findBtn.addEventListener('click', async () => {
const msg = `Found ${items.length} video(s) across ${scanned} tab(s).`; const msg = `Found ${items.length} video(s) across ${scanned} tab(s).`;
setStatus(skipped > 0 ? `${msg} ${skipped} tab(s) skipped (idle).` : msg); setStatus(skipped > 0 ? `${msg} ${skipped} tab(s) skipped (idle).` : msg);
btnRow.style.display = 'flex'; 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) { } catch (e) {
setStatus(`Scan failed: ${e.message}`); setStatus(`Scan failed: ${e.message}`);
warn('executeScript failed:', e); warn('executeScript failed:', e);
+202 -12
View File
@@ -52,6 +52,134 @@ const gridN = parseInt(params.get('grid'), 10) || 2;
const tags = params.get('tags') ? JSON.parse(params.get('tags')) : []; const tags = params.get('tags') ? JSON.parse(params.get('tags')) : [];
const panelsEl = document.getElementById('panels'); const panelsEl = document.getElementById('panels');
/** Track all Hls instances so we can destroy them on unload. */
/**
* Custom HLS loader that proxies phncdn.com URLs through the service worker
* (bypassing CORS) and fetches everything else natively.
* Implements hls.js Loader interface: load(config), abort().
*/
function HlsLoader(config) {
this._config = config;
this._xhr = null;
}
HlsLoader.prototype.abort = function () {
if (this._xhr) { this._xhr.abort(); this._xhr = null; }
};
HlsLoader.prototype.load = function (config) {
this.abort();
this._loaderContext = config.type + ': ' + (config.url || '').substring(0, 100);
this._onSuccess = config.onSuccess;
this._onError = config.onError;
this._onTimeout = config.onTimeout;
this._timeout = config.timeout || 10000;
const url = config.url;
const needProxy = url.includes('phncdn') || url.includes('pornhubcdn');
if (needProxy) {
// Use service worker proxy to bypass CORS
console.log('[goon-viewer] Proxying via SW:', url.substring(0, 120));
chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url }, (resp) => {
if (chrome.runtime.lastError || !resp || !resp.ok) {
const err = new Error('Proxy fetch failed: ' + (chrome.runtime.lastError?.message || resp?.error || 'unknown'));
console.error('[goon-viewer] Proxy error:', err.message);
this._onError(config, { url, context: this._loaderContext, body: null, stats: { trequest: Date.now(), tfirst: 0, tload: Date.now(), loadingPeriod: 0 }, error: err });
return;
}
// Decode base64 → ArrayBuffer
let binary = '';
const data = resp.data || '';
const CHUNK = 32768;
for (let i = 0; i < data.length; i += CHUNK) {
binary += String.fromCharCode.apply(null, Array.from(data.subarray?.(i, i + CHUNK) || new Uint8Array().subarray(0, Math.min(CHUNK, data.length - i))));
}
// Simpler approach: use atob on the full string
try {
binary = atob(data);
} catch {}
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
this._onSuccess(config, { response: { headers: {}, body: bytes.buffer }, trequest: Date.now(), tfirst: Date.now(), tload: Date.now(), loadingPeriod: Date.now() - config.trequest });
});
} else {
// Native XHR for non-proxy URLs
const xhr = new XMLHttpRequest();
this._xhr = xhr;
xhr.open('GET', url, true);
xhr.withCredentials = false;
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
this._onSuccess(config, { response: { headers: xhr.getAllResponseHeaders(), body: xhr.response }, trequest: config.trequest, tfirst: Date.now(), tload: Date.now(), loadingPeriod: Date.now() - config.trequest });
} else {
this._onError(config, { url, context: this._loaderContext, body: null, stats: { trequest: config.trequest, tfirst: 0, tload: Date.now(), loadingPeriod: 0 }, error: new Error('HTTP ' + xhr.status) });
}
};
xhr.onerror = () => {
this._onError(config, { url, context: this._loaderContext, body: null, stats: { trequest: config.trequest, tfirst: 0, tload: Date.now(), loadingPeriod: 0 }, error: new Error('XHR network error') });
};
xhr.ontimeout = () => {
this._onTimeout(config, { url, context: this._loaderContext, stats: { trequest: config.trequest, tfirst: 0, tload: Date.now(), loadingPeriod: 0 } });
};
xhr.timeout = this._timeout;
xhr.responseType = needProxy ? 'arraybuffer' : 'arraybuffer';
xhr.send();
}
};
HlsLoader.prototype.destroy = function () { this.abort(); };
HlsLoader.canSupportLoader = function () { return true; };
// === CORS diagnostics — check if phncdn.com allows extension-origin requests ===
(async function checkCors() {
try {
const resp = await fetch('https://phncdn.com/images/favicon.ico', { method: 'HEAD', mode: 'cors' });
console.log('[goon-viewer] CORS check phncdn.com:', resp.ok ? 'ALLOWED (status ' + resp.status + ')' : 'BLOCKED');
} catch(e) {
console.warn('[goon-viewer] CORS check phncdn.com: BLOCKED —', e && e.message ? e.message : e);
}
})();
// === HLS segment proxy helper ===
// Proxies a video URL through the service worker (no CORS restrictions).
// Returns an ArrayBuffer of the response content.
async function proxyFetch(url) {
try {
const resp = await chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url });
if (resp && resp.data && resp.ok) {
// Decode base64 → ArrayBuffer
const binary = atob(resp.data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
return null;
} catch(e) {
console.warn('[goon-viewer] proxyFetch failed for:', url.substring(0, 120), e && e.message ? e.message : e);
return null;
}
}
// === Check if any manifest URLs require proxying (phncdn.com / cdn) ===
function urlsNeedProxy(urls) {
return urls.some(u => u && typeof u === 'string' && (u.includes('phncdn') || u.includes('pornhubcdn')));
}
/** Check whether fetching a URL from the extension page works (CORS test). */
async function corsCheck(url) {
try {
const resp = await fetch(url, { method: 'HEAD', mode: 'cors' });
return resp.ok;
} catch {
return false;
}
}
/** Track all Hls instances so we can destroy them on unload. */ /** Track all Hls instances so we can destroy them on unload. */
const hlsInstances = new Map(); const hlsInstances = new Map();
@@ -682,9 +810,26 @@ function playHls(video, manifestUrl, panel) {
return; return;
} }
// Check whether this manifest URL needs proxying (phncdn.com without CORS headers
// blocks extension-origin requests from the viewer page).
const needProxy = manifestUrl.includes('phncdn') || manifestUrl.includes('pornhubcdn');
if (needProxy) {
console.log('[goon-viewer] Manifest needs proxying via service worker');
}
destroyHls(video); destroyHls(video);
const hls = new window.Hls(HLS_CONFIG); const config = Object.assign({}, HLS_CONFIG);
if (needProxy) {
// Override hls.js's default XHRLoader with our custom proxy loader.
// Must NOT use DefaultConfig.loader || HlsLoader — the XHRLoader is always truthy,
// so that would never apply HlsLoader. Set it directly on config (hls.js uses
// the instance config, not DefaultConfig, when present).
config.loader = HlsLoader;
}
const hls = new window.Hls(config);
hlsInstances.set(video, hls); hlsInstances.set(video, hls);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => { hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
@@ -709,26 +854,41 @@ function playHls(video, manifestUrl, panel) {
origDestroy(); origDestroy();
}; };
// Log the manifest URL being loaded for debugging (helps diagnose playback failures).
console.log(`[goon-viewer] Loading HLS manifest:`, manifestUrl.substring(0, 200));
hls.loadSource(manifestUrl); hls.loadSource(manifestUrl);
hls.attachMedia(video); hls.attachMedia(video);
} }
/** /**
* Load hls.js from the extension bundle. Returns a Promise that resolves when ready. * Load hlds.js (renamed from hls.js) from the extension bundle. Returns a Promise that resolves when ready.
* We load from chrome.runtime.getURL() (extension origin) instead of a CDN to * Strategy: <script src="chrome-extension://.../hlds.js"> — esbuild minification is disabled so the
* avoid CSP blocks — sites like Pornhub restrict script-src to 'self', which * original ES5 UMD bundle preserves function expressions (no arrow functions), avoiding Chrome's
* would block any cross-origin <script src> injection in the viewer window. * content-based ESM auto-detection that prevents window.Hls from registering globally.
*/ */
function loadHlsJs() { function loadHlsJs() {
if (window.Hls && typeof window.Hls.isSupported === 'function') {
return Promise.resolve();
}
const hldsSrc = chrome.runtime.getURL('hlds.js');
console.log('[goon-viewer] Trying script src:', hldsSrc);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (window.Hls && window.Hls.isSupported) {
resolve();
return;
}
const script = document.createElement('script'); const script = document.createElement('script');
script.src = chrome.runtime.getURL('hls.min.js'); script.src = hldsSrc;
script.onload = () => resolve(); script.onload = () => {
script.onerror = () => reject(new Error('Hls.js bundle unavailable - rebuild the extension.')); if (window.Hls && typeof window.Hls.isSupported === 'function') {
console.log('[goon-viewer] hlds.js via script src OK, Hls.isSupported:', window.Hls.isSupported());
resolve();
} else {
reject(new Error('hlds.js loaded but did not register global Hls'));
}
};
script.onerror = () => {
reject(new Error('hlds.js failed to load: resource not found or CSP blocked'));
};
document.head.appendChild(script); document.head.appendChild(script);
}); });
} }
@@ -807,6 +967,36 @@ async function init() {
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;background:rgba(0,0,0,0.85);color:#fff;padding:8px 16px;font-family:sans-serif;font-size:14px;z-index:999;'; overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;background:rgba(0,0,0,0.85);color:#fff;padding:8px 16px;font-family:sans-serif;font-size:14px;z-index:999;';
document.body.style.position = 'relative'; document.body.style.position = 'relative';
document.body.insertBefore(overlay, document.body.firstChild); document.body.insertBefore(overlay, document.body.firstChild);
// === DIAGNOSTIC: Check storage + CORS ===
console.log('[goon-viewer] Viewer ID:', id, 'Grid:', gridN + 'x' + gridN);
console.log('[goon-viewer] Raw stored data:', JSON.stringify(videoUrls));
if (videoUrls.length > 0) {
for (const entry of videoUrls) {
const src = (entry.src || '').substring(0, 120);
console.log('[goon-viewer] Entry:', {
src: src,
mediaType: entry.mediaType,
hlsManifest: (entry.hlsManifest || '').substring(0, 120),
poster: (entry.poster || '').substring(0, 80),
});
}
// Test CORS on all video URLs
const urlsToTest = videoUrls.map(v => v.src || v.hlsManifest || 'none').filter(u => u !== 'none' && typeof u === 'string');
for (const url of [...new Set(urlsToTest)]) {
const corsOk = await corsCheck(url);
console.log('[goon-viewer] CORS', corsOk ? 'OK' : 'BLOCKED:', url.substring(0, 120));
}
// Check if any URLs point to phncdn.com (proxy will be needed if CORS blocked)
const needsProxy = urlsToTest.some(u => u.includes('phncdn') || u.includes('pornhubcdn'));
console.log('[goon-viewer] Proxy required:', needsProxy);
} else {
console.warn('[goon-viewer] No video entries in storage for ID:', id, '— data may have been cleared or never saved');
}
overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`; overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`;
statusElRef = overlay; statusElRef = overlay;
+9 -6
View File
@@ -8,8 +8,8 @@
*/ */
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { resolve } from 'path'; import { resolve } from 'path';
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
import sharp from 'sharp'; import sharp from 'sharp';
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
export default defineConfig({ export default defineConfig({
root: 'src', root: 'src',
@@ -51,10 +51,8 @@ export default defineConfig({
build: { build: {
target: 'chrome110', target: 'chrome110',
outDir: '../dist', outDir: '../dist',
emptyOutDir: true, emptyOutdir: true,
modulePreload: { minify: false,
polyfill: false,
},
rollupOptions: { rollupOptions: {
input: { input: {
background: resolve(__dirname, 'src/background/background.js'), background: resolve(__dirname, 'src/background/background.js'),
@@ -62,7 +60,9 @@ export default defineConfig({
viewer: resolve(__dirname, 'src/viewer/viewer.html'), viewer: resolve(__dirname, 'src/viewer/viewer.html'),
viewerIndex: resolve(__dirname, 'src/viewer/index.html'), viewerIndex: resolve(__dirname, 'src/viewer/index.html'),
interceptors: resolve(__dirname, 'src/inject/interceptors.js'), interceptors: resolve(__dirname, 'src/inject/interceptors.js'),
hls: resolve(__dirname, 'src/inject/hls.min.js'), // NOTE: hlds is NOT built by Vite — copied from source as pure ES5 to avoid
// esbuild injecting arrow functions (Chrome flags these as "ES module content"
// in extension URLs, preventing window.Hls registration).
}, },
output: { output: {
entryFileNames: '[name].js', entryFileNames: '[name].js',
@@ -70,5 +70,8 @@ export default defineConfig({
assetFileNames: '[name][extname]', assetFileNames: '[name][extname]',
}, },
}, },
modulePreload: {
polyfill: false,
},
}, },
}); });