Compare commits
5
Commits
a1f4d2241b
...
001cf146ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001cf146ee | ||
|
|
b234c8dcae | ||
|
|
fa0a0d76cd | ||
|
|
509948def1 | ||
|
|
877feec731 |
No files matched your search
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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>"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
-9
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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)`);
|
||||||
@@ -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
@@ -31,16 +31,25 @@
|
|||||||
* 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');
|
||||||
(document.head || document.documentElement).appendChild(script);
|
script.onload = function () {
|
||||||
// Remove the script element after execution but keep interceptors active
|
// Script loaded and executed — safe to remove the element now
|
||||||
|
if (script.parentNode) {
|
||||||
script.parentNode.removeChild(script);
|
script.parentNode.removeChild(script);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
(document.head || document.documentElement).appendChild(script);
|
||||||
|
// Keep the element in the DOM — onload will remove it after execution
|
||||||
|
}
|
||||||
|
|
||||||
// Inject immediately at document_start
|
// Inject immediately at document_start
|
||||||
injectInterceptors();
|
injectInterceptors();
|
||||||
@@ -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,
|
||||||
|
|||||||
+618
-67
@@ -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',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 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')) {
|
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)';
|
||||||
|
log('scanVideos step1: tab', tabsToScan[i].id, '(' + tabUrl + ') has', unique.length, 'unique manifests');
|
||||||
|
totalManifests += unique.length;
|
||||||
tabManifestMap.set(tabsToScan[i].id, unique);
|
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
@@ -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() {
|
||||||
return new Promise((resolve, reject) => {
|
if (window.Hls && typeof window.Hls.isSupported === 'function') {
|
||||||
if (window.Hls && window.Hls.isSupported) {
|
return Promise.resolve();
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hldsSrc = chrome.runtime.getURL('hlds.js');
|
||||||
|
console.log('[goon-viewer] Trying script src:', hldsSrc);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
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
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user