// ==UserScript== // @name 国内主流视频平台流下载器(FFmpeg.wasm 浏览器内合并) // @namespace https://github.com/workbuddy/video-downloader // @version 1.2.0 // @description 从网页提取 DASH / playinfo 音视频流,使用 GM_xmlhttpRequest 下载并用 FFmpeg.wasm 在浏览器内合并为 mp4。支持「边播边抓」模式:播放时缓存分片响应体、播放完自动合并、刷新不丢(IndexedDB 持久化,绕开短命 token 防盗链);粘贴直链框带「限时签名到期预警」(识别 PPTV k 时间戳 / 1905 key1 日期);打开 m3u8/mp4 直链可按设置自动下载。B 站(window.__playinfo__)完整可用,其余平台提供解析器扩展点。 // @author WorkBuddy // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/bangumi/* // @match https://player.bilibili.com/* // @match https://www.iqiyi.com/* // @match https://www.iqiyi.com/v_* // @match https://v.youku.com/* // @match https://v.qq.com/* // @match https://www.qq.com/* // @match https://www.mgtv.com/* // @match https://www.pptv.com/* // @match https://v.pptv.com/* // @match https://*.pptv.com/* // 覆盖 pptv CDN 子域(txyun.vod / *.vod 等直达 m3u8;k token 限时,尽早下) // @match https://www.wasu.cn/* // @match https://www.yangshipin.cn/* // @match https://w.yangshipin.cn/* // @match https://m.yangshipin.cn/* // @match https://www.1905.com/* // @match https://vip.1905.com/* // @match https://*.m1905.com/* // 覆盖 1905 CDN 子域(flvhd.vodfile 等直达 mp4;key1/key2 签名限时) // @match https://tv.sohu.com/* // @match https://m.tv.sohu.com/* // @match https://video.sina.com.cn/* // @match https://weibo.com/* // @match https://*.weibocdn.com/* // 微博视频 CDN(直达 m3u8/mp4,分段相对路径、无 token、任意 Referer 可下) // @match https://www.douyin.com/* // @match https://www.kuaishou.com/* // @match https://www.xiaohongshu.com/* // @match https://www.ixigua.com/* // @match https://cache.0567890.xyz/* // 第三方 Youku 代理缓存(直达 m3u8,分段在 cdn.hls.one,带静态 sign) // @match https://cdn.hls.one/* // 同上代理的分片 CDN(仅兜底,实际由粘贴框/边播边抓驱动) // @grant GM_xmlhttpRequest // @grant GM_download // @grant GM_registerMenuCommand // @connect * // @require https://cdn.jsdelivr.net/npm/@ffmpeg/ffmpeg@0.12.10/dist/umd/ffmpeg.js // @run-at document-idle // @license MIT // ==/UserScript== /* * 重要说明(请先读) * ------------------------------------------------------------------ * 1. window.__playinfo__ 是 B 站(Bilibili)页面特有的全局变量,结构为 * data.dash.{video[],audio[]}(DASH 分离流)或 data.durl[](老 FLV 单流)。 * 因此本脚本的“完整可用”解析器就是 B 站;其余平台也在此框架内,但 * 它们的流地址普遍经过签名 / 加密 / cookie 校验,纯前端无法稳定提取, * 需要各自的反向工程解析器。脚本预留了 parser 注册表,可自行扩展。 * 2. 下载通过 GM_xmlhttpRequest 发起(绕过浏览器同源 / CORS 限制), * 并自动附带 Referer 等必要请求头。合并使用 FFmpeg.wasm 在浏览器内完成, * 视频文件不会离开你的设备。 * 3. 请仅对拥有合法下载权的内容使用本脚本,并遵守各平台服务条款与相关法律法规。 */ (function () { 'use strict'; /* ============================ 0. 基础工具 ============================ */ // 安全的文件名 function safeName(s) { return (s || 'video') .replace(/[\\/:*?"<>|\n\r\t]+/g, '_') .replace(/\s+/g, ' ') .trim() .slice(0, 120) || 'video'; } // GM_xmlhttpRequest 封装为 Promise,支持进度回调 function gmFetch(url, opts = {}) { const { responseType = 'arraybuffer', headers = {}, onprogress } = opts; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType, headers, anonymous: false, // 带上本站 cookie,部分流需要鉴权 onprogress: (e) => { if (onprogress && e.lengthComputable) onprogress(e.loaded, e.total, e.lengthComputable); }, onload: (r) => { if (r.status >= 200 && r.status < 300) resolve(r); else reject(new Error('HTTP ' + r.status + ' ' + (r.statusText || ''))); }, onerror: (e) => reject(new Error('网络错误: ' + (e.error || e.statusText || ''))), onabort: () => reject(new Error('已取消')), ontimeout: () => reject(new Error('请求超时')), }); }); } // 通过 GM_xhr 拉取 FFmpeg 核心并转成 blob: URL(绕开页面 CSP,避免跨域) async function toBlobURL(url, mime) { const isText = mime.indexOf('javascript') !== -1; const r = await gmFetch(url, { responseType: isText ? 'text' : 'arraybuffer' }); const blob = isText ? new Blob([r.responseText], { type: mime }) : new Blob([r.response], { type: mime }); return URL.createObjectURL(blob); } function triggerDownload(blob, name) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 5000); } // 文本请求(用于 API / M3U8 播放列表) function gmFetchText(url, opts = {}) { return gmFetch(url, { responseType: 'text', headers: opts.headers || {} }) .then((r) => r.responseText); } // 解析可能带 JSONP 包裹的响应(name({...}) 或 name = {...}; 两种形式) function parseJsonp(text) { text = (text || '').trim(); const cb = text.match(/^[\w$]+\s*\(/); if (cb) { const i = text.indexOf('('); const j = text.lastIndexOf(')'); if (i !== -1 && j !== -1) text = text.slice(i + 1, j); } const eq = text.match(/^[\w$]+\s*=\s*/); if (eq) text = text.slice(eq[0].length); text = text.replace(/;\s*$/, ''); return JSON.parse(text); } // 芒果 TV tk2 签名:base64(utf8) → +/_ =/- → 反转 function encodeTk2(str) { let s = btoa(unescape(encodeURIComponent(str))); s = s.replace(/\+/g, '_').replace(/\//g, '~').replace(/=/g, '-'); return s.split('').reverse().join(''); } function resolveUrl(u, base) { try { return new URL(u, base).href; } catch (e) { return u; } } function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } // 纯 JS MD5(UTF-8 安全,爱奇艺 tmts / 搜狐 等签名用) function md5(input) { function rotateLeft(n, s) { return (n << s) | (n >>> (32 - s)); } function add(x, y) { const l = (x & 0xffff) + (y & 0xffff); const m = (x >> 16) + (y >> 16) + (l >> 16); return (m << 16) | (l & 0xffff); } function cmn(q, a, b, x, s, t) { a = add(add(a, q), add(x, t)); return add(rotateLeft(a, s), b); } function ff(a, b, c, d, x, s, t) { return cmn((b & c) | ((~b) & d), a, b, x, s, t); } function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & (~d)), a, b, x, s, t); } function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t); } function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | (~d)), a, b, x, s, t); } function utf8(s) { let out = ''; for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); if (c < 128) out += String.fromCharCode(c); else if (c < 2048) out += String.fromCharCode(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); else out += String.fromCharCode(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); } return out; } const str = utf8(input); const len = str.length; const words = []; for (let i = 0; i < len - 3; i += 4) { words.push(str.charCodeAt(i) | (str.charCodeAt(i + 1) << 8) | (str.charCodeAt(i + 2) << 16) | (str.charCodeAt(i + 3) << 24)); } const n = len & 3; let tail = 0; if (n === 1) tail = str.charCodeAt(len - 1); else if (n === 2) tail = str.charCodeAt(len - 2) | (str.charCodeAt(len - 1) << 8); else if (n === 3) tail = str.charCodeAt(len - 3) | (str.charCodeAt(len - 2) << 8) | (str.charCodeAt(len - 1) << 16); words.push(tail | (0x80 << (n * 8))); while (words.length % 16 !== 14) words.push(0); words.push(len << 3); words.push(len >>> 29); let a = 1732584193, b = -271733879, c = -1732584194, d = 271733878; for (let k = 0; k < words.length; k += 16) { const oa = a, ob = b, oc = c, od = d; a = ff(a, b, c, d, words[k + 0], 7, -680876936); d = ff(d, a, b, c, words[k + 1], 12, -389564586); c = ff(c, d, a, b, words[k + 2], 17, 606105819); b = ff(b, c, d, a, words[k + 3], 22, -1044525330); a = ff(a, b, c, d, words[k + 4], 7, -176418897); d = ff(d, a, b, c, words[k + 5], 12, 1200080426); c = ff(c, d, a, b, words[k + 6], 17, -1473231341); b = ff(b, c, d, a, words[k + 7], 22, -45705983); a = ff(a, b, c, d, words[k + 8], 7, 1770035416); d = ff(d, a, b, c, words[k + 9], 12, -1958414417); c = ff(c, d, a, b, words[k + 10], 17, -42063); b = ff(b, c, d, a, words[k + 11], 22, -1990404162); a = ff(a, b, c, d, words[k + 12], 7, 1804603682); d = ff(d, a, b, c, words[k + 13], 12, -40341101); c = ff(c, d, a, b, words[k + 14], 17, -1502002290); b = ff(b, c, d, a, words[k + 15], 22, 1236535329); a = gg(a, b, c, d, words[k + 1], 5, -165796510); d = gg(d, a, b, c, words[k + 6], 9, -1069501632); c = gg(c, d, a, b, words[k + 11], 14, 643717713); b = gg(b, c, d, a, words[k + 0], 20, -373897302); a = gg(a, b, c, d, words[k + 5], 5, -701558691); d = gg(d, a, b, c, words[k + 10], 9, 38016083); c = gg(c, d, a, b, words[k + 15], 14, -660478335); b = gg(b, c, d, a, words[k + 4], 20, -405537848); a = gg(a, b, c, d, words[k + 9], 5, 568446438); d = gg(d, a, b, c, words[k + 14], 9, -1019803690); c = gg(c, d, a, b, words[k + 3], 14, -187363961); b = gg(b, c, d, a, words[k + 8], 20, 1163531501); a = gg(a, b, c, d, words[k + 13], 5, -1444681467); d = gg(d, a, b, c, words[k + 2], 9, -51403784); c = gg(c, d, a, b, words[k + 7], 14, 1735328473); b = gg(b, c, d, a, words[k + 12], 20, -1926607734); a = hh(a, b, c, d, words[k + 5], 4, -378558); d = hh(d, a, b, c, words[k + 8], 11, -2022574463); c = hh(c, d, a, b, words[k + 11], 16, 1839030562); b = hh(b, c, d, a, words[k + 14], 23, -35309556); a = hh(a, b, c, d, words[k + 1], 4, -1530992060); d = hh(d, a, b, c, words[k + 4], 11, 1272893353); c = hh(c, d, a, b, words[k + 7], 16, -155497632); b = hh(b, c, d, a, words[k + 10], 23, -1094730640); a = hh(a, b, c, d, words[k + 13], 4, 681279174); d = hh(d, a, b, c, words[k + 0], 11, -358537222); c = hh(c, d, a, b, words[k + 3], 16, -722521979); b = hh(b, c, d, a, words[k + 6], 23, 76029189); a = ii(a, b, c, d, words[k + 0], 6, -198630844); d = ii(d, a, b, c, words[k + 7], 10, 1126891415); c = ii(c, d, a, b, words[k + 14], 15, -1416354905); b = ii(b, c, d, a, words[k + 5], 21, -57434055); a = ii(a, b, c, d, words[k + 12], 6, 1700485571); d = ii(d, a, b, c, words[k + 3], 10, -1894986606); c = ii(c, d, a, b, words[k + 10], 15, -1051523); b = ii(b, c, d, a, words[k + 1], 21, -2054922799); a = ii(a, b, c, d, words[k + 8], 6, 1873313359); d = ii(d, a, b, c, words[k + 15], 10, -30611744); c = ii(c, d, a, b, words[k + 6], 15, -1560198380); b = ii(b, c, d, a, words[k + 13], 21, 1309151649); a = ii(a, b, c, d, words[k + 4], 6, -145523070); d = ii(d, a, b, c, words[k + 11], 10, -1120210379); c = ii(c, d, a, b, words[k + 2], 15, 718787259); b = ii(b, c, d, a, words[k + 9], 21, -343485551); a = add(a, oa); b = add(b, ob); c = add(c, oc); d = add(d, od); } function hex(v) { let s = ''; for (let i = 0; i <= 3; i++) { const x = (v >>> (i * 8)) & 0xff; s += '0123456789abcdef'.charAt((x >>> 4) & 0xf) + '0123456789abcdef'.charAt(x & 0xf); } return s; } return hex(a) + hex(b) + hex(c) + hex(d); } // 解析 XML 文本为 Document(华数 / PPTV 用) function parseXml(text) { const dom = new DOMParser().parseFromString(text, 'application/xml'); return dom; } // 十六进制 <-> 字节 function hexToBytes(hex) { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); return out; } function bytesToHex(bytes) { let s = ''; for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0'); return s; } // 央视频 cKey 所需的 AES-128-CBC(固定密钥 / IV,来自前端播放器) async function aesCbcEncryptHex(text) { if (!crypto || !crypto.subtle) throw new Error('当前环境不支持 Web Crypto(需 HTTPS 页面)'); const keyBytes = hexToBytes('4E2918885FD98109869D14E0231A0BF4'); const ivBytes = hexToBytes('16B17E519DDD0CE5B79D7A63A4DD801C'); const enc = new TextEncoder(); let data = enc.encode(text); const padLen = 16 - (data.length % 16); const padded = new Uint8Array(data.length + padLen); padded.set(data); for (let i = data.length; i < padded.length; i++) padded[i] = padLen; const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-CBC' }, false, ['encrypt']); const ct = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ivBytes }, cryptoKey, padded); return bytesToHex(new Uint8Array(ct)).toUpperCase(); } // 央视频 cKey:固定盐 + 自定义 32 位哈希 + AES-CBC,结果前缀 "--01" async function genYangshipinCkey(vid, tt, guid, platform, originUrl, appVer) { const wu = `|${vid}|${tt}|mg3c3b04ba|${appVer}|${guid}|${platform}|${originUrl}|mozilla/5.0 (iphone; cpu||Mozilla|Netscape|Win32| `; let u = 0; for (let i = 0; i < wu.length; i++) { const code = wu.charCodeAt(i); u = (u << 5) - u + code; u = u & 0xffffffff; } const bu = u | 0; // 转有符号 32 位 const xu = `|${bu}${wu}`; return '--01' + (await aesCbcEncryptHex(xu)); } /* ============================ 1. FFmpeg.wasm 懒加载 ============================ */ let ffmpegInst = null; let ffmpegLoading = null; let onFfmpegLog = () => {}; let onFfmpegProgress = () => {}; async function getFFmpeg() { if (ffmpegInst) return ffmpegInst; if (ffmpegLoading) return ffmpegLoading; ffmpegLoading = (async () => { const FFmpegCls = (window.FFmpegWASM && window.FFmpegWASM.FFmpeg) || window.FFmpeg; if (!FFmpegCls) { throw new Error('FFmpeg.wasm 未加载,请检查脚本头部的 @require 是否可访问。'); } const inst = new FFmpegCls(); inst.on('log', ({ message }) => onFfmpegLog(message)); inst.on('progress', ({ progress }) => onFfmpegProgress(Math.max(0, Math.min(1, progress || 0)))); const base = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd'; const coreURL = await toBlobURL(`${base}/ffmpeg-core.js`, 'text/javascript'); const wasmURL = await toBlobURL(`${base}/ffmpeg-core.wasm`, 'application/wasm'); await inst.load({ coreURL, wasmURL }); ffmpegInst = inst; return inst; })(); return ffmpegLoading; } /* ============================ 2. 解析器 ============================ */ const VIDEO_QLABEL = { 127: '8K 超清', 126: '杜比视界', 125: 'HDR', 120: '4K 超清', 116: '1080P60', 112: '1080P+ 高码率', 80: '1080P 高清', 74: '720P60', 64: '720P 高清', 48: '720P', 32: '480P 清晰', 16: '360P 流畅', }; function getPageTitle() { try { const s = window.__INITIAL_STATE__; if (s && s.videoData && s.videoData.title) return s.videoData.title; } catch (e) {} const og = document.querySelector('meta[property="og:title"]'); if (og && og.content) return og.content.replace(/_哔哩哔哩_bilibili$/, '').trim(); return document.title.replace(/_哔哩哔哩_bilibili$/, '').trim(); } // --- B 站解析器(核心可用)--- function parseBilibili() { const raw = window.__playinfo__; if (!raw || !raw.data) return null; const data = raw.data; const title = getPageTitle(); if (data.dash) { const videos = (data.dash.video || []).map((v) => ({ kind: 'video', id: v.id, label: VIDEO_QLABEL[v.id] || ('清晰度' + v.id), bandwidth: v.bandwidth || 0, codecid: v.codecid, baseUrl: v.baseUrl, backupUrl: v.backupUrl || [], })); videos.sort((a, b) => b.bandwidth - a.bandwidth); const audios = (data.dash.audio || []).map((a) => ({ kind: 'audio', id: a.id, label: '音频 ' + ((a.bandwidth || 0) / 1000).toFixed(0) + 'kbps', bandwidth: a.bandwidth || 0, baseUrl: a.baseUrl, backupUrl: a.backupUrl || [], })); audios.sort((a, b) => b.bandwidth - a.bandwidth); if (!videos.length && !audios.length) return null; return { ok: true, platform: 'Bilibili', title, type: 'dash', videos, audios }; } if (data.durl) { const streams = data.durl.map((d, i) => ({ kind: 'single', id: i, label: 'FLV 单流 #' + (i + 1), size: d.size || 0, baseUrl: d.url, backupUrl: d.backup_url || [], })); return { ok: true, platform: 'Bilibili', title, type: 'single', streams }; } return null; } // --- 通用解析器:尝试在已知全局变量中找 DASH 结构 --- function parseGeneric() { // 任何站点若暴露了类似 B 站的 {data:{dash}} 结构都可直接复用 const candidates = [window.__playinfo__, window.__playInfo__]; for (const c of candidates) { if (c && c.data && c.data.dash) return parseBilibiliLike(c); } return null; } function parseBilibiliLike(raw) { const data = raw.data; const title = getPageTitle(); if (data.dash) { const videos = (data.dash.video || []).map((v) => ({ kind: 'video', id: v.id, label: VIDEO_QLABEL[v.id] || ('清晰度' + v.id), bandwidth: v.bandwidth || 0, baseUrl: v.baseUrl, backupUrl: v.backupUrl || [], })); const audios = (data.dash.audio || []).map((a) => ({ kind: 'audio', id: a.id, label: '音频', bandwidth: a.bandwidth || 0, baseUrl: a.baseUrl, backupUrl: a.backupUrl || [], })); videos.sort((a, b) => b.bandwidth - a.bandwidth); audios.sort((a, b) => b.bandwidth - a.bandwidth); return { ok: true, platform: location.hostname, title, type: 'dash', videos, audios }; } if (data.durl) { const streams = data.durl.map((d, i) => ({ kind: 'single', id: i, label: '单流 #' + (i + 1), size: d.size || 0, baseUrl: d.url, backupUrl: d.backup_url || [], })); return { ok: true, platform: location.hostname, title, type: 'single', streams }; } return null; } // --- 芒果 TV 解析器(真实可用)--- // 流程:tk2 签名 -> player/video 取 pm2 -> player/getSource 取 stream_domain + stream // 返回的每个清晰度是一条 M3U8 分片播放列表(部分清晰度可能是直链 mp4) async function parseMgtv() { // 1) 取 video_id:URL 形如 https://www.mgtv.com/b/{cid}/{vid}.html let vid = null; const m = location.pathname.match(/\/(\d+)\.html/); if (m) vid = m[1]; if (!vid) { const mm = document.body.innerHTML.match(/vid[: =]+(\d+)/i); if (mm) vid = mm[1]; } if (!vid) return null; const did = 'f11dee65-4e0d-4d25-bfce-719ad9dc991d'; const clit = Math.floor(Date.now() / 1000); const baseHeaders = { 'Referer': 'https://www.mgtv.com/', 'User-Agent': navigator.userAgent, 'Cookie': 'PM_CHKID=1', }; // 2) 取 pm2 const tk2_0 = encodeTk2(`did=${did}|pno=1030|ver=5.5.1|clit=${clit}`); let j1; try { const r1 = await gmFetchText( `https://pcweb.api.mgtv.com/player/video?video_id=${vid}&tk2=${tk2_0}&type=pch5&_support=10000000`, { headers: baseHeaders } ); j1 = parseJsonp(r1); } catch (e) { return { ok: false, platform: '芒果TV', error: '获取 pm2 失败:' + e.message }; } const atc = j1 && j1.data && j1.data.atc; const pm2 = atc && atc.pm2; if (!pm2) return { ok: false, platform: '芒果TV', error: (j1 && j1.msg) || '获取 pm2 失败' }; const tk2 = (atc.tk2) || tk2_0; // 优先用服务端回传的 tk2 // 3) 取播放源(pcweb 优先,失败回退 pstream) let j2 = null; for (const h of ['pcweb.api.mgtv.com', 'pstream.api.mgtv.com']) { try { const r2 = await gmFetchText( `https://${h}/player/getSource?_support=10000000&tk2=${tk2}&pm2=${pm2}` + `&video_id=${vid}&type=pch5&did=${did}&suuid=${uuid()}`, { headers: { 'Referer': 'https://www.mgtv.com/', 'User-Agent': navigator.userAgent } } ); const j = parseJsonp(r2); if (j && j.data && j.data.stream && j.data.stream.length) { j2 = j; break; } } catch (e) { /* 尝试下一个域名 */ } } if (!j2) return { ok: false, platform: '芒果TV', error: '获取播放源失败(可能需登录 / VIP)' }; const domains = j2.data.stream_domain || []; const streams = (j2.data.stream || []) .map((s) => { let url = s.url || ''; if (url && !/^https?:\/\//i.test(url) && domains.length) url = domains[0] + url; const isM3u8 = /\.m3u8/i.test(url); return { label: s.def || '未知清晰度', url, kind: isM3u8 ? 'm3u8' : 'single' }; }) .filter((s) => s.url); if (!streams.length) return { ok: false, platform: '芒果TV', error: '未返回可用清晰度' }; const title = (j2.data.info && (j2.data.info.title || j2.data.info.videoName)) || getPageTitle(); return { ok: true, platform: '芒果TV', title, type: 'm3u8', streams }; } // --- 央视频(yangshipin.cn)解析器(真实可用)--- // 流程:URL 取 vid/pid -> 生成 cKey(AES-128-CBC) -> playvv.yangshipin.cn/playvinfo // 取 vl.vi -> 拼 baseurl + fn?vkey=fvkey 得到播放地址(mp4 或 m3u8) async function parseYangshipin() { const q = new URLSearchParams(location.search); const vid = q.get('vid'); const pid = q.get('pid') || ''; if (!vid) return null; const tt = Math.floor(Date.now() / 1000); const guid = 'ko7djb70_vbjvrg5gcm'; // 固定设备号;部分付费内容可换成自己 cookie 里的 guid const platform = 4330701; // 两种 cKey 形态都试(不同端点 / 版本对来源 URL、appVer 略有差异) const ckeyCandidates = [ await genYangshipinCkey(vid, tt, guid, platform, 'https://w.yangshipin.cn/', '0.2.0'), await genYangshipinCkey(vid, tt, guid, platform, 'https://m.yangshipin.cn/', '3.0.37'), ]; const headers = { 'Referer': 'https://www.yangshipin.cn/', 'User-Agent': navigator.userAgent, }; // 1) 主端点:playvinfo(返回结构化清晰度列表) const defns = ['4k', 'fhd', 'shd', 'hd', 'sd']; const streams = []; let title = ''; outer: for (const ckey of ckeyCandidates) { for (const defn of defns) { try { const url = 'https://playvv.yangshipin.cn/playvinfo?' + new URLSearchParams({ guid, platform: String(platform), vid, defn, charge: '0', defaultfmt: 'auto', otype: 'json', defnpayver: '1', appVer: '0.2.0', sphttps: '1', sphls: '1', spwm: '4', dtype: '3', defsrc: '2', encryptVer: '8.1', sdtfrom: '4330701', cKey: ckey, flowid: uuid().replace(/-/g, ''), }).toString(); const j = parseJsonp(await gmFetchText(url, { headers })); const vi = j && j.vl && j.vl.vi && j.vl.vi[0]; if (!vi || !vi.fn) continue; const baseurl = vi.ul && vi.ul.ui && vi.ul.ui[0] && vi.ul.ui[0].url; if (!baseurl) continue; const playurl = baseurl + vi.fn + '?vkey=' + vi.fvkey; const isM3u8 = /\.m3u8/i.test(vi.fn); title = vi.ti || title; streams.push({ label: (vi.br || defn) + (isM3u8 ? '(m3u8)' : ''), url: playurl, baseUrl: playurl, kind: isM3u8 ? 'm3u8' : 'single', }); } catch (e) { /* 换下一个清晰度 / cKey */ } } if (streams.length) break outer; } if (streams.length) { return { ok: true, platform: '央视频', title: title || getPageTitle(), type: 'm3u8', streams }; } // 2) 兜底端点:liveinfo(返回单个 playurl) for (const ckey of ckeyCandidates) { try { const url = 'https://liveinfo.yangshipin.cn/?' + new URLSearchParams({ cmd: '2', cnlid: vid, pla: '0', stream: '2', system: '1', appVer: '3.0.37', encryptVer: '8.1', qq: '0', device: 'PC', guid, host: 'yangshipin.cn', livepid: pid, logintype: '1', vip_status: '1', livequeue: '1', fntick: String(tt), tm: String(tt), sdtfrom: '113', platform: String(platform), cKey: ckey, queueStatus: '0', uhd_flag: '4', flowid: uuid().replace(/-/g, ''), callback: 'txvlive_videoinfoget_9046016361', }).toString(); const j = parseJsonp(await gmFetchText(url, { headers })); const playurl = j && (j.playurl || j.url); if (playurl) { const isM3u8 = /\.m3u8/i.test(playurl); return { ok: true, platform: '央视频', title: getPageTitle(), type: isM3u8 ? 'm3u8' : 'single', streams: [{ label: isM3u8 ? 'm3u8 流' : '视频流', url: playurl, baseUrl: playurl, kind: isM3u8 ? 'm3u8' : 'single' }], }; } } catch (e) { /* 换下一个 cKey */ } } return { ok: false, platform: '央视频', error: '播放地址获取失败(cKey 校验未通过 / 接口已变更 / 需登录)' }; } // --- 腾讯视频(v.qq.com)解析器(真实可用)--- // 流程:取 vid -> vv.video.qq.com/getinfo(QZOutputJson 包裹)-> vl.vi[0] // 取 fn + fvkey + CDN -> 拼 url+fn?vkey=fvkey;遍历 fl.fi 列出全部清晰度 function extractTencentVid() { const m = location.pathname.match(/\/([a-zA-Z0-9]+)\.html/); if (m) return m[1]; const q = new URLSearchParams(location.search); if (q.get('vid')) return q.get('vid'); return null; } async function fetchTencentInfo(vid, defn) { const p = new URLSearchParams({ vids: vid, platform: '101001', charge: '0', otype: 'json', defnpayver: '1', }); if (defn) p.set('defn', defn); const txt = await gmFetchText('https://vv.video.qq.com/getinfo?' + p.toString(), { headers: { 'Referer': 'https://v.qq.com/', 'User-Agent': navigator.userAgent }, }); const j = parseJsonp(txt); if (j && j.s && j.s !== 'o') throw new Error('接口返回错误: ' + j.s); return j; } async function parseTencent() { const vid = extractTencentVid(); if (!vid) return null; let info; try { info = await fetchTencentInfo(vid, ''); } catch (e) { return { ok: false, platform: '腾讯视频', error: 'getinfo 请求失败:' + e.message }; } const vi0 = info && info.vl && info.vl.vi && info.vl.vi[0]; if (!vi0 || !vi0.fn) return { ok: false, platform: '腾讯视频', error: '未返回视频信息(可能需登录 / VIP)' }; const streams = []; const seen = new Set(); const addStream = (vi, label) => { if (!vi || !vi.fn || !vi.fvkey) return; if (seen.has(vi.fn)) return; seen.add(vi.fn); const ui = (vi.ul && vi.ul.ui) || []; const url = (ui[ui.length - 1] || ui[0] || {}).url; if (!url) return; const isM3u8 = /\.m3u8/i.test(vi.fn); const playurl = url + vi.fn + '?vkey=' + vi.fvkey; streams.push({ label: label + (isM3u8 ? '(m3u8)' : ''), url: playurl, baseUrl: playurl, kind: isM3u8 ? 'm3u8' : 'single', }); }; addStream(vi0, '默认(' + (vi0.br || '') + 'k)'); // 遍历 fl.fi 声明的清晰度,逐个请求对应 fn + fvkey const defs = (info.fl && info.fl.fi) || []; for (const f of defs) { if (!f.name) continue; try { const j = await fetchTencentInfo(vid, f.name); addStream(j && j.vl && j.vl.vi && j.vl.vi[0], f.cname || f.name); } catch (e) { /* 跳过该清晰度 */ } } if (!streams.length) return { ok: false, platform: '腾讯视频', error: '未解析出可下载流' }; const title = vi0.ti || getPageTitle(); return { ok: true, platform: '腾讯视频', title, type: 'single', streams }; } // --- 爱奇艺(iqiyi.com)解析器(实验性)--- // 现代 dash 接口需要 cmd5x(wasm) 混淆签名,纯前端难以内联; // 此处用旧版移动端 tmts 接口,vf = md5(请求路径 + 固定盐),可复现但端点可能已弃用。 async function parseIqiyi() { const html = document.documentElement.innerHTML; const tvid = (html.match(/tvid\s*[:=]\s*["']([^"']+)["']/) || [])[1] || (html.match(/["']tvid["']\s*:\s*["']([^"']+)["']/) || [])[1]; const vid = (html.match(/\bvid\s*[:=]\s*["']([^"']+)["']/) || [])[1] || (html.match(/["']vid["']\s*:\s*["']([^"']+)["']/) || [])[1]; if (!tvid || !vid) return { ok: false, platform: '爱奇艺', error: '未从页面取到 tvid/vid(请在播放页运行)' }; const ts = Math.floor(Date.now() / 1000); const qyid = uuid().replace(/-/g, ''); const fp = 'a16da00a581aa149139fe169e3914993e4ff9cb705a50e3a41fc7927f988f2cb3e'; const params = new URLSearchParams({ uid: '', cupid: 'qc_100001_100186', platForm: 'h5', qyid, agenttype: '13', type: 'mp4', nolimit: '', k_ft1: '8', rate: '1', sgti: '13_' + qyid + '_' + ts, codeflag: '1', preIDAll: '', dfp: fp, qd_v: '1', qdy: 'a', qds: '0', tm: String(ts), src: '02020031010000000000', callback: 'tmtsCallback', }); const path = '/jp/tmts/' + tvid + '/' + vid + '/?' + params.toString(); const vf = md5(path + '3sj8xof48xof4tk9f4tk9ypgk9ypg5ul'); const url = 'https://cache.m.iqiyi.com' + path + '&vf=' + vf; let j; try { j = parseJsonp(await gmFetchText(url, { headers: { 'Referer': 'https://www.iqiyi.com/', 'User-Agent': navigator.userAgent } })); } catch (e) { return { ok: false, platform: '爱奇艺', error: 'tmts 请求失败:' + e.message }; } const vidl = j && j.data && j.data.vidl; if (!vidl || !vidl.length) return { ok: false, platform: '爱奇艺', error: 'tmts 未返回流(端点可能已弃用 / 需登录 VIP)' }; const streams = []; for (const v of vidl) { const label = v.screen || v.rate || '清晰度'; if (v.m3u8) streams.push({ label: label + '(m3u8)', url: v.m3u8, baseUrl: v.m3u8, kind: 'm3u8' }); if (v.mp4) streams.push({ label: label + '(mp4)', url: v.mp4, baseUrl: v.mp4, kind: 'single' }); } if (!streams.length) return { ok: false, platform: '爱奇艺', error: '未解析出可用流' }; const title = (j.data && j.data.video && j.data.video.title) || getPageTitle(); return { ok: true, platform: '爱奇艺', title, type: 'm3u8', streams }; } // --- 优酷(youku.com)解析器(实验性 / 受限)--- // 优酷新接口需 ckey(由站点混淆 JS 动态生成,纯前端难以稳定复现),此处仅做兜底尝试。 async function parseYouku() { const m = location.pathname.match(/id_([^./]+)\.html/) || location.href.match(/vid=([^&]+)/); const vid = m ? m[1] : null; if (!vid) return { ok: false, platform: '优酷', error: '未从 URL 取到 vid' }; const url = 'https://ups.youku.com/ups/get.json?vid=' + encodeURIComponent(vid) + '&ccode=0508&client_ip=192.168.1.1&utid=&rg=0&v=1.0.0&ct=12'; let j; try { j = parseJsonp(await gmFetchText(url, { headers: { 'Referer': 'https://v.youku.com/', 'User-Agent': navigator.userAgent } })); } catch (e) { return { ok: false, platform: '优酷', error: 'ups 请求失败:' + e.message }; } const stream = (j && j.data && j.data.stream) || []; if (!stream.length) return { ok: false, platform: '优酷', error: '未返回流(需 ckey 强签名,纯前端难稳定复现,建议改用本地工具 yt-dlp)' }; const streams = []; for (const s of stream) { const urls = (s.segs || []).map((x) => x.playUrl || x.url).filter(Boolean); if (urls.length === 1) streams.push({ label: s.stream_type, url: urls[0], baseUrl: urls[0], kind: /\.m3u8/i.test(urls[0]) ? 'm3u8' : 'single' }); else if (urls.length) streams.push({ label: s.stream_type + `(${urls.length}段)`, parts: urls, kind: 'parts' }); } if (!streams.length) return { ok: false, platform: '优酷', error: '未解析出可用流' }; return { ok: true, platform: '优酷', title: getPageTitle(), type: 'm3u8', streams }; } // --- PPTV(v.pptv.com)解析器(实验性 / 旧端点已失效)--- // 实测(2026-08-17):web-play.pptv.com/webplay3-*.xml 返回 404(端点已变), // 新版走 getWebPlayInfoAddr + buildOnePlayVodSubStreamDetails 的 JS 加密(m3u8), // 纯前端难稳定复现。保留 webplay3 正则兜底,大概率会失败。 async function parsePptv() { const m = location.pathname.match(/show\/([A-Za-z0-9]+)/) || location.href.match(/id_([^./]+)/); const id = m ? m[1] : null; if (!id) return { ok: false, platform: 'PPTV', error: '未取到视频 id' }; const url = `https://web-play.pptv.com/webplay3-0-${id}.xml?o=0&version=6&type=mhpptv&appid=pptv.web.h5&appplt=web&appver=4.0.7&cb=a`; let xml; try { xml = await gmFetchText(url, { headers: { 'Referer': 'https://v.pptv.com/', 'User-Agent': navigator.userAgent } }); } catch (e) { return { ok: false, platform: 'PPTV', error: 'webplay3 请求失败:' + e.message }; } const streams = []; const m3u8 = [...xml.matchAll(/https?:\/\/[^"'\s\\]+\.m3u8[^"'\s\\]*/g)].map((x) => x[0]); if (m3u8.length) streams.push({ label: 'm3u8 流', url: m3u8[0], baseUrl: m3u8[0], kind: 'm3u8' }); const mp4 = [...xml.matchAll(/https?:\/\/[^"'\s\\]+\.mp4[^"'\s\\]*/g)].map((x) => x[0]); if (mp4.length) streams.push({ label: `mp4 分片(${mp4.length})`, parts: mp4, kind: 'parts' }); if (!streams.length) return { ok: false, platform: 'PPTV', error: 'webplay3 中未解析到地址(接口/结构可能已变更)' }; return { ok: true, platform: 'PPTV', title: getPageTitle(), type: 'm3u8', streams }; } // --- 搜狐(tv.sohu.com)解析器(可用)--- // 移动端 phone_playinfo 接口返回 m3u8 多清晰度 + mp4 多段,h5 端点可直接取。 async function parseSohu() { let vid = new URLSearchParams(location.search).get('vid'); if (!vid) { const mm = location.pathname.match(/\/v\/([^.\/]+)/); vid = mm ? mm[1] : null; } if (!vid) return { ok: false, platform: '搜狐', error: '未取到 vid' }; const api = 'http://m.tv.sohu.com/phone_playinfo?callback=jsonpx11&vid=' + encodeURIComponent(vid) + '&site=1&appid=tv&api_key=f351515304020cad28c92f70f002261c&plat=17&sver=1.0&partner=1'; let j; try { j = parseJsonp(await gmFetchText(api, { headers: { 'Referer': 'https://tv.sohu.com/', 'User-Agent': navigator.userAgent } })); } catch (e) { return { ok: false, platform: '搜狐', error: 'phone_playinfo 请求失败:' + e.message }; } const data = j && j.data; if (!data) return { ok: false, platform: '搜狐', error: '接口未返回数据' }; const streams = []; // mp4 / m3u8 是按画质分组的对象:{ nor:[分段URL...], hig:[...], ori:[...] } const collect = (obj, isM3u8) => { for (const k in obj) { const arr = (Array.isArray(obj[k]) ? obj[k] : [obj[k]]).filter(Boolean); if (!arr.length) continue; if (arr.length === 1) { streams.push({ label: k + (isM3u8 ? '(m3u8)' : '(mp4)'), url: arr[0], baseUrl: arr[0], kind: isM3u8 ? 'm3u8' : 'single' }); } else if (!isM3u8) { // mp4 多段:逐段下载后 concat 合并(可行) streams.push({ label: k + `(${arr.length}段)`, parts: arr, kind: 'parts' }); } // m3u8 多段暂不支持(需逐片下载 TS 再合并),跳过以免产出损坏文件 } }; const urlsObj = data.urls || {}; collect(urlsObj.mp4 || {}, false); collect(urlsObj.m3u8 || {}, true); // downloadUrl 兜底(二维数组) const du = urlsObj.downloadUrl || data.downloadUrl; if (du && du.length && !streams.length) { const urls = (Array.isArray(du[0]) ? du[0] : du).filter(Boolean); if (urls.length === 1) streams.push({ label: '下载地址', url: urls[0], baseUrl: urls[0], kind: 'single' }); else if (urls.length) streams.push({ label: `下载地址(${urls.length}段)`, parts: urls, kind: 'parts' }); } if (!streams.length) return { ok: false, platform: '搜狐', error: '未解析出可用流' }; return { ok: true, platform: '搜狐', title: data.video_name || getPageTitle(), type: 'm3u8', streams }; } // --- 华数TV(wasu.cn)解析器(实验性 / 旧端点已失效)--- // 实测(2026-08-17):www.wasu.cn/Api/getPlayInfoById 现返回 SPA 首页 HTML, // 旧 XML 端点已下線;新版鉴权需逆向新 API。保留逻辑供真机核对,大概率会失败。 async function parseWasu() { const m = location.pathname.match(/id\/(\d+)/) || location.href.match(/id[_=](\d+)/); const id = m ? m[1] : null; if (!id) return { ok: false, platform: '华数TV', error: '未取到视频 id' }; let xml; try { xml = await gmFetchText('http://www.wasu.cn/Api/getPlayInfoById/id/' + id + '/datatype/xml', { headers: { 'Referer': 'https://www.wasu.cn/', 'User-Agent': navigator.userAgent } }); } catch (e) { return { ok: false, platform: '华数TV', error: 'getPlayInfoById 失败:' + e.message }; } const keyM = xml.match(/([^<]+)<\/key>/) || xml.match(/key["']?\s*[:=]\s*["']([^"']+)["']/); const urlM = xml.match(/([^<]+)<\/url>/) || xml.match(/([^<]+)<\/videourl>/); if (!keyM || !urlM) return { ok: false, platform: '华数TV', error: 'XML 中未取到 key/url' }; let real; try { real = await gmFetchText('http://apiontime.wasu.cn/Auth/getVideoUrl?id=' + id + '&key=' + encodeURIComponent(keyM[1]) + '&url=' + encodeURIComponent(urlM[1]), { headers: { 'Referer': 'https://www.wasu.cn/', 'User-Agent': navigator.userAgent } }); } catch (e) { return { ok: false, platform: '华数TV', error: 'getVideoUrl 失败:' + e.message }; } const mp4 = (real.match(/https?:\/\/[^"'\s\\]+\.mp4[^"'\s\\]*/) || [])[0]; if (!mp4) return { ok: false, platform: '华数TV', error: '未解析出 mp4 地址' }; return { ok: true, platform: '华数TV', title: getPageTitle(), type: 'single', streams: [{ label: '视频流', url: mp4, baseUrl: mp4, kind: 'single' }] }; } // --- 1905 电影网(vip.1905.com)解析器(实验性)--- // 站点用 JS 动态鉴权,纯前端难稳定提取;此处兜底扫描页面内联的 m3u8/mp4。 async function parse1905() { const html = document.documentElement.innerHTML; const urls = [...html.matchAll(/https?:\/\/[^"'\s\\]+\.(?:m3u8|mp4)[^"'\s\\]*/g)].map((x) => x[0]); const m3 = urls.filter((u) => /\.m3u8/i.test(u)); const mp4 = urls.filter((u) => /\.mp4/i.test(u) && !/\.m3u8/i.test(u)); const streams = []; if (m3.length) streams.push({ label: 'm3u8 流', url: m3[0], baseUrl: m3[0], kind: 'm3u8' }); if (mp4.length) streams.push({ label: 'mp4 流', url: mp4[0], baseUrl: mp4[0], kind: 'single' }); if (!streams.length) return { ok: false, platform: '1905', error: '页面未内联可提取地址(1905 用 JS 动态鉴权,纯前端难稳定提取)' }; return { ok: true, platform: '1905', title: getPageTitle(), type: 'm3u8', streams }; } // --- 新浪视频(video.sina.com.cn)解析器(实验性)--- // 旧端点 v.iask.com/v_play.php?vid= 返回真实地址 XML,legacy 内容可能仍可用。 async function parseSina() { const m = location.pathname.match(/\/v\/b\/(\d+)/) || location.pathname.match(/\/v\/(\d+)/) || location.href.match(/vid=([^&]+)/); const vid = m ? m[1] : null; if (!vid) return { ok: false, platform: '新浪', error: '未取到 vid' }; let xml; try { xml = await gmFetchText('http://v.iask.com/v_play.php?vid=' + encodeURIComponent(vid), { headers: { 'User-Agent': navigator.userAgent } }); } catch (e) { return { ok: false, platform: '新浪', error: 'v_play.php 失败:' + e.message }; } const urls = [...xml.matchAll(/https?:\/\/[^"'\s\\]+\.(?:flv|mp4)[^"'\s\\]*/g)].map((x) => x[0]); if (!urls.length) return { ok: false, platform: '新浪', error: '未解析出地址(旧端点可能已弃用)' }; return { ok: true, platform: '新浪', title: getPageTitle(), type: 'single', streams: [{ label: '视频流', url: urls[0], baseUrl: urls[0], kind: 'single' }] }; } // 解析器注册表:扩展点。自行实现后在此登记即可让面板自动识别该站点。 // 例如: 'v.qq.com': parseTencent const PARSERS = { 'bilibili.com': parseBilibili, 'mgtv.com': parseMgtv, 'yangshipin.cn': parseYangshipin, 'qq.com': parseTencent, 'iqiyi.com': parseIqiyi, 'youku.com': parseYouku, 'pptv.com': parsePptv, 'sohu.com': parseSohu, 'wasu.cn': parseWasu, '1905.com': parse1905, 'sina.com.cn': parseSina, 'iask.com': parseSina, }; function detectParser() { const host = location.hostname; for (const key in PARSERS) { if (host.indexOf(key) !== -1) return PARSERS[key]; } return parseGeneric; } function pickUrl(item) { if (item.baseUrl) return item.baseUrl; if (item.url) return item.url; if (item.backupUrl && item.backupUrl.length) return item.backupUrl[0]; return null; } // 边播边抓:优先从内存缓存取字节(页面播放器已经下过的分片),缺失再走 GM_xhr 重新下载 async function getBytes(url, headers, onprogress) { if (CAP.useCache && CAP.seg.has(url)) { const buf = CAP.seg.get(url); if (onprogress) onprogress(buf.length, buf.length); return buf; } const r = await gmFetch(url, { headers, onprogress }); return new Uint8Array(r.response); } async function getTextCached(url, headers) { if (CAP.useCache && CAP.seg.has(url)) { return new TextDecoder().decode(CAP.seg.get(url)); } return gmFetchText(url, { headers }); } /* ============================ 3. 下载 + 合并 ============================ */ function hostOf(url) { try { return new URL(url).hostname; } catch (e) { return ''; } } function refererFor(host) { if (host.indexOf('bilibili') !== -1) return 'https://www.bilibili.com/'; if (host.indexOf('iqiyi') !== -1) return 'https://www.iqiyi.com/'; if (host.indexOf('youku') !== -1) return 'https://v.youku.com/'; if (host.indexOf('qq.com') !== -1) return 'https://v.qq.com/'; if (host.indexOf('mgtv') !== -1) return 'https://www.mgtv.com/'; if (host.indexOf('yangshipin') !== -1) return 'https://www.yangshipin.cn/'; if (host.indexOf('pptv') !== -1) return 'https://v.pptv.com/'; if (host.indexOf('sohu') !== -1) return 'https://tv.sohu.com/'; if (host.indexOf('wasu') !== -1) return 'https://www.wasu.cn/'; if (host.indexOf('1905') !== -1) return 'https://vip.1905.com/'; if (host.indexOf('sina') !== -1) return 'https://video.sina.com.cn/'; return location.origin + '/'; } async function downloadStream(item, onprogress) { const url = pickUrl(item); if (!url) throw new Error('未找到可用的流地址'); const ref = item.referer || refererFor(location.hostname); // 边播边抓:命中缓存则直接返回已下载的响应体(无需再向 CDN 请求,绕开短命 token) if (CAP.useCache && CAP.seg.has(url)) { const buf = CAP.seg.get(url); if (onprogress) onprogress(buf.length, buf.length); return buf; } const r = await gmFetch(url, { headers: { 'Referer': ref, 'User-Agent': navigator.userAgent, 'Origin': new URL(ref).origin, }, onprogress, }); return new Uint8Array(r.response); } // 手动粘贴的 m3u8 / mp4 直链:用链接自身域名作 Referer(带本机会话 cookie),走已有下载链路 // 同样享受边播边抓缓存(若本页播放时已缓存过对应分片,则免重下) async function downloadRawUrl(rawUrl, ui) { const url = (rawUrl || '').trim(); if (!url) { ui.setStatus('请先粘贴 m3u8 / mp4 链接'); return; } const host = hostOf(url) || location.hostname; const referer = 'https://' + host + '/'; const pathname = url.split('?')[0]; CAP.useCache = true; // 直链场景默认优先用已缓存分片(绕开短命 token) ui.setStatus('加载 FFmpeg 内核…'); await getFFmpeg(); if (/\.m3u8(\?|$)/i.test(pathname) || /m3u8/i.test(pathname)) { await downloadM3u8Stream({ title: host + ' 直链' }, { url, baseUrl: url, kind: 'm3u8', referer }, ui); } else if (/\.(mp4|flv|m4s|webm|mov|mkv)(\?|$)/i.test(pathname)) { ui.setStatus('下载单文件…'); const buf = await downloadStream({ url, baseUrl: url, referer, kind: 'single' }, (l, t) => ui.setVideo(l, t)); triggerDownload(new Blob([buf], { type: 'video/mp4' }), safeName(host + '-video') + '.mp4'); ui.setMerge(1); ui.setStatus('完成:已触发浏览器下载。'); } else { ui.setStatus('无法识别后缀,按 M3U8 尝试…'); await downloadM3u8Stream({ title: host + ' 直链' }, { url, baseUrl: url, kind: 'm3u8', referer }, ui); } } // DASH:下载视频 + 音频,FFmpeg 合并 async function downloadAndMergeDash(parsed, videoItem, audioItem, ui) { const ff = await getFFmpeg(); ui.setStatus('正在加载 FFmpeg 内核(首次约 31MB)…'); ui.setMerge(0.02); ui.setStatus('下载视频流…'); const vBuf = await downloadStream(videoItem, (loaded, total) => ui.setVideo(loaded, total)); ui.setStatus('下载音频流…'); const aBuf = await downloadStream(audioItem, (loaded, total) => ui.setAudio(loaded, total)); await ff.writeFile('v.m4s', vBuf); await ff.writeFile('a.m4s', aBuf); ui.setStatus('FFmpeg 合并音视频中…'); await ff.exec(['-i', 'v.m4s', '-i', 'a.m4s', '-c', 'copy', '-movflags', '+faststart', 'out.mp4']); const data = await ff.readFile('out.mp4'); const blob = new Blob([data], { type: 'video/mp4' }); triggerDownload(blob, safeName(parsed.title) + '.mp4'); try { await ff.deleteFile('v.m4s'); await ff.deleteFile('a.m4s'); await ff.deleteFile('out.mp4'); } catch (e) {} ui.setMerge(1); ui.setStatus('完成:已触发浏览器下载。'); } // 单流(FLV / 已含音视频):直接下载 async function downloadSingle(parsed, item, ui) { ui.setStatus('下载视频(含音轨)…'); const buf = await downloadStream(item, (loaded, total) => ui.setVideo(loaded, total)); const blob = new Blob([buf], { type: 'video/mp4' }); triggerDownload(blob, safeName(parsed.title) + '.mp4'); ui.setStatus('完成:已触发浏览器下载。'); } // 多段 mp4:逐段下载写入 FFmpeg FS,用 concat 解复用器合并为 mp4(搜狐/PPTV/优酷 多段流) async function downloadPartsStream(parsed, item, ui) { const ff = await getFFmpeg(); const referer = refererFor(location.hostname); const parts = item.parts || []; const total = parts.length; ui.setStatus('下载分片 0/' + total + '…'); ui.setMerge(0.02); const listLines = []; for (let i = 0; i < total; i++) { const r = await gmFetch(parts[i], { headers: { 'Referer': referer, 'User-Agent': navigator.userAgent }, onprogress: (l, t) => ui.setVideo(((i + (t ? l / t : 0)) / total) * 100, 100), }); const name = `p${String(i).padStart(4, '0')}.mp4`; await ff.writeFile(name, new Uint8Array(r.response)); listLines.push(`file '${name}'`); } await ff.writeFile('list.txt', listLines.join('\n')); ui.setStatus('FFmpeg 合并分片…'); await ff.exec(['-f', 'concat', '-safe', '0', '-i', 'list.txt', '-c', 'copy', '-movflags', '+faststart', 'out.mp4']); const data = await ff.readFile('out.mp4'); triggerDownload(new Blob([data], { type: 'video/mp4' }), safeName(parsed.title) + '.mp4'); for (let i = 0; i < total; i++) { try { await ff.deleteFile(`p${String(i).padStart(4, '0')}.mp4`); } catch (e) {} } try { await ff.deleteFile('list.txt'); await ff.deleteFile('out.mp4'); } catch (e) {} ui.setMerge(1); ui.setStatus('完成:已触发浏览器下载。'); } // M3U8:拉取播放列表 -> 处理主/媒体嵌套、AES-128 密钥(#EXT-X-KEY)与初始化段(#EXT-X-MAP) // -> 全部改写为 FFmpeg FS 内本地相对路径 -> 由 FFmpeg 原生处理加密与合并。 // 这样即便 iQiyi / 芒果 / 央视频 等站点是加密 HLS,密钥也能经 GM_xmlhttpRequest(绕过 CORS)取得并本地化。 async function downloadM3u8Stream(parsed, item, ui) { const ff = await getFFmpeg(); const referer = item.referer || refererFor(hostOf(item.url) || location.hostname); ui.setStatus('获取 M3U8 播放列表…'); ui.setMerge(0.02); const fetchText = (u) => getTextCached(u, { headers: { 'Referer': referer } }); // 1) 取媒体播放列表(master -> 选带宽最高的变体) let topUrl = item.url; let top = await fetchText(topUrl); let lines = top.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); const hasMaster = lines.some((l) => l[0] !== '#' && /\.m3u8/i.test(l)); if (hasMaster) { let best = null, bestBw = -1, pending = null; for (const l of lines) { if (l.indexOf('#EXT-X-STREAM-INF') === 0) { const bw = +(l.match(/BANDWIDTH=(\d+)/) || [0, 0])[1]; if (bw > bestBw) { bestBw = bw; pending = l; } } else if (pending && l[0] !== '#' && /\.m3u8/i.test(l)) { best = resolveUrl(l, topUrl); pending = null; } } if (best) { topUrl = best; top = await fetchText(topUrl); lines = top.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); ui.log('检测到主播放列表,选择变体(BANDWIDTH=' + bestBw + ')'); } } // 2) 解析并下载密钥 / 初始化段(在改写前取原始 URI) let keyUri = null; const keyLine = lines.find((l) => l.indexOf('#EXT-X-KEY') === 0); if (keyLine && !/METHOD=NONE/i.test(keyLine)) { const mm = keyLine.match(/URI="([^"]+)"/); if (mm) keyUri = resolveUrl(mm[1], topUrl); } let mapUri = null; const mapLine = lines.find((l) => l.indexOf('#EXT-X-MAP') === 0); if (mapLine) { const mm = mapLine.match(/URI="([^"]+)"/); if (mm) mapUri = resolveUrl(mm[1], topUrl); } if (keyUri) { const fromCache = CAP.useCache && CAP.seg.has(keyUri); ui.log('检测到加密流,' + (fromCache ? '使用缓存密钥' : '下载解密密钥') + '…'); const kb = await getBytes(keyUri, { headers: { 'Referer': referer } }); await ff.writeFile('key.bin', kb); } if (mapUri) { const fromCache = CAP.useCache && CAP.seg.has(mapUri); ui.log('下载初始化段(EXT-X-MAP)' + (fromCache ? '(缓存)' : '') + '…'); const mb = await getBytes(mapUri, { headers: { 'Referer': referer } }); await ff.writeFile('init.mp4', mb); } // 3) 收集分片并改写播放列表为本地相对路径 const segs = []; for (const line of lines) { if (line[0] === '#') continue; const u = resolveUrl(line, topUrl); const ext = (u.split('?')[0].match(/\.(\w+)$/) || [, 'ts'])[1] || 'ts'; segs.push({ url: u, name: `seg${String(segs.length).padStart(4, '0')}.${ext}` }); } if (!segs.length) throw new Error('播放列表为空或解析失败'); let m3u8 = top; if (keyUri) m3u8 = m3u8.split(keyUri).join('key.bin'); if (mapUri) m3u8 = m3u8.split(mapUri).join('init.mp4'); for (const s of segs) m3u8 = m3u8.split(s.url).join(s.name); await ff.writeFile('index.m3u8', m3u8); // 4) 下载全部分片(GM_xmlhttpRequest 绕过 CORS) const total = segs.length; const cachedCount = segs.filter((s) => CAP.useCache && CAP.seg.has(s.url)).length; ui.setStatus('下载分片 0/' + total + '…'); ui.log('共 ' + total + ' 个分片' + (keyUri ? '(已本地化密钥)' : '') + (cachedCount ? `,其中 ${cachedCount} 个来自边播边抓缓存` : '')); for (let i = 0; i < segs.length; i++) { const r = await getBytes(segs[i].url, { headers: { 'Referer': referer }, onprogress: (l, t) => ui.setVideo(((i + (t ? l / t : 0)) / total) * 100, 100), }); await ff.writeFile(segs[i].name, r); } // 5) FFmpeg 原生处理 HLS(含 AES-128 解密)+ 合并为 mp4 ui.setStatus('FFmpeg 合并(含解密)…'); await ff.exec(['-allowed_extensions', 'ALL', '-protocol_whitelist', 'file,http,https,tcp,tls,crypto', '-i', 'index.m3u8', '-c', 'copy', '-movflags', '+faststart', 'out.mp4']); const data = await ff.readFile('out.mp4'); triggerDownload(new Blob([data], { type: 'video/mp4' }), safeName(parsed.title) + '.mp4'); for (let i = 0; i < segs.length; i++) { try { await ff.deleteFile(segs[i].name); } catch (e) {} } try { await ff.deleteFile('index.m3u8'); if (keyUri) await ff.deleteFile('key.bin'); if (mapUri) await ff.deleteFile('init.mp4'); await ff.deleteFile('out.mp4'); } catch (e) {} ui.setMerge(1); ui.setStatus('完成:已触发浏览器下载。'); } /* ============================ 3.5 网络捕获兜底(SPA / 动态流站点) ============================ */ // 适用:华数 wap、1905、以及任何播放器自行请求 m3u8/mp4 的 SPA 站点。 // 原理:猴子补丁 window.fetch + XMLHttpRequest,截获页面自身发出的媒体地址(请求 URL 与响应体)。 // 这样无需逐个逆向各站私有接口——页面播放时必然请求真实流,我们截到即可下载。 // 边播边抓缓存: // - list / seen:已捕获的媒体「地址」(m3u8 / mp4 等,供面板选择) // - seg:url -> Uint8Array,页面播放器实际请求过的「分片 / 密钥 / 播放列表响应体」 // 播放完直接合并,无需再向 CDN 重下(可绕开 iQiyi 等短命 token 防盗链 D2102) const CAP = { list: [], seen: new Set(), seg: new Map(), // url -> Uint8Array(已缓存的响应体) bytes: 0, // 当前缓存占用字节数 maxBytes: 2 * 1024 * 1024 * 1024, // 2GB 软上限:超出后停止写入新分片,避免内存爆炸 persistCap: 1500 * 1024 * 1024, // IndexedDB 持久化上限:超出后仅留内存,避免超额配额 useCache: true, // 下载时优先使用已缓存分片 }; function capIsMediaBody(url) { return /\.(ts|m4s|key|mp4|m4a|aac|m4v|webm|m3u8)(\?|$)/i.test(url) || /playlist\.m3u8|index\.m3u8/i.test(url); } // 缓存一个响应体(自动遵守软上限) function capBody(url, buf) { if (!url || !buf || buf.length === 0) return; if (CAP.seg.has(url)) return; if (buf.byteLength === undefined && buf.length !== undefined) buf = new Uint8Array(buf); if (CAP.bytes + buf.length > CAP.maxBytes) { console.warn('[vd] 边播边抓缓存已达软上限(' + (CAP.maxBytes / 1048576 / 1024).toFixed(0) + 'GB),停止缓存新分片;已有缓存仍可用于合并。'); return; } CAP.seg.set(url, buf instanceof Uint8Array ? buf : new Uint8Array(buf)); CAP.bytes += buf.length; // 持久化到 IndexedDB(刷新后仍能复用);受 persistCap 限制,避免超额配额 if (CAP.bytes <= CAP.persistCap) IDB.put(url, CAP.seg.get(url)); } function capAdd(url, src) { if (!url || typeof url !== 'string') return; if (CAP.seen.has(url)) return; // 只保留媒体相关地址 if (!/\.(m3u8|mp4|ts|flv|m4s|key)(\?|$)/i.test(url) && !/playlist\.m3u8|m3u8\?/i.test(url)) return; CAP.seen.add(url); const kind = /\.m3u8/i.test(url) || /playlist\.m3u8/i.test(url) ? 'm3u8' : 'single'; CAP.list.push({ url, kind, label: (kind === 'm3u8' ? 'M3U8·' : '视频·') + src + ' ' + url.slice(0, 64), baseUrl: url }); } function installCapture() { if (window.__vd_cap_installed) return; window.__vd_cap_installed = true; try { const realFetch = window.fetch.bind(window); window.fetch = function (input, init) { const u = typeof input === 'string' ? input : (input && input.url) || ''; if (u) capAdd(u, 'fetch'); return realFetch(input, init).then(async (resp) => { try { const ct = (resp.headers && resp.headers.get && resp.headers.get('content-type')) || ''; const isMedia = capIsMediaBody(u) || /mpegurl|mp2t|video\//i.test(ct); // 边播边抓:缓存分片 / 密钥 / 播放列表的响应体(clone 不消耗原响应,播放器不受影响) if (isMedia && CAP.useCache && resp.clone) { try { const ab = await resp.clone().arrayBuffer(); if (ab && ab.byteLength) capBody(u, new Uint8Array(ab)); } catch (e) {} } // 文本 / JSON 响应体里可能内嵌更多媒体地址 if (/json|text|xml|mpegurl/i.test(ct) && resp.clone) { const txt = await resp.clone().text(); const re = /(https?:\/\/[^\s"'\\]+?\.(?:m3u8|mp4|ts|flv|m4s)(?:\?[^"'\\]*)?)/gi; let m; while ((m = re.exec(txt))) capAdd(m[1], 'fetch-body'); } } catch (e) {} return resp; }); }; } catch (e) {} try { const RealOpen = XMLHttpRequest.prototype.open; const RealSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url) { this.__vd_url = url; return RealOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { const u = this.__vd_url; if (u) capAdd(u, 'xhr'); this.addEventListener('load', () => { try { const ct = (this.getResponseHeader && this.getResponseHeader('content-type')) || ''; const isMedia = capIsMediaBody(u) || /mpegurl|mp2t|video\//i.test(ct); // 边播边抓:缓存分片 / 密钥 / 播放列表响应体(只读 this.response,不消耗播放器数据) if (isMedia && CAP.useCache) { const r = this.response; if (r instanceof ArrayBuffer) { if (r.byteLength) capBody(u, new Uint8Array(r)); } else if (typeof Blob !== 'undefined' && r instanceof Blob) { r.arrayBuffer().then((ab) => { if (ab && ab.byteLength) capBody(u, new Uint8Array(ab)); }).catch(() => {}); } else if (typeof r === 'string') { const b = new TextEncoder().encode(r); if (b.length) capBody(u, b); } } if (/json|text|xml|mpegurl/i.test(ct)) { const txt = this.responseText || ''; const re = /(https?:\/\/[^\s"'\\]+?\.(?:m3u8|mp4|ts|flv|m4s)(?:\?[^"'\\]*)?)/gi; let m; while ((m = re.exec(txt))) capAdd(m[1], 'xhr-body'); } } catch (e) {} }); return RealSend.apply(this, arguments); }; } catch (e) {} } function captureToParsed() { if (!CAP.list.length) return null; const streams = CAP.list.map((s) => ({ label: s.label, url: s.url, baseUrl: s.url, kind: s.kind })); return { ok: true, platform: '网络捕获', title: getPageTitle(), type: 'single', streams }; } /* ============================ 3.6 IndexedDB 持久化 + 限时签名识别 ============================ */ // IndexedDB:把边播边抓的分片响应体落盘,刷新页面后仍能复用(避免重下,尤其限时签名链接) const IDB = (function () { let dbp = null; function open() { if (dbp) return dbp; dbp = new Promise((res, rej) => { try { const req = indexedDB.open('vd-cache-db', 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains('segments')) db.createObjectStore('segments', { keyPath: 'url' }); }; req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); } catch (e) { rej(e); } }); return dbp; } function put(url, buf) { return open().then((db) => new Promise((res, rej) => { try { const tx = db.transaction('segments', 'readwrite'); tx.objectStore('segments').put({ url, buf: buf.buffer || buf, ts: Date.now() }); tx.oncomplete = res; tx.onerror = () => rej(tx.error); } catch (e) { rej(e); } })).catch(() => {}); } function loadAll() { return open().then((db) => new Promise((res, rej) => { const out = []; const tx = db.transaction('segments', 'readonly'); const cur = tx.objectStore('segments').openCursor(); cur.onsuccess = () => { const c = cur.result; if (c) { out.push(c.value); c.continue(); } else res(out); }; cur.onerror = () => rej(cur.error); })).catch(() => []); } function clear() { return open().then((db) => new Promise((res, rej) => { const tx = db.transaction('segments', 'readwrite'); tx.objectStore('segments').clear(); tx.oncomplete = res; tx.onerror = () => rej(tx.error); })).catch(() => {}); } return { put, loadAll, clear }; })(); // 限时签名识别:返回 { kind, ts(ms), label } 或 null // - PPTV: k=...-<10 位 unix 时间戳>(推测失效时间) // - 1905: key1=YYYYMMDDHHMM(签发时间) function parseTokenInfo(url) { if (!url) return null; let m; if ((m = url.match(/[?&]k=[^&]*?-(\d{10})(?:&|$)/))) { return { kind: 'PPTV k', ts: (+m[1]) * 1000, label: 'PPTV 签名时间戳(k)' }; } if ((m = url.match(/[?&]key1=(\d{12})(?:&|$)/))) { const s = m[1]; const ts = new Date(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8), +s.slice(8, 10), +s.slice(10, 12)).getTime(); return { kind: '1905 key1', ts, label: '1905 签名时间(key1)' }; } return null; } function fmtDur(sec) { sec = Math.abs(sec); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = Math.floor(sec % 60); if (h) return h + ' 小时 ' + m + ' 分'; if (m) return m + ' 分 ' + s + ' 秒'; return s + ' 秒'; } // 在 #vd-raw-info 上渲染限时签名预警 function renderTokenInfo(url) { const info = document.getElementById('vd-raw-info'); if (!info) return; const t = parseTokenInfo(url); if (!t) { info.innerHTML = ''; info.style.color = ''; return; } const now = Date.now(); const abs = new Date(t.ts).toLocaleString(); if (t.kind === 'PPTV k') { const diff = (t.ts - now) / 1000; if (diff > 0) { info.style.color = diff < 1800 ? '#c60' : '#187'; info.innerHTML = '🕒 PPTV 限时签名:时间戳推测约 ' + fmtDur(diff) + ' 后失效
' + abs + '(k token,过期后整条 403)'; } else { info.style.color = '#c00'; info.innerHTML = '⚠️ PPTV 签名时间戳已过期 ' + fmtDur(-diff) + '(链接大概率已 403)
' + abs + ''; } } else { info.style.color = '#c60'; info.innerHTML = '🕒 1905 限时签名(key1 签发于 ' + abs + '):签名链接,建议尽快下载
CDN 大概率有有效期窗口,过期后 403'; } } /* ============================ 4. UI 面板 ============================ */ function buildUI() { if (document.getElementById('vd-panel')) return document.getElementById('vd-panel').__api; const css = ` #vd-panel{position:fixed;right:16px;bottom:16px;width:320px;max-height:80vh;overflow:auto; background:#fff;color:#222;border:1px solid #d0d7de;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.18); font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;font-size:13px;z-index:2147483647;} #vd-panel h3{margin:0;padding:10px 12px;background:#fb7299;color:#fff;font-size:14px;cursor:move; display:flex;justify-content:space-between;align-items:center;} #vd-panel .body{padding:10px 12px;} #vd-panel label{display:block;margin:8px 0 3px;color:#555;} #vd-panel select,#vd-panel button{width:100%;box-sizing:border-box;padding:6px 8px;border-radius:6px; border:1px solid #ccd1d9;background:#fafafa;color:#222;font-size:13px;} #vd-panel button{background:#fb7299;color:#fff;border:none;cursor:pointer;margin-top:10px;font-weight:600;} #vd-panel button:disabled{background:#f3b6c8;cursor:not-allowed;} #vd-panel button.alt{background:#fff;color:#fb7299;border:1px solid #fb7299;} #vd-panel .bar{height:8px;background:#eee;border-radius:6px;overflow:hidden;margin-top:4px;} #vd-panel .bar>i{display:block;height:100%;width:0;background:#00a1d6;transition:width .2s;} #vd-panel .row{margin:6px 0;} #vd-panel .status{margin-top:8px;padding:6px 8px;background:#f6f8fa;border-radius:6px;color:#444;min-height:18px;} #vd-panel .log{margin-top:6px;max-height:90px;overflow:auto;color:#888;font-size:11px;white-space:pre-wrap;} #vd-panel .close{cursor:pointer;font-weight:700;} #vd-panel .hint{color:#b00;font-size:12px;line-height:1.5;} #vd-panel .chk{display:flex;align-items:center;gap:6px;color:#444;font-weight:400;margin:6px 0 2px;cursor:pointer;} `; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); const panel = document.createElement('div'); panel.id = 'vd-panel'; panel.innerHTML = `

视频流下载器×

准备中…
视频下载 0%
音频下载 0%
合并 0%
`; document.body.appendChild(panel); const el = (id) => panel.querySelector(id); const api = { setStatus: (t) => { el('#vd-status').textContent = t; }, log: (t) => { const l = el('#vd-log'); l.textContent += t + '\n'; l.scrollTop = l.scrollHeight; }, setVideo: (loaded, total) => { const p = total ? Math.min(100, (loaded / total) * 100) : 0; el('#vd-vbar').style.width = p + '%'; el('#vd-vtxt').textContent = '视频下载 ' + p.toFixed(0) + '%'; }, setAudio: (loaded, total) => { const p = total ? Math.min(100, (loaded / total) * 100) : 0; el('#vd-abar').style.width = p + '%'; el('#vd-atxt').textContent = '音频下载 ' + p.toFixed(0) + '%'; }, setMerge: (r) => { const p = Math.min(100, r * 100); el('#vd-mbar').style.width = p + '%'; el('#vd-mtxt').textContent = '合并 ' + p.toFixed(0) + '%'; }, fillStreams: (parsed) => { const vSel = el('#vd-video'), aSel = el('#vd-audio'); vSel.innerHTML = ''; aSel.innerHTML = ''; if (parsed.type === 'dash') { parsed.videos.forEach((v, i) => { const o = document.createElement('option'); o.value = i; o.textContent = v.label + '(' + (v.bandwidth / 1000).toFixed(0) + 'kbps)'; vSel.appendChild(o); }); parsed.audios.forEach((a, i) => { const o = document.createElement('option'); o.value = i; o.textContent = a.label; aSel.appendChild(o); }); } else { parsed.streams.forEach((s, i) => { const o = document.createElement('option'); o.value = i; o.textContent = s.label + (s.size ? '(' + (s.size / 1048576).toFixed(1) + 'MB)' : ''); vSel.appendChild(o); }); aSel.style.display = 'none'; el('#vd-audio').previousElementSibling.style.display = 'none'; } }, setHint: (t) => { el('#vd-hint').innerHTML = t || ''; }, setCapture: (list) => { const sel = el('#vd-cap'); sel.innerHTML = ''; if (!list.length) { sel.innerHTML = ''; return; } list.forEach((s, i) => { const o = document.createElement('option'); o.value = i; o.textContent = s.label; sel.appendChild(o); }); }, applyCapture: () => { const parsed = captureToParsed(); if (!parsed) { api.setStatus('暂未捕获到流,请先在本页播放视频。'); return; } api_parsed = parsed; api.parsed = parsed; api.fillStreams(parsed); api.setStatus('网络捕获到 ' + parsed.streams.length + ' 条流,选择后点解析并下载'); api.setHint(''); }, parsed: null, }; panel.__api = api; onFfmpegLog = (m) => api.log(m.slice(0, 200)); onFfmpegProgress = (r) => api.setMerge(r); el('.close').addEventListener('click', () => { panel.style.display = 'none'; }); // 拖动 let drag = false, ox = 0, oy = 0; el('h3').addEventListener('mousedown', (e) => { if (e.target.classList.contains('close')) return; drag = true; ox = e.clientX - panel.offsetLeft; oy = e.clientY - panel.offsetTop; }); document.addEventListener('mousemove', (e) => { if (!drag) return; panel.style.left = (e.clientX - ox) + 'px'; panel.style.top = (e.clientY - oy) + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); document.addEventListener('mouseup', () => { drag = false; }); el('#vd-ff').addEventListener('click', async () => { try { api.setStatus('加载 FFmpeg 内核中…'); await getFFmpeg(); api.setStatus('FFmpeg 内核就绪。'); } catch (e) { api.setStatus('内核加载失败:' + e.message); } }); el('#vd-cap-refresh').addEventListener('click', () => { api.setCapture(CAP.list.slice()); api.applyCapture(); }); el('#vd-cache-first').addEventListener('change', () => { CAP.useCache = el('#vd-cache-first').checked; }); // 粘贴框输入时实时识别限时签名 el('#vd-raw').addEventListener('input', () => renderTokenInfo(el('#vd-raw').value)); // 限时签名倒计时(每 30s 刷新一次提示) setInterval(() => { const v = el('#vd-raw').value; if (parseTokenInfo(v)) renderTokenInfo(v); }, 30000); // 清空缓存(内存 + IndexedDB) el('#vd-cache-clear').addEventListener('click', async () => { CAP.seg.clear(); CAP.bytes = 0; await IDB.clear(); api.setStatus('已清空边播边抓缓存(含 IndexedDB)。'); api.log('缓存已清空'); }); // 播放到末尾自动触发合并(边播边抓):监控页面 video,ended 时自动点合并 (function setupAutoMerge() { const wired = new WeakSet(); const tryWire = () => { if (!el('#vd-automerge').checked) return; const v = document.querySelector('video'); if (!v || wired.has(v)) return; wired.add(v); const fire = () => { if (!el('#vd-automerge').checked) return; if (CAP.seg.size > 0) { api.log('检测到播放结束,自动合并已缓存分片…'); el('#vd-cap-merge').click(); } else { api.setStatus('播放已结束,但边播边抓缓存为空,无法自动合并(请先完整播放)。'); } }; v.addEventListener('ended', fire); // 兜底:部分播放器在末尾暂停而不触发 ended v.addEventListener('timeupdate', () => { if (v.duration && v.currentTime >= v.duration - 0.6 && v.paused) fire(); }); }; const iv = setInterval(tryWire, 2000); setTimeout(() => clearInterval(iv), 10 * 60 * 1000); // 最多监控 10 分钟 })(); // 边播边抓:直接合并已缓存的分片响应体,无需再向 CDN 重下 el('#vd-cap-merge').addEventListener('click', async () => { const sel = el('#vd-cap'); const item = CAP.list[+sel.value]; if (!item) { api.setStatus('请先在捕获列表选择一条流,或先在本页播放视频。'); return; } CAP.useCache = el('#vd-cache-first').checked; try { el('#vd-cap-merge').disabled = true; if (item.kind === 'm3u8') { await downloadM3u8Stream({ title: getPageTitle() }, { url: item.url, baseUrl: item.url, kind: 'm3u8' }, api); } else { // 单文件(mp4 等):缓存命中则直接导出,否则回退重下 if (CAP.useCache && CAP.seg.has(item.url)) { const buf = CAP.seg.get(item.url); triggerDownload(new Blob([buf], { type: 'video/mp4' }), safeName(getPageTitle()) + '.mp4'); api.setMerge(1); api.setStatus('完成:已用边播边抓缓存直接导出(' + (buf.length / 1048576).toFixed(0) + ' MB)。'); } else { const buf = await downloadStream({ url: item.url, baseUrl: item.url, kind: 'single' }, (l, t) => api.setVideo(l, t)); triggerDownload(new Blob([buf], { type: 'video/mp4' }), safeName(getPageTitle()) + '.mp4'); api.setMerge(1); api.setStatus('完成:已触发浏览器下载。'); } } } catch (e) { api.setStatus('合并失败:' + e.message); api.log('边播边抓错误: ' + e.message); } finally { el('#vd-cap-merge').disabled = false; } }); el('#vd-raw-go').addEventListener('click', async () => { const v = el('#vd-raw').value; if (!v.trim()) { api.setStatus('请先在上方粘贴链接。'); return; } try { el('#vd-raw-go').disabled = true; await downloadRawUrl(v, api); } catch (e) { api.setStatus('直链下载失败:' + e.message); api.log('直链错误: ' + e.message); } finally { el('#vd-raw-go').disabled = false; } }); el('#vd-go').addEventListener('click', async () => { const parsed = api.parsed; if (!parsed || !parsed.ok) { api.setStatus('请先解析当前页面。'); return; } CAP.useCache = el('#vd-cache-first').checked; try { el('#vd-go').disabled = true; if (parsed.type === 'dash') { const v = parsed.videos[+el('#vd-video').value] || parsed.videos[0]; const a = parsed.audios[+el('#vd-audio').value] || parsed.audios[0]; await downloadAndMergeDash(parsed, v, a, api); } else { const s = parsed.streams[+el('#vd-video').value] || parsed.streams[0]; if (!s) { api.setStatus('未选择流。'); return; } if (s.kind === 'm3u8') await downloadM3u8Stream(parsed, s, api); else if (s.kind === 'parts') await downloadPartsStream(parsed, s, api); else await downloadSingle(parsed, s, api); } } catch (e) { api.setStatus('失败:' + e.message); api.log('ERR ' + e.message); } finally { el('#vd-go').disabled = false; } }); return api; } /* ============================ 5. 主流程 ============================ */ async function run() { const ui = buildUI(); installCapture(); // 启用网络捕获兜底(SPA / 动态流站点) // 从 IndexedDB 恢复此前缓存的分片(刷新页面后仍能复用,绕开重复下载/限时签名失效) IDB.loadAll().then((rows) => { let n = 0; for (const r of rows) { const u = r.url; if (!CAP.seg.has(u)) { try { const b = new Uint8Array(r.buf); CAP.seg.set(u, b); CAP.bytes += b.length; n++; } catch (e) {} } } if (n) api.log('已从 IndexedDB 恢复 ' + n + ' 个缓存分片(' + (CAP.bytes / 1048576).toFixed(0) + ' MB)。'); }).catch(() => {}); // 解析当前页面(解析器可能返回 Promise) const parser = detectParser(); let parsed = null; try { const p = parser(); parsed = p && typeof p.then === 'function' ? await p : p; } catch (e) { ui.log('解析异常: ' + e.message); } if (parsed && parsed.ok) { api_parsed = parsed; ui.parsed = parsed; ui.fillStreams(parsed); ui.setStatus('已解析 ' + parsed.platform + ':《' + parsed.title + '》 点击下载'); ui.setHint(''); } else { const errMsg = parsed && parsed.error ? ('
解析信息:' + parsed.error) : ''; ui.setStatus('当前页面未检测到可提取的音视频流'); ui.setHint( '本脚本已内置 B 站(window.__playinfo__)、芒果 TV(tk2 + getSource)、' + ' 央视频(cKey + playvinfo)、腾讯视频(getinfo + vkey)、' + ' 搜狐(phone_playinfo,已实测可用)解析器。
' + ' 华数TV / PPTV 的旧接口经实测已失效(返回 SPA / 404),爱奇艺 / 优酷 / 1905 / 新浪' + ' 为实验性解析器(端点可能随改版变动,需真机核对)。抖音 / 快手 / 小红书 / 西瓜 仍未实现。' + errMsg + '
' + '若当前站点是 SPA(如华数 wap):先在本页点击播放,等页面请求出真实流后,' + '用面板「网络捕获」区的「刷新捕获列表」即可直接下载(脚本会截获页面自身发出的 m3u8/mp4)。
' + '已升级 边播边抓:播放时脚本会把每个分片 / 密钥的响应体缓存进内存(面板实时显示已缓存大小),' + '播放完点「合并已缓存分片(边播边抓)」即可直接合成——无需再向 CDN 重下,可绕开 iQiyi 等短命 token 防盗链(D2102)。' + '如需支持,请在脚本 PARSERS 注册表中为其实现专用解析函数(参考 parseSohu)。' ); } // 直达 m3u8 / mp4 链接(第三方代理如 cache.0567890.xyz 等):自动填入粘贴框,并按设置自动下载 const pathLC = (location.pathname + location.search).toLowerCase(); if (/\.m3u8(\?|$)/i.test(pathLC) || /\.(mp4|flv|m4s|webm|mov|mkv)(\?|$)/i.test(pathLC)) { const rawEl = document.getElementById('vd-raw'); if (rawEl && !rawEl.value.trim()) { rawEl.value = location.href; renderTokenInfo(location.href); const autoDl = document.getElementById('vd-autodl') && document.getElementById('vd-autodl').checked; ui.log('检测到本页即直达流链接,已自动填入「手动粘贴直链」框' + (autoDl ? ',并按设置自动下载。' : ',点击「下载此链接」即可。')); ui.setStatus('本页为直达流链接,已自动填入' + (autoDl ? '并自动下载(限时签名请尽快)' : ',点击「下载此链接」')); if (autoDl) { // 限时签名链接:打开即自动抓取,避免过期 setTimeout(() => { const go = document.getElementById('vd-raw-go'); if (go && !go.disabled) go.click(); }, 800); } } } // 网络捕获自动刷新:解析器失败但页面已请求出流时,自动填充捕获列表 let lastCap = 0; const capTimer = setInterval(() => { const ci = document.getElementById('vd-cache-info'); if (ci) { ci.textContent = '边播边抓缓存:' + CAP.seg.size + ' 个分片 / ' + (CAP.bytes / 1048576).toFixed(0) + ' MB'; } if (CAP.list.length === lastCap) return; lastCap = CAP.list.length; ui.setCapture(CAP.list.slice()); if (!api_parsed || !api_parsed.ok) { const cap = captureToParsed(); if (cap) { api_parsed = cap; ui.parsed = cap; ui.fillStreams(cap); ui.setStatus('网络捕获到 ' + cap.streams.length + ' 条流(' + cap.streams[0].kind + '),可直接下载'); ui.setHint(''); } } }, 1500); } let api_parsed = null; GM_registerMenuCommand('打开视频流下载面板', () => { const p = document.getElementById('vd-panel'); if (p) p.style.display = 'block'; }); // 等待页面 JS 执行完毕后再解析(B 站 playinfo 为异步注入) if (location.hostname.indexOf('bilibili') !== -1) { let tries = 0; const timer = setInterval(() => { tries++; if (window.__playinfo__ || tries > 20) { clearInterval(timer); run(); } }, 500); } else { setTimeout(run, 800); } })();