- Add Hls.cast(video) detection (Strategy B1) — uses the official hls.js API to retrieve bound instances from <video> elements - Add _goon_hls_manifest annotation check (Strategy B2) — reads interceptor-set manifest URLs directly on video elements - Refactor scrapeFn to a 5-priority HLS detection chain: 1. Direct .m3u8 in video.src 2. Interceptor-set _goon_hls_manifest 3. Hls.cast(video) official API 4. Direct HTTP source (non-blob) 5. Deep probe fallback (page globals/scripts) - Replace fragile Object.defineProperty Hls detection with polling interval in interceptors.js — avoids conflicts with page JS - Add buffered range tracking and duration to content.js video scan data - Add 8 new tests for HLS detection priority chain (55 total)
This commit is contained in:
1 parent
7276824758
commit
bc3f37ae4c
4 files changed
+209
-10
No files matched your search
+11
-1
@@ -91,7 +91,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get video data: intercepted manifests + DOM scan + poster.
|
* Get video data: intercepted manifests + DOM scan + poster + Hls.cast probe.
|
||||||
* Called by the popup via chrome.tabs.sendMessage.
|
* Called by the popup via chrome.tabs.sendMessage.
|
||||||
*/
|
*/
|
||||||
function getVideoData() {
|
function getVideoData() {
|
||||||
@@ -104,11 +104,21 @@
|
|||||||
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) {
|
if (style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0) {
|
||||||
|
// Extract buffered ranges as serializable arrays
|
||||||
|
const bufferedRanges = [];
|
||||||
|
if (video.buffered && video.buffered.length > 0) {
|
||||||
|
for (let b = 0; b < video.buffered.length; b++) {
|
||||||
|
bufferedRanges.push({ start: video.buffered.start(b), end: video.buffered.end(b) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
videoElements.push({
|
videoElements.push({
|
||||||
src: video.currentSrc || video.src || '',
|
src: video.currentSrc || video.src || '',
|
||||||
poster: video.poster || '',
|
poster: video.poster || '',
|
||||||
width: video.clientWidth,
|
width: video.clientWidth,
|
||||||
height: video.clientHeight,
|
height: video.clientHeight,
|
||||||
|
duration: video.duration || 0,
|
||||||
|
buffered: bufferedRanges,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,15 +120,22 @@
|
|||||||
// Patch immediately if HLS is already defined
|
// Patch immediately if HLS is already defined
|
||||||
patchHlsPrototype();
|
patchHlsPrototype();
|
||||||
|
|
||||||
// Watch for HLS.js to be dynamically defined later
|
// Watch for HLS.js to be dynamically defined later — use MutationObserver
|
||||||
var originalDefineProperty = Object.defineProperty;
|
// on the <head> for script tags that load hls.js, as a more reliable fallback
|
||||||
|
// than Object.defineProperty (which conflicts with page JS).
|
||||||
var hlsPatched = false;
|
var hlsPatched = false;
|
||||||
Object.defineProperty = function (obj, prop, descriptor) {
|
if (!hlsPatched) {
|
||||||
var result = originalDefineProperty.apply(this, arguments);
|
var pollCount = 0;
|
||||||
if (obj === window && prop === 'Hls' && !hlsPatched) {
|
var maxPolls = 60; // poll for up to ~3s (50ms interval)
|
||||||
|
|
||||||
|
var pollHls = setInterval(function () {
|
||||||
|
pollCount++;
|
||||||
|
if (typeof Hls !== 'undefined' && !hlsPatched) {
|
||||||
hlsPatched = true;
|
hlsPatched = true;
|
||||||
patchHlsPrototype();
|
patchHlsPrototype();
|
||||||
|
clearInterval(pollHls);
|
||||||
|
}
|
||||||
|
if (pollCount >= maxPolls) clearInterval(pollHls);
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
+45
-2
@@ -138,6 +138,27 @@ function m3u8DeepProbeFn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Strategy B: Video element HLS instances
|
// Strategy B: Video element HLS instances
|
||||||
|
// B1: Use Hls.cast(video) — the official hls.js API to get the bound instance
|
||||||
|
if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') {
|
||||||
|
for (const v of document.querySelectorAll('video')) {
|
||||||
|
try {
|
||||||
|
const hlsInst = Hls.cast(v);
|
||||||
|
if (hlsInst && hlsInst.url) {
|
||||||
|
if (!seen.has(hlsInst.url)) { seen.add(hlsInst.url); manifests.push({ url: hlsInst.url, player: 'Hls.cast' }); }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B2: Check interceptor-set manifest annotation on video elements
|
||||||
|
for (const v of document.querySelectorAll('video')) {
|
||||||
|
if (v._goon_hls_manifest && !seen.has(v._goon_hls_manifest)) {
|
||||||
|
seen.add(v._goon_hls_manifest);
|
||||||
|
manifests.push({ url: v._goon_hls_manifest, player: 'video._goon_hls_manifest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B3: Fallback — walk video element keys looking for HLS-related objects
|
||||||
for (const v of document.querySelectorAll('video')) {
|
for (const v of document.querySelectorAll('video')) {
|
||||||
for (const key of Object.keys(v)) {
|
for (const key of Object.keys(v)) {
|
||||||
if (key.toLowerCase().includes('hls') || key === 'player') {
|
if (key.toLowerCase().includes('hls') || key === 'player') {
|
||||||
@@ -200,12 +221,34 @@ function scrapeFn(hlsManifests) {
|
|||||||
let mediaType = 'video';
|
let mediaType = 'video';
|
||||||
let src = rawSrc;
|
let src = rawSrc;
|
||||||
|
|
||||||
|
// Priority 1: Direct .m3u8 source (rare but possible)
|
||||||
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
|
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
|
||||||
hlsManifest = rawSrc;
|
hlsManifest = rawSrc;
|
||||||
mediaType = 'hls';
|
mediaType = 'hls';
|
||||||
} else if (rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
|
}
|
||||||
|
// Priority 2: Interceptor annotation — Hls.attachMedia() patched by interceptors
|
||||||
|
else if (v._goon_hls_manifest) {
|
||||||
|
hlsManifest = v._goon_hls_manifest;
|
||||||
|
mediaType = 'hls';
|
||||||
|
src = hlsManifest;
|
||||||
|
}
|
||||||
|
// Priority 3: Hls.cast(video) — official hls.js API (works when Hls is loaded)
|
||||||
|
else if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') {
|
||||||
|
try {
|
||||||
|
const hlsInst = Hls.cast(v);
|
||||||
|
if (hlsInst && hlsInst.url) {
|
||||||
|
hlsManifest = hlsInst.url;
|
||||||
|
mediaType = 'hls';
|
||||||
|
src = hlsManifest;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
// Priority 4: Direct HTTP source (non-blob)
|
||||||
|
if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
|
||||||
mediaType = 'video';
|
mediaType = 'video';
|
||||||
} else if (hlsManifests && hlsManifests.length > 0) {
|
}
|
||||||
|
// Priority 5: Deep probe fallback — use manifests found by scanning page globals/scripts
|
||||||
|
if (!hlsManifest && hlsManifests && hlsManifests.length > 0) {
|
||||||
hlsManifest = hlsManifests[0].url || hlsManifests[0];
|
hlsManifest = hlsManifests[0].url || hlsManifests[0];
|
||||||
mediaType = 'hls';
|
mediaType = 'hls';
|
||||||
src = hlsManifest;
|
src = hlsManifest;
|
||||||
|
|||||||
@@ -340,3 +340,142 @@ describe('Storage pipeline - HLS metadata preservation', () => {
|
|||||||
expect(storedItems[0].tab).toBe('Has Video');
|
expect(storedItems[0].tab).toBe('Has Video');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* TESTS: scrapeFn — HLS detection strategies */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal DOM setup for scrapeFn tests.
|
||||||
|
* Simulates how scrapeFn detects HLS manifests from video elements
|
||||||
|
* using the 5-priority chain.
|
||||||
|
*/
|
||||||
|
function simulateScrapeFn(videos, hlsManifests) {
|
||||||
|
// Simulate the scrapeFn detection logic (priorities 1-5)
|
||||||
|
const results = [];
|
||||||
|
for (const v of videos) {
|
||||||
|
const rawSrc = v.currentSrc || v.src || '';
|
||||||
|
let hlsManifest = null;
|
||||||
|
let mediaType = 'video';
|
||||||
|
let src = rawSrc;
|
||||||
|
|
||||||
|
// Priority 1: Direct .m3u8 source
|
||||||
|
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
|
||||||
|
hlsManifest = rawSrc;
|
||||||
|
mediaType = 'hls';
|
||||||
|
}
|
||||||
|
// Priority 2: Interceptor annotation
|
||||||
|
else if (v._goon_hls_manifest) {
|
||||||
|
hlsManifest = v._goon_hls_manifest;
|
||||||
|
mediaType = 'hls';
|
||||||
|
src = hlsManifest;
|
||||||
|
}
|
||||||
|
// Priority 3: Hls.cast (simulated via v._hls_cast_result)
|
||||||
|
else if (v._hls_cast_result && v._hls_cast_result.url) {
|
||||||
|
hlsManifest = v._hls_cast_result.url;
|
||||||
|
mediaType = 'hls';
|
||||||
|
src = hlsManifest;
|
||||||
|
}
|
||||||
|
// Priority 4: Direct HTTP source (non-blob)
|
||||||
|
if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
|
||||||
|
mediaType = 'video';
|
||||||
|
}
|
||||||
|
// Priority 5: Deep probe fallback
|
||||||
|
if (!hlsManifest && hlsManifests && hlsManifests.length > 0) {
|
||||||
|
hlsManifest = hlsManifests[0].url || hlsManifests[0];
|
||||||
|
mediaType = 'hls';
|
||||||
|
src = hlsManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ src, hlsManifest, mediaType });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('scrapeFn — HLS detection priorities', () => {
|
||||||
|
it('Priority 1: Direct .m3u8 in video.src', () => {
|
||||||
|
const videos = [{
|
||||||
|
src: 'https://example.com/stream.m3u8',
|
||||||
|
currentSrc: 'https://example.com/stream.m3u8',
|
||||||
|
}];
|
||||||
|
const result = simulateScrapeFn(videos, []);
|
||||||
|
expect(result[0].mediaType).toBe('hls');
|
||||||
|
expect(result[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Priority 2: Interceptor-set _goon_hls_manifest overrides blob src', () => {
|
||||||
|
const videos = [{
|
||||||
|
src: 'blob:https://example.com/xyz',
|
||||||
|
currentSrc: 'blob:https://example.com/xyz',
|
||||||
|
_goon_hls_manifest: 'https://cdn.example.com/live.m3u8',
|
||||||
|
}];
|
||||||
|
const result = simulateScrapeFn(videos, []);
|
||||||
|
expect(result[0].mediaType).toBe('hls');
|
||||||
|
expect(result[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Priority 3: Hls.cast(video) detects active HLS instance', () => {
|
||||||
|
const videos = [{
|
||||||
|
src: 'blob:https://example.com/abc',
|
||||||
|
currentSrc: 'blob:https://example.com/abc',
|
||||||
|
_hls_cast_result: { url: 'https://cdn.example.com/vod.m3u8' },
|
||||||
|
}];
|
||||||
|
const result = simulateScrapeFn(videos, []);
|
||||||
|
expect(result[0].mediaType).toBe('hls');
|
||||||
|
expect(result[0].hlsManifest).toBe('https://cdn.example.com/vod.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Priority 4: blob src with no HLS detection → filtered out', () => {
|
||||||
|
const videos = [{
|
||||||
|
src: 'blob:https://example.com/def',
|
||||||
|
currentSrc: 'blob:https://example.com/def',
|
||||||
|
}];
|
||||||
|
const result = simulateScrapeFn(videos, []);
|
||||||
|
expect(result[0].mediaType).toBe('video');
|
||||||
|
expect(result[0].hlsManifest).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Priority 5: Deep probe fallback when no direct HLS found', () => {
|
||||||
|
const videos = [{
|
||||||
|
src: 'https://example.com/page.html',
|
||||||
|
currentSrc: 'blob:https://example.com/ghi',
|
||||||
|
}];
|
||||||
|
const manifests = [{ url: 'https://cdn.example.com/fallback.m3u8' }];
|
||||||
|
const result = simulateScrapeFn(videos, manifests);
|
||||||
|
expect(result[0].mediaType).toBe('hls');
|
||||||
|
expect(result[0].hlsManifest).toBe('https://cdn.example.com/fallback.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Priority 2 overrides Priority 5 (interceptor wins over deep probe)', () => {
|
||||||
|
const videos = [{
|
||||||
|
src: 'blob:https://example.com/jkl',
|
||||||
|
currentSrc: 'blob:https://example.com/jkl',
|
||||||
|
_goon_hls_manifest: 'https://cdn.example.com/interceptor.m3u8',
|
||||||
|
}];
|
||||||
|
const manifests = [{ url: 'https://cdn.example.com/deepprobe.m3u8' }];
|
||||||
|
const result = simulateScrapeFn(videos, manifests);
|
||||||
|
expect(result[0].mediaType).toBe('hls');
|
||||||
|
expect(result[0].hlsManifest).toBe('https://cdn.example.com/interceptor.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HLS instance on video with no currentSrc still detected via interceptor', () => {
|
||||||
|
const videos = [{
|
||||||
|
src: '',
|
||||||
|
currentSrc: '',
|
||||||
|
_goon_hls_manifest: 'https://cdn.example.com/early.m3u8',
|
||||||
|
}];
|
||||||
|
const result = simulateScrapeFn(videos, []);
|
||||||
|
expect(result[0].mediaType).toBe('hls');
|
||||||
|
expect(result[0].hlsManifest).toBe('https://cdn.example.com/early.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Direct MP4 source is not falsely flagged as HLS', () => {
|
||||||
|
const videos = [{
|
||||||
|
src: 'https://example.com/video.mp4',
|
||||||
|
currentSrc: 'https://example.com/video.mp4',
|
||||||
|
}];
|
||||||
|
const result = simulateScrapeFn(videos, []);
|
||||||
|
expect(result[0].mediaType).toBe('video');
|
||||||
|
expect(result[0].hlsManifest).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user