// ==UserScript== // @name ParseVideo 视频下载助手 // @namespace https://pv.vlogdownloader.com/ // @version 2.1.1 // @description 在任意网页一键解析下载视频 - 支持B站智能切换服务器、音视频合并、批量解析、下载历史、自定义快捷键 // @author ParseVideo Assistant // @match *://*/* // @require https://cdn.jsdelivr.net/npm/js-md5@0.8.3/src/md5.min.js // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @grant GM_openInTab // @grant GM_setClipboard // @grant GM_download // @connect pv.vlogdownloader.com // @connect * // @icon https://pv.vlogdownloader.com/assets/img/logo.png // @run-at document-idle // @noframes // @license LGPL-2.1 // ==/UserScript== (function () { 'use strict'; if (typeof md5 !== 'function') { console.error('[ParseVideo] js-md5 未加载'); return; } const API_BASE = 'https://pv.vlogdownloader.com'; const API_ENDPOINT = '/parsevideo/api.html'; const SIGN_KEY = '%8vcf'; const VERSION = '2.1.1'; const DEBUG = true; // ffmpeg.wasm(0.11 单线程核心,首次合并时才懒加载) const FFMPEG_JS_URL = 'https://unpkg.com/@ffmpeg/ffmpeg@0.11.6/dist/ffmpeg.min.js'; const FFMPEG_CORE_URL = 'https://unpkg.com/@ffmpeg/core@0.11.0/dist/ffmpeg-core.js'; const MERGE_SIZE_LIMIT = 600 * 1024 * 1024; // 合并总大小上限(浏览器内存保护) const log = (...a) => DEBUG && console.log('%c[ParseVideo]', 'color:#6366f1;font-weight:bold', ...a); const warn = (...a) => console.warn('%c[ParseVideo]', 'color:#f59e0b;font-weight:bold', ...a); // ============== 设置 ============== const DEFAULT_HOTKEY = { alt: true, ctrl: false, shift: false, key: 'p' }; const getSettings = () => ({ proxyip: GM_getValue('pv_proxyip', ''), randomip: GM_getValue('pv_randomip', false), useragent: GM_getValue('pv_useragent', ''), autoFloat: GM_getValue('pv_autofloat', true), btnPosition: GM_getValue('pv_btnpos', 'right'), autoRetry: GM_getValue('pv_autoretry', true), hotkey: GM_getValue('pv_hotkey', DEFAULT_HOTKEY), historyMax: GM_getValue('pv_historymax', 200), }); const saveSettings = (s) => { GM_setValue('pv_proxyip', s.proxyip); GM_setValue('pv_randomip', s.randomip); GM_setValue('pv_useragent', s.useragent); GM_setValue('pv_autofloat', s.autoFloat); GM_setValue('pv_btnpos', s.btnPosition); GM_setValue('pv_autoretry', s.autoRetry); GM_setValue('pv_hotkey', s.hotkey); GM_setValue('pv_historymax', s.historyMax); }; const hotkeyLabel = (hk) => { hk = hk || DEFAULT_HOTKEY; return [hk.ctrl && 'Ctrl', hk.alt && 'Alt', hk.shift && 'Shift', (hk.key || 'p').toUpperCase()] .filter(Boolean).join('+'); }; // ============== 平台检测 ============== const VIP_PATTERNS = [ { r: /iqiyi\.com/i, name: '爱奇艺', note: 'VIP/会员专属' }, { r: /v\.qq\.com/i, name: '腾讯视频', note: 'VIP/会员专属' }, { r: /youku\.com/i, name: '优酷', note: 'VIP/会员专属' }, { r: /mgtv\.com/i, name: '芒果TV', note: 'VIP/会员专属' }, ]; const checkVip = (url) => { for (const p of VIP_PATTERNS) if (p.r.test(url)) return p; if (/bilibili\.com\/bangumi\//i.test(url)) return { name: 'B站番剧', note: '大会员/付费番剧' }; return null; }; // 2026-08 实测的服务器端兼容性 const PLATFORM_INFO = [ { r: /bilibili\.com|b23\.tv/i, name: 'B站', status: 'ok', note: '免费视频可解析,将自动使用「中国」服务器' }, { r: /youtube\.com|youtu\.be/i, name: 'YouTube', status: 'ok', note: '' }, { r: /vimeo\.com/i, name: 'Vimeo', status: 'ok', note: '' }, { r: /soundcloud\.com/i, name: 'SoundCloud', status: 'ok', note: '音频平台' }, { r: /archive\.org/i, name: 'Archive.org', status: 'ok', note: '' }, { r: /(x\.com|twitter\.com)/i, name: 'X/Twitter', status: 'bad', note: '服务器端解析当前不可用' }, { r: /dailymotion\.com/i, name: 'Dailymotion', status: 'bad', note: '服务器端解析当前不可用' }, { r: /twitch\.tv/i, name: 'Twitch', status: 'bad', note: '服务器端解析当前不可用' }, { r: /(weibo\.com|ixigua\.com|douyin\.com)/i, name: '微博/西瓜/抖音', status: 'bad', note: '服务器端解析当前不可用' }, { r: /(pinterest\.|reddit\.com)/i, name: 'Pinterest/Reddit', status: 'bad', note: '服务器端解析当前不可用' }, ]; const checkPlatform = (url) => { for (const p of PLATFORM_INFO) if (p.r.test(url)) return p; return null; }; // ============== 工具 ============== const escapeHtml = (s) => s == null ? '' : String(s).replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); const humanSize = (b) => { if (!b || isNaN(b)) return ''; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0, n = Number(b); while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return n.toFixed(n >= 100 ? 0 : 1) + ' ' + u[i]; }; const safeName = (s) => (s || 'video').replace(/[\\\/:*?"<>|#\r\n]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 80); const timeStr = (t) => { const d = new Date(t); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; }; let toastTimer = null; const showToast = (msg) => { let t = document.getElementById('pv-toast'); if (!t) { t = document.createElement('div'); t.id = 'pv-toast'; document.body.appendChild(t); } t.textContent = msg; t.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 2200); }; const openSiteFallback = (url) => { const full = API_BASE + '/' + '#' + encodeURIComponent(url || ''); try { GM_openInTab(full, { active: true }); } catch (e) { window.open(full, '_blank'); } }; const downloadBlob = (blob, name) => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 120000); }; const downloadFile = (url, name) => { try { GM_download({ url: url, name: name || 'video', onerror: (e) => { warn('GM_download 失败', e && e.error); showToast('下载失败,已改为打开直链,可右键另存为'); window.open(url, '_blank'); }, }); } catch (err) { window.open(url, '_blank'); } }; // ============== 下载历史 ============== const getHistory = () => { try { return GM_getValue('pv_history', []) || []; } catch (e) { return []; } }; const addHistory = (url, title, maxHeight, formatCount) => { try { const list = getHistory(); // 同一 URL 只保留最新一条 const filtered = list.filter(x => x.url !== url); filtered.unshift({ t: Date.now(), url, title: title || '', h: maxHeight || 0, n: formatCount || 0 }); const max = getSettings().historyMax || 200; GM_setValue('pv_history', filtered.slice(0, max)); } catch (e) { warn('写历史失败', e); } }; const clearHistory = () => GM_setValue('pv_history', []); // ============== API ============== // 关键:API 必须携带首页下发的 PHPSESSID,否则返回 // {"code":0,"msg":"Please refresh the page or reopen the website!"}(HTTP 200,极易被当成"解析失败"静默吞掉) let sessionCookie = null; // 'PHPSESSID=xxx' let sessionTime = 0; // 上次成功获取 session 的时间 const SESSION_TTL = 20 * 60 * 1000; // 20 分钟视为过期 function gmRequest(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest(Object.assign({}, opts, { onload: (r) => resolve(r), onerror: () => reject(new Error('网络错误(无法连接 pv.vlogdownloader.com)')), ontimeout: () => reject(new Error('请求超时')), })); }); } /** GET 首页拿 PHPSESSID */ async function ensureSession(force) { if (!force && sessionCookie && (Date.now() - sessionTime) < SESSION_TTL) return sessionCookie; try { const r = await gmRequest({ method: 'GET', url: API_BASE + '/', timeout: 30000, headers: { 'Accept': 'text/html, */*' }, }); const m = /(?:^|\n)set-cookie:\s*PHPSESSID=([^;\r\n]+)/i.exec(r.responseHeaders || ''); if (m) { sessionCookie = 'PHPSESSID=' + m[1]; sessionTime = Date.now(); log('session 初始化成功'); } else { warn('首页未返回 PHPSESSID(沿用旧值)'); } } catch (e) { warn('获取 session 失败:', e.message); } return sessionCookie; } /** 识别"session 失效"响应 */ const isSessionError = (d) => !!d && d.code !== undefined && (d.data === '/' || /refresh/i.test(String(d.msg || ''))); async function callParseAPI(videoUrl, settings) { await ensureSession(false); const doRequest = () => new Promise((resolve, reject) => { const timestamp = Date.now(); const sign = md5(videoUrl + SIGN_KEY + timestamp); const apiUrl = API_BASE + API_ENDPOINT + '?hash=' + sign + '×tamp=' + timestamp; const fd = new URLSearchParams(); fd.append('url', videoUrl); if (settings.proxyip) fd.append('proxyip', settings.proxyip); if (settings.randomip) fd.append('randomip', 'on'); if (settings.useragent) fd.append('useragent', settings.useragent); const headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Referer': API_BASE + '/', 'Origin': API_BASE, 'Accept': 'application/json, text/plain, */*', }; if (sessionCookie) headers['Cookie'] = sessionCookie; // 显式携带,不依赖浏览器 cookie jar log('POST', apiUrl, 'proxyip=', settings.proxyip || '(none)', 'randomip=', settings.randomip, 'ua=', settings.useragent || '(none)'); GM_xmlhttpRequest({ method: 'POST', url: apiUrl, data: fd.toString(), headers: headers, timeout: 90000, onload: (r) => { log('Response', r.status, 'len=', (r.responseText || '').length); // 响应若下发了新 session,记录之 const m = /(?:^|\n)set-cookie:\s*PHPSESSID=([^;\r\n]+)/i.exec(r.responseHeaders || ''); if (m) { sessionCookie = 'PHPSESSID=' + m[1]; sessionTime = Date.now(); } let d = null; try { d = JSON.parse(r.responseText); } catch (e) { return reject(new Error('返回非JSON: ' + (r.responseText || '').slice(0, 200))); } resolve(d); }, onerror: (e) => reject(new Error('网络错误' + (e && e.error ? ' - ' + e.error : ''))), ontimeout: () => reject(new Error('请求超时(90s)')), }); }); let d = await doRequest(); if (isSessionError(d)) { log('session 失效,刷新后重试'); await ensureSession(true); d = await doRequest(); if (isSessionError(d)) { throw new Error('会话初始化失败:请先访问一次 pv.vlogdownloader.com 再回来重试'); } } return d; } /** * API 响应两套格式: * 1) 失败: { "code":0, "msg":"解析失败", "data":null } * 2) 成功: 直接就是 yt-dlp JSON,含 title, formats[].url, 顶层 url * 返回 { title, formats, muxed, videoOnly, audioOnly } */ function parseApiResponse(data) { if (!data) throw new Error('空响应'); if (data.code !== undefined && (data.data === null || data.data === undefined || data.data === '/')) { throw new Error(data.msg || '解析失败'); } const title = data.title || data.fulltitle || '未知标题'; const directUrl = (typeof data.url === 'string' && /^https?:\/\//i.test(data.url)) ? data.url : ''; let formats = []; if (Array.isArray(data.formats)) formats = formats.concat(data.formats); if (directUrl && !formats.some(f => f.url === directUrl)) { formats.unshift({ format_id: 'best', format_note: data.height ? (data.height + 'p') : '推荐画质', ext: data.ext || 'mp4', url: directUrl, width: data.width, height: data.height, filesize: data.filesize || data.filesize_approx, vcodec: data.vcodec || 'unknown', acodec: data.acodec || 'unknown', }); } formats = formats.filter(f => f && typeof f.url === 'string' && /^https?:\/\//i.test(f.url)); const seen = new Set(); formats = formats.filter(f => { if (seen.has(f.url)) return false; seen.add(f.url); return true; }); const hasV = (f) => f.vcodec && f.vcodec !== 'none'; const hasA = (f) => f.acodec && f.acodec !== 'none'; const muxed = formats.filter(f => hasV(f) && hasA(f)); const videoOnly = formats.filter(f => hasV(f) && !hasA(f)); const audioOnly = formats.filter(f => !hasV(f) && hasA(f)); const unknown = formats.filter(f => !f.vcodec && !f.acodec); // 未标注编解码(B站等) const known = muxed.concat(videoOnly, audioOnly); if (known.length === 0 && unknown.length === 0) { throw new Error('未返回任何视频格式(可能为付费/会员视频或受地区限制)'); } const byHeightDesc = (a, b) => (b.height || 0) - (a.height || 0); muxed.sort(byHeightDesc); videoOnly.sort(byHeightDesc); audioOnly.sort((a, b) => (b.abr || b.tbr || 0) - (a.abr || a.tbr || 0)); // 展示顺序:合成流 → 未标注 → 视频纯流 → 音频纯流 const display = unknown.concat(muxed, videoOnly, audioOnly); const maxHeight = Math.max(0, ...display.map(f => f.height || 0)); return { title, formats: display, muxed, videoOnly, audioOnly, unknown, maxHeight }; } /** 智能解析:失败自动换服务器/参数重试 */ async function parseSmart(url, settings, onStatus) { const isBili = /bilibili\.com|b23\.tv/i.test(url); const attempts = [ { ...settings } ]; if (settings.autoRetry) { if (isBili) { attempts.push({ ...settings, proxyip: 'CN' }); attempts.push({ ...settings, proxyip: 'CN', randomip: true }); } else { attempts.push({ ...settings, proxyip: 'HK', useragent: 'spider', randomip: true }); attempts.push({ ...settings, proxyip: 'CN' }); } } let lastErr = null; for (let i = 0; i < attempts.length; i++) { if (i > 0 && onStatus) onStatus(`解析失败,自动切换服务器重试 (${i}/${attempts.length - 1})...`); try { const raw = await callParseAPI(url, attempts[i]); const parsed = parseApiResponse(raw); return { ...parsed, usedSettings: attempts[i] }; } catch (e) { lastErr = e; } } throw lastErr || new Error('解析失败'); } // ============== ffmpeg.wasm 音视频合并 ============== let ffmpegInstance = null; function loadScriptOnce(src) { return new Promise((resolve, reject) => { if (document.querySelector(`script[data-pv-src="${src}"]`)) return resolve(); const s = document.createElement('script'); s.src = src; s.dataset.pvSrc = src; s.onload = () => resolve(); s.onerror = () => reject(new Error('脚本加载失败(可能被页面 CSP 拦截)')); (document.head || document.documentElement).appendChild(s); }); } async function ensureFFmpeg(onProgress) { if (ffmpegInstance) return ffmpegInstance; onProgress && onProgress('加载合并引擎 (ffmpeg.wasm, 首次约 30MB)...'); await loadScriptOnce(FFMPEG_JS_URL); if (!window.FFmpeg || typeof window.FFmpeg.createFFmpeg !== 'function') { throw new Error('ffmpeg.wasm 加载异常'); } const ffmpeg = window.FFmpeg.createFFmpeg({ log: false, corePath: FFMPEG_CORE_URL, progress: (p) => { if (p && p.ratio >= 0) onProgress && onProgress(`合并中 ${Math.round(p.ratio * 100)}%`); }, }); await ffmpeg.load(); ffmpegInstance = ffmpeg; return ffmpeg; } function gmFetchBinary(url, onProgress) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer', timeout: 600000, headers: { 'Referer': '' }, onprogress: (e) => { if (e && e.total) onProgress && onProgress(e.loaded / e.total); }, onload: (r) => { if (r.response) resolve(new Uint8Array(r.response)); else reject(new Error('下载返回空数据')); }, onerror: () => reject(new Error('视频流下载失败(直链可能已过期,请重新解析)')), ontimeout: () => reject(new Error('视频流下载超时')), }); }); } /** * 浏览器内合并视频纯流 + 音频纯流 * @returns {Promise<{ok:boolean, error?:string}>} */ async function mergeStreams(videoFmt, audioFmt, title, onStatus) { const vExt = (videoFmt.ext || 'mp4').replace(/[^a-z0-9]/gi, ''); const aExt = (audioFmt.ext || 'm4a').replace(/[^a-z0-9]/gi, ''); // 容器选择:h264 → mp4,其他(vp9/av1 等)→ webm(-c copy 无损封装) const isAvc = /^avc/i.test(videoFmt.vcodec || ''); const outExt = isAvc ? 'mp4' : 'webm'; const vName = 'pv_v.' + vExt, aName = 'pv_a.' + aExt, oName = 'pv_out.' + outExt; onStatus && onStatus('准备合并引擎...'); const ffmpeg = await ensureFFmpeg((s) => onStatus && onStatus(s)); onStatus && onStatus('下载视频流 0%...'); const vData = await gmFetchBinary(videoFmt.url, (p) => onStatus && onStatus(`下载视频流 ${Math.round(p * 100)}%...`)); ffmpeg.FS('writeFile', vName, vData); onStatus && onStatus('下载音频流 0%...'); const aData = await gmFetchBinary(audioFmt.url, (p) => onStatus && onStatus(`下载音频流 ${Math.round(p * 100)}%...`)); ffmpeg.FS('writeFile', aName, aData); onStatus && onStatus('合并中 0%...'); await ffmpeg.run('-i', vName, '-i', aName, '-c', 'copy', oName); const out = ffmpeg.FS('readFile', oName); if (!out || out.length === 0) throw new Error('合并输出为空'); onStatus && onStatus('生成下载...'); const mime = outExt === 'mp4' ? 'video/mp4' : 'video/webm'; downloadBlob(new Blob([out.buffer], { type: mime }), safeName(title) + '.' + outExt); // 释放 wasm 内存中的临时文件 try { ffmpeg.FS('unlink', vName); ffmpeg.FS('unlink', aName); ffmpeg.FS('unlink', oName); } catch (e) {} return true; } // ============== 样式 ============== GM_addStyle(` #pv-fab { position: fixed; bottom: 80px; z-index: 2147483646; width: 52px; height: 52px; border-radius: 50%; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; border: none; cursor: pointer; box-shadow: 0 4px 15px rgba(99,102,241,.4); display: flex; align-items: center; justify-content: center; transition: transform .2s, box-shadow .2s; user-select: none; font-family: system-ui, sans-serif; } #pv-fab:hover { transform: scale(1.08); box-shadow: 0 6px 20px rgba(99,102,241,.55); } #pv-fab.pos-left { left: 20px; } #pv-fab.pos-right { right: 20px; } #pv-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 2147483647; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .2s; } #pv-overlay.show { opacity: 1; } #pv-modal { background: #fff; border-radius: 16px; width: 90%; max-width: 660px; max-height: 88vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,.3); transform: scale(.92); transition: transform .2s; font-family: system-ui,"PingFang SC","Microsoft YaHei",sans-serif; overflow: hidden; } #pv-overlay.show #pv-modal { transform: scale(1); } #pv-header { padding: 16px 22px 12px; border-bottom: 1px solid #e5e7eb; } #pv-header-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } #pv-title { font-size: 17px; font-weight: 700; color: #1f2937; display: flex; align-items: center; gap: 8px; } .pv-close { width: 32px; height: 32px; border: none; background: #f3f4f6; border-radius: 50%; cursor: pointer; font-size: 20px; color: #6b7280; display: flex; align-items: center; justify-content: center; } .pv-close:hover { background: #e5e7eb; color: #1f2937; } #pv-tabs { display: flex; gap: 4px; } .pv-tab { padding: 7px 14px; border: none; background: #f3f4f6; color: #6b7280; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s; } .pv-tab:hover { background: #e5e7eb; } .pv-tab.on { background: #6366f1; color: #fff; } #pv-body { padding: 18px 22px; overflow-y: auto; flex: 1; } .pv-pane { display: none; } .pv-pane.on { display: block; } .pv-row { display: flex; gap: 8px; margin-bottom: 14px; } #pv-url { flex: 1; padding: 10px 14px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 14px; outline: none; transition: border-color .15s; min-width: 0; } #pv-url:focus { border-color: #6366f1; } #pv-parse, #pv-opensite { padding: 10px 18px; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; } #pv-parse { background: #6366f1; } #pv-parse:hover { background: #4f46e5; } #pv-parse:disabled { background: #c7d2fe; cursor: not-allowed; } #pv-opensite { background: #f59e0b; } #pv-opensite:hover { background: #d97706; } .pv-platform-hint { display: none; padding: 8px 12px; border-radius: 8px; font-size: 12px; margin-bottom: 12px; line-height: 1.6; } .pv-platform-hint.ok { display: block; background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; } .pv-platform-hint.bad { display: block; background: #fffbeb; color: #92400e; border: 1px solid #fde68a; } .pv-settings { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; padding: 12px; background: #f9fafb; border-radius: 8px; } .pv-group { display: flex; align-items: center; gap: 6px; } .pv-label { font-size: 12px; font-weight: 600; color: #6b7280; margin-right: 4px; } .pv-opt { padding: 4px 10px; border: 1.5px solid #e5e7eb; background: #fff; border-radius: 6px; font-size: 12px; cursor: pointer; color: #6b7280; transition: all .15s; } .pv-opt:hover { border-color: #c7d2fe; } .pv-opt.on { background: #6366f1; color: #fff; border-color: #6366f1; } .pv-tog { position: relative; width: 36px; height: 20px; background: #d1d5db; border-radius: 10px; cursor: pointer; transition: background .2s; flex: none; } .pv-tog.on { background: #6366f1; } .pv-tog::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: transform .2s; } .pv-tog.on::after { transform: translateX(16px); } #pv-progress { display: none; margin-bottom: 14px; } .pv-bar { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; } .pv-bar-fill { height: 100%; background: linear-gradient(90deg, #6366f1, #8b5cf6); width: 0%; transition: width .3s; border-radius: 3px; } #pv-ptext { text-align: center; font-size: 12px; color: #9ca3af; margin-top: 6px; } #pv-results { display: none; } .pv-merge-card { border: 2px solid #6366f1; border-radius: 10px; margin-bottom: 12px; padding: 14px; background: #f5f6ff; } .pv-merge-title { font-size: 14px; font-weight: 700; color: #4338ca; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; } .pv-merge-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; } .pv-merge-row select { flex: 1; min-width: 200px; padding: 6px 8px; border: 1.5px solid #c7d2fe; border-radius: 6px; font-size: 12px; background: #fff; color: #374151; } .pv-merge-actions { display: flex; gap: 8px; flex-wrap: wrap; } .pv-merge-actions button, .pv-merge-actions a { padding: 8px 14px; border: none; border-radius: 7px; font-size: 12px; font-weight: 600; cursor: pointer; text-decoration: none; } .pv-btn-merge { background: #6366f1; color: #fff !important; } .pv-btn-merge:hover { background: #4f46e5; } .pv-btn-merge:disabled { background: #c7d2fe; cursor: not-allowed; } .pv-btn-ghost { background: #fff; color: #6b7280 !important; border: 1.5px solid #e5e7eb !important; } .pv-btn-ghost:hover { background: #f3f4f6; } .pv-merge-note { font-size: 11px; color: #9ca3af; margin-top: 8px; line-height: 1.6; } .pv-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 10px; overflow: hidden; } .pv-card-h { padding: 10px 14px; background: #f9fafb; font-size: 13px; font-weight: 600; color: #374151; word-break: break-all; } .pv-card-a { display: flex; } .pv-card-a a, .pv-card-a button { flex: 1; padding: 10px; text-align: center; font-size: 13px; border: none; cursor: pointer; text-decoration: none; transition: background .15s; border-right: 1px solid #e5e7eb; background: #fff; color: #374151; font-family: inherit; } .pv-card-a a:last-child, .pv-card-a button:last-child { border-right: none; } .pv-dl { background: #ef4444 !important; color: #fff !important; } .pv-dl:hover { background: #dc2626 !important; } .pv-cp:hover { background: #e5e7eb; } .pv-pl { background: #10b981 !important; color: #fff !important; } .pv-pl:hover { background: #059669 !important; } #pv-error { display: none; padding: 16px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; color: #991b1b; font-size: 13px; line-height: 1.6; } #pv-error .pv-eicon { font-size: 28px; display: block; margin-bottom: 6px; } #pv-error-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; } #pv-error-actions button { padding: 6px 12px; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; font-family: inherit; color: #fff; } .pv-btn-retry { background: #6366f1; } .pv-btn-site { background: #f59e0b; } .pv-btn-debug { background: #6b7280; } #pv-empty { text-align: center; padding: 26px; color: #9ca3af; font-size: 13px; line-height: 1.7; } /* 批量 */ #pv-batch-input { width: 100%; box-sizing: border-box; height: 140px; padding: 10px 12px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 12px; resize: vertical; outline: none; font-family: inherit; } #pv-batch-input:focus { border-color: #6366f1; } .pv-batch-bar { display: flex; gap: 8px; margin: 10px 0 14px; align-items: center; } .pv-batch-bar button { padding: 8px 16px; border: none; border-radius: 8px; background: #6366f1; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; } .pv-batch-bar button:disabled { background: #c7d2fe; cursor: not-allowed; } .pv-batch-bar button.pv-ghostbtn { background: #fff; color: #6b7280; border: 1.5px solid #e5e7eb; } #pv-batch-count { font-size: 12px; color: #9ca3af; margin-left: auto; } .pv-batch-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 8px; font-size: 12px; } .pv-batch-item .st { flex: none; width: 18px; height: 18px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 11px; } .pv-batch-item .st.ok { background: #10b981; } .pv-batch-item .st.bad { background: #ef4444; } .pv-batch-item .st.wait { background: #9ca3af; } .pv-batch-item .st.run { background: #f59e0b; } .pv-batch-item .bd { flex: 1; min-width: 0; } .pv-batch-item .u { color: #9ca3af; word-break: break-all; } .pv-batch-item .t { font-weight: 600; color: #374151; margin: 2px 0; } .pv-batch-item .ops { display: flex; gap: 6px; margin-top: 4px; flex-wrap: wrap; } .pv-batch-item .ops button { padding: 3px 10px; border: none; border-radius: 5px; font-size: 11px; cursor: pointer; background: #ef4444; color: #fff; } .pv-batch-item .ops button.ghost { background: #f3f4f6; color: #6b7280; } .pv-batch-item .ops button:hover { opacity: .85; } /* 历史 */ .pv-hist-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 8px; font-size: 12px; } .pv-hist-item .tm { flex: none; color: #9ca3af; font-size: 11px; } .pv-hist-item .bd { flex: 1; min-width: 0; } .pv-hist-item .t { font-weight: 600; color: #374151; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pv-hist-item .m { color: #9ca3af; margin-top: 2px; } .pv-hist-item .ops { display: flex; gap: 6px; flex: none; } .pv-hist-item .ops button { padding: 4px 10px; border: 1.5px solid #e5e7eb; border-radius: 6px; background: #fff; color: #6b7280; font-size: 11px; cursor: pointer; } .pv-hist-item .ops button:hover { background: #f3f4f6; } .pv-hist-foot { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; } .pv-hist-foot button { padding: 7px 14px; border: 1.5px solid #e5e7eb; border-radius: 7px; background: #fff; color: #374151; font-size: 12px; font-weight: 600; cursor: pointer; } .pv-hist-foot button:hover { background: #f3f4f6; } .pv-hist-foot button.danger { color: #dc2626; border-color: #fecaca; } /* 设置 */ .pv-set-group { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; } .pv-set-group h4 { margin: 0 0 4px; font-size: 13px; color: #374151; } .pv-set-group p { margin: 0 0 10px; font-size: 11px; color: #9ca3af; line-height: 1.6; } .pv-set-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .pv-kbd { display: inline-block; padding: 3px 10px; background: #1f2937; color: #fff; border-radius: 6px; font-size: 12px; font-family: ui-monospace, Consolas, monospace; } .pv-recbtn { padding: 6px 14px; border: none; border-radius: 7px; background: #6366f1; color: #fff; font-size: 12px; font-weight: 600; cursor: pointer; } .pv-recbtn:hover { background: #4f46e5; } .pv-recbtn.rec { background: #ef4444; animation: pv-pulse 1s infinite; } @keyframes pv-pulse { 50% { opacity: .6; } } .pv-set-row select { padding: 5px 8px; border: 1.5px solid #e5e7eb; border-radius: 6px; font-size: 12px; } #pv-footer { padding: 10px 22px; border-top: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #9ca3af; } #pv-footer a { color: #6366f1; text-decoration: none; font-weight: 600; } #pv-footer a:hover { text-decoration: underline; } #pv-toast { position: fixed; bottom: 150px; left: 50%; transform: translateX(-50%) translateY(20px); background: #1f2937; color: #fff; padding: 10px 20px; border-radius: 8px; font-size: 13px; z-index: 2147483647; opacity: 0; transition: all .25s; pointer-events: none; font-family: system-ui, sans-serif; } #pv-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } @media (max-width: 600px) { #pv-modal { width: 95%; border-radius: 12px; } .pv-settings { flex-direction: column; gap: 8px; } } `); // ============== UI ============== let overlay = null; let inFlight = null; let recordingHotkey = false; function closePanel() { if (inFlight) { try { inFlight.aborted = true; } catch (e) {} inFlight = null; } recordingHotkey = false; if (overlay) { overlay.remove(); overlay = null; } } function switchTab(name) { overlay.querySelectorAll('.pv-tab').forEach(b => b.classList.toggle('on', b.dataset.tab === name)); overlay.querySelectorAll('.pv-pane').forEach(p => p.classList.toggle('on', p.id === 'pv-pane-' + name)); if (name === 'history') renderHistory(); if (name === 'settings') renderSettingsPane(); } function openPanel(prefilledUrl, startTab) { closePanel(); const settings = getSettings(); overlay = document.createElement('div'); overlay.id = 'pv-overlay'; overlay.innerHTML = `
ParseVideo 视频下载
服务器
随机IP
模拟
正在解析,请稍候...
输入视频网址后点击「解析」
实测可用:YouTube / B站免费视频(自动切换中国服务器)/ Vimeo / SoundCloud / Archive.org
⚠️ 不支持各平台 VIP / 付费视频;X·Dailymotion·Twitch·微博·西瓜·抖音 服务器端暂不支持
!

快捷键

打开解析面板的组合键。建议至少带一个修饰键(Alt/Ctrl/Shift),避免影响正常打字。

${escapeHtml(hotkeyLabel(settings.hotkey))}

智能重试

解析失败时自动切换服务器重试(B站自动走「中国」服务器,其他站尝试 香港+蜘蛛+随机IP → 中国)。

${settings.autoRetry ? '已开启' : '已关闭'}

浮动按钮

页面右下角的圆形下载按钮。

显示

历史记录上限

下载历史最多保留的条数(超出后自动删最旧的)。

`; document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('show')); if (startTab) switchTab(startTab); const currentSettings = { ...settings }; let lastParsed = null; // { title, formats, videoOnly, audioOnly, ... } // ============== 事件(全部委托,规避 CSP) ============== overlay.addEventListener('click', (e) => { if (e.target === overlay) { closePanel(); return; } if (e.target.closest('[data-act="close"]')) { closePanel(); return; } const tab = e.target.closest('.pv-tab'); if (tab) { switchTab(tab.dataset.tab); return; } if (e.target.closest('#pv-parse')) { doParse(); return; } if (e.target.closest('#pv-opensite')) { openSiteFallback(document.getElementById('pv-url').value.trim()); return; } // 单选按钮组 const optP = e.target.closest('[data-proxyip]'); if (optP) { overlay.querySelectorAll('[data-proxyip]').forEach(b => b.classList.remove('on')); optP.classList.add('on'); currentSettings.proxyip = optP.dataset.proxyip; saveSettings(currentSettings); return; } const optU = e.target.closest('[data-ua]'); if (optU) { overlay.querySelectorAll('[data-ua]').forEach(b => b.classList.remove('on')); optU.classList.add('on'); currentSettings.useragent = optU.dataset.ua; saveSettings(currentSettings); return; } if (e.target.closest('#pv-tog-rip')) { const tog = e.target.closest('#pv-tog-rip'); tog.classList.toggle('on'); currentSettings.randomip = tog.classList.contains('on'); saveSettings(currentSettings); return; } // 合并卡片操作 const mergeBtn = e.target.closest('[data-act="merge"]'); if (mergeBtn) { doMerge(mergeBtn); return; } const dualBtn = e.target.closest('[data-act="dualdl"]'); if (dualBtn) { doDualDownload(); return; } const cmdBtn = e.target.closest('[data-act="copycmd"]'); if (cmdBtn) { copyFfmpegCmd(); return; } // 普通下载按钮 const dlBtn = e.target.closest('[data-act="dl"]'); if (dlBtn) { downloadFile(dlBtn.dataset.url, dlBtn.dataset.name); return; } // 历史 / 设置区操作 const act = e.target.closest('[data-act]'); if (act) { switch (act.dataset.act) { case 'retry': doParse(); return; case 'site': openSiteFallback(document.getElementById('pv-url').value.trim()); return; case 'debug': GM_setClipboard(JSON.stringify({ version: VERSION, url: document.getElementById('pv-url').value, settings: currentSettings, lastRaw: window.__pvLastRaw || null, }, null, 2)); showToast('调试信息已复制'); return; case 'reparse': { const u = act.dataset.url; switchTab('single'); const inp = document.getElementById('pv-url'); inp.value = u; updatePlatformHint(u); setTimeout(doParse, 50); return; } case 'copyurl': GM_setClipboard(act.dataset.url); showToast('网址已复制'); return; case 'exp-json': exportHistory('json'); return; case 'exp-csv': exportHistory('csv'); return; case 'clr-hist': clearHistory(); renderHistory(); showToast('历史已清空'); return; } } // 设置区 if (e.target.closest('#pv-hk-rec')) { startHotkeyRecord(); return; } if (e.target.closest('#pv-hk-reset')) { currentSettings.hotkey = { ...DEFAULT_HOTKEY }; saveSettings(currentSettings); document.getElementById('pv-hk-label').textContent = hotkeyLabel(DEFAULT_HOTKEY); document.getElementById('pv-footer-hk').textContent = hotkeyLabel(DEFAULT_HOTKEY); showToast('已恢复默认 ' + hotkeyLabel(DEFAULT_HOTKEY)); return; } if (e.target.closest('#pv-tog-retry')) { const tog = e.target.closest('#pv-tog-retry'); tog.classList.toggle('on'); currentSettings.autoRetry = tog.classList.contains('on'); saveSettings(currentSettings); tog.nextElementSibling.textContent = currentSettings.autoRetry ? '已开启' : '已关闭'; return; } if (e.target.closest('#pv-tog-fab')) { const tog = e.target.closest('#pv-tog-fab'); tog.classList.toggle('on'); currentSettings.autoFloat = tog.classList.contains('on'); saveSettings(currentSettings); if (currentSettings.autoFloat) createFab(); else removeFab(); return; } const posBtn = e.target.closest('[data-fabpos]'); if (posBtn) { overlay.querySelectorAll('[data-fabpos]').forEach(b => b.classList.remove('on')); posBtn.classList.add('on'); currentSettings.btnPosition = posBtn.dataset.fabpos; saveSettings(currentSettings); const fab = document.getElementById('pv-fab'); if (fab) { fab.classList.toggle('pos-left', currentSettings.btnPosition === 'left'); fab.classList.toggle('pos-right', currentSettings.btnPosition !== 'left'); fab.style.left = ''; fab.style.right = ''; fab.style.top = ''; fab.style.bottom = '80px'; } return; } // 批量区 if (e.target.closest('#pv-batch-run')) { runBatch(); return; } if (e.target.closest('#pv-batch-clear')) { document.getElementById('pv-batch-input').value = ''; document.getElementById('pv-batch-results').innerHTML = ''; updateBatchCount(); return; } const bDl = e.target.closest('[data-act="bdl"]'); if (bDl) { downloadFile(bDl.dataset.url, bDl.dataset.name); return; } // 复制按钮 const cp = e.target.closest('[data-act-copy]'); if (cp) { GM_setClipboard(cp.dataset.actCopy); showToast('链接已复制'); e.preventDefault(); } }); overlay.addEventListener('keydown', (e) => { if (recordingHotkey) return; // 录制快捷键时全局接管 if (e.key === 'Enter' && e.target.id === 'pv-url') { e.preventDefault(); doParse(); } }); overlay.addEventListener('input', (e) => { if (e.target.id === 'pv-batch-input') updateBatchCount(); }); document.getElementById('pv-histmax').addEventListener('change', (e) => { currentSettings.historyMax = parseInt(e.target.value, 10) || 200; saveSettings(currentSettings); showToast('历史上限已设为 ' + currentSettings.historyMax + ' 条'); }); // ============== 平台提示 ============== function updatePlatformHint(url) { const hint = document.getElementById('pv-phint'); hint.className = 'pv-platform-hint'; hint.textContent = ''; if (!url) return; const vip = checkVip(url); if (vip) { hint.className = 'pv-platform-hint bad'; hint.textContent = `[${vip.name}] ${vip.note}视频较多(DRM/加密),限免或开放影片仍可点击解析尝试`; return; } const p = checkPlatform(url); if (p) { hint.className = 'pv-platform-hint ' + (p.status === 'ok' ? 'ok' : 'bad'); hint.textContent = p.status === 'ok' ? `[${p.name}] ${p.note || '支持解析'}` : `[${p.name}] ${p.note}`; } } // ============== 单个解析 ============== function doParse() { const urlInput = document.getElementById('pv-url'); const url = (urlInput.value || '').trim(); if (!url || !/^https?:\/\/\S+/i.test(url)) { showError('请输入有效的视频网址(以 http/https 开头)'); return; } const vip = checkVip(url); if (vip) { // 实测:腾讯/爱奇艺等平台的限免、开放视频是可以解析的,只弹确认、不直接拦截 const go = window.confirm( `[${vip.name}] 视频多为 ${vip.note}(DRM/加密),但限免或开放影片可以解析。\n\n` + `是否仍然尝试?(解析会消耗每日免费额度,VIP 内容将会失败)` ); if (!go) return; } runParse(url, currentSettings); } function runParse(url, settings) { const progress = document.getElementById('pv-progress'); const barFill = document.getElementById('pv-bar-fill'); const ptext = document.getElementById('pv-ptext'); const resultsDiv = document.getElementById('pv-results'); const emptyDiv = document.getElementById('pv-empty'); const parseBtn = document.getElementById('pv-parse'); resultsDiv.style.display = 'none'; resultsDiv.innerHTML = ''; document.getElementById('pv-error').style.display = 'none'; emptyDiv.style.display = 'none'; progress.style.display = 'block'; barFill.style.width = '0%'; ptext.textContent = '正在解析,请稍候...'; parseBtn.disabled = true; parseBtn.textContent = '解析中...'; let pv = 0; const t = setInterval(() => { pv = Math.min(pv + Math.random() * 10, 90); barFill.style.width = pv + '%'; }, 300); inFlight = { aborted: false }; const ctrl = inFlight; parseSmart(url, settings, (status) => { ptext.textContent = status; }) .then(parsed => { if (ctrl.aborted) return; window.__pvLastRaw = { __parsedSummary: { title: parsed.title, count: parsed.formats.length } }; lastParsed = parsed; clearInterval(t); barFill.style.width = '100%'; setTimeout(() => { progress.style.display = 'none'; barFill.style.width = '0%'; }, 350); renderResults(parsed); addHistory(url, parsed.title, parsed.maxHeight, parsed.formats.length); }) .catch(err => { if (ctrl.aborted) return; clearInterval(t); barFill.style.width = '100%'; setTimeout(() => { progress.style.display = 'none'; barFill.style.width = '0%'; }, 350); warn('解析失败:', err.message); showError(err.message + '\n\n请检查:\n1) 网址是否正确(限免/公开视频)\n2) 该平台服务器端是否支持(见输入框上方提示)\n3) 点击底部"打开官网解析"在 ParseVideo 网站手动试试'); }) .finally(() => { parseBtn.disabled = false; parseBtn.textContent = '解析'; if (inFlight === ctrl) inFlight = null; }); } function showError(msg) { const box = document.getElementById('pv-error'); const m = document.getElementById('pv-error-msg'); m.textContent = msg; m.style.whiteSpace = 'pre-wrap'; box.style.display = 'block'; } function fmtLabel(fmt, idx) { const bits = []; if (fmt.height) bits.push(fmt.height + 'p'); if (fmt.format_id) bits.push(fmt.format_id); if (fmt.ext) bits.push(fmt.ext); if (!bits.length) bits.push('格式 ' + (idx + 1)); bits.push(fmt.acodec === 'none' ? '无声' : (fmt.vcodec === 'none' ? '纯音频' : '')); return bits.filter(Boolean).join(' · '); } function renderResults(parsed) { const rd = document.getElementById('pv-results'); rd.innerHTML = ''; lastParsed = parsed; const t = document.createElement('div'); t.style.cssText = 'font-weight:600;font-size:15px;color:#374151;margin-bottom:10px;word-break:break-all;'; t.textContent = parsed.title; rd.appendChild(t); // ---- 音视频合并卡片 ---- if (parsed.videoOnly.length > 0 && parsed.audioOnly.length > 0) { rd.appendChild(buildMergeCard(parsed)); } // ---- 格式列表 ---- parsed.formats.forEach((fmt, idx) => { const desc = [ fmtLabel(fmt, idx), fmt.width && fmt.height ? (fmt.width + 'x' + fmt.height) : '', fmt.protocol || '', fmt.filesize ? humanSize(fmt.filesize) : '', ].filter(Boolean).join(' · '); const name = safeName(parsed.title) + '.' + (fmt.ext || 'mp4'); const card = document.createElement('div'); card.className = 'pv-card'; card.innerHTML = '
' + escapeHtml(desc) + '
' + '
' + '' + '' + '预览' + '
'; rd.appendChild(card); }); const tip = document.createElement('div'); tip.style.cssText = 'font-size:12px;color:#9ca3af;margin-top:8px;text-align:center;'; tip.textContent = '共 ' + parsed.formats.length + ' 个格式,如下载被浏览器拦截请用「复制链接」+ 下载工具'; rd.appendChild(tip); rd.style.display = 'block'; } function buildMergeCard(parsed) { const card = document.createElement('div'); card.className = 'pv-merge-card'; const vBest = parsed.videoOnly[0]; const aBest = parsed.audioOnly[0]; const vOpts = parsed.videoOnly.map((f, i) => ``).join(''); const aOpts = parsed.audioOnly.map((f, i) => ``).join(''); const total = (vBest.filesize || 0) + (aBest.filesize || 0); card.innerHTML = `
高清合并下载(视频流 + 音频流 → 单文件)
视频
音频
高画质(1080p+)通常视频/音频分离,此功能自动下载双流并用 ffmpeg 无损封装为单文件${total ? `,预计需下载约 ${humanSize(total)}` : ''}。
首次使用需下载约 30MB 合并引擎;单次合并上限 ${humanSize(MERGE_SIZE_LIMIT)}。若页面 CSP 拦截引擎,请改用「下载双流 + ffmpeg 命令」。
`; return card; } function doMerge(btn) { if (!lastParsed) return; const vSel = document.getElementById('pv-merge-v'); const aSel = document.getElementById('pv-merge-a'); if (!vSel || !aSel) return; const vFmt = lastParsed.videoOnly[parseInt(vSel.value, 10) || 0]; const aFmt = lastParsed.audioOnly[parseInt(aSel.value, 10) || 0]; if (!vFmt || !aFmt) return; const total = (vFmt.filesize || 0) + (aFmt.filesize || 0); if (total > MERGE_SIZE_LIMIT) { showError(`所选流合计约 ${humanSize(total)},超出浏览器合并上限(${humanSize(MERGE_SIZE_LIMIT)})。\n请选择较低画质,或使用「下载双流文件」+「复制 ffmpeg 命令」在本地合并。`); return; } if (total > 200 * 1024 * 1024 && !confirm(`将下载约 ${humanSize(total)} 数据并在浏览器内合并,可能占用较多内存,继续?`)) return; btn.disabled = true; const oldText = btn.textContent; const progress = document.getElementById('pv-progress'); const barFill = document.getElementById('pv-bar-fill'); const ptext = document.getElementById('pv-ptext'); progress.style.display = 'block'; barFill.style.width = '10%'; const setStatus = (s) => { ptext.textContent = s; }; mergeStreams(vFmt, aFmt, lastParsed.title, setStatus) .then(() => { barFill.style.width = '100%'; setStatus('合并完成,已开始下载'); showToast('合并完成'); setTimeout(() => { progress.style.display = 'none'; barFill.style.width = '0%'; }, 1500); }) .catch(err => { warn('合并失败:', err); progress.style.display = 'none'; showError('浏览器内合并失败:' + err.message + '\n\n备选方案:点「下载双流文件」保存两个流文件,再点「复制 ffmpeg 命令」在本地执行合并。'); }) .finally(() => { btn.disabled = false; btn.textContent = oldText; }); } function doDualDownload() { if (!lastParsed) return; const vSel = document.getElementById('pv-merge-v'); const aSel = document.getElementById('pv-merge-a'); const vFmt = lastParsed.videoOnly[parseInt((vSel && vSel.value) || 0, 10) || 0]; const aFmt = lastParsed.audioOnly[parseInt((aSel && aSel.value) || 0, 10) || 0]; if (!vFmt || !aFmt) return; const base = safeName(lastParsed.title); downloadFile(vFmt.url, base + '_video.' + (vFmt.ext || 'mp4')); setTimeout(() => downloadFile(aFmt.url, base + '_audio.' + (aFmt.ext || 'm4a')), 800); showToast('已开始下载双流文件'); } function copyFfmpegCmd() { if (!lastParsed) return; const vSel = document.getElementById('pv-merge-v'); const aSel = document.getElementById('pv-merge-a'); const vFmt = lastParsed.videoOnly[parseInt((vSel && vSel.value) || 0, 10) || 0]; const aFmt = lastParsed.audioOnly[parseInt((aSel && aSel.value) || 0, 10) || 0]; if (!vFmt || !aFmt) return; const isAvc = /^avc/i.test(vFmt.vcodec || ''); const out = isAvc ? 'output.mp4' : 'output.webm'; const cmd = `ffmpeg -i "视频文件.${vFmt.ext || 'mp4'}" -i "音频文件.${aFmt.ext || 'm4a'}" -c copy "${out}"`; GM_setClipboard(cmd); showToast('ffmpeg 命令已复制:' + cmd); } // ============== 批量解析 ============== function updateBatchCount() { const urls = getBatchUrls(); document.getElementById('pv-batch-count').textContent = urls.length ? `已识别 ${urls.length} 个网址` : ''; } function getBatchUrls() { const raw = (document.getElementById('pv-batch-input').value || '') .split(/[\n\r]+/) .map(s => s.trim()) .filter(s => /^https?:\/\/\S+/i.test(s)); return Array.from(new Set(raw)); } async function runBatch() { const urls = getBatchUrls(); if (!urls.length) { showToast('请先输入至少一个网址'); return; } const runBtn = document.getElementById('pv-batch-run'); const resultsDiv = document.getElementById('pv-batch-results'); const prog = document.getElementById('pv-batch-progress'); const barFill = document.getElementById('pv-batch-barfill'); const ptext = document.getElementById('pv-batch-ptext'); const settings = getSettings(); runBtn.disabled = true; resultsDiv.innerHTML = ''; prog.style.display = 'block'; let done = 0, okCount = 0; for (const u of urls) { const item = document.createElement('div'); item.className = 'pv-batch-item'; item.innerHTML = `
${escapeHtml(u)}
解析中...
`; resultsDiv.appendChild(item); item.scrollIntoView({ block: 'nearest' }); try { const parsed = await parseSmart(u, settings); okCount++; addHistory(u, parsed.title, parsed.maxHeight, parsed.formats.length); const best = parsed.formats[0]; item.innerHTML = `
${escapeHtml(u)}
${escapeHtml(parsed.title)}
${parsed.formats.length} 个格式${parsed.maxHeight ? ' · 最高 ' + parsed.maxHeight + 'p' : ''}
`; } catch (err) { item.innerHTML = `
${escapeHtml(u)}
失败:${escapeHtml(err.message)}
`; } done++; barFill.style.width = Math.round(done / urls.length * 100) + '%'; ptext.textContent = `${done} / ${urls.length}(成功 ${okCount})`; if (done < urls.length) await new Promise(r => setTimeout(r, 1200)); // 限速,避免触发服务器频控 } ptext.textContent = `完成:${okCount}/${urls.length} 成功`; runBtn.disabled = false; showToast(`批量解析完成:${okCount}/${urls.length} 成功`); } // ============== 历史 ============== function renderHistory() { const list = document.getElementById('pv-hist-list'); if (!list) return; const hist = getHistory(); if (!hist.length) { list.innerHTML = '
暂无下载历史
解析成功的视频会自动记录在这里
'; return; } list.innerHTML = hist.map(h => `
${escapeHtml(timeStr(h.t))}
${escapeHtml(h.title || h.url)}
${escapeHtml(h.url)}${h.h ? ' · ' + h.h + 'p' : ''}${h.n ? ' · ' + h.n + ' 个格式' : ''}
`).join(''); } function exportHistory(type) { const hist = getHistory(); if (!hist.length) { showToast('暂无历史可导出'); return; } let blob, name; if (type === 'json') { blob = new Blob([JSON.stringify(hist, null, 2)], { type: 'application/json' }); name = 'parsevideo-history-' + new Date().toISOString().slice(0, 10) + '.json'; } else { const esc = (s) => '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; const rows = [['时间', '标题', '网址', '最高画质', '格式数']] .concat(hist.map(h => [timeStr(h.t), h.title || '', h.url, h.h ? h.h + 'p' : '', h.n])); const csv = '\uFEFF' + rows.map(r => r.map(esc).join(',')).join('\r\n'); blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); name = 'parsevideo-history-' + new Date().toISOString().slice(0, 10) + '.csv'; } downloadBlob(blob, name); showToast('已导出 ' + hist.length + ' 条记录'); } // ============== 设置:快捷键录制 ============== function startHotkeyRecord() { const btn = document.getElementById('pv-hk-rec'); recordingHotkey = true; btn.classList.add('rec'); btn.textContent = '请按下组合键(Esc 取消)...'; const onKey = (e) => { e.preventDefault(); e.stopPropagation(); if (e.key === 'Escape') { cleanup(); showToast('已取消录制'); return; } if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return; // 等待主键 const hk = { alt: e.altKey, ctrl: e.ctrlKey, shift: e.shiftKey, key: e.key.length === 1 ? e.key.toLowerCase() : e.key, }; if (!hk.alt && !hk.ctrl && !hk.shift && !hk.meta) { // 无修饰键的字母/数字键容易误触,提醒但允许 showToast('提示:建议加 Alt/Ctrl/Shift 修饰键'); } currentSettings.hotkey = hk; saveSettings(currentSettings); document.getElementById('pv-hk-label').textContent = hotkeyLabel(hk); document.getElementById('pv-footer-hk').textContent = hotkeyLabel(hk); cleanup(); showToast('快捷键已设为 ' + hotkeyLabel(hk)); }; const cleanup = () => { recordingHotkey = false; document.removeEventListener('keydown', onKey, true); btn.classList.remove('rec'); btn.textContent = '点击录制新快捷键'; }; document.addEventListener('keydown', onKey, true); } // 初始化 updatePlatformHint(prefilledUrl || ''); const urlEl = document.getElementById('pv-url'); urlEl.addEventListener('input', () => updatePlatformHint(urlEl.value.trim())); urlEl.focus(); urlEl.setSelectionRange(urlEl.value.length, urlEl.value.length); updateBatchCount(); if (prefilledUrl) setTimeout(doParse, 200); } // ============== 浮动按钮 ============== function createFab() { if (document.getElementById('pv-fab')) return; const s = getSettings(); const fab = document.createElement('button'); fab.id = 'pv-fab'; fab.className = 'pos-' + (s.btnPosition === 'left' ? 'left' : 'right'); fab.title = 'ParseVideo 视频下载 (' + hotkeyLabel(s.hotkey) + ')'; fab.innerHTML = ''; fab.addEventListener('click', (e) => { e.stopPropagation(); log('FAB clicked, location.href=', location.href); openPanel(location.href); }); document.body.appendChild(fab); // 拖动 let dx = 0, dy = 0; fab.addEventListener('mousedown', (e) => { dx = e.clientX; dy = e.clientY; }); document.addEventListener('mousemove', (e) => { if (e.buttons === 0) return; if (Math.abs(e.clientX - dx) > 5 || Math.abs(e.clientY - dy) > 5) { fab.style.left = (e.clientX - 26) + 'px'; fab.style.top = (e.clientY - 26) + 'px'; fab.style.right = 'auto'; fab.style.bottom = 'auto'; } }); document.addEventListener('mouseup', () => {}); log('FAB 已创建'); } const removeFab = () => { const el = document.getElementById('pv-fab'); if (el) el.remove(); }; // ============== 菜单 / 快捷键 ============== GM_registerMenuCommand('解析当前页面视频', () => openPanel(location.href)); GM_registerMenuCommand('手动输入网址解析', () => openPanel('', 'single')); GM_registerMenuCommand('批量解析 / 归档', () => openPanel('', 'batch')); GM_registerMenuCommand('查看下载历史', () => openPanel('', 'history')); GM_registerMenuCommand('设置(快捷键等)', () => openPanel('', 'settings')); GM_registerMenuCommand('打开 ParseVideo 官网', () => openSiteFallback(location.href)); GM_registerMenuCommand('显示/隐藏浮动按钮', () => { const s = getSettings(); s.autoFloat = !s.autoFloat; saveSettings(s); if (s.autoFloat) { createFab(); showToast('浮动按钮已显示'); } else { removeFab(); showToast('浮动按钮已隐藏'); } }); const isTyping = (el) => { if (!el) return false; const tag = (el.tagName || '').toLowerCase(); return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable; }; document.addEventListener('keydown', (e) => { // 面板打开时优先处理 Escape(快捷键录制时除外) if (e.key === 'Escape' && overlay && !recordingHotkey) { closePanel(); return; } const hk = getSettings().hotkey || DEFAULT_HOTKEY; const key = (e.key || '').length === 1 ? e.key.toLowerCase() : e.key; if (key !== hk.key) return; if (!!e.altKey !== !!hk.alt || !!e.ctrlKey !== !!hk.ctrl || !!e.shiftKey !== !!hk.shift) return; // 无修饰键的组合在输入框内不触发,避免打字误触 if (!hk.alt && !hk.ctrl && !hk.shift && isTyping(e.target)) return; e.preventDefault(); openPanel(location.href); }); // ============== 启动 ============== const s = getSettings(); log('已加载 v' + VERSION, '| autoFloat:', s.autoFloat, '| hotkey:', hotkeyLabel(s.hotkey), '| autoRetry:', s.autoRetry); if (s.autoFloat) setTimeout(createFab, 800); })();