fix(scan): recover simple <video> detection site-agnostically

The no-mention-of-pornhub branch stopped finding plain <video> elements on
pages like thisvid.com. Two site-agnostic causes, both fixed without any
site-specific references:

1. Predicate over-rejection: isValidManifestUrl (and the nested isManifestUrl
   in m3u8DeepProbeFn) ran the placeholder/`%`/trailing-slash rejections
   BEFORE the concrete media signals. thisvid serves its direct MP4 WITH a
   trailing slash (.../14644093.mp4/?rnd=...), so `path.endsWith('/')`
   rejected a real, fetchable source. Concrete media signals (.mp4/.webm/.ogv/
   .mpd/.m4s/.m3u8/.hlsvod//hls/) now win outright, so a coincidental '%' or
   trailing slash never sinks a real source — while genuine route templates
   (/playlists/, /playlist/%1%/, /videos/{id}/) are still rejected.

2. Runtime cause on ad-iframe-heavy pages: scrapeTab ran only a MAIN-world
   scrape. A MAIN-world + allFrames injection can reject on pages with
   sandboxed/opaque-origin ad iframes, returning 0 videos even when a <video>
   is visible. scrapeTab now falls back to an isolated-world scrape (no `world`
   key — always permitted) when the MAIN-world run finds nothing. The per-frame
   reduction was extracted into a shared reduceScrapeFrames helper.

Adds __tests__/simple_video.test.mjs: a thisvid isValidManifestUrl battery
(trailing-slash MP4 accepted, templates still rejected, %2B/%2F tokens
preserved) plus a structural guard that scrapeTab keeps both a MAIN-world
primary and an isolated-world fallback.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
kfj001andClaude committed 2026-08-28 01:17:35 -07:00
1 parent 7139c5ea6f
commit 0030d031f1
3 files changed
+225 -63

No files matched your search

+93 -63
View File
@@ -139,21 +139,23 @@ function m3u8DeepProbeFn() {
}
function isManifestUrl(url) {
if (!url) return false;
const u = url.toLowerCase();
// Dynamic route TEMPLATES (e.g. /playlist/%1%/ or /videos/{id}/) are nav
// links, not fetchable manifests. A placeholder is a `%` that is NOT the
// start of a valid %-encoded hex pair (so real tokens like %2B, %2F are
// preserved) or a {name} brace token — so genuine %-encoded auth tokens are
// never mistaken for template placeholders.
if (/%(?!([0-9A-Fa-f]{2}))/i.test(u) || /\{[a-z0-9_]+\}/i.test(u)) return false;
// .m3u8 / .hlsvod / /hls/ are concrete HLS signals.
if (u.includes('.m3u8') || u.includes('.hlsvod') || u.includes('/hls/')) return true;
if (!url) return false;
const u = url.toLowerCase();
// Concrete HLS/manifest signals win over the template heuristics below: a
// real .m3u8/.hlsvod//hls/ path is fetchable even if it happens to carry a
// trailing '%' query token or a trailing slash — so check these first.
if (u.includes('.m3u8') || u.includes('.hlsvod') || u.includes('/hls/')) return true;
// Dynamic route TEMPLATES (e.g. /playlist/%1%/ or /videos/{id}/) are nav
// links, not fetchable manifests. A placeholder is a `%` that is NOT the
// start of a valid %-encoded hex pair (so real tokens like %2B, %2F are
// preserved) or a {name} brace token — so genuine %-encoded auth tokens are
// never mistaken for template placeholders.
if (/%(?!([0-9A-Fa-f]{2}))/i.test(u) || /\{[a-z0-9_]+\}/i.test(u)) return false;
// Plural collection paths are nav menus, not manifests.
if (u.includes('/playlists') || u.includes('/manifests')) return false;
if (u.includes('/playlists') || u.includes('/manifests')) return false;
// A singular manifest-ish path segment with no concrete file may still be a
// real (rare, extensionless) endpoint, so allow it when it carries http.
return (u.includes('/manifest') || u.includes('/playlist')) && u.includes('http');
return (u.includes('/manifest') || u.includes('/playlist')) && u.includes('http');
}
function walk(obj, path, depth) {
@@ -792,37 +794,64 @@ async function queryVideoRequests(tabId) {
* @param {Array} manifests - HLS manifests found by probing
* @returns {Promise<Object|null>} scrape result or null on error
*/
async function scrapeTab(tabId, manifests) {
try {
const raw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
world: 'MAIN',
func: scrapeFn,
args: [manifests],
});
/**
* Reduce a per-frame executeScript result array to the single largest-video
* result (or a "no videos" result / null). Shared by the MAIN- and
* isolated-world scrape attempts in scrapeTab.
*/
function reduceScrapeFrames(raw) {
// When allFrames is true, destroyed / sandboxed frames may return null entries.
// Guard: ensure raw is an array, then filter to objects with a defined .result.
const frameResults = Array.isArray(raw)
? raw.filter(r => r != null && typeof r === 'object' && 'result' in r)
.map(r => r.result)
: [];
const valid = frameResults.filter(r => r && r.videos && r.videos.length > 0);
if (valid.length === 0) {
const firstWithResult = frameResults.find(r => r != null);
return firstWithResult || null;
}
valid.sort((a, b) => {
const aArea = a.videos[0]?.area || 0;
const bArea = b.videos[0]?.area || 0;
return bArea - aArea;
});
return valid[0];
} catch(e) {
warn('scrapeTab: tab', tabId, 'error:', e && e.message ? e.message : e);
return null;
}
const frameResults = Array.isArray(raw)
? raw.filter(r => r != null && typeof r === 'object' && 'result' in r)
.map(r => r.result)
: [];
const valid = frameResults.filter(r => r && r.videos && r.videos.length > 0);
if (valid.length === 0) {
const firstWithResult = frameResults.find(r => r != null);
return firstWithResult || null;
}
valid.sort((a, b) => {
const aArea = a.videos[0]?.area || 0;
const bArea = b.videos[0]?.area || 0;
return bArea - aArea;
});
return valid[0];
}
async function scrapeTab(tabId, manifests) {
// Primary: MAIN world — scrapeFn can read page globals the main frame
// exposes (interceptor annotations, Hls instances, custom <video> props).
try {
const mainRaw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
world: 'MAIN',
func: scrapeFn,
args: [manifests],
});
let result = reduceScrapeFrames(mainRaw);
// Isolated-world fallback: some pages embed sandboxed / opaque-origin ad
// iframes that make the MAIN-world injection reject, so the primary run can
// return no videos even when a <video> is visible. scrapeFn only needs the
// shared DOM plus the passed-in manifest list, so an isolated-world run (no
// `world` key — always permitted) still sees the same <video> elements.
if (!result || !(result.videos && result.videos.length > 0)) {
const isoRaw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
func: scrapeFn,
args: [manifests],
});
const iso = reduceScrapeFrames(isoRaw);
if (iso && iso.videos && iso.videos.length > 0) result = iso;
else if (!result) result = iso;
}
return result;
} catch(e) {
warn('scrapeTab: tab', tabId, 'error:', e && e.message ? e.message : e);
return null;
}
}
/**
* Filter intercepted URLs to prefer actual HLS manifests over segment files.
* Interceptors capture both .m3u8 manifests and .ts segment URLs (any URL
@@ -887,31 +916,32 @@ function filterManifests(manifests) {
* path separator), which filters out obviously-broken single-segment paths.
*/
function isValidManifestUrl(url) {
if (!url || typeof url !== 'string') return false;
// Dynamic route templates / nav links are not fetchable manifests. A
// placeholder is a `%` not followed by two hex digits (real %2B etc. are
// kept) or a {name} brace token.
if (/%(?!([0-9A-Fa-f]{2}))/i.test(url) || /\{[a-z0-9_]+\}/i.test(url)) return false;
let urlObj;
try {
urlObj = new URL(url);
} catch {
return false;
}
const path = urlObj.pathname.toLowerCase();
// Plural collection paths and trailing-slash directory listings are nav menus,
// not manifest files (e.g. /playlists/, /playlists/liked/, /playlist/%id%/).
if (path.includes('/playlists') || path.includes('/manifests') || path.endsWith('/')) {
return false;
if (!url || typeof url !== 'string') return false;
let urlObj;
try {
urlObj = new URL(url);
} catch {
return false;
}
const isManifestLike = path.includes('.m3u8') || path.includes('.hlsvod') ||
(path.includes('/master') && path.includes('.m3u')) ||
(path.includes('/playlist') && !path.endsWith('.ts')) ||
path.includes('/manifest');
// Not a manifest path — allow it (it may be a direct media source, not HLS).
if (!isManifestLike) return true;
// A manifest path must sit in a directory, not be a bare top-level file.
return path.includes('/');
const path = urlObj.pathname.toLowerCase();
// 1) Concrete media/manifest signals win outright: a real file extension or
// /hls/ path is fetchable, even with a trailing slash (thisvid serves
// .../14644093.mp4/) or a token-bearing query — so check these before the
// template heuristics and never let a coincidental '%'/'/' sink a source.
if (path.includes('.m3u8') || path.includes('.hlsvod') || path.includes('/hls/') ||
path.includes('/manifest') || path.includes('.mp4') || path.includes('.webm') ||
path.includes('.ogv') || path.includes('.mpd') || path.includes('.m4s')) return true;
// 2) Dynamic route templates / nav links: a `%` that isn't a valid %-encoded
// hex pair (real %2B etc. are kept) or a {name} brace token.
if (/%(?!([0-9A-Fa-f]{2}))/i.test(url) || /\{[a-z0-9_]+\}/i.test(url)) return false;
// 3) Plural collection paths and trailing-slash directory listings are nav
// menus, not manifest files (e.g. /playlists/, /playlists/liked/).
if (path.includes('/playlists') || path.includes('/manifests') || path.endsWith('/')) return false;
// 4) A singular /playlist endpoint may be a real (extensionless) manifest;
// allow it when it sits in a directory.
if (path.includes('/playlist')) return path.includes('/');
// 5) Otherwise treat as a direct media source — allow it.
return true;
}
/**