Greasy Fork

Greasy Fork is available in English.

通用视频嗅探器 V18 (多级列表修复+0B校验)

修复PC端下载0B无报错的问题,自动解析多级M3U8列表(Master),检测加密视频并提示。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Universal Video Sniffer V18 (Master Playlist Fix)
// @name:zh-CN   通用视频嗅探器 V18 (多级列表修复+0B校验)
// @namespace    http://tampermonkey.net/
// @version      18.0
// @description  Fix 0B file issue on PC, auto-resolve master playlist, detect encryption.
// @description:zh-CN  修复PC端下载0B无报错的问题,自动解析多级M3U8列表(Master),检测加密视频并提示。
// @author       jw23
// @license      MIT
// @match        *://*/*
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @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-v18-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. Utils
    // ==========================================
    const Utils = {
        // Pure JS DOM Builder
        el: (tag, attrs = {}, children = []) => {
            const element = document.createElement(tag);
            Object.entries(attrs).forEach(([key, value]) => {
                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);
            });
            if (!Array.isArray(children)) children = [children];
            children.forEach(child => {
                if (typeof child === 'string' || typeof child === 'number') element.appendChild(document.createTextNode(String(child)));
                else if (child instanceof Node) element.appendChild(child);
            });
            return element;
        },

        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: 60000,
                    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) => {
            if (blob.size === 0) return alert('生成文件 0B,下载失败。\n原因:视频可能加密或格式不支持。');
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            a.style.display = 'none';
            (document.body || document.documentElement).appendChild(a);
            a.click();
            setTimeout(() => { if(a.parentNode)a.parentNode.removeChild(a); URL.revokeObjectURL(url); }, 30000);
        },

        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);
        },
        
        // 解析 URL,处理相对路径
        resolveUrl: (baseUrl, relativeUrl) => {
            if (relativeUrl.startsWith('http')) return relativeUrl;
            if (relativeUrl.startsWith('/')) {
                const u = new URL(baseUrl);
                return u.origin + relativeUrl;
            }
            return baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1) + relativeUrl;
        }
    };

    // ==========================================
    // 3. Logic Modules
    // ==========================================
    class EventBus {
        constructor() { this.listeners = {}; }
        on(event, callback) { (this.listeners[event] = this.listeners[event] || []).push(callback); }
        emit(event, data) { (this.listeners[event] || []).forEach(cb => cb(data)); }
    }
    const Bus = new EventBus();

    const Detector = {
        rules: [
            { type: 'm3u8', check: (u, c) => u.split('?')[0].endsWith('.m3u8') || c.includes('mpegurl') },
            { type: 'mp4',  check: (u, c) => u.split('?')[0].endsWith('.mp4') || c.includes('video/mp4') },
            { type: 'mov',  check: (u, c) => u.split('?')[0].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;
            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: url, type: rule.type });
                    break;
                }
            }
        }
    };

    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);
                try { res.clone().headers.forEach((v, k) => k.toLowerCase() === 'content-type' && Detector.identify(url, v)); } 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() {
            setInterval(() => {
                if (!window.performance) return;
                window.performance.getEntriesByType('resource').forEach(e => Detector.identify(e.name));
            }, CONFIG.scanInterval);
        }
    }

    class Writer {
        constructor() {
            this.mode = 'memory'; 
            this.chunks = []; 
            this.rawChunks = []; 
            this.writable = null; 
            this.totalSize = 0; // 记录写入总字节数
        }
        async init(filename) {
            if (CONFIG.isMobile) { this.mode = 'memory'; return; }
            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';
        }
        async write(data, rawData) {
            if (this.mode === 'stream') {
                if (data && data.length > 0) {
                    await this.writable.write(data);
                    this.totalSize += data.length;
                }
            } else {
                if (data && data.length > 0) { 
                    this.chunks.push(data); 
                    this.totalSize += data.length; 
                }
                if (rawData) this.rawChunks.push(rawData);
            }
        }
        async close(filename) {
            if (this.mode === 'stream') {
                await this.writable.close();
                // PC端 0B 校验修复
                if (this.totalSize === 0) {
                    alert('⚠️ 下载完成,但文件大小为 0B。\n\n可能原因:\n1. 视频包含加密(AES-128)\n2. 视频格式(HEVC/fMP4)不支持转码\n\n请尝试使用 N_m3u8DL-RE 等工具下载。');
                }
            } else {
                let finalBlob;
                if (this.totalSize < 1024) { 
                    console.warn('[Writer] Transcoding failed. Using RAW.');
                    finalBlob = new Blob(this.rawChunks, { type: 'video/mp2t' });
                    if (!filename.endsWith('.ts')) filename += '.ts';
                    if (finalBlob.size > 0) alert('转码失败(格式不兼容),已保存为原始 TS 格式。');
                } else {
                    finalBlob = new Blob(this.chunks, { type: 'video/mp4' });
                }
                Utils.saveBlob(finalBlob, filename);
                this.chunks = null; this.rawChunks = null;
            }
        }
    }

    // ==========================================
    // 4. Download Strategies
    // ==========================================
    const DownloadStrategies = {
        async m3u8(url, updateProgress, writer) {
            updateProgress(0, '获取播放列表...');
            let content = await Utils.gmFetch(url);
            
            // --- 修复:检查是否为 Master Playlist (多级列表) ---
            if (content.includes('#EXT-X-STREAM-INF')) {
                updateProgress(0, '解析多级列表...');
                console.log('[Sniffer] Master Playlist detected. Parsing...');
                const lines = content.split('\n');
                let bestBandwidth = 0;
                let bestUrl = '';
                
                for (let i = 0; i < lines.length; i++) {
                    const line = lines[i].trim();
                    if (line.startsWith('#EXT-X-STREAM-INF')) {
                        // 提取带宽
                        const match = line.match(/BANDWIDTH=(\d+)/);
                        const bandwidth = match ? parseInt(match[1]) : 0;
                        
                        // 获取下一行作为 URL
                        let nextLine = lines[i + 1]?.trim();
                        if (nextLine && !nextLine.startsWith('#')) {
                            if (bandwidth > bestBandwidth) {
                                bestBandwidth = bandwidth;
                                bestUrl = Utils.resolveUrl(url, nextLine);
                            }
                        }
                    }
                }
                
                if (bestUrl) {
                    console.log(`[Sniffer] Switched to best quality: ${bestUrl}`);
                    url = bestUrl; // 切换 URL
                    content = await Utils.gmFetch(url); // 重新获取子 m3u8 内容
                } else {
                    throw new Error('无法解析 Master Playlist');
                }
            }
            
            // --- 修复:检查加密 ---
            if (content.includes('#EXT-X-KEY')) {
                throw new Error('视频包含 AES-128 加密,本脚本无法解密。\n请复制链接使用 IDM 或 N_m3u8DL-RE 下载。');
            }

            const baseUrl = url.substring(0, url.lastIndexOf('/') + 1);
            const segments = content.split('\n')
                .map(l => l.trim())
                .filter(l => l && !l.startsWith('#'))
                .map(l => Utils.resolveUrl(url, l));

            if (!segments.length) throw new Error('播放列表为空');

            const transmuxer = new muxjs.mp4.Transmuxer();
            let currentTransmuxedSegment = [];

            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);
                currentTransmuxedSegment.push(data);
            });

            // Multi-thread Download
            let nextIndex = 0, completed = 0, nextWrite = 0;
            const bufferMap = new Map();

            const worker = async () => {
                while (nextIndex < segments.length) {
                    const idx = nextIndex++;
                    let retries = CONFIG.maxRetries;
                    let success = false;
                    while (retries-- >= 0 && !success) {
                        try {
                            const buf = await Utils.gmFetch(segments[idx], { type: 'arraybuffer' });
                            bufferMap.set(idx, { raw: new Uint8Array(buf) });
                            success = true;
                        } catch (e) { await Utils.sleep(CONFIG.retryDelay); }
                    }
                    
                    while (bufferMap.has(nextWrite)) {
                        const item = bufferMap.get(nextWrite);
                        bufferMap.delete(nextWrite);
                        currentTransmuxedSegment = [];
                        
                        // 尝试转码
                        try { transmuxer.push(item.raw); transmuxer.flush(); } catch(e){}
                        
                        if (currentTransmuxedSegment.length > 0) {
                            for (const chunk of currentTransmuxedSegment) await writer.write(chunk, null);
                            // 同时保存原始数据引用(内存模式兜底用)
                            await writer.write(null, item.raw);
                        } else {
                            // 转码无产出,只写入 raw (Writer 会根据模式处理)
                            await writer.write(null, item.raw);
                        }
                        nextWrite++;
                    }
                    completed++;
                    updateProgress(((completed/segments.length)*100).toFixed(1) + '%');
                }
            };

            const threads = Array(Math.min(CONFIG.maxThreads, segments.length)).fill(0).map(() => worker());
            await Promise.all(threads);
        },

        async direct(url, updateProgress, writer) {
            updateProgress(0, '下载中...');
            const buffer = await Utils.gmFetch(url, { type: 'arraybuffer' });
            await writer.write(new Uint8Array(buffer), null);
            updateProgress('100%');
        }
    };

    class TaskRunner {
        async start(url, type, btnElement) {
            const originalText = btnElement.innerText;
            let filename = Utils.getFilename(url);
            if(type === 'm3u8' && !filename.endsWith('.mp4')) filename += '.mp4';
            
            const writer = new Writer();
            try {
                await writer.init(filename);
                btnElement.innerText = '0%';
                const strategy = type === 'm3u8' ? DownloadStrategies.m3u8 : DownloadStrategies.direct;
                await strategy(url, (txt) => btnElement.innerText = txt, writer);
                
                btnElement.innerText = '保存...';
                await writer.close(filename);
                btnElement.innerText = '完成';
            } catch (e) {
                if(e.message === 'User Cancelled') btnElement.innerText = '已取消';
                else { 
                    console.error(e); 
                    btnElement.innerText = '错误'; 
                    // 使用 setTimeout 避免阻塞 UI 更新
                    setTimeout(() => alert(e.message), 10);
                }
            } finally {
                setTimeout(() => btnElement.innerText = originalText, 3000);
            }
        }
    }
    const Runner = new TaskRunner();

    // ==========================================
    // 5. UI (CSP Safe)
    // ==========================================
    class Overlay {
        constructor() { this.root=null; this.list=null; this.header=null; this.toggleBtn=null; Bus.on('video-found', d=>this.addItem(d)); }
        createStyle() {
            const color = CONFIG.isTop ? '#4caf50' : '#e91e63';
            return `
            :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.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}}.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}
            .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}`;
        }
        init() {
            if(document.getElementById(CONFIG.uiId))return;
            const h=document.createElement('div'); h.id=CONFIG.uiId;
            h.style.cssText='position:fixed;top:15%;right:2%;z-index:2147483647;';
            const s=h.attachShadow({mode:'open'});
            const st=document.createElement('style'); st.textContent=this.createStyle();
            this.toggleBtn=Utils.el('span',{class:'toggle'},'-');
            this.header=Utils.el('div',{class:'header'},[Utils.el('span',{class:'title'},'Sniffer V18'),this.toggleBtn]);
            this.list=Utils.el('div',{class:'list'},[]);
            this.root=Utils.el('div',{class:'box'},[this.header,this.list]);
            s.appendChild(st); s.appendChild(this.root);
            (document.body||document.documentElement).appendChild(h);
            this.bindEvents(h);
        }
        bindEvents(h) {
            const tf=(e)=>{e.stopPropagation();this.root.classList.toggle('minimized');this.toggleBtn.textContent=this.root.classList.contains('minimized')?'🎬':'-'};
            this.toggleBtn.onclick=tf;
            let d=false,sx,sy,ir,it,hm=false;
            const sd=(e)=>{if(e.target===this.toggleBtn)return;d=true;hm=false;sx=e.clientX||e.touches[0].clientX;sy=e.clientY||e.touches[0].clientY;const r=h.getBoundingClientRect();ir=window.innerWidth-r.right;it=r.top};
            const md=(e)=>{if(!d)return;const cx=e.clientX||(e.touches?e.touches[0].clientX:0);const cy=e.clientY||(e.touches?e.touches[0].clientY:0);if(Math.abs(cx-sx)>5||Math.abs(cy-sy)>5)hm=true;h.style.right=(ir+(sx-cx))+'px';h.style.top=(it+(cy-sy))+'px';if(e.preventDefault)e.preventDefault()};
            const su=(e)=>{if(d&&!hm&&this.root.classList.contains('minimized'))tf(e);d=false};
            this.header.addEventListener('mousedown',sd);document.addEventListener('mousemove',md);document.addEventListener('mouseup',su);
            this.header.addEventListener('touchstart',sd);document.addEventListener('touchmove',md,{passive:false});document.addEventListener('touchend',su);
        }
        addItem({url,type}){
            this.init(); if(this.root.classList.contains('minimized')&&this.list.children.length===0)this.toggleBtn.click();
            const fn=Utils.getFilename(url); const div=Utils.el('div',{class:'item'},[
                Utils.el('div',{class:'info-row'},[Utils.el('span',{class:'tag'},type),Utils.el('span',{class:'filename',title:url},fn)]),
                Utils.el('div',{class:'btns'},[Utils.el('button',{class:'btn-cp'},'复制链接'),Utils.el('button',{class:'btn-dl'},'下载')])
            ]);
            const cp=div.querySelector('.btn-cp'); const fnEl=div.querySelector('.filename');
            const cf=()=>{navigator.clipboard.writeText(url);const o=cp.innerText;cp.innerText='已复制';setTimeout(()=>cp.innerText=o,1000)};
            cp.onclick=cf; fnEl.onclick=cf;
            div.querySelector('.btn-dl').onclick=e=>Runner.start(url,type,e.target);
            if(this.list.firstChild)this.list.insertBefore(div,this.list.firstChild); else this.list.appendChild(div);
        }
    }

    new Interceptor(); new Overlay();
})();