Greasy Fork is available in English.
探测 M3U8/MP4 视频,支持多线程并发下载,手机端自动降级内存模式,圆形悬浮球UI。
当前为
// ==UserScript== // @name Universal Video Sniffer V14 (Multi-thread) // @name:zh-CN 通用视频嗅探器 V14 (多线程下载) // @namespace http://tampermonkey.net/ // @version 14.0 // @description Sniff video (m3u8/mp4), multi-thread download, smart fallback, round UI. // @description:zh-CN 探测 M3U8/MP4 视频,支持多线程并发下载,手机端自动降级内存模式,圆形悬浮球UI。 // @author jw23 // @license MIT // @match *://*/* // @connect * // @grant GM_addStyle // @grant unsafeWindow // @grant GM_xmlhttpRequest // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/mux.min.js // @run-at document-start // ==/UserScript== (function() { 'use strict'; // ========================================== // 1. Configuration & Constants // ========================================== const CONFIG = { scanInterval: 2000, // 历史记录扫描间隔 uiId: 'gm-sniffer-v14-root', // UI DOM ID isTop: window.self === window.top, // 移动端正则判断 isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent), maxThreads: 4, // M3U8 并发下载线程数 maxRetries: 3, // 片段下载失败重试次数 retryDelay: 1000 // 重试延迟(ms) }; // ========================================== // 2. Utilities // ========================================== const Utils = { // 封装 GM_xhr 为 Promise gmFetch: (url, opt = {}) => { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: opt.type || 'text', headers: { 'Referer': location.href, 'Origin': location.origin, ...opt.headers }, timeout: 30000, // 30s timeout onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error(`HTTP ${r.status}`)), onerror: reject, ontimeout: () => reject(new Error('Timeout')) }); }); }, // 延迟函数 sleep: (ms) => new Promise(r => setTimeout(r, ms)), // 触发 Blob 下载 saveBlob: (blob, filename) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 1000); }, // 获取文件名 getFilename: (url) => { const clean = url.split('?')[0]; let name = clean.split('/').pop(); if (!name || name === '/') name = `video_${Date.now()}.mp4`; return decodeURIComponent(name); } }; // ========================================== // 3. Event Bus (Pub/Sub) // ========================================== class EventBus { constructor() { this.listeners = {}; } on(event, callback) { if (!this.listeners[event]) this.listeners[event] = []; this.listeners[event].push(callback); } emit(event, data) { if (this.listeners[event]) this.listeners[event].forEach(cb => cb(data)); } } const Bus = new EventBus(); // ========================================== // 4. Detector Module // ========================================== const Detector = { rules: [ { type: 'm3u8', check: (u, c) => u.includes('.m3u8') || c.includes('mpegurl') }, { type: 'mp4', check: (u, c) => u.includes('.mp4') || c.includes('video/mp4') }, { type: 'mov', check: (u, c) => u.includes('.mov') || c.includes('video/quicktime') } ], cache: new Set(), identify(url, contentType = '') { if (!url || url.startsWith('blob:') || url.startsWith('data:') || url.match(/\.(png|jpg|gif|css|js|svg|woff)($|\?)/i)) return; const cleanUrl = url.split('?')[0]; if (this.cache.has(cleanUrl)) return; const u = url.toLowerCase(); const c = contentType ? contentType.toLowerCase() : ''; for (const rule of this.rules) { if (rule.check(u, c)) { this.cache.add(cleanUrl); console.log(`[Sniffer] Detected ${rule.type}: ${url}`); Bus.emit('video-found', { url, type: rule.type }); break; } } } }; // ========================================== // 5. Interceptor Module // ========================================== class Interceptor { constructor() { this.hookFetch(); this.hookXHR(); this.scanHistory(); } hookFetch() { const origin = unsafeWindow.fetch; unsafeWindow.fetch = async (...args) => { const url = args[0] instanceof Request ? args[0].url : args[0]; const res = await origin.apply(unsafeWindow, args); res.clone().headers.forEach((v, k) => k.toLowerCase() === 'content-type' && Detector.identify(url, v)); if (!res.headers.get('content-type')) Detector.identify(url); return res; }; } hookXHR() { const OriginXHR = unsafeWindow.XMLHttpRequest; unsafeWindow.XMLHttpRequest = class extends OriginXHR { open(m, u, ...a) { this._u = u; super.open(m, u, ...a); } send(...a) { this.addEventListener('readystatechange', () => { if (this.readyState === 4) Detector.identify(this.responseURL || this._u, this.getResponseHeader('Content-Type')); }); super.send(...a); } }; } scanHistory() { setInterval(() => { if (!window.performance) return; window.performance.getEntriesByType('resource').forEach(e => Detector.identify(e.name)); }, CONFIG.scanInterval); } } // ========================================== // 6. Writer Module (Storage Abstraction) // ========================================== class Writer { constructor() { this.mode = 'memory'; this.chunks = []; this.writable = null; } async init(filename) { // 移动端强制内存模式 if (CONFIG.isMobile) { this.mode = 'memory'; this.chunks = []; return; } // PC 端尝试流式 if (window.showSaveFilePicker) { try { const handle = await window.showSaveFilePicker({ suggestedName: filename }); this.writable = await handle.createWritable(); this.mode = 'stream'; return; } catch (e) { if (e.name === 'AbortError') throw new Error('User Cancelled'); console.warn('[Writer] Stream API failed, fallback to memory.'); } } // 默认回退 this.mode = 'memory'; this.chunks = []; } async write(data) { if (this.mode === 'stream') await this.writable.write(data); else this.chunks.push(data); } async close(filename) { if (this.mode === 'stream') { await this.writable.close(); } else { const blob = new Blob(this.chunks, { type: 'video/mp4' }); Utils.saveBlob(blob, filename); this.chunks = []; } } } // ========================================== // 7. Downloader Module (Multi-thread Core) // ========================================== const DownloadStrategies = { // --- 并发下载 M3U8 策略 --- async m3u8(url, updateProgress, writer) { // 1. 解析 m3u8 const content = await Utils.gmFetch(url); const baseUrl = url.substring(0, url.lastIndexOf('/') + 1); const lines = content.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); const segments = lines.map(l => l.startsWith('http') ? l : (l.startsWith('/') ? new URL(url).origin + l : baseUrl + l)); if (!segments.length) throw new Error('No segments found'); // 2. 准备转码器与缓冲区 const transmuxer = new muxjs.mp4.Transmuxer(); transmuxer.on('data', segment => { const data = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength); data.set(segment.initSegment, 0); data.set(segment.data, segment.initSegment.byteLength); writer.write(data); }); // 3. 多线程控制器状态 let nextIndex = 0; // 下一个需要下载的任务索引 let completedCount = 0; // 已完成数量 let nextWriteIndex = 0; // 下一个需要写入的索引(保证顺序) const bufferMap = new Map(); // 缓存乱序下载的片段: index -> Uint8Array // 4. 定义工作线程 const worker = async (threadId) => { while (nextIndex < segments.length) { // 领取任务 const currentIndex = nextIndex++; const segUrl = segments[currentIndex]; let retries = CONFIG.maxRetries; let success = false; // 重试循环 while (retries >= 0 && !success) { try { const buf = await Utils.gmFetch(segUrl, { type: 'arraybuffer' }); bufferMap.set(currentIndex, new Uint8Array(buf)); success = true; } catch (e) { retries--; if (retries >= 0) await Utils.sleep(CONFIG.retryDelay); else console.error(`[Thread ${threadId}] Failed seg ${currentIndex}`); } } // 尝试顺序写入 (Write Loop) // 只有当 buffer 中存在 nextWriteIndex 对应的数据时才写入 // 这样即使下载是乱序的,写入永远是顺序的 while (bufferMap.has(nextWriteIndex)) { const chunk = bufferMap.get(nextWriteIndex); bufferMap.delete(nextWriteIndex); // 释放内存 // 喂给转码器 (同步操作) try { transmuxer.push(chunk); transmuxer.flush(); } catch(e) { console.warn('Mux error', e); } nextWriteIndex++; } // 更新进度 completedCount++; updateProgress((completedCount / segments.length) * 100); } }; // 5. 启动并发线程 const threads = []; const threadCount = Math.min(CONFIG.maxThreads, segments.length); for (let i = 0; i < threadCount; i++) { threads.push(worker(i)); } // 6. 等待所有线程结束 await Promise.all(threads); // 确保最后没有残留(理论上 Write Loop 会处理完,除非下载彻底失败) if (nextWriteIndex < segments.length) { console.warn(`Download incomplete. Expected ${segments.length}, wrote ${nextWriteIndex}`); } }, // --- 直接下载策略 (MP4/MOV) --- async direct(url, updateProgress, writer) { updateProgress(0); const buffer = await Utils.gmFetch(url, { type: 'arraybuffer' }); writer.write(new Uint8Array(buffer)); updateProgress(100); } }; class TaskRunner { async start(url, type, btn) { const originalText = btn.innerText; const filename = Utils.getFilename(url); const writer = new Writer(); try { await writer.init(filename); btn.innerText = '0%'; const strategy = (type === 'mp4' || type === 'mov') ? DownloadStrategies.direct : DownloadStrategies.m3u8; await strategy(url, pct => btn.innerText = Math.floor(pct)+'%', writer); await writer.close(filename); btn.innerText = '完成'; } catch (e) { if (e.message === 'User Cancelled') { btn.innerText = '已取消'; } else { console.error(e); btn.innerText = '错误'; alert('Download Error: ' + e.message); } } finally { setTimeout(() => btn.innerText = originalText, 3000); } } } const Runner = new TaskRunner(); // ========================================== // 8. UI Module (Round Floating + Fixed Layout) // ========================================== class Overlay { constructor() { this.root = null; this.list = null; Bus.on('video-found', data => this.addItem(data)); } init() { if (document.getElementById(CONFIG.uiId)) return; const host = document.createElement('div'); host.id = CONFIG.uiId; host.style.cssText = 'position:fixed; top:15%; right:2%; z-index:2147483647;'; const shadow = host.attachShadow({mode: 'open'}); const color = CONFIG.isTop ? '#4caf50' : '#e91e63'; shadow.innerHTML = ` <style> :host { font-family: sans-serif; } .box { width: 250px; background: rgba(0,0,0,0.9); color: #fff; border-radius: 8px; border: 1px solid ${color}; backdrop-filter: blur(5px); display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.5); transition: width 0.3s, height 0.3s, border-radius 0.3s; } .header { padding: 10px 12px; background: rgba(255,255,255,0.08); display: flex; justify-content: space-between; align-items: center; cursor: move; user-select: none; border-bottom: 1px solid rgba(255,255,255,0.1); } .title { font-size: 13px; font-weight: 700; color: ${color}; letter-spacing: 0.5px; } .list { max-height: 250px; overflow-y: auto; padding: 0; } /* Item Styling */ .item { padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.08); display: flex; flex-direction: column; gap: 6px; } .info-row { display: flex; align-items: center; gap: 8px; width: 100%; } .tag { background: ${color}; color: #000; font-size: 10px; font-weight: bold; padding: 2px 5px; border-radius: 3px; text-transform: uppercase; flex-shrink: 0; } .filename { font-size: 12px; color: #eee; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; cursor: pointer; } .filename:hover { color: #fff; text-decoration: underline; } .btns { display: flex; gap: 8px; margin-top: 2px; } button { flex: 1; border: none; padding: 6px 0; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: 600; color: #fff; transition: opacity 0.2s; } button:hover { opacity: 0.9; } .btn-cp { background: #555; } .btn-dl { background: ${color}; color: #000; } /* Minimized Round Style */ .box.minimized { width: 48px; height: 48px; border-radius: 50%; border-width: 2px; align-items: center; justify-content: center; cursor: move; } .box.minimized .list, .box.minimized .title { display: none; } .box.minimized .header { background: transparent; padding: 0; border: none; width: 100%; height: 100%; justify-content: center; } .toggle { font-size: 18px; cursor: pointer; color: #fff; width: 100%; text-align: center; line-height: 1; } ::-webkit-scrollbar { width: 4px; } ::-webkit-scrollbar-thumb { background: #666; border-radius: 2px; } </style> <div class="box"> <div class="header"><span class="title">Sniffer V14</span><span class="toggle">-</span></div> <div class="list"></div> </div> `; this.root = shadow.querySelector('.box'); this.list = shadow.querySelector('.list'); const toggle = shadow.querySelector('.toggle'); const header = shadow.querySelector('.header'); const toggleFn = (e) => { e.stopPropagation(); this.root.classList.toggle('minimized'); toggle.innerText = this.root.classList.contains('minimized') ? '🎬' : '-'; }; toggle.onclick = toggleFn; // Dragging Logic let isDrag = false, startX, startY, initR, initT, hasMoved = false; header.onmousedown = e => { if(this.root.classList.contains('minimized') && e.target !== toggle) {} // allow drag on round body else if(e.target === toggle) return; isDrag = true; hasMoved = false; startX = e.clientX; startY = e.clientY; const r = host.getBoundingClientRect(); initR = window.innerWidth - r.right; initT = r.top; }; document.onmousemove = e => { if (!isDrag) return; if (Math.abs(e.clientX - startX) > 5 || Math.abs(e.clientY - startY) > 5) hasMoved = true; host.style.right = (initR + (startX - e.clientX)) + 'px'; host.style.top = (initT + (e.clientY - startY)) + 'px'; }; document.onmouseup = (e) => { if(isDrag && !hasMoved && this.root.classList.contains('minimized')) toggleFn(e); isDrag = false; }; // Touch Support header.ontouchstart = e => { if(e.target === toggle) return; isDrag = true; hasMoved = false; startX = e.touches[0].clientX; startY = e.touches[0].clientY; const r = host.getBoundingClientRect(); initR = window.innerWidth - r.right; initT = r.top; }; document.ontouchmove = e => { if (!isDrag) return; e.preventDefault(); hasMoved = true; host.style.right = (initR + (startX - e.touches[0].clientX)) + 'px'; host.style.top = (initT + (e.touches[0].clientY - startY)) + 'px'; }; document.ontouchend = (e) => { if(isDrag && !hasMoved && this.root.classList.contains('minimized')) toggleFn(e); isDrag = false; }; (document.body || document.documentElement).appendChild(host); } addItem({ url, type }) { this.init(); if (this.root.classList.contains('minimized') && this.list.children.length === 0) { this.root.querySelector('.toggle').click(); } const filename = Utils.getFilename(url); const div = document.createElement('div'); div.className = 'item'; div.innerHTML = ` <div class="info-row"> <span class="tag">${type}</span> <span class="filename" title="${url}">${filename}</span> </div> <div class="btns"> <button class="btn-cp">复制链接</button> <button class="btn-dl">下载</button> </div> `; div.querySelector('.filename').onclick = div.querySelector('.btn-cp').onclick = (e) => { navigator.clipboard.writeText(url); const btn = div.querySelector('.btn-cp'); const org = btn.innerText; btn.innerText = '已复制'; setTimeout(()=>btn.innerText=org, 1000); }; div.querySelector('.btn-dl').onclick = (e) => { Runner.start(url, type, e.target); }; this.list.prepend(div); } } // ========================================== // 8. Main Entry // ========================================== new Interceptor(); new Overlay(); })();