Greasy Fork is available in English.
修复PC端下载0B无报错的问题,自动解析多级M3U8列表(Master),检测加密视频并提示。
当前为
// ==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(); })();