Greasy Fork is available in English.
修复手机端误将m3u8识别为mp4导致只下载文件的问题,支持多线程解析下载。
当前为
// ==UserScript== // @name Universal Video Sniffer V15 // @name:zh-CN 通用视频嗅探器 V15 // @namespace http://tampermonkey.net/ // @version 15.0 // @description Sniff video (m3u8/mp4), fix mobile detection bugs, multi-thread download. // @description:zh-CN 修复手机端误将m3u8识别为mp4导致只下载文件的问题,支持多线程解析下载。 // @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. Config & Constants // ========================================== const CONFIG = { scanInterval: 2000, uiId: 'gm-sniffer-v15-root', isTop: window.self === window.top, // 严格的移动端判断 isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent), maxThreads: 4, // 并发数 maxRetries: 3, // 重试次数 retryDelay: 1000 }; // ========================================== // 2. Utilities // ========================================== const Utils = { 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: 45000, // 增加超时时间以适应移动网络 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)), 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.trim() === '' || name === '/') name = `video_${Date.now()}.mp4`; return decodeURIComponent(name); } }; // ========================================== // 3. Event Bus // ========================================== 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 (关键修复: 严格检测) // ========================================== const Detector = { // 修复逻辑: // 1. 使用 split('?')[0] 忽略 URL 参数,防止 .m3u8?file=xx.mp4 被误判 // 2. 必须以 .m3u8 结尾,或者 Content-Type 明确 rules: [ { type: 'm3u8', check: (u, c) => { const cleanUrl = u.split('?')[0]; return cleanUrl.endsWith('.m3u8') || c.includes('application/vnd.apple.mpegurl') || c.includes('application/x-mpegurl'); } }, { type: 'mp4', check: (u, c) => { const cleanUrl = u.split('?')[0]; return cleanUrl.endsWith('.mp4') || c.includes('video/mp4'); } }, { type: 'mov', check: (u, c) => { const cleanUrl = u.split('?')[0]; return cleanUrl.endsWith('.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; // 使用去参后的 URL 作为缓存 Key,避免同一视频不同参数重复添加 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}`); // 传递原始 URL 用于下载 Bus.emit('video-found', { url: url, type: rule.type }); break; } } } }; // ========================================== // 5. Interceptor // ========================================== 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); // 克隆 response 读取 header,不影响原请求 try { res.clone().headers.forEach((v, k) => k.toLowerCase() === 'content-type' && Detector.identify(url, v)); if (!res.headers.get('content-type')) Detector.identify(url); } catch(e) {} 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) { try { Detector.identify(this.responseURL || this._u, this.getResponseHeader('Content-Type')); } catch(e){} } }); super.send(...a); } }; } scanHistory() { // 定期扫描,防止因为没有 Hook 到 header 而漏掉 setInterval(() => { if (!window.performance) return; window.performance.getEntriesByType('resource').forEach(e => Detector.identify(e.name)); }, CONFIG.scanInterval); } } // ========================================== // 6. Writer Module (手机内存优化版) // ========================================== class Writer { constructor() { this.mode = 'memory'; this.chunks = []; this.writable = null; } async init(filename) { // 手机端强制内存模式,且不尝试 FS API (避免报错) 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'); } } // 兜底 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 { // 手机端下载完成后,合并 Blob 并触发下载 const blob = new Blob(this.chunks, { type: 'video/mp4' }); if (blob.size > 1024 * 1024 * 500 && CONFIG.isMobile) { alert('视频过大(>500MB),手机浏览器可能会在保存时崩溃,请留意。'); } Utils.saveBlob(blob, filename); this.chunks = []; } } } // ========================================== // 7. Downloader (Multi-thread M3U8) // ========================================== const DownloadStrategies = { async m3u8(url, updateProgress, writer) { // 1. 获取 m3u8 文本 updateProgress(0, '解析中...'); const content = await Utils.gmFetch(url); const baseUrl = url.substring(0, url.lastIndexOf('/') + 1); // 2. 提取分片链接 const lines = content.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); const segments = lines.map(l => { if (l.startsWith('http')) return l; if (l.startsWith('/')) return new URL(url).origin + l; return baseUrl + l; }); if (!segments.length) throw new Error('M3U8解析为空'); // 3. 转码器 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); }); // 4. 多线程下载核心 let nextIndex = 0; let completedCount = 0; let nextWriteIndex = 0; const bufferMap = new Map(); // 缓存乱序下载的数据 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); } } if (!success) console.warn(`Seg ${currentIndex} failed after retries`); // 顺序写入 (必须按顺序喂给 mux.js) while (bufferMap.has(nextWriteIndex)) { const chunk = bufferMap.get(nextWriteIndex); bufferMap.delete(nextWriteIndex); try { transmuxer.push(chunk); transmuxer.flush(); } catch(e) {} nextWriteIndex++; } completedCount++; const pct = ((completedCount / segments.length) * 100).toFixed(1); updateProgress(pct, `${pct}%`); } }; // 启动线程 const threads = []; const threadCount = Math.min(CONFIG.maxThreads, segments.length); for (let i = 0; i < threadCount; i++) threads.push(worker(i)); await Promise.all(threads); if (nextWriteIndex < segments.length) console.warn('Possible incomplete download'); }, async direct(url, updateProgress, writer) { updateProgress(0, '下载中...'); const buffer = await Utils.gmFetch(url, { type: 'arraybuffer' }); writer.write(new Uint8Array(buffer)); updateProgress(100, '100%'); } }; class TaskRunner { async start(url, type, btn) { const originalText = btn.innerText; // 确保文件名有后缀 let filename = Utils.getFilename(url); if (!filename.endsWith('.mp4')) filename += '.mp4'; const writer = new Writer(); try { // 1. 初始化 (手机端内存,PC端流式) await writer.init(filename); btn.innerText = '准备...'; // 2. 选择策略 (严谨判断) const strategy = (type === 'm3u8') ? DownloadStrategies.m3u8 : DownloadStrategies.direct; // 3. 执行下载 await strategy(url, (pct, text) => { btn.innerText = text || `${Math.floor(pct)}%`; }, writer); // 4. 保存关闭 btn.innerText = '保存中...'; await writer.close(filename); btn.innerText = '完成'; } catch (e) { if (e.message === 'User Cancelled') { btn.innerText = '已取消'; } else { console.error(e); btn.innerText = '错误'; alert('错误: ' + e.message); } } finally { setTimeout(() => btn.innerText = originalText, 3000); } } } const Runner = new TaskRunner(); // ========================================== // 8. UI (Round Floating) // ========================================== 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; } .header { padding: 10px 12px; background: rgba(255,255,255,0.1); 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 { 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; } .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; } .btn-cp { background: #555; } .btn-dl { background: ${color}; color: #000; } .box.minimized { width: 48px; height: 48px; border-radius: 50%; border-width: 2px; align-items: center; justify-content: center; } .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; } ::-webkit-scrollbar { width: 4px; } ::-webkit-scrollbar-thumb { background: #666; border-radius: 2px; } </style> <div class="box"> <div class="header"><span class="title">Sniffer V15</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; let isDrag = false, startX, startY, initR, initT, hasMoved = false; const startDrag = (x, y) => { isDrag=true; hasMoved=false; startX=x; startY=y; const r=host.getBoundingClientRect(); initR=window.innerWidth-r.right; initT=r.top; }; const moveDrag = (x, y) => { if(!isDrag)return; if(Math.abs(x-startX)>5||Math.abs(y-startY)>5)hasMoved=true; host.style.right=(initR+(startX-x))+'px'; host.style.top=(initT+(y-startY))+'px'; }; header.onmousedown = e => { if(e.target!==toggle) startDrag(e.clientX, e.clientY); }; document.onmousemove = e => moveDrag(e.clientX, e.clientY); document.onmouseup = e => { if(isDrag&&!hasMoved&&this.root.classList.contains('minimized')) toggleFn(e); isDrag=false; }; header.ontouchstart = e => { if(e.target!==toggle) startDrag(e.touches[0].clientX, e.touches[0].clientY); }; document.ontouchmove = e => { if(isDrag){ e.preventDefault(); moveDrag(e.touches[0].clientX, e.touches[0].clientY); } }; 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 b=div.querySelector('.btn-cp'); const o=b.innerText; b.innerText='已复制'; setTimeout(()=>b.innerText=o,1000); }; div.querySelector('.btn-dl').onclick = (e) => Runner.start(url, type, e.target); this.list.prepend(div); } } new Interceptor(); new Overlay(); })();