Greasy Fork

Greasy Fork is available in English.

通用嗅探工具

嗅探并且下载网页视频

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Universal Video Sniffer V13 (Mobile Fix)
// @name:zh-CN   通用嗅探工具
// @namespace    http://tampermonkey.net/
// @version      13.0
// @description  Based on V9. Sniff video (m3u8/mp4). Force memory download on mobile to avoid 'cancelled' error.
// @description:zh-CN  嗅探并且下载网页视频
// @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. Utils & Config
    // ==========================================
    const Config = {
        scanInterval: 2000,
        uiId: 'gm-sniffer-v13-root',
        isTop: window.self === window.top,
        // 增强的移动端检测:通过 UA 判断,而不仅仅是 API
        isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
    };

    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 },
                    onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error(`HTTP ${r.status}`)),
                    onerror: reject
                });
            });
        },
        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);
        }
    };

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

    // ==========================================
    // 3. Detector (Rules)
    // ==========================================
    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 = '', source = '') {
            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.toLowerCase();

            for (const rule of this.rules) {
                if (rule.check(u, c)) {
                    this.cache.add(cleanUrl);
                    console.log(`[Sniffer] Detected ${rule.type}`);
                    Bus.emit('video-found', { url, type: rule.type });
                    break;
                }
            }
        }
    };

    // ==========================================
    // 4. Interceptor (Network Hook)
    // ==========================================
    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);
        }
    }

    // ==========================================
    // 5. Downloader (Fixed Logic)
    // ==========================================
    class Writer {
        constructor() {
            this.useMemory = false;
            this.chunks = []; 
            this.writable = null; 
        }
        
        async init(filename) {
            // 策略调整:如果是移动端,直接使用内存模式,跳过所有 FS API 检测
            // 这避免了手机浏览器即使有 API 也抛出 AbortError 导致的“已取消”误报
            if (Config.isMobile) {
                console.log('[Writer] Mobile detected, enforcing memory mode.');
                this.useMemory = true;
                this.chunks = [];
                return;
            }

            // PC 端:尝试流式下载
            if (window.showSaveFilePicker) {
                try {
                    const handle = await window.showSaveFilePicker({ suggestedName: filename });
                    this.writable = await handle.createWritable();
                    this.useMemory = false;
                    return;
                } catch (e) {
                    if (e.name === 'AbortError') {
                        // 真·用户取消
                        throw new Error('User Cancelled'); 
                    }
                    console.warn('[Writer] FS API failed, fallback to memory.', e);
                    // 其他错误继续向下,进入内存模式
                }
            }
            
            // 兜底:内存模式
            this.useMemory = true;
            this.chunks = [];
        }

        async write(data) {
            if (this.useMemory) {
                this.chunks.push(data);
            } else {
                await this.writable.write(data);
            }
        }

        async close(filename) {
            if (this.useMemory) {
                const blob = new Blob(this.chunks, { type: 'video/mp4' });
                Utils.saveBlob(blob, filename);
                this.chunks = [];
            } else {
                await this.writable.close();
            }
        }
    }

    const DownloadStrategies = {
        async m3u8(url, updateProgress, writer) {
            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');
            
            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);
            });

            for (let i = 0; i < segments.length; i++) {
                updateProgress((i / segments.length) * 100);
                try {
                    const buffer = await Utils.gmFetch(segments[i], { type: 'arraybuffer' });
                    transmuxer.push(new Uint8Array(buffer));
                    transmuxer.flush();
                } catch(e){}
            }
        },
        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();

    // ==========================================
    // 6. UI (V9 Round Style + V11 Fix)
    // ==========================================
    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: 240px; background: rgba(0,0,0,0.85); color: #fff; border-radius: 8px; 
                    border: 2px solid ${color}; backdrop-filter: blur(4px); transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
                    overflow: hidden; display: flex; flex-direction: column;
                }
                .header { 
                    padding: 10px; background: rgba(255,255,255,0.1); display: flex; 
                    justify-content: space-between; align-items: center; cursor: move; user-select: none;
                }
                .title { font-size: 12px; font-weight: bold; color: ${color}; }
                .list { max-height: 200px; overflow-y: auto; padding: 0; font-size: 11px; }
                
                .item { padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.1); }
                .url { color: #aaa; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 5px; }
                .btns { display: flex; gap: 5px; }
                button { flex: 1; border: none; padding: 5px; border-radius: 4px; cursor: pointer; color: #fff; font-size: 10px; }
                .btn-cp { background: #555; }
                .btn-dl { background: ${color}; }
                
                .box.minimized {
                    width: 45px; height: 45px; border-radius: 50%; border-width: 2px;
                    justify-content: center; align-items: center; cursor: move;
                    box-shadow: 0 4px 10px rgba(0,0,0,0.5);
                }
                .box.minimized .list, .box.minimized .title { display: none; }
                .box.minimized .header { padding: 0; background: transparent; justify-content: center; width: 100%; height: 100%; }
                .toggle { font-size: 18px; cursor: pointer; font-weight: bold; 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 V13</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;
            
            header.onmousedown = e => { 
                if(this.root.classList.contains('minimized') && e.target !== toggle) { }
                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;
            };
            
            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="url" title="${url}">[${type}] ${filename}</div>
                <div class="btns">
                    <button class="btn-cp">复制</button>
                    <button class="btn-dl">下载</button>
                </div>
            `;
            
            div.querySelector('.btn-cp').onclick = (e) => {
                navigator.clipboard.writeText(url);
                e.target.innerText = '已复制';
                setTimeout(()=>e.target.innerText='复制',1000);
            };
            div.querySelector('.btn-dl').onclick = (e) => {
                Runner.start(url, type, e.target);
            };

            this.list.prepend(div);
        }
    }

    // ==========================================
    // 7. Main
    // ==========================================
    new Interceptor();
    new Overlay();

})();