Greasy Fork is available in English.
嗅探并且下载网页视频
当前为
// ==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(); })();