Greasy Fork

Greasy Fork is available in English.

通用视频嗅探器 V14 (多线程下载)

探测 M3U8/MP4 视频,支持多线程并发下载,手机端自动降级内存模式,圆形悬浮球UI。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

})();