// ==UserScript== // @name ParseVideo 视频下载助手 // @namespace https://pv.vlogdownloader.com/ // @version 2.1.0 // @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 MIT // ==/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.0'; 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 ============== function callParseAPI(videoUrl, settings) { return 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); log('POST', apiUrl, 'proxyip=', settings.proxyip || '(none)', 'randomip=', settings.randomip, 'ua=', settings.useragent || '(none)'); GM_xmlhttpRequest({ method: 'POST', url: apiUrl, data: fd.toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Referer': API_BASE + '/', 'Origin': API_BASE, 'Accept': 'application/json, text/plain, */*', }, timeout: 90000, onload: (r) => { log('Response', r.status, 'len=', (r.responseText || '').length); 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)')), }); }); } /** * 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)) { 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 = `
打开解析面板的组合键。建议至少带一个修饰键(Alt/Ctrl/Shift),避免影响正常打字。
解析失败时自动切换服务器重试(B站自动走「中国」服务器,其他站尝试 香港+蜘蛛+随机IP → 中国)。
页面右下角的圆形下载按钮。
下载历史最多保留的条数(超出后自动删最旧的)。