Greasy Fork

Greasy Fork is available in English.

通用视频嗅探器 V15

修复手机端误将m3u8识别为mp4导致只下载文件的问题,支持多线程解析下载。

当前为 2025-12-03 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();