专为安卓大文件设计:使用 StreamSaver 代理流式下载,几乎不占内存,支持 M3U8 转 MP4。
当前为
// ==UserScript== // @name StreamSaver Video Sniffer // @name:zh-CN 通用视频嗅探器 (流式下载版) // @namespace org.jw23.videosniffer // @version 23.0-Stream // @description Sniff video (m3u8/mp4) and download using StreamSaver (Low Memory). // @description:zh-CN 专为安卓大文件设计:使用 StreamSaver 代理流式下载,几乎不占内存,支持 M3U8 转 MP4。 // @author jw23 (Modified for StreamSaver) // @license MIT // @match *://*/* // @connect * // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/mux.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/StreamSaver.min.js // @run-at document-start // ==/UserScript== (function() { 'use strict'; // 配置 StreamSaver 的中间人代理 // 如果 GitHub 访问慢,可以考虑自己部署或寻找国内镜像 streamSaver.mitm = 'https://jimmywarting.github.io/StreamSaver.js/mitm.html'; // ========================================== // 1. 全局配置 (Configuration) // ========================================== const Config = { scanInterval: 2000, uiId: 'gm-sniffer-stream-v23', // 默认并发线程数 get maxThreads() { // 流式写入虽然省内存,但并发太高会导致写入锁等待,建议安卓设为 3 return GM_getValue('max_threads', 3); }, maxRetries: 5, retryDelay: 2000, colors: { primary: '#03a9f4', // 换个蓝色风格代表流式 background: 'rgba(0, 0, 0, 0.9)', text: '#ffffff' } }; GM_registerMenuCommand(`⚙️ 设置并发下载数 (当前: ${Config.maxThreads})`, () => { const input = prompt('请输入并发数 (建议 2-5):', Config.maxThreads); const val = parseInt(input); if (val && val > 0 && val <= 16) { GM_setValue('max_threads', val); alert(`设置成功,刷新生效。`); } }); // ========================================== // 2. 工具函数库 (Utilities) // ========================================== const Utils = { request: (url, isBinary = false) => { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: isBinary ? 'arraybuffer' : 'text', headers: { 'Referer': location.href, 'Origin': location.origin }, timeout: 60000, onload: (res) => { if (res.status >= 200 && res.status < 300) resolve(res.response); else reject(new Error(`HTTP Error ${res.status}`)); }, onerror: (err) => reject(err), ontimeout: () => reject(new Error('Timeout')) }); }); }, sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)), createElement: (tag, attrs = {}, children = []) => { const element = document.createElement(tag); for (const [key, value] of Object.entries(attrs)) { if (key === 'style' && typeof value === 'object') Object.assign(element.style, value); else if (key.startsWith('on') && typeof value === 'function') element.addEventListener(key.substring(2).toLowerCase(), value); else element.setAttribute(key, value); } const childList = Array.isArray(children) ? children : [children]; childList.forEach(child => { if (child instanceof Node) element.appendChild(child); else if (child !== null && child !== undefined) element.appendChild(document.createTextNode(String(child))); }); return element; }, getFilename: (url) => { const cleanUrl = url.split('?')[0]; let name = cleanUrl.split('/').pop(); if (!name || name.trim() === '' || name === '/') name = `video_${Date.now()}.mp4`; return decodeURIComponent(name); }, resolveUrl: (baseUrl, relativeUrl) => { if (relativeUrl.startsWith('http')) return relativeUrl; if (relativeUrl.startsWith('/')) { const u = new URL(baseUrl); return u.origin + relativeUrl; } const path = baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1); return path + relativeUrl; } }; // ========================================== // 3. 加密解密模块 (Crypto) // ========================================== const AESCrypto = { hexToBytes: (hex) => { if (!hex) return null; const cleanHex = hex.replace(/^0x/i, ''); const bytes = new Uint8Array(cleanHex.length / 2); for (let i = 0; i < cleanHex.length; i += 2) { bytes[i / 2] = parseInt(cleanHex.substring(i, i + 2), 16); } return bytes; }, sequenceToIV: (sequenceNumber) => { const buffer = new ArrayBuffer(16); const view = new DataView(buffer); view.setUint32(12, sequenceNumber, false); return new Uint8Array(buffer); }, decrypt: async (data, key, iv) => { try { const algorithm = { name: 'AES-CBC', iv: iv }; const cryptoKey = await window.crypto.subtle.importKey('raw', key, algorithm, false, ['decrypt']); return new Uint8Array(await window.crypto.subtle.decrypt(algorithm, cryptoKey, data)); } catch (error) { console.error('[Crypto] 解密失败:', error); return null; } } }; // ========================================== // 4. 事件总线 // ========================================== const Bus = { events: {}, on(event, callback) { if (!this.events[event]) this.events[event] = []; this.events[event].push(callback); }, emit(event, data) { if (this.events[event]) this.events[event].forEach(cb => cb(data)); } }; // ========================================== // 5. 网络嗅探器 (保持原样) // ========================================== class Sniffer { constructor() { this.seenUrls = new Set(); this.rules = { m3u8: /\.m3u8($|\?)|application\/.*mpegurl/i, mp4: /\.mp4($|\?)|video\/mp4/i, mov: /\.mov($|\?)|video\/quicktime/i }; } start() { this.hookFetch(); this.hookXHR(); setInterval(() => this.scanPerformance(), Config.scanInterval); } detect(url, contentType = '') { if (!url) return; if (url.match(/^data:|^blob:|\.(png|jpg|jpeg|gif|css|js|woff|svg)($|\?)/i)) return; const cleanKey = url.split('?')[0]; if (this.seenUrls.has(cleanKey)) return; const typeStr = contentType ? contentType.toLowerCase() : ''; for (const [type, regex] of Object.entries(this.rules)) { if (regex.test(url) || regex.test(typeStr)) { this.seenUrls.add(cleanKey); console.log(`[Sniffer] Found ${type}: ${url}`); Bus.emit('video-found', { url, type }); return; } } } hookFetch() { const originalFetch = unsafeWindow.fetch; unsafeWindow.fetch = async (...args) => { const url = args[0] instanceof Request ? args[0].url : args[0]; const response = await originalFetch.apply(unsafeWindow, args); try { const clone = response.clone(); clone.headers.forEach((val, key) => { if (key.toLowerCase() === 'content-type') this.detect(url, val); }); } catch(e) {} return response; }; } hookXHR() { const originalXHR = unsafeWindow.XMLHttpRequest; const self = this; class HijackedXHR extends originalXHR { open(method, url, ...args) { this._requestUrl = url; super.open(method, url, ...args); } send(...args) { this.addEventListener('readystatechange', () => { if (this.readyState === 4) { try { const contentType = this.getResponseHeader('content-type'); self.detect(this.responseURL || this._requestUrl, contentType); } catch(e) {} } }); super.send(...args); } } unsafeWindow.XMLHttpRequest = HijackedXHR; } scanPerformance() { if (!window.performance) return; performance.getEntriesByType('resource').forEach(entry => this.detect(entry.name)); } } // ========================================== // 6. 流式下载管理器 (StreamSaver 核心) // ========================================== class StreamSaverWriter { constructor() { this.fileStream = null; this.writer = null; } /** * 初始化下载流 * @param {string} filename 文件名 * @param {number} size (可选) 文件预估大小,用于进度条 */ async init(filename) { try { // 创建 streamSaver 的写入流 this.fileStream = streamSaver.createWriteStream(filename); this.writer = this.fileStream.getWriter(); console.log('[Writer] StreamSaver initialized for', filename); } catch (e) { console.error('[Writer] StreamSaver init failed', e); throw new Error('无法启动流式下载,可能是浏览器不支持或网络问题'); } } /** * 写入数据块 * @param {Uint8Array} data 数据块 */ async write(data) { if (this.writer && data && data.length > 0) { // 等待写入完成,确保存储顺序 await this.writer.write(data); } } async close() { if (this.writer) { await this.writer.close(); console.log('[Writer] Stream closed.'); } } async abort() { if (this.writer) { await this.writer.abort(); } } } // M3U8 下载与转码逻辑 (适配 StreamSaver) const downloadM3u8 = async (url, onProgress, writer) => { onProgress(0, '解析播放列表...'); let content = await Utils.request(url); // 处理 Master Playlist (递归找最高画质) if (content.includes('#EXT-X-STREAM-INF')) { const lines = content.split('\n'); let bestBandwidth = 0; let bestUrl = null; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF')) { const bandwidth = parseInt((lines[i].match(/BANDWIDTH=(\d+)/) || [0,0])[1]); const nextLine = lines[i+1]?.trim(); if (nextLine && !nextLine.startsWith('#') && bandwidth > bestBandwidth) { bestBandwidth = bandwidth; bestUrl = Utils.resolveUrl(url, nextLine); } } } if (bestUrl) { url = bestUrl; content = await Utils.request(url); } } const lines = content.split('\n'); const segments = []; let currentKey = null, currentIV = null, sequence = 0; for (const line of lines) { const l = line.trim(); if (!l) continue; if (l.startsWith('#EXT-X-KEY')) { const method = (l.match(/METHOD=([^,]+)/) || [])[1]; const uri = (l.match(/URI="([^"]+)"/) || [])[1]; const ivHex = (l.match(/IV=(0x[\da-f]+)/i) || [])[1]; if (method === 'AES-128' && uri) { currentKey = Utils.resolveUrl(url, uri); currentIV = ivHex ? AESCrypto.hexToBytes(ivHex) : null; } } else if (l.startsWith('#EXT-X-MEDIA-SEQUENCE')) { sequence = parseInt(l.split(':')[1]); } else if (!l.startsWith('#')) { segments.push({ url: Utils.resolveUrl(url, l), key: currentKey, iv: currentIV, seq: sequence++ }); } } if (segments.length === 0) throw new Error('未找到分片'); // 预下载密钥 const keyCache = new Map(); const uniqueKeys = [...new Set(segments.filter(s => s.key).map(s => s.key))]; if (uniqueKeys.length > 0) { onProgress(0, '获取密钥...'); for (const keyUrl of uniqueKeys) { const keyData = await Utils.request(keyUrl, true); keyCache.set(keyUrl, new Uint8Array(keyData)); } } // 初始化转码器 const transmuxer = new muxjs.mp4.Transmuxer(); let bufferQueue = []; // 监听转码数据 transmuxer.on('data', (segment) => { // 合并 header 和 body const data = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength); data.set(segment.initSegment, 0); data.set(segment.data, segment.initSegment.byteLength); // 放入队列,等待写入硬盘 bufferQueue.push(data); }); let nextIndex = 0; let completedCount = 0; let writeIndex = 0; const downloadBuffer = new Map(); const worker = async () => { while (nextIndex < segments.length) { const index = nextIndex++; const segment = segments[index]; let rawData = null; let retries = Config.maxRetries; // 下载分片 while (!rawData && retries >= 0) { try { let data = await Utils.request(segment.url, true); if (segment.key) { const key = keyCache.get(segment.key); const iv = segment.iv || AESCrypto.sequenceToIV(segment.seq); data = (await AESCrypto.decrypt(data, key, iv)).buffer; } rawData = data; } catch (e) { retries--; await Utils.sleep(Config.retryDelay); } } // 即使下载失败也放入空数据,保证顺序 downloadBuffer.set(index, rawData ? new Uint8Array(rawData) : new Uint8Array(0)); // 顺序写入流 (关键逻辑:必须按顺序喂给转码器,转码器按顺序吐出 MP4,然后写入 Stream) // 这里加锁:JS 是单线程的,但 await 会让出控制权。我们需要确保 transmuxer 和 writer 是串行的 while (downloadBuffer.has(writeIndex)) { const chunk = downloadBuffer.get(writeIndex); downloadBuffer.delete(writeIndex); bufferQueue = []; // 清空之前的队列 if (chunk.length > 0) { try { transmuxer.push(chunk); transmuxer.flush(); } catch (e) { console.warn('Mux Error at chunk ' + writeIndex, e); } } // 将转码出来的所有 MP4 片段写入硬盘 if (bufferQueue.length > 0) { for (const d of bufferQueue) { await writer.write(d); // 写入 StreamSaver } } writeIndex++; } completedCount++; const percent = ((completedCount / segments.length) * 100).toFixed(1); onProgress(percent, `下载中 ${percent}%`); } }; const threads = Array(Math.min(Config.maxThreads, segments.length)).fill(null).map(() => worker()); await Promise.all(threads); }; // MP4 直链流式下载 (模拟 fetch stream) const downloadMp4Stream = async (url, onProgress, writer) => { onProgress(0, '连接流...'); // 使用 fetch 获取流 const response = await fetch(url, { headers: { 'Referer': location.href } }); if (!response.body) throw new Error('ReadableStream not supported by response'); const reader = response.body.getReader(); const contentLength = +response.headers.get('Content-Length'); let receivedLength = 0; while(true) { const {done, value} = await reader.read(); if (done) break; await writer.write(value); // 写入 StreamSaver receivedLength += value.length; if (contentLength) { const pct = ((receivedLength / contentLength) * 100).toFixed(1); onProgress(pct, `${pct}%`); } else { onProgress(0, `已下载 ${(receivedLength / 1024 / 1024).toFixed(1)} MB`); } } onProgress(100, '完成'); }; const TaskRunner = async (url, type, btn) => { const originalText = btn.textContent; let filename = Utils.getFilename(url); if (type === 'm3u8' && !filename.endsWith('.mp4')) filename += '.mp4'; const writer = new StreamSaverWriter(); try { // 1. 建立流 await writer.init(filename); btn.textContent = '准备中...'; // 2. 开始下载并喂数据 if (type === 'm3u8') { await downloadM3u8(url, (pct, txt) => btn.textContent = txt || pct + '%', writer); } else { await downloadMp4Stream(url, (pct, txt) => btn.textContent = txt || pct + '%', writer); } // 3. 关闭流 (触发下载结束) btn.textContent = '封包中...'; await writer.close(); btn.textContent = '完成'; } catch (error) { console.error(error); btn.textContent = '错误'; alert(`StreamSaver 错误: ${error.message}\n请确保在 HTTPS 页面使用,且不是无痕模式。`); try { await writer.abort(); } catch(e) {} } finally { setTimeout(() => btn.textContent = originalText, 3000); } }; // ========================================== // 7. UI 界面 (保持原样) // ========================================== class UI { constructor() { this.root = null; this.list = null; Bus.on('video-found', (data) => this.addItem(data)); } init() { if (document.getElementById(Config.uiId)) return; const host = Utils.createElement('div', { id: Config.uiId, style: { position: 'fixed', top: '15%', right: '2%', zIndex: 999999 } }); const shadow = host.attachShadow({ mode: 'open' }); const style = Utils.createElement('style'); style.textContent = ` :host { font-family: sans-serif; font-size: 12px; } .box { width: 220px; background: ${Config.colors.background}; color: ${Config.colors.text}; border: 1px solid ${Config.colors.primary}; border-radius: 6px; backdrop-filter: blur(5px); display: flex; flex-direction: column; box-shadow: 0 4px 15px rgba(0,0,0,0.5); } .head { padding: 8px; background: rgba(255,255,255,0.1); display: flex; justify-content: space-between; align-items: center; cursor: move; } .title { font-weight: bold; color: ${Config.colors.primary}; } .list { max-height: 200px; overflow-y: auto; } .item { padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.1); } .row { display: flex; gap: 5px; align-items: center; margin-bottom: 5px; } .tag { background: ${Config.colors.primary}; color: #000; padding: 1px 3px; border-radius: 3px; font-weight: bold; font-size: 10px; } .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } .actions { display: flex; gap: 5px; } button { flex: 1; border: none; padding: 6px; border-radius: 3px; cursor: pointer; font-size: 12px; } .btn-copy { background: #555; color: white; } .btn-down { background: ${Config.colors.primary}; color: #000; font-weight: bold; } .box.min { width: 40px; height: 40px; border-radius: 50%; justify-content: center; align-items: center; } .box.min .list, .box.min .title { display: none; } .toggle { font-size: 16px; padding: 0 5px; cursor: pointer; } `; const toggleBtn = Utils.createElement('span', { class: 'toggle' }, '-'); const titleEl = Utils.createElement('span', { class: 'title' }, `流式嗅探`); const head = Utils.createElement('div', { class: 'head' }, [titleEl, toggleBtn]); this.list = Utils.createElement('div', { class: 'list' }); this.root = Utils.createElement('div', { class: 'box' }, [head, this.list]); shadow.appendChild(style); shadow.appendChild(this.root); (document.body || document.documentElement).appendChild(host); toggleBtn.onclick = (e) => { e.stopPropagation(); this.root.classList.toggle('min'); toggleBtn.textContent = this.root.classList.contains('min') ? '⚡' : '-'; }; // 触摸拖拽支持 let isDrag = false, startX, startY, initRight, initTop; const onDown = (e) => { if(e.target === toggleBtn) return; isDrag = true; const touch = e.touches ? e.touches[0] : e; startX = touch.clientX; startY = touch.clientY; const rect = host.getBoundingClientRect(); initRight = window.innerWidth - rect.right; initTop = rect.top; }; const onMove = (e) => { if (!isDrag) return; if (e.preventDefault) e.preventDefault(); const touch = e.touches ? e.touches[0] : e; host.style.right = (initRight + (startX - touch.clientX)) + 'px'; host.style.top = (initTop + (touch.clientY - startY)) + 'px'; }; const onUp = () => isDrag = false; head.addEventListener('touchstart', onDown); document.addEventListener('touchmove', onMove, {passive: false}); document.addEventListener('touchend', onUp); head.addEventListener('mousedown', onDown); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } addItem({ url, type }) { this.init(); if (this.root.classList.contains('min') && this.list.children.length === 0) { this.root.querySelector('.toggle').click(); } const item = Utils.createElement('div', { class: 'item' }, [ Utils.createElement('div', { class: 'row' }, [ Utils.createElement('span', { class: 'tag' }, type), Utils.createElement('span', { class: 'name', title: url, onclick: () => { navigator.clipboard.writeText(url); alert('链接已复制'); }}, Utils.getFilename(url)) ]), Utils.createElement('div', { class: 'actions' }, [ Utils.createElement('button', { class: 'btn-copy', onclick: (e) => { navigator.clipboard.writeText(url); e.target.textContent = '已复制'; setTimeout(() => e.target.textContent = '复制', 1000); }}, '复制'), Utils.createElement('button', { class: 'btn-down', onclick: (e) => TaskRunner(url, type, e.target) }, '流式下载') ]) ]); if (this.list.firstChild) this.list.insertBefore(item, this.list.firstChild); else this.list.appendChild(item); } } new Sniffer().start(); new UI(); })();