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:
1 parent
7139c5ea6f
commit
0030d031f1
3 files changed
+225
-63
No files matched your search
+93
-63
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user