StreamSaver Video Sniffer

Sniff video (m3u8/mp4) and download using StreamSaver (Low Memory).

目前為 2025-12-03 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         StreamSaver Video Sniffer
// @name:zh-CN   通用视频嗅探器 (流式下载版)
// @namespace    org.jw23.videosniffer
// @version      23.1
// @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://xn--ghqr82bqvkxl7a.xn--10vm87c.xn--6qq986b3xl/mitm';

    // ==========================================
    // 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 (err) {
            console.error(err);
            btn.textContent = '错误';
            alert(`StreamSaver 错误: ${err}\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();

})();