// ==UserScript== // @name 视频下载助手 - 多平台 // @namespace https://github.com/MakotoArai-CN/video-download-helper // @version 0.2.5 // @author Makoto // @description 支持哔哩哔哩原生下载,以及抖音、快手、小红书、微博、今日头条、X 等站点的内容解析下载,新增智能诊断日志反馈功能,脚本仅供学习研究使用。 // @license MIT // @icon https://www.bilibili.com/favicon.ico // @match *://www.bilibili.com/video/* // @match *://www.bilibili.com/bangumi/play/* // @match *://douyin.com/* // @match *://*.douyin.com/* // @match *://iesdouyin.com/* // @match *://*.iesdouyin.com/* // @match *://kuaishou.com/* // @match *://*.kuaishou.com/* // @match *://xiaohongshu.com/* // @match *://*.xiaohongshu.com/* // @match *://xhslink.com/* // @match *://*.xhslink.com/* // @match *://xhs.cn/* // @match *://*.xhs.cn/* // @match *://weibo.com/* // @match *://*.weibo.com/* // @match *://weibo.cn/* // @match *://*.weibo.cn/* // @match *://toutiao.com/* // @match *://*.toutiao.com/* // @match *://x.com/* // @match *://*.x.com/* // @match *://twitter.com/* // @match *://*.twitter.com/* // @connect api.bilibili.com // @connect bilivideo.com // @connect bilivideo.cn // @connect bilivideo.net // @connect akamaized.net // @connect x.com // @connect twitter.com // @connect twimg.com // @connect video.twimg.com // @connect * // @grant GM_addStyle // @grant GM_getValue // @grant GM_info // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // @run-at document-start // ==/UserScript== (function() { "use strict"; var X_TEXT_LIGHT = "#0F1419"; var X_BLUE = "#1D9BF0"; var BILIBILI_BRAND = { brand: "#FF6699", brandDeep: "#E84B85", brandAccent: "#00AEEC", brandContrast: "#FFFFFF" }; var BILIBILI_SKIN = { height: "28px", padding: "0", radius: "0", gap: "4px", fontSize: "13px", fontWeight: "500", color: "rgb(97, 102, 109)", colorHover: "var(--brand_blue, #00AEEC)", iconSize: "28px", bg: "transparent", bgHover: "transparent" }; var IMMERSIVE_BAR_SKIN = { height: "36px", padding: "0 10px", radius: "8px", gap: "6px", fontSize: "13px", fontWeight: "500", color: "#FFFFFF", colorHover: "#FFFFFF", iconSize: "22px", bg: "rgba(255, 255, 255, 0.12)", bgHover: "rgba(255, 255, 255, 0.24)" }; var PLATFORMS = { douyin: { label: "抖音", hosts: ["douyin.com", "iesdouyin.com"], mediaReferer: "https://www.douyin.com/", mediaOrigin: "https://www.douyin.com", brand: { brand: "#FE2C55", brandDeep: "#D21B46", brandAccent: "#25F4EE", brandContrast: "#FFFFFF" }, mount: { selector: ".immersive-player-switch-on-hide-interaction-area", matchVisible: true, anchorSelector: ":scope > *:has([data-e2e=\"video-player-digg\"])", fixedSkin: true }, skin: IMMERSIVE_BAR_SKIN, surface: "dark", watch: { urlChangeDelayMs: 0, debounceMs: 300, videoPlay: true, classIdSelector: "[class*=\"sliderVideo\"]", classIdPattern: /\bvideo_(\d{15,20})\b/ } }, kuaishou: { label: "快手", hosts: ["kuaishou.com"], mediaReferer: "https://www.kuaishou.com/", mediaOrigin: "https://www.kuaishou.com", brand: { brand: "#FE3566", brandDeep: "#DE1F4E", brandAccent: "#FFA832", brandContrast: "#FFFFFF" }, mount: { selector: ".photo-btns", matchVisible: true, anchorSelector: ":scope > .more", fixedSkin: true }, skin: IMMERSIVE_BAR_SKIN, surface: "dark", watch: { videoPlay: true, debounceMs: 300 } }, xiaohongshu: { label: "小红书", hosts: [ "xiaohongshu.com", "xhslink.com", "xhs.cn" ], mediaReferer: "https://www.xiaohongshu.com/", mediaOrigin: "https://www.xiaohongshu.com", brand: { brand: "#FF2442", brandDeep: "#E00E2B", brandAccent: "#FF6B81", brandContrast: "#FFFFFF" }, mount: { selector: ".engage-bar .interact-container div[class=\"left\"]", insertAt: "end" } }, weibo: { label: "微博", hosts: ["weibo.com", "weibo.cn"], mediaReferer: "https://weibo.com/", mediaOrigin: "https://weibo.com", brand: { brand: "#FF8200", brandDeep: "#FF5900", brandAccent: "#EB7350", brandContrast: "#FFFFFF" }, skin: { height: "16px", padding: "0", radius: "0", gap: "4px", fontSize: "13px", fontWeight: "400", color: "rgb(128, 128, 128)", colorHover: "#FF8200", iconSize: "15px", bg: "transparent", bgHover: "transparent" }, watch: { debounceMs: 500, videoPlay: true } }, toutiao: { label: "今日头条", hosts: ["toutiao.com"], mediaReferer: "https://www.toutiao.com/", mediaOrigin: "https://www.toutiao.com", brand: { brand: "#FF403A", brandDeep: "#E02722", brandAccent: "#F04142", brandContrast: "#FFFFFF" }, mount: { selector: ".actions-list", urlPattern: /^https?:\/\/[^/?#]+\/video\//i, insertAt: "end", hideSample: true, skinSampleSelector: ".video-action-button" } }, x: { label: "X (Twitter)", hosts: ["x.com", "twitter.com"], mediaReferer: "https://x.com/", mediaOrigin: "https://x.com", brand: { brand: X_TEXT_LIGHT, brandDeep: "#272C30", brandAccent: X_BLUE, brandContrast: "#FFFFFF", brandSpark: X_BLUE }, mount: { selector: "#layers > div", insertAt: "start", sampleIndex: 0, cloneSampleStyle: true, styleOverride: { transform: "translateY(-134px)" }, skinSampleSelector: "[role=\"button\"][aria-label]:has(svg)", cloneSampleSkeleton: "[role=\"button\"]" }, skin: { height: "34px", padding: "0 6px", radius: "9999px", gap: "4px", fontSize: "13px", fontWeight: "400", color: "inherit", colorHover: X_BLUE, iconSize: "22px", bg: "transparent", bgHover: "rgba(239, 243, 244, 0.1)" }, pageContextMedia: /(?:^|\.)video\.twimg\.com$/i } }; var NEUTRAL_SKIN = { height: "32px", padding: "0 10px", radius: "6px", gap: "6px", fontSize: "13px", fontWeight: "500", color: "inherit", colorHover: "inherit", iconSize: "20px", bg: "transparent", bgHover: "rgba(127, 127, 127, 0.12)" }; var NEUTRAL_BRAND = BILIBILI_BRAND; var BILIBILI_SKINS = { bilibili: BILIBILI_SKIN, bangumi: { ...BILIBILI_SKIN, iconSize: "24px" } }; function getSkin(key) { if (key === "bilibili" || key === "bangumi") return BILIBILI_SKINS[key]; return PLATFORMS[key].skin ?? null; } function getBrand(key) { if (key === "bilibili" || key === "bangumi") return BILIBILI_BRAND; return PLATFORMS[key].brand; } function matchPlatform(host) { const lower = host.toLowerCase(); for (const [platform, def] of Object.entries(PLATFORMS)) if (def.hosts.some((h) => lower === h || lower.endsWith("." + h))) return platform; return null; } var Utils = { getVideoId() { const pathname = window.location.pathname; const bvidMatch = pathname.match(/\/video\/(BV[\w]+)/i); if (bvidMatch) return { type: "video", id: bvidMatch[1] }; const epMatch = pathname.match(/\/bangumi\/play\/ep(\d+)/i); if (epMatch) return { type: "bangumi", id: "ep" + epMatch[1] }; const ssMatch = pathname.match(/\/bangumi\/play\/ss(\d+)/i); if (ssMatch) return { type: "bangumi", id: "ss" + ssMatch[1] }; return null; }, getSiteContext() { const host = window.location.hostname.toLowerCase(); const videoId = this.getVideoId(); if (host.endsWith("bilibili.com") && videoId) return { kind: "bilibili", platform: "bilibili", sourceType: videoId.type }; const platform = matchPlatform(host); if (platform) return { kind: "short-video", platform }; return { kind: "unsupported", platform: null }; }, getCurrentPage() { const urlParams = new URLSearchParams(window.location.search); return parseInt(urlParams.get("p") || "1") || 1; }, formatDuration(seconds) { if (!seconds) return "00:00"; const h = Math.floor(seconds / 3600); const m = Math.floor(seconds % 3600 / 60); const s = Math.floor(seconds % 60); if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); }, formatDurationMs(milliseconds) { if (!milliseconds) return "--"; return this.formatDuration(milliseconds >= 1e3 ? Math.round(milliseconds / 1e3) : milliseconds); }, formatBytes(bytes) { if (!bytes) return "0 B"; const k = 1024; const sizes = [ "B", "KB", "MB", "GB" ]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }, sanitizeFilename(filename) { const cleaned = filename.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/\s+/g, " ").trim(); if (cleaned.length <= 180) return cleaned; let result = ""; for (const char of cleaned) { if (result.length + char.length > 180) break; result += char; } return result; }, inferExtension(url, fallback) { const match = url.split("#")[0].split("?")[0].match(/\.([a-zA-Z0-9]{2,6})$/); if (!match) return fallback; return match[1].toLowerCase(); }, getShortVideoFilename(title, author) { const parts = [title, author].filter(Boolean).join(" - "); return this.sanitizeFilename(parts || "short-video"); }, delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }, getCPUCores() { return navigator.hardwareConcurrency || 2; }, getOptimalThreads() { const cores = this.getCPUCores(); if (cores <= 4) return 1; return Math.floor(cores * .6 / 2) * 2; } }; var MAX_ENTRIES = 300; var REPO_ISSUE_URL = "https://github.com/MakotoArai-CN/video-download-helper/issues/new"; var SCRIPT_VERSION = typeof GM_info !== "undefined" && GM_info?.script?.version ? GM_info.script.version : "0.2.3"; function safeStringify(value) { if (value === null || value === void 0) return String(value); if (typeof value === "string") return value; if (value instanceof Error) return `${value.name}: ${value.message}${value.stack ? `\n${value.stack}` : ""}`; try { return JSON.stringify(value, (_key, val) => { if (val instanceof Error) return { name: val.name, message: val.message, stack: val.stack }; return val; }, 2); } catch (err) { return `[unstringifiable: ${err.message}]`; } } function redact(text) { if (!text) return text; return text.replace(/\b(SESSDATA|bili_jct|DedeUserID|access_key|token|cookie)\s*[=:]\s*[^;\s"']+/gi, "$1=***REDACTED***").replace(/([?&](?:SESSDATA|bili_jct|access_key|token))=([^&\s]+)/gi, "$1=***REDACTED***"); } var Diagnostics = { entries: [], listeners: [], errorHooksInstalled: false, init() { this.installGlobalHooks(); this.log("info", "diagnostics", `诊断模块已启用 v${SCRIPT_VERSION}`); this.log("info", "env", this.getEnvSummary()); }, installGlobalHooks() { if (this.errorHooksInstalled) return; this.errorHooksInstalled = true; try { window.addEventListener("error", (event) => { const err = event.error || event.message; this.log("error", "window.error", safeStringify(err), event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : void 0); }); window.addEventListener("unhandledrejection", (event) => { this.log("error", "unhandledrejection", safeStringify(event.reason)); }); } catch {} }, log(level, scope, message, detail) { const entry = { time: Date.now(), level, scope, message: redact(safeStringify(message)), detail: detail === void 0 ? void 0 : redact(safeStringify(detail)) }; this.entries.push(entry); if (this.entries.length > MAX_ENTRIES) this.entries.splice(0, this.entries.length - MAX_ENTRIES); this.emit(); }, info(scope, message, detail) { this.log("info", scope, message, detail); }, warn(scope, message, detail) { this.log("warn", scope, message, detail); }, error(scope, message, detail) { this.log("error", scope, message, detail); }, debug(scope, message, detail) { this.log("debug", scope, message, detail); }, clear() { this.entries = []; this.emit(); }, subscribe(listener) { this.listeners.push(listener); listener(this.entries.slice()); return () => { const index = this.listeners.indexOf(listener); if (index >= 0) this.listeners.splice(index, 1); }; }, emit() { const snapshot = this.entries.slice(); for (const listener of this.listeners) try { listener(snapshot); } catch {} }, getEnvSummary() { const nav = navigator; const hasSAB = typeof SharedArrayBuffer !== "undefined"; const isolated = typeof globalThis.crossOriginIsolated === "boolean" ? globalThis.crossOriginIsolated : "unknown"; return [ `URL: ${redact(location.href)}`, `UA: ${navigator.userAgent}`, `Platform: ${nav.userAgentData?.platform || nav.platform || "unknown"}`, `Language: ${navigator.language}`, `Viewport: ${window.innerWidth}x${window.innerHeight}`, `DPR: ${window.devicePixelRatio}`, `Cores: ${navigator.hardwareConcurrency || "unknown"}`, `Memory: ${nav.deviceMemory || "unknown"}GB`, `SharedArrayBuffer: ${hasSAB ? "available" : "unavailable"}`, `crossOriginIsolated: ${isolated}`, `Time: ${new Date().toISOString()}`, `Script: video-download-helper v${SCRIPT_VERSION}` ].join("\n"); }, buildReport(userNote) { const lines = []; lines.push("# 视频下载助手 - 诊断报告"); lines.push(""); lines.push("## 环境信息"); lines.push("```"); lines.push(this.getEnvSummary()); lines.push("```"); lines.push(""); if (userNote && userNote.trim().length > 0) { lines.push("## 用户描述"); lines.push(userNote.trim()); lines.push(""); } lines.push("## 运行日志"); lines.push("```"); if (this.entries.length === 0) lines.push("(暂无日志)"); else for (const entry of this.entries) { const timestamp = new Date(entry.time).toISOString(); lines.push(`[${timestamp}] [${entry.level.toUpperCase()}] [${entry.scope}] ${entry.message}`); if (entry.detail) lines.push(` detail: ${entry.detail.replace(/\n/g, "\n ")}`); } lines.push("```"); lines.push(""); lines.push("_日志已自动屏蔽 SESSDATA / bili_jct / token 等敏感字段,仍请在提交前确认无隐私信息。_"); return lines.join("\n"); }, buildIssueUrl(userNote) { const report = this.buildReport(userNote); const maxBodyLen = 6500; const body = report.length > maxBodyLen ? report.slice(0, maxBodyLen) + "\n\n... (日志过长,已截断,请附上完整报告文件)" : report; return `${REPO_ISSUE_URL}?title=${encodeURIComponent("[Bug] 使用中遇到的问题")}&body=${encodeURIComponent(body)}&labels=bug`; }, downloadReport(userNote) { const report = this.buildReport(userNote); const blob = new Blob([report], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); const filename = `vdh-diagnostic-${new Date().toISOString().replace(/[:.]/g, "-")}.md`; const a = document.createElement("a"); a.href = url; a.download = filename; a.style.display = "none"; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 200); }, async copyReport(userNote) { const report = this.buildReport(userNote); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(report); return true; } } catch {} try { const textarea = document.createElement("textarea"); textarea.value = report; textarea.style.position = "fixed"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); textarea.select(); const ok = document.execCommand("copy"); document.body.removeChild(textarea); return ok; } catch { return false; } } }; function shortUrl(url) { if (!url) return url; return url.length > 140 ? url.slice(0, 140) + "..." : url; } var Network = { fetchJSON(url, headers) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, headers: { "Referer": "https://www.bilibili.com", "User-Agent": navigator.userAgent, ...headers }, responseType: "json", onload(res) { if (res.status >= 200 && res.status < 300) { let data = res.response; if (typeof data === "string") data = JSON.parse(data); resolve(data); } else { Diagnostics.warn("network", `fetchJSON HTTP ${res.status}`, shortUrl(url)); reject(new Error("HTTP " + res.status)); } }, onerror() { Diagnostics.error("network", "fetchJSON 网络错误", shortUrl(url)); reject(new Error("网络错误")); }, ontimeout() { Diagnostics.error("network", "fetchJSON 请求超时", shortUrl(url)); reject(new Error("请求超时")); } }); }); }, downloadBufferWithFallback(urls, onProgress, headers) { const unique = [...new Set(urls.filter(Boolean))]; if (unique.length === 0) return Promise.reject(new Error("无可用下载地址")); const tryNext = (index, lastError) => { if (index >= unique.length) return Promise.reject(lastError || new Error("所有地址均不可用")); return this.downloadBuffer(unique[index], onProgress, headers).catch((err) => tryNext(index + 1, err)); }; return tryNext(0); }, downloadBuffer(url, onProgress, headers) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, headers: { "Referer": "https://www.bilibili.com", "Origin": "https://www.bilibili.com", "User-Agent": navigator.userAgent, ...headers }, responseType: "arraybuffer", onprogress(e) { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); }, onload(res) { if (res.status >= 200 && res.status < 300) resolve(res.response); else { Diagnostics.warn("network", `downloadBuffer HTTP ${res.status}`, shortUrl(url)); reject(new Error("下载失败: " + res.status)); } }, onerror() { Diagnostics.error("network", "downloadBuffer 网络错误", shortUrl(url)); reject(new Error("下载网络错误")); }, ontimeout() { Diagnostics.error("network", "downloadBuffer 超时", shortUrl(url)); reject(new Error("下载超时")); } }); }); }, downloadBlob(url, onProgress, headers) { return new Promise((resolve, reject) => { const defaults = !headers || !headers.Referer && !headers["Referer"] ? { "Referer": "https://www.bilibili.com", "Origin": "https://www.bilibili.com", "User-Agent": navigator.userAgent } : { "User-Agent": navigator.userAgent }; GM_xmlhttpRequest({ method: "GET", url, headers: { ...defaults, ...headers }, responseType: "blob", onprogress(e) { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); }, onload(res) { if (res.status >= 200 && res.status < 300) resolve(res.response); else reject(new Error("下载失败: " + res.status)); }, onerror() { reject(new Error("下载网络错误")); }, ontimeout() { reject(new Error("下载超时")); } }); }); }, downloadBlobInPageContext(url, onProgress) { const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; return win.fetch.bind(win)(url, { credentials: "omit", mode: "cors" }).then(async (response) => { if (!response.ok) throw new Error("下载失败: " + response.status); const total = parseInt(response.headers.get("Content-Length") || "0", 10); if (!response.body || !total) return response.blob(); const reader = response.body.getReader(); const chunks = []; let received = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { chunks.push(value); received += value.length; if (onProgress) onProgress(received, total); } } return new Blob(chunks); }); }, fetchFileWithProgress(url, onProgress, headers) { return fetch(url, { headers: { "Referer": "https://www.bilibili.com", "Origin": "https://www.bilibili.com", ...headers } }).then((response) => { const reader = response.body.getReader(); const contentLength = parseInt(response.headers.get("Content-Length") || "0"); if (!contentLength) return response.arrayBuffer().then((data) => new Uint8Array(data)); let receivedLength = 0; const chunks = []; function processChunk(result) { if (result.done) { const totalLength = chunks.reduce((acc, c) => acc + c.length, 0); const combined = new Uint8Array(totalLength); let position = 0; for (const chunk of chunks) { combined.set(chunk, position); position += chunk.length; } return combined; } const chunk = result.value; chunks.push(chunk); receivedLength += chunk.length; if (onProgress) onProgress(receivedLength, contentLength); return reader.read().then(processChunk); } return reader.read().then(processChunk); }); } }; var ThreadManager = { maxThreads: 1, activeThreads: 0, queue: [], init() { const cores = Utils.getCPUCores(); if (cores <= 4) this.maxThreads = 1; else { this.maxThreads = Math.floor(cores * .6); if (this.maxThreads % 2 !== 0) this.maxThreads -= 1; } }, canRunTask() { return this.activeThreads < this.maxThreads; }, runTask(task) { if (this.canRunTask()) { this.activeThreads++; return task().finally(() => { this.activeThreads--; this.processQueue(); }); } else return new Promise((resolve, reject) => { this.queue.push(() => task().then(resolve).catch(reject)); }); }, processQueue() { if (this.queue.length > 0 && this.canRunTask()) { const nextTask = this.queue.shift(); this.activeThreads++; nextTask().finally(() => { this.activeThreads--; this.processQueue(); }); } }, downloadWithThread(videoUrls, audioUrls, onVideoProgress, onAudioProgress) { const vUrls = Array.isArray(videoUrls) ? videoUrls : [videoUrls]; const aUrls = audioUrls ? Array.isArray(audioUrls) ? audioUrls : [audioUrls] : null; let videoPromise; let audioPromise; if (this.maxThreads > 1) { videoPromise = this.runTask(() => Network.downloadBufferWithFallback(vUrls, onVideoProgress)); audioPromise = aUrls ? this.runTask(() => Network.downloadBufferWithFallback(aUrls, onAudioProgress)) : Promise.resolve(null); } else { videoPromise = Network.downloadBufferWithFallback(vUrls, onVideoProgress); audioPromise = aUrls ? videoPromise.then(() => Network.downloadBufferWithFallback(aUrls, onAudioProgress)) : Promise.resolve(null); } return Promise.all([videoPromise, audioPromise]).then(([videoBuffer, audioBuffer]) => ({ videoBuffer, audioBuffer })); } }; var STYLES = ` :host { all: initial; color-scheme: light; font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif; line-height: 1.4; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; /* 品牌色令牌声明在 :host 而非 #bdl-panel:完成动画等浮层挂在 shadow root 下但不在面板子树内,只有 :host 能覆盖整个根节点。 此处的值是 syncBrand() 执行前的兜底,与 platforms.ts 的 BILIBILI_BRAND 保持一致;applyBrand() 写在宿主元素的行内样式上, 优先级高于本规则,因此按平台改色不会被这里覆盖。 */ --bdl-brand: #ff6699; --bdl-brand-deep: #e84b85; --bdl-brand-accent: #00aeec; --bdl-brand-contrast: #ffffff; --bdl-brand-spark: var(--bdl-brand); --bdl-brand-soft: color-mix(in srgb, var(--bdl-brand) 12%, transparent); --bdl-brand-light: color-mix(in srgb, var(--bdl-brand) 55%, white); --bdl-brand-accent-deep: color-mix(in srgb, var(--bdl-brand-accent) 82%, black); --bdl-brand-accent-light: color-mix(in srgb, var(--bdl-brand-accent) 45%, white); /* 中性色令牌,浅色为默认值。 */ --bdl-surface: #ffffff; --bdl-surface-sub: #fafafa; --bdl-surface-raise: #ffffff; --bdl-border: #e8e8e8; --bdl-border-soft: #f0f0f0; --bdl-text: #333333; --bdl-text-sub: #666666; --bdl-text-weak: #999999; --bdl-overlay: rgba(0, 0, 0, 0.18); /* 完成动画的全屏底色,只取中性值:站点主色偏黑时染色会让整屏发脏。 */ --bdl-veil: rgba(255, 255, 255, 0.72); /* 状态色令牌,浅色为默认值。文字色需与同组底色叠在 --bdl-surface 上 仍满足 4.5:1,故深浅两套各自给出,不由单侧值推算。 */ --bdl-info-bg: #e6f7ff; --bdl-info-border: #91d5ff; --bdl-info-text: #0050b3; --bdl-success-bg: #f6ffed; --bdl-success-border: #b7eb8f; --bdl-success-text: #389e0d; --bdl-warning-bg: #fffbe6; --bdl-warning-border: #ffe58f; --bdl-warning-text: #ad6800; --bdl-danger-bg: #fff1f0; --bdl-danger-border: #ffa39e; --bdl-danger-text: #cf1322; /* --bdl-brand-soft 底色上的文字色。深色面板下压暗的品牌色对比度不足, 改用提亮值。 */ --bdl-brand-on-soft: var(--bdl-brand-deep); } /* 深色站点的面板配色,由 applySurface() 在宿主元素上加 .bdl-dark 触发。 */ :host(.bdl-dark) { color-scheme: dark; --bdl-surface: #1c1c22; --bdl-surface-sub: #16161b; --bdl-surface-raise: #26262d; --bdl-border: #34343d; --bdl-border-soft: #2a2a32; --bdl-text: #f5f5f5; --bdl-text-sub: #b8b8c0; --bdl-text-weak: #85858f; --bdl-overlay: rgba(0, 0, 0, 0.55); --bdl-veil: rgba(10, 10, 14, 0.66); --bdl-info-bg: rgba(56, 139, 253, 0.16); --bdl-info-border: rgba(56, 139, 253, 0.42); --bdl-info-text: #79c0ff; --bdl-success-bg: rgba(63, 185, 80, 0.16); --bdl-success-border: rgba(63, 185, 80, 0.42); --bdl-success-text: #56d364; --bdl-warning-bg: rgba(227, 179, 65, 0.16); --bdl-warning-border: rgba(227, 179, 65, 0.42); --bdl-warning-text: #e3b341; --bdl-danger-bg: rgba(248, 81, 73, 0.16); --bdl-danger-border: rgba(248, 81, 73, 0.45); --bdl-danger-text: #ff7b72; --bdl-brand-on-soft: color-mix(in srgb, var(--bdl-brand) 55%, white); } :host, :host * { box-sizing: border-box; } :host button, :host input, :host select { font: inherit; } #bdl-entry, .bdl-popup, .bdl-complete-overlay { pointer-events: auto; } #bdl-panel { position: fixed; right: 20px; bottom: 80px; z-index: 100000; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; } #bdl-main-btn { width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, var(--bdl-brand-accent) 0%, var(--bdl-brand-accent-deep) 100%); border: none; cursor: pointer; box-shadow: 0 4px 15px color-mix(in srgb, var(--bdl-brand-accent) 50%, transparent); display: flex; align-items: center; justify-content: center; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; } #bdl-main-btn:hover { transform: scale(1.08) translateY(-2px); box-shadow: 0 6px 25px color-mix(in srgb, var(--bdl-brand-accent) 60%, transparent); } #bdl-main-btn:active { transform: scale(1.02); } #bdl-main-btn:disabled { cursor: not-allowed; } #bdl-main-btn svg { width: 30px; height: 30px; fill: white; position: relative; z-index: 2; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); } #bdl-main-btn:hover svg { transform: translateY(-1px); } #bdl-progress-circle { position: absolute; bottom: 0; left: 0; width: 100%; background: linear-gradient(180deg, var(--bdl-brand) 0%, var(--bdl-brand-deep) 100%); transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1); height: 0%; border-radius: 0 0 30px 30px; overflow: hidden; box-shadow: 0 -2px 10px color-mix(in srgb, var(--bdl-brand) 30%, transparent) inset; } #bdl-progress-circle::before { content: ''; position: absolute; top: -15px; left: -50%; width: 200%; height: 30px; background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.5) 0%, transparent 50%); border-radius: 45%; animation: bdlCircleWave 2.5s ease-in-out infinite; } #bdl-progress-circle::after { content: ''; position: absolute; top: -12px; left: -50%; width: 200%; height: 25px; background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.3) 0%, transparent 50%); border-radius: 40%; animation: bdlCircleWave 3s ease-in-out infinite reverse; } @keyframes bdlCircleWave { 0%, 100% { transform: translateX(0) translateY(0) rotate(0deg); } 25% { transform: translateX(-15%) translateY(-2px) rotate(-2deg); } 50% { transform: translateX(-25%) translateY(-4px) rotate(0deg); } 75% { transform: translateX(-35%) translateY(-2px) rotate(2deg); } } .bdl-progress-bubble { position: absolute; bottom: 0; width: 4px; height: 4px; background: rgba(255, 255, 255, 0.6); border-radius: 50%; animation: bdlBubbleRise 3s ease-in infinite; opacity: 0; } .bdl-progress-bubble:nth-child(1) { left: 20%; animation-delay: 0s; animation-duration: 2.5s; } .bdl-progress-bubble:nth-child(2) { left: 50%; animation-delay: 0.8s; animation-duration: 3s; } .bdl-progress-bubble:nth-child(3) { left: 70%; animation-delay: 1.5s; animation-duration: 2.8s; } @keyframes bdlBubbleRise { 0% { bottom: 0; opacity: 0; transform: translateX(0) scale(0.5); } 10% { opacity: 1; } 50% { opacity: 0.8; transform: translateX(10px) scale(1); } 100% { bottom: 100%; opacity: 0; transform: translateX(-10px) scale(0.5); } } .bdl-popup { position: absolute; bottom: 75px; right: 0; width: 420px; background: var(--bdl-surface); border-radius: 16px; box-shadow: 0 10px 50px var(--bdl-overlay); display: none; overflow: hidden; } .bdl-popup.show { display: block; animation: bdlFadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); } @keyframes bdlFadeIn { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes bdlFadeOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } } .bdl-header { background: linear-gradient(135deg, var(--bdl-brand-accent) 0%, var(--bdl-brand-accent-deep) 100%); color: var(--bdl-brand-contrast); padding: 18px 20px; display: flex; justify-content: space-between; align-items: center; } .bdl-header-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; gap: 8px; } .bdl-close { width: 30px; height: 30px; border: none; background: rgba(255,255,255,0.2); border-radius: 50%; cursor: pointer; font-size: 18px; color: white; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .bdl-close:hover { background: rgba(255,255,255,0.3); transform: rotate(90deg); } .bdl-body { padding: 20px; max-height: 65vh; overflow-y: auto; } .bdl-info-card { background: var(--bdl-surface-sub); border-radius: 12px; padding: 15px; margin-bottom: 18px; } .bdl-info-title { font-size: 15px; font-weight: 600; color: var(--bdl-text); margin-bottom: 10px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .bdl-info-meta { display: flex; gap: 15px; font-size: 13px; color: var(--bdl-text-sub); } .bdl-info-meta-item { display: flex; align-items: center; gap: 5px; } .bdl-vip-badge { display: inline-block; padding: 2px 6px; font-size: 10px; border-radius: 4px; font-weight: 600; margin-left: 5px; } .bdl-vip-badge.guest { background: var(--bdl-border); color: var(--bdl-text-sub); } .bdl-vip-badge.normal { background: var(--bdl-brand-light); color: var(--bdl-brand-contrast); } .bdl-vip-badge.vip { background: var(--bdl-brand); color: var(--bdl-brand-contrast); } .bdl-section { margin-bottom: 18px; } .bdl-section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .bdl-section-title { font-size: 14px; font-weight: 600; color: var(--bdl-text); } .bdl-pages-container { max-height: 200px; overflow-y: auto; border: 1px solid var(--bdl-border); border-radius: 10px; padding: 10px; background: var(--bdl-surface-sub); } .bdl-page-item { display: flex; align-items: center; padding: 10px; margin-bottom: 8px; border: 2px solid var(--bdl-border); border-radius: 8px; cursor: pointer; transition: all 0.2s; background: var(--bdl-surface-raise); } .bdl-page-item:last-child { margin-bottom: 0; } .bdl-page-item:hover { border-color: var(--bdl-brand-accent); } .bdl-page-item.active { border-color: var(--bdl-brand-accent); background: linear-gradient(135deg, color-mix(in srgb, var(--bdl-brand-accent) 8%, transparent) 0%, color-mix(in srgb, var(--bdl-brand-accent-deep) 8%, transparent) 100%); } .bdl-page-checkbox { width: 18px; height: 18px; margin-right: 12px; cursor: pointer; accent-color: var(--bdl-brand-accent); } .bdl-page-info { flex: 1; min-width: 0; } .bdl-page-num { font-size: 12px; color: var(--bdl-text-weak); margin-bottom: 3px; } .bdl-page-title { font-size: 13px; color: var(--bdl-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .bdl-page-duration { font-size: 12px; color: var(--bdl-text-weak); margin-left: 10px; } .bdl-short-items-section { display: none; } .bdl-short-items-section.show { display: block; } .bdl-short-items-toolbar { display: flex; gap: 8px; margin-bottom: 8px; } .bdl-short-select-btn { flex: 1; height: 28px; border: 1px solid var(--bdl-border); border-radius: 8px; background: var(--bdl-surface-raise); color: var(--bdl-text-sub); font-size: 12px; font-family: inherit; cursor: pointer; transition: all 0.2s; } .bdl-short-select-btn:hover { border-color: var(--bdl-brand-accent); color: var(--bdl-brand-accent); } .bdl-short-items { max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid var(--bdl-border); border-radius: 10px; background: var(--bdl-surface-sub); } .bdl-short-item-row { display: flex; flex-direction: column; gap: 8px; } .bdl-short-assets { display: grid; grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 6px; padding: 8px; border: 1px solid var(--bdl-border); border-radius: 8px; background: var(--bdl-surface-raise); } .bdl-short-asset { position: relative; display: block; width: 100%; aspect-ratio: 1; padding: 0; border: 2px solid var(--bdl-border); border-radius: 6px; overflow: hidden; background: var(--bdl-surface-sub); font-family: inherit; cursor: pointer; opacity: 0.5; transition: opacity 0.2s, border-color 0.2s; } .bdl-short-asset:hover { opacity: 0.8; } .bdl-short-asset.selected { border-color: var(--bdl-brand-accent); opacity: 1; } .bdl-short-asset-thumb { width: 100%; height: 100%; object-fit: cover; display: block; } .bdl-short-asset-index { position: absolute; right: 3px; bottom: 3px; padding: 0 4px; border-radius: 4px; background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 10px; line-height: 14px; } .bdl-short-item { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 10px; padding: 10px; border: 2px solid var(--bdl-border); border-radius: 10px; background: var(--bdl-surface-raise); color: var(--bdl-text); cursor: pointer; text-align: left; transition: all 0.2s; } .bdl-short-item:hover { border-color: var(--bdl-brand-accent); color: var(--bdl-brand-accent); } .bdl-short-item.selected { border-color: var(--bdl-brand-accent); background: linear-gradient(135deg, color-mix(in srgb, var(--bdl-brand-accent) 8%, transparent) 0%, color-mix(in srgb, var(--bdl-brand-accent-deep) 8%, transparent) 100%); } /* 图集内只选了一部分:虚线外框区别于整项选中 */ .bdl-short-item.partial { border-style: dashed; border-color: var(--bdl-brand-accent); } /* 信息区当前展示的媒体项 */ .bdl-short-item.previewing { box-shadow: inset 3px 0 0 var(--bdl-brand-accent-deep); } .bdl-short-item-mark { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; min-width: 42px; height: 24px; padding: 0 8px; border-radius: 999px; background: color-mix(in srgb, var(--bdl-brand-accent) 10%, transparent); color: var(--bdl-brand-accent-deep); font-size: 11px; font-weight: 600; } .bdl-short-item-title { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 13px; } /* 同一作品的多路清晰度标题相同,靠右侧标识区分 */ .bdl-short-item-label { flex: 0 0 auto; max-width: 45%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--bdl-text-weak); font-size: 11px; font-weight: 600; } .bdl-pages-actions { display: flex; gap: 10px; margin-top: 10px; } .bdl-pages-actions button { flex: 1; padding: 8px; border: 1px solid var(--bdl-brand-accent); border-radius: 6px; background: var(--bdl-surface-raise); color: var(--bdl-brand-accent); font-size: 12px; cursor: pointer; transition: all 0.2s; } .bdl-pages-actions button:hover { background: var(--bdl-brand-accent); color: var(--bdl-brand-contrast); } .bdl-quality-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .bdl-quality-btn { padding: 10px 8px; border: 2px solid var(--bdl-border); border-radius: 10px; background: var(--bdl-surface-raise); font-size: 12px; cursor: pointer; transition: all 0.2s; text-align: center; color: var(--bdl-text-sub); } .bdl-quality-btn:hover { border-color: var(--bdl-brand-accent); color: var(--bdl-brand-accent); } .bdl-quality-btn.active { border-color: var(--bdl-brand-accent); background: var(--bdl-brand-accent); color: var(--bdl-brand-contrast); } .bdl-quality-btn.disabled { opacity: 0.4; cursor: not-allowed; } .bdl-codec-selector { margin-bottom: 18px; } .bdl-codec-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .bdl-codec-item { display: flex; flex-direction: column; gap: 6px; } .bdl-codec-label { font-size: 12px; color: var(--bdl-text-sub); font-weight: 500; } .bdl-codec-select { padding: 8px 10px; border: 2px solid var(--bdl-border); border-radius: 8px; background: var(--bdl-surface-raise); font-size: 13px; color: var(--bdl-text); cursor: pointer; transition: all 0.2s; } .bdl-codec-select:hover { border-color: var(--bdl-brand-accent); } .bdl-codec-select:focus { outline: none; border-color: var(--bdl-brand-accent); } .bdl-method-list { display: flex; flex-direction: column; gap: 10px; } .bdl-method-item { display: flex; align-items: center; padding: 12px 15px; border: 2px solid var(--bdl-border); border-radius: 12px; cursor: pointer; transition: all 0.2s; background: var(--bdl-surface-raise); } .bdl-method-item:hover { border-color: var(--bdl-brand-accent); } .bdl-method-item.active { border-color: var(--bdl-brand-accent); background: linear-gradient(135deg, color-mix(in srgb, var(--bdl-brand-accent) 8%, transparent) 0%, color-mix(in srgb, var(--bdl-brand-accent-deep) 8%, transparent) 100%); } .bdl-method-radio { width: 20px; height: 20px; border: 2px solid var(--bdl-border); border-radius: 50%; margin-right: 12px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .bdl-method-item.active .bdl-method-radio { border-color: var(--bdl-brand-accent); } .bdl-method-item.active .bdl-method-radio::after { content: ''; width: 10px; height: 10px; background: var(--bdl-brand-accent); border-radius: 50%; } .bdl-method-content { flex: 1; } .bdl-method-name { font-size: 14px; font-weight: 600; color: var(--bdl-text); margin-bottom: 3px; display: flex; align-items: center; gap: 8px; } .bdl-method-desc { font-size: 12px; color: var(--bdl-text-weak); } .bdl-method-status { font-size: 11px; padding: 3px 8px; border-radius: 10px; font-weight: 500; } .bdl-method-status.ready { background: color-mix(in srgb, var(--bdl-success-text) 14%, transparent); color: var(--bdl-success-text); } .bdl-method-status.loading { background: color-mix(in srgb, var(--bdl-warning-text) 16%, transparent); color: var(--bdl-warning-text); } .bdl-extra-downloads { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; } .bdl-extra-btn { flex: 1; min-width: calc(50% - 4px); padding: 10px; border: 2px solid var(--bdl-border); border-radius: 8px; background: var(--bdl-surface-raise); font-size: 12px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 5px; } .bdl-extra-btn:hover { border-color: var(--bdl-brand-accent); color: var(--bdl-brand-accent); } .bdl-progress-section { background: var(--bdl-surface-sub); border-radius: 12px; padding: 15px; margin-bottom: 18px; display: none; } .bdl-progress-section.show { display: block; } .bdl-progress-row { margin-bottom: 12px; } .bdl-progress-row:last-child { margin-bottom: 0; } .bdl-progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 13px; } .bdl-progress-label { color: var(--bdl-text-sub); font-weight: 500; } .bdl-progress-value { color: var(--bdl-text-weak); } .bdl-progress-track { height: 10px; background: var(--bdl-surface-sub); border-radius: 5px; overflow: hidden; position: relative; } .bdl-progress-bar { height: 100%; border-radius: 5px; transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); width: 0%; position: relative; overflow: hidden; background: linear-gradient(90deg, var(--bdl-brand), var(--bdl-brand-light)); } .bdl-progress-bar::before { content: ''; position: absolute; top: -50%; left: 0; width: 100%; height: 200%; background: repeating-linear-gradient( 90deg, transparent, transparent 10px, rgba(255,255,255,0.15) 10px, rgba(255,255,255,0.15) 20px ); animation: bdlStripe 1s linear infinite; } .bdl-progress-bar::after { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 50%; background: linear-gradient(to bottom, rgba(255,255,255,0.3), transparent); border-radius: 5px 5px 0 0; } @keyframes bdlStripe { 0% { transform: translateX(-20px); } 100% { transform: translateX(0); } } .bdl-progress-bar.video { background: linear-gradient(90deg, var(--bdl-brand), var(--bdl-brand-light)); } .bdl-progress-bar.audio { background: linear-gradient(90deg, var(--bdl-brand-accent), var(--bdl-brand-accent-light)); } .bdl-progress-bar.merge { background: linear-gradient(90deg, var(--bdl-brand), var(--bdl-brand-accent)); } .bdl-alert { padding: 12px 15px; border-radius: 10px; font-size: 13px; margin-bottom: 18px; display: none; line-height: 1.5; } .bdl-alert.show { display: block; } .bdl-alert.info { background: var(--bdl-info-bg); border: 1px solid var(--bdl-info-border); color: var(--bdl-info-text); } .bdl-alert.success { background: var(--bdl-success-bg); border: 1px solid var(--bdl-success-border); color: var(--bdl-success-text); } .bdl-alert.warning { background: var(--bdl-warning-bg); border: 1px solid var(--bdl-warning-border); color: var(--bdl-warning-text); } .bdl-alert.error { background: var(--bdl-danger-bg); border: 1px solid var(--bdl-danger-border); color: var(--bdl-danger-text); } .bdl-download-btn { width: 100%; padding: 15px; border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); background: linear-gradient(135deg, var(--bdl-brand-accent) 0%, var(--bdl-brand-accent-deep) 100%); color: var(--bdl-brand-contrast); display: flex; align-items: center; justify-content: center; gap: 8px; position: relative; overflow: hidden; } .bdl-download-progress-fill { position: absolute; inset: 0 auto 0 0; width: var(--bdl-download-progress, 0%); background: linear-gradient(90deg, rgba(255,255,255,0.24), rgba(255,255,255,0.36)); transition: width 0.25s ease; pointer-events: none; } .bdl-download-label { position: relative; z-index: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .bdl-download-btn.is-active.can-queue { cursor: pointer; } .bdl-download-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 20px color-mix(in srgb, var(--bdl-brand-accent) 40%, transparent); } .bdl-download-btn:disabled { background: linear-gradient(135deg, var(--bdl-border) 0%, var(--bdl-text-weak) 100%); cursor: not-allowed; transform: none; } .bdl-footer { text-align: center; padding: 15px 20px; background: var(--bdl-surface-sub); font-size: 12px; color: var(--bdl-text-weak); line-height: 1.6; cursor: pointer; user-select: none; transition: all 0.2s; } .bdl-footer:hover { background: var(--bdl-surface-sub); color: var(--bdl-text-sub); } .bdl-tips { background: var(--bdl-warning-bg); border: 1px solid var(--bdl-warning-border); border-radius: 10px; padding: 12px 15px; margin-bottom: 18px; font-size: 12px; color: var(--bdl-warning-text); line-height: 1.6; } .bdl-tips-title { font-weight: 600; margin-bottom: 5px; display: flex; align-items: center; gap: 5px; } .bdl-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--bdl-brand-contrast); border-radius: 50%; border-top-color: transparent; animation: bdlSpin 0.8s linear infinite; } @keyframes bdlSpin { to { transform: rotate(360deg); } } .bdl-badge { display: inline-block; padding: 2px 6px; font-size: 10px; border-radius: 4px; font-weight: 600; } .bdl-badge.recommended { background: #52c41a; color: white; } .bdl-complete-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: var(--bdl-veil); backdrop-filter: blur(8px); z-index: 100001; display: flex; align-items: center; justify-content: center; animation: bdlOverlayIn 0.4s cubic-bezier(0.4, 0, 0.2, 1); } @keyframes bdlOverlayIn { from { opacity: 0; backdrop-filter: blur(0px); } to { opacity: 1; backdrop-filter: blur(8px); } } .bdl-complete-container { position: relative; display: flex; align-items: center; justify-content: center; } .bdl-complete-icon { width: 140px; height: 140px; background: var(--bdl-brand); border-radius: 50%; display: flex; align-items: center; justify-content: center; animation: bdlIconPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); box-shadow: 0 12px 32px color-mix(in srgb, var(--bdl-brand) 24%, transparent); position: relative; z-index: 2; } /* 单色描边环,靠缩放与透明度做呼吸感,不叠第二种颜色。 */ .bdl-complete-icon::before { content: ''; position: absolute; inset: -10px; border-radius: 50%; border: 2px solid color-mix(in srgb, var(--bdl-brand) 34%, transparent); animation: bdlIconPulse 2s ease-in-out infinite; z-index: -1; } @keyframes bdlIconPop { 0% { transform: scale(0) rotate(-180deg); opacity: 0; } 50% { transform: scale(1.15) rotate(10deg); } 100% { transform: scale(1) rotate(0deg); opacity: 1; } } @keyframes bdlIconPulse { 0%, 100% { transform: scale(1); opacity: 0.9; } 50% { transform: scale(1.12); opacity: 0.2; } } .bdl-complete-icon svg { width: 80px; height: 80px; fill: none; stroke: var(--bdl-brand-contrast); stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; } .bdl-complete-icon svg path { stroke-dasharray: 80; stroke-dashoffset: 80; animation: bdlCheckDraw 0.6s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards; } @keyframes bdlCheckDraw { to { stroke-dashoffset: 0; } } .bdl-complete-ripple { position: absolute; top: 50%; left: 50%; width: 140px; height: 140px; margin: -70px 0 0 -70px; border-radius: 50%; border: 2px solid color-mix(in srgb, var(--bdl-brand) 40%, transparent); animation: bdlRippleOut 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; z-index: 1; } .bdl-complete-ripple:nth-child(2) { border-color: color-mix(in srgb, var(--bdl-brand-spark) 40%, transparent); animation-delay: 0.15s; } .bdl-complete-ripple:nth-child(3) { border-color: color-mix(in srgb, var(--bdl-brand) 24%, transparent); animation-delay: 0.3s; } @keyframes bdlRippleOut { 0% { transform: scale(1); opacity: 1; } 100% { transform: scale(2.8); opacity: 0; } } .bdl-complete-particles { position: absolute; top: 50%; left: 50%; width: 0; height: 0; z-index: 3; } .bdl-particle { position: absolute; border-radius: 50%; background: var(--bdl-brand); animation: bdlParticleFly 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; } .bdl-particle.is-light { background: var(--bdl-brand-light); } .bdl-particle.is-spark { background: var(--bdl-brand-spark); } /* 深色底上主色为黑的站点(X 暗色主题)粒子会看不见,改用前景色一族。 */ :host(.bdl-dark) .bdl-particle { background: var(--bdl-brand-contrast); } :host(.bdl-dark) .bdl-particle.is-light { background: color-mix(in srgb, var(--bdl-brand-contrast) 55%, transparent); } :host(.bdl-dark) .bdl-particle.is-spark { background: var(--bdl-brand-spark); } /* 主色为黑的站点在深色底上圆盘与底色几乎同色,靠一圈前景色描边界定形状。 */ :host(.bdl-dark) .bdl-complete-icon { box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 2px color-mix(in srgb, var(--bdl-brand-contrast) 45%, transparent); } :host(.bdl-dark) .bdl-complete-icon::before { border-color: color-mix(in srgb, var(--bdl-brand-contrast) 32%, transparent); } :host(.bdl-dark) .bdl-complete-ripple, :host(.bdl-dark) .bdl-complete-ripple:nth-child(3) { border-color: color-mix(in srgb, var(--bdl-brand-contrast) 26%, transparent); } @keyframes bdlParticleFly { 0% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 1; } 100% { transform: translate(var(--tx), var(--ty)) scale(0) rotate(360deg); opacity: 0; } } .bdl-complete-text { position: absolute; top: calc(50% + 110px); left: 50%; transform: translateX(-50%); font-size: 24px; font-weight: 700; color: var(--bdl-text); animation: bdlTextFade 0.5s cubic-bezier(0.4, 0, 0.2, 1) 0.5s both; white-space: nowrap; letter-spacing: 2px; } @keyframes bdlTextFade { from { opacity: 0; transform: translateX(-50%) translateY(20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } .bdl-complete-sparkles { position: absolute; top: 50%; left: 50%; width: 200px; height: 200px; margin: -100px 0 0 -100px; z-index: 4; } .bdl-sparkle { position: absolute; width: 6px; height: 6px; background: var(--bdl-brand-spark); clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); animation: bdlSparkle 1.5s ease-in-out infinite; opacity: 0; } @keyframes bdlSparkle { 0%, 100% { opacity: 0; transform: scale(0) rotate(0deg); } 50% { opacity: 1; transform: scale(1) rotate(180deg); } } #bdl-pages-section { max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, margin-bottom 0.3s ease; margin-bottom: 0; } #bdl-pages-section.show { max-height: 500px; opacity: 1; margin-bottom: 18px; } #bdl-ugc-section { max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, margin-bottom 0.3s ease; margin-bottom: 0; } #bdl-ugc-section.show { max-height: 500px; opacity: 1; margin-bottom: 18px; } /* 中性色令牌统一声明在 :host,此处不再重复声明, 否则会遮蔽 :host(.bdl-dark) 的深色取值。 */ #bdl-panel, #bdl-entry { font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif; } #bdl-entry { display: none; flex: none; position: relative; } #bdl-entry[data-mounted="true"] { display: flex; } #bdl-entry[data-mode="bangumi"] { align-items: center; } #bdl-entry[data-mode="video"], #bdl-entry[data-mode="bangumi"] { position: fixed; margin-right: 0; z-index: 100000; } #bdl-entry[data-mode="floating"] { position: fixed; right: 24px; bottom: 112px; margin-right: 0; z-index: 100000; touch-action: none; cursor: grab; user-select: none; } #bdl-entry[data-mode="floating"].is-dragging { cursor: grabbing; } #bdl-entry[data-mode="floating"] #bdl-main-btn { cursor: grab; } #bdl-entry[data-mode="floating"].is-dragging #bdl-main-btn { cursor: grabbing; } #bdl-main-btn { width: auto; height: 32px; padding: 0 14px 0 10px; border-radius: 999px; border: 1px solid transparent; background: transparent; box-shadow: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; color: var(--bdl-text-sub); font-size: 13px; font-weight: 500; overflow: hidden; } #bdl-entry[data-mode="bangumi"] #bdl-main-btn { height: 30px; padding: 0 12px 0 8px; } /* 浮动按钮:深色半透明胶囊,默认低透明度,hover 才完全显现。 短视频站点多为深色界面,故不使用品牌色渐变。 */ #bdl-entry[data-mode="floating"] #bdl-main-btn { min-width: 88px; height: 40px; padding: 0 16px 0 12px; background: rgba(22, 24, 28, 0.72); -webkit-backdrop-filter: blur(12px) saturate(150%); backdrop-filter: blur(12px) saturate(150%); border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.92); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22); opacity: 0.66; transition: opacity 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; } #bdl-entry[data-mode="floating"] #bdl-main-btn:hover { background: rgba(22, 24, 28, 0.9); color: #fff; border-color: rgba(255, 255, 255, 0.22); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3); opacity: 1; } /* 下载中与面板展开时保持完全不透明 */ #bdl-entry[data-mode="floating"].is-downloading #bdl-main-btn, #bdl-entry[data-mode="floating"] #bdl-main-btn[aria-expanded="true"] { opacity: 1; background: rgba(22, 24, 28, 0.9); color: #fff; } #bdl-main-btn:hover { transform: none; box-shadow: none; color: var(--bdl-brand); background: var(--bdl-brand-soft); border-color: color-mix(in srgb, var(--bdl-brand) 20%, transparent); } #bdl-main-btn:active { transform: scale(0.98); } #bdl-main-btn[aria-expanded="true"] { color: var(--bdl-brand); background: var(--bdl-brand-soft); border-color: color-mix(in srgb, var(--bdl-brand) 22%, transparent); } #bdl-main-btn:disabled { opacity: 0.6; cursor: not-allowed; } #bdl-main-btn svg { width: 18px; height: 18px; fill: currentColor; z-index: 2; position: relative; } /* ── 无缝集成模式 ────────────────────────────────────────── 按钮已插进站点自己的操作栏,视觉完全由 --bdl-skin-* 变量驱动, 这些变量来自实测数值或运行时对相邻原生按钮的取样。 这里刻意不设任何品牌色/阴影/渐变,让它看起来就是站点自带的。 */ #bdl-entry[data-mode="native-bar"] { display: inline-flex; align-items: center; position: static; } #bdl-entry[data-mode="native-bar"] #bdl-main-btn { height: var(--bdl-skin-height, 28px); padding: var(--bdl-skin-padding, 0); border-radius: var(--bdl-skin-radius, 0); gap: var(--bdl-skin-gap, 4px); font-size: var(--bdl-skin-font-size, 13px); font-weight: var(--bdl-skin-font-weight, 500); color: var(--bdl-skin-color, inherit); background: var(--bdl-skin-bg, transparent); border: none; box-shadow: none; /* 继承站点字体,与相邻原生按钮字形一致 */ font-family: inherit; white-space: nowrap; } #bdl-entry[data-mode="native-bar"] #bdl-main-btn:hover { color: var(--bdl-skin-color-hover, inherit); background: var(--bdl-skin-bg-hover, transparent); border-color: transparent; box-shadow: none; transform: none; } #bdl-entry[data-mode="native-bar"] #bdl-main-btn:active { transform: none; } #bdl-entry[data-mode="native-bar"] #bdl-main-btn[aria-expanded="true"] { color: var(--bdl-skin-color-hover, inherit); background: var(--bdl-skin-bg-hover, transparent); border-color: transparent; } #bdl-entry[data-mode="native-bar"] #bdl-main-btn svg { width: var(--bdl-skin-icon-size, 20px); height: var(--bdl-skin-icon-size, 20px); fill: currentColor; } /* 下载进度:不铺满整个按钮,只在底部走一条细线, 跟站点原生按钮的克制风格一致 */ #bdl-entry[data-mode="native-bar"] #bdl-progress-circle { inset: auto 0 0; height: 2px; border-radius: 0; background: var(--bdl-skin-color-hover, var(--bdl-brand-accent)); opacity: 0; transition: opacity 0.2s ease, width 0.3s ease; } #bdl-entry[data-mode="native-bar"].has-entry-progress #bdl-progress-circle { opacity: 1; } #bdl-main-btn:hover svg { transform: none; } .bdl-toolbar-icon, .bdl-toolbar-text { position: relative; z-index: 2; } .bdl-toolbar-icon { display: inline-flex; align-items: center; justify-content: center; } .bdl-toolbar-text { letter-spacing: 0.2px; } #bdl-progress-circle { position: absolute; inset: auto 0 0; width: 100%; height: 0%; border-radius: inherit; background: linear-gradient(180deg, rgba(16, 185, 129, 0.42) 0%, rgba(5, 150, 105, 0.86) 100%); box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.55) inset; transition: height 0.25s ease; z-index: 1; pointer-events: none; } #bdl-entry.has-entry-progress[data-mode="floating"] #bdl-main-btn { background: linear-gradient(180deg, #111827 0%, #1f2937 100%); box-shadow: 0 14px 28px rgba(17, 24, 39, 0.3); } #bdl-entry.has-entry-progress[data-mode="floating"] #bdl-main-btn:hover { background: linear-gradient(180deg, #111827 0%, #263244 100%); box-shadow: 0 16px 32px rgba(17, 24, 39, 0.36); } #bdl-progress-circle::before, #bdl-progress-circle::after { display: none; } #bdl-panel { position: static; inset: auto; z-index: 100000; } .bdl-popup { position: fixed; top: 0; left: 0; right: auto; bottom: auto; width: min(432px, calc(100vw - 24px)); max-height: min(78vh, 760px); border-radius: 18px; border: 1px solid var(--bdl-border); background: var(--bdl-surface); box-shadow: 0 24px 56px var(--bdl-overlay); overflow: hidden; display: none; transform-origin: top right; z-index: 100001; } .bdl-popup.is-dragging { user-select: none; transition: none; cursor: grabbing; } .bdl-popup.show { display: flex; flex-direction: column; animation: bdlFadeIn 0.18s ease-out; } .bdl-header { background: linear-gradient(180deg, color-mix(in srgb, var(--bdl-brand) 12%, transparent) 0%, color-mix(in srgb, var(--bdl-brand) 4%, transparent) 100%); color: var(--bdl-text); border-bottom: 1px solid var(--bdl-border); padding: 16px 18px 14px; cursor: grab; user-select: none; touch-action: none; } .bdl-popup.is-dragging .bdl-header { cursor: grabbing; } .bdl-header-title { font-size: 16px; font-weight: 600; color: var(--bdl-text); gap: 0; } .bdl-close { width: 30px; height: 30px; background: color-mix(in srgb, var(--bdl-text) 8%, transparent); color: var(--bdl-text-sub); border-radius: 50%; } .bdl-close:hover { background: color-mix(in srgb, var(--bdl-brand) 12%, transparent); color: var(--bdl-brand); transform: none; } .bdl-body { padding: 16px 18px 18px; max-height: min(70vh, 660px); overflow-y: auto; background: var(--bdl-surface); } .bdl-body::-webkit-scrollbar, .bdl-pages-container::-webkit-scrollbar { width: 6px; } .bdl-body::-webkit-scrollbar-thumb, .bdl-pages-container::-webkit-scrollbar-thumb { background: var(--bdl-border); border-radius: 999px; } .bdl-info-card { background: linear-gradient(180deg, var(--bdl-surface-raise) 0%, var(--bdl-surface-sub) 100%); border: 1px solid var(--bdl-border); border-radius: 16px; padding: 14px 14px 12px; margin-bottom: 16px; } .bdl-info-title { font-size: 16px; line-height: 1.6; color: var(--bdl-text); margin-bottom: 12px; } .bdl-info-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--bdl-text-sub); } .bdl-info-meta-item { gap: 6px; padding: 6px 10px; border-radius: 999px; background: var(--bdl-surface-raise); border: 1px solid var(--bdl-border); } .bdl-meta-label { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; height: 20px; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--bdl-brand) 12%, transparent); color: var(--bdl-brand-on-soft); font-size: 11px; font-weight: 600; line-height: 20px; } .bdl-vip-badge { margin-left: 0; padding: 4px 8px; border-radius: 999px; font-size: 11px; line-height: 1; } .bdl-vip-badge.site { background: color-mix(in srgb, var(--bdl-brand) 12%, transparent); color: var(--bdl-brand-on-soft); } .bdl-section { margin-bottom: 16px; } .bdl-section-header { margin-bottom: 10px; } .bdl-section-title { font-size: 13px; font-weight: 600; color: var(--bdl-text); } .bdl-section-count { font-size: 12px; color: var(--bdl-text-weak); } .bdl-pages-container { max-height: 208px; padding: 10px; border: 1px solid var(--bdl-border); border-radius: 14px; background: var(--bdl-surface-sub); } .bdl-page-item { padding: 11px 12px; margin-bottom: 8px; border: 1px solid transparent; border-radius: 12px; background: var(--bdl-surface-raise); } .bdl-page-item:hover { border-color: color-mix(in srgb, var(--bdl-brand) 24%, transparent); background: color-mix(in srgb, var(--bdl-brand) 4%, transparent); } .bdl-page-item.active { border-color: color-mix(in srgb, var(--bdl-brand) 28%, transparent); background: color-mix(in srgb, var(--bdl-brand) 8%, transparent); } .bdl-page-checkbox { accent-color: var(--bdl-brand); } .bdl-page-num, .bdl-page-duration { color: var(--bdl-text-weak); } .bdl-page-title { color: var(--bdl-text); } .bdl-pages-actions { gap: 8px; margin-top: 10px; } .bdl-pages-actions button, .bdl-quality-btn, .bdl-extra-btn, .bdl-codec-select { border-radius: 12px; border: 1px solid var(--bdl-border); background: var(--bdl-surface-raise); color: var(--bdl-text-sub); } .bdl-pages-actions button { padding: 9px 8px; color: var(--bdl-text-sub); } .bdl-pages-actions button:hover, .bdl-quality-btn:hover, .bdl-extra-btn:hover { background: var(--bdl-brand-soft); border-color: color-mix(in srgb, var(--bdl-brand) 24%, transparent); color: var(--bdl-brand); } .bdl-quality-grid { gap: 8px; } .bdl-quality-btn { min-height: 40px; border-width: 1px; color: var(--bdl-text-sub); } .bdl-quality-btn.active { border-color: transparent; background: var(--bdl-brand); color: var(--bdl-brand-contrast); } .bdl-codec-label { color: var(--bdl-text-sub); } .bdl-codec-select { min-height: 40px; border-width: 1px; color: var(--bdl-text); } .bdl-codec-select:hover, .bdl-codec-select:focus { border-color: color-mix(in srgb, var(--bdl-brand) 24%, transparent); } .bdl-method-item { padding: 12px 14px; border-width: 1px; border-radius: 14px; background: var(--bdl-surface-raise); } .bdl-method-item:hover { border-color: color-mix(in srgb, var(--bdl-brand) 24%, transparent); } .bdl-method-item.active { border-color: color-mix(in srgb, var(--bdl-brand) 30%, transparent); background: color-mix(in srgb, var(--bdl-brand) 6%, transparent); } .bdl-method-radio { margin-right: 10px; } .bdl-method-item.active .bdl-method-radio { border-color: var(--bdl-brand); } .bdl-method-item.active .bdl-method-radio::after { background: var(--bdl-brand); } .bdl-method-name, .bdl-method-desc { color: var(--bdl-text); } .bdl-method-desc { color: var(--bdl-text-weak); } .bdl-method-status.ready { background: color-mix(in srgb, var(--bdl-success-text) 14%, transparent); color: var(--bdl-success-text); } .bdl-method-status.loading { background: color-mix(in srgb, var(--bdl-warning-text) 16%, transparent); color: var(--bdl-warning-text); } .bdl-extra-downloads { gap: 8px; margin-bottom: 16px; } .bdl-extra-btn { min-width: calc(50% - 4px); min-height: 42px; padding: 10px 12px; justify-content: flex-start; gap: 10px; } .bdl-extra-btn-mark { display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 22px; padding: 0 8px; border-radius: 999px; background: color-mix(in srgb, var(--bdl-brand) 12%, transparent); color: var(--bdl-brand-deep); font-size: 11px; font-weight: 600; } .bdl-progress-section { padding: 14px; border: 1px solid var(--bdl-border); border-radius: 16px; background: linear-gradient(180deg, var(--bdl-surface-raise) 0%, var(--bdl-surface-sub) 100%); margin-bottom: 16px; } .bdl-progress-label { color: var(--bdl-text-sub); } .bdl-progress-track { background: var(--bdl-border); } .bdl-alert { border-radius: 14px; margin-bottom: 16px; } .bdl-download-btn { min-height: 46px; border-radius: 14px; background: linear-gradient(180deg, var(--bdl-brand) 0%, var(--bdl-brand-deep) 100%); box-shadow: 0 10px 24px color-mix(in srgb, var(--bdl-brand) 22%, transparent); } .bdl-download-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 12px 28px color-mix(in srgb, var(--bdl-brand) 28%, transparent); } .bdl-footer { padding: 12px 18px 16px; background: var(--bdl-surface); border-top: 1px solid var(--bdl-border); color: var(--bdl-text-weak); cursor: default; } .bdl-footer:hover { background: var(--bdl-surface); color: var(--bdl-text-weak); } .bdl-tips { border-radius: 14px; margin-bottom: 16px; } .bdl-tips-title { gap: 0; color: var(--bdl-warning-text); } #bdl-pages-section, #bdl-ugc-section { transition: max-height 0.25s ease, opacity 0.2s ease, margin-bottom 0.2s ease; } /* ========== 诊断面板 ========== */ .bdl-diag-trigger { background: none; border: none; padding: 4px 8px; border-radius: 8px; color: inherit; font: inherit; cursor: pointer; opacity: 0.7; transition: opacity 0.2s, background 0.2s; } .bdl-diag-trigger:hover { opacity: 1; background: color-mix(in srgb, var(--bdl-text) 8%, transparent); } .bdl-diag-modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(15, 20, 30, 0.55); z-index: 2147483647; pointer-events: auto; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } .bdl-diag-modal.show { display: flex; animation: bdlFadeIn 0.2s ease; } .bdl-diag-card { width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 48px); background: var(--bdl-surface); border-radius: 16px; box-shadow: 0 24px 60px var(--bdl-overlay); display: flex; flex-direction: column; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; } .bdl-diag-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--bdl-border-soft); background: linear-gradient(180deg, var(--bdl-surface-sub) 0%, var(--bdl-surface) 100%); } .bdl-diag-title { font-size: 15px; font-weight: 600; color: var(--bdl-text); } .bdl-diag-close { background: none; border: none; font-size: 22px; line-height: 1; color: var(--bdl-text-sub); cursor: pointer; padding: 4px 8px; border-radius: 8px; } .bdl-diag-close:hover { background: color-mix(in srgb, var(--bdl-text) 8%, transparent); color: var(--bdl-text); } .bdl-diag-body { flex: 1; overflow: auto; padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; } .bdl-diag-desc { font-size: 12px; color: var(--bdl-text-sub); line-height: 1.6; } .bdl-diag-note-label { font-size: 12px; font-weight: 600; color: var(--bdl-text); } .bdl-diag-note { width: 100%; min-height: 72px; resize: vertical; padding: 10px 12px; border: 1px solid var(--bdl-border); border-radius: 10px; font: inherit; font-size: 13px; color: var(--bdl-text); background: var(--bdl-surface-sub); outline: none; transition: border-color 0.15s, box-shadow 0.15s; } .bdl-diag-note:focus { border-color: var(--bdl-brand-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bdl-brand-accent) 16%, transparent); } .bdl-diag-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .bdl-diag-toolbar-spacer { flex: 1; } .bdl-diag-log { background: #0f172a; color: #d5dbe7; border-radius: 10px; padding: 10px 12px; font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; font-size: 11.5px; line-height: 1.55; max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-word; } .bdl-diag-log-empty { color: #6b7280; font-style: italic; } .bdl-diag-log-line { display: block; padding: 2px 0; } .bdl-diag-log-line.level-info { color: #a7d4ff; } .bdl-diag-log-line.level-warn { color: #ffd28a; } .bdl-diag-log-line.level-error { color: #ff8a8a; } .bdl-diag-log-line.level-debug { color: #a3a3a3; } .bdl-diag-log-detail { display: block; color: #9ca3af; padding-left: 12px; border-left: 2px solid #1f2937; margin: 2px 0 4px 4px; } .bdl-diag-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--bdl-border); background: var(--bdl-surface-raise); color: var(--bdl-text); font-size: 13px; font-weight: 500; cursor: pointer; transition: background 0.15s, border-color 0.15s, color 0.15s; } .bdl-diag-btn:hover { background: var(--bdl-surface-sub); border-color: var(--bdl-text-weak); } .bdl-diag-btn.primary { background: linear-gradient(135deg, var(--bdl-brand-accent) 0%, var(--bdl-brand-accent-deep) 100%); border-color: transparent; color: var(--bdl-brand-contrast); box-shadow: 0 6px 14px color-mix(in srgb, var(--bdl-brand-accent-deep) 28%, transparent); } .bdl-diag-btn.primary:hover { transform: translateY(-1px); box-shadow: 0 8px 18px color-mix(in srgb, var(--bdl-brand-accent-deep) 34%, transparent); background: linear-gradient(135deg, color-mix(in srgb, var(--bdl-brand-accent) 90%, white) 0%, var(--bdl-brand-accent) 100%); } .bdl-diag-btn.danger { color: var(--bdl-danger-text); border-color: var(--bdl-danger-border); background: var(--bdl-danger-bg); } .bdl-diag-btn.danger:hover { background: color-mix(in srgb, var(--bdl-danger-text) 22%, transparent); } .bdl-diag-toast { font-size: 12px; color: var(--bdl-success-text); padding: 4px 0; min-height: 20px; } .bdl-diag-toast.error { color: var(--bdl-danger-text); } @media (max-width: 720px) { .bdl-diag-card { width: calc(100vw - 20px); max-height: calc(100vh - 32px); } .bdl-diag-log { max-height: 200px; } } @media (max-width: 720px) { #bdl-entry[data-mode="floating"] { right: 16px; bottom: 92px; } #bdl-main-btn { padding: 0 10px; } .bdl-toolbar-text { display: none; } .bdl-popup { width: calc(100vw - 20px); max-height: calc(100vh - 24px); border-radius: 16px; } .bdl-body { max-height: calc(100vh - 100px); } } `; var LEARNING_DISCLAIMER = "本视频通过学习工具下载,仅供个人学习研究使用,请勿用于商业用途,请支持正版内容创作者。"; var CONFIG = { ZIP_THRESHOLD: 15, QUALITY_MAP: { 127: "8K 超高清", 126: "杜比视界", 125: "HDR 真彩色", 120: "4K 超清", 116: "1080P 60帧", 112: "1080P 高码率", 80: "1080P 高清", 74: "720P 60帧", 64: "720P 高清", 32: "480P 清晰", 16: "360P 流畅" }, QUALITY_LIMIT: { 0: 32, 1: 80, 2: 127 }, VIDEO_CODEC_MAP: { "avc1": "H.264/AVC", "hev1": "H.265/HEVC", "hvc1": "H.265/HEVC", "av01": "AV1" }, AUDIO_CODEC_MAP: { 30280: "AAC 64K", 30232: "AAC 132K", 30216: "AAC 192K", 30250: "AAC Dolby", 30251: "FLAC" }, MERGE_METHODS: { JSMERGE: "js-merge", FFMPEG: "ffmpeg-merge", SEPARATE: "separate" }, GIF_METHODS: { JS: "js-gif", FFMPEG: "ffmpeg-gif" }, MAX_MERGE_BYTES: 700 * 1024 * 1024, MAX_FFMPEG_MERGE_BYTES: 350 * 1024 * 1024 }; var BiliAPI = { userVipType: 0, getUserInfo() { return Network.fetchJSON("https://api.bilibili.com/x/web-interface/nav").then((res) => { if (res.code === 0 && res.data) if (res.data.vipStatus === 1 && res.data.vipType === 2) this.userVipType = 2; else if (res.data.isLogin) this.userVipType = 1; else this.userVipType = 0; return this.userVipType; }).catch(() => { this.userVipType = 0; return 0; }); }, getVideoInfo(bvid) { return Network.fetchJSON("https://api.bilibili.com/x/web-interface/view?bvid=" + bvid).then((res) => { if (res.code !== 0) throw new Error(res.message || "获取视频信息失败"); return res.data; }); }, getUGCSeasonInfo(bvid) { return Network.fetchJSON("https://api.bilibili.com/x/web-interface/view?bvid=" + bvid).then((res) => { if (res.code !== 0) throw new Error(res.message || "获取合集信息失败"); const data = res.data; if (data.ugc_season) return { hasUGC: true, title: data.ugc_season.title, episodes: data.ugc_season.sections[0].episodes, cover: data.ugc_season.cover }; return { hasUGC: false }; }); }, getBangumiInfo(videoId) { const isEp = videoId.indexOf("ep") === 0; const id = videoId.replace(/^(ep|ss)/, ""); let url = "https://api.bilibili.com/pgc/view/web/season?"; url += isEp ? "ep_id=" + id : "season_id=" + id; return Network.fetchJSON(url).then((res) => { if (res.code !== 0) throw new Error(res.message || "获取番剧信息失败"); const result = res.result; const episodes = result.episodes || []; const pages = []; let currentEpId = null; if (isEp) currentEpId = parseInt(id); else { const urlMatch = window.location.pathname.match(/ep(\d+)/); if (urlMatch) currentEpId = parseInt(urlMatch[1]); } for (let i = 0; i < episodes.length; i++) { const ep = episodes[i]; pages.push({ cid: ep.cid, page: i + 1, part: ep.long_title || ep.title || "第" + (i + 1) + "集", duration: ep.duration / 1e3, ep_id: ep.id, bvid: ep.bvid }); } let currentIndex = 0; if (currentEpId) { for (let j = 0; j < pages.length; j++) if (pages[j].ep_id === currentEpId) { currentIndex = j; break; } } const totalDuration = episodes.reduce((acc, ep) => acc + ep.duration / 1e3, 0); return { title: result.season_title || result.title, pages, owner: { name: result.up_info ? result.up_info.uname : "番剧" }, duration: totalDuration, desc: result.evaluate || "", currentPage: currentIndex + 1, type: "bangumi", cover: result.cover }; }); }, getPlayUrl(params) { let url; if (params.type === "bangumi") url = "https://api.bilibili.com/pgc/player/web/playurl?ep_id=" + params.ep_id + "&cid=" + params.cid + "&qn=" + params.qn + "&fnval=4048&fnver=0&fourk=1"; else url = "https://api.bilibili.com/x/player/playurl?bvid=" + params.bvid + "&cid=" + params.cid + "&qn=" + params.qn + "&fnval=4048&fnver=0&fourk=1"; return Network.fetchJSON(url).then((res) => { if (res.code !== 0) throw new Error(res.message || "获取播放地址失败"); return res.result || res.data; }); }, getSubtitles(bvid, cid) { return Network.fetchJSON("https://api.bilibili.com/x/player/v2?bvid=" + bvid + "&cid=" + cid).then((res) => { if (res.code === 0 && res.data?.subtitle?.subtitles) return res.data.subtitle.subtitles; return []; }).catch(() => []); }, getDanmaku(cid) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: "https://api.bilibili.com/x/v1/dm/list.so?oid=" + cid, onload(res) { if (res.status >= 200 && res.status < 300) resolve(res.responseText); else reject(new Error("获取弹幕失败")); }, onerror() { reject(new Error("获取弹幕网络错误")); } }); }); }, getAvailableQualities(playData) { const list = []; const userLimit = CONFIG.QUALITY_LIMIT[this.userVipType] || 32; if (playData.accept_quality && playData.accept_description) for (let i = 0; i < playData.accept_quality.length; i++) { const qn = playData.accept_quality[i]; list.push({ qn, desc: playData.accept_description[i] || CONFIG.QUALITY_MAP[qn] || qn + "P", available: qn <= userLimit }); } return list; }, getVideoCodecs(playData) { const dash = playData.dash; if (!dash?.video) return []; const codecsMap = {}; for (const video of dash.video) { const codecType = video.codecs.split(".")[0]; if (!codecsMap[codecType]) codecsMap[codecType] = { type: codecType, name: CONFIG.VIDEO_CODEC_MAP[codecType] || codecType, videos: [] }; codecsMap[codecType].videos.push(video); } return Object.values(codecsMap).sort((a, b) => { const order = { "avc1": 0, "hev1": 1, "hvc1": 1, "av01": 2 }; return (order[a.type] || 99) - (order[b.type] || 99); }); }, getAudioCodecs(playData) { const dash = playData.dash; if (!dash) return []; const codecs = []; if (dash.audio) for (const a of dash.audio) codecs.push({ id: a.id, name: CONFIG.AUDIO_CODEC_MAP[a.id] || "AAC", data: a }); if (dash.dolby?.audio?.[0]) codecs.push({ id: 30250, name: "Dolby Atmos", data: dash.dolby.audio[0] }); if (dash.flac?.audio) codecs.push({ id: 30251, name: "FLAC", data: dash.flac.audio }); return codecs.sort((a, b) => b.data.bandwidth - a.data.bandwidth); }, getStreams(playData, targetQn, videoCodec, audioCodec) { const dash = playData.dash; if (!dash) throw new Error("该视频不支持DASH格式"); let video = null; let audio = null; if (dash.video && dash.video.length > 0) { const sorted = dash.video.slice().sort((a, b) => b.id !== a.id ? b.id - a.id : b.bandwidth - a.bandwidth); if (videoCodec) video = sorted.find((v) => v.id === targetQn && v.codecs.split(".")[0] === videoCodec) || null; if (!video) video = sorted.find((v) => v.id === targetQn && v.codecs?.startsWith("avc1")) || null; if (!video) video = sorted.find((v) => v.id === targetQn) || null; if (!video) video = sorted.find((v) => v.id <= targetQn && v.codecs?.startsWith("avc1")) || null; if (!video) video = sorted.find((v) => v.id <= targetQn) || null; if (!video) video = sorted[sorted.length - 1]; } if (audioCodec) { if (audioCodec === 30250 && dash.dolby?.audio?.[0]) audio = dash.dolby.audio[0]; else if (audioCodec === 30251 && dash.flac?.audio) audio = dash.flac.audio; else if (dash.audio) audio = dash.audio.find((a) => a.id === audioCodec) || null; } if (!audio) { if (dash.audio?.length) audio = dash.audio.slice().sort((a, b) => b.bandwidth - a.bandwidth)[0]; if (dash.dolby?.audio?.[0] && (!audio || dash.dolby.audio[0].bandwidth > audio.bandwidth)) audio = dash.dolby.audio[0]; if (dash.flac?.audio && (!audio || dash.flac.audio.bandwidth > audio.bandwidth)) audio = dash.flac.audio; } return { video, audio }; } }; function sampleNativeStyle(sample, base) { if (!sample) return base; try { const cs = getComputedStyle(sample); const rect = sample.getBoundingClientRect(); const svg = sample.querySelector("svg"); const svgSize = svg ? Math.round(svg.getBoundingClientRect().width) : 0; const height = rect.height > 8 && rect.height < 80 ? `${Math.round(rect.height)}px` : base.height; const fontSize = parseFloat(cs.fontSize) >= 10 && parseFloat(cs.fontSize) <= 20 ? cs.fontSize : base.fontSize; const color = cs.color && cs.color !== "rgba(0, 0, 0, 0)" ? cs.color : base.color; return { ...base, height, fontSize, fontWeight: cs.fontWeight || base.fontWeight, color, colorHover: color, iconSize: svgSize >= 12 && svgSize <= 40 ? `${svgSize}px` : base.iconSize, radius: cs.borderRadius && cs.borderRadius !== "0px" ? cs.borderRadius : base.radius }; } catch { return base; } } function applySkin(host, skin) { if (!host) return; const map = { "--bdl-skin-height": skin.height, "--bdl-skin-padding": skin.padding, "--bdl-skin-radius": skin.radius, "--bdl-skin-gap": skin.gap, "--bdl-skin-font-size": skin.fontSize, "--bdl-skin-font-weight": skin.fontWeight, "--bdl-skin-color": skin.color, "--bdl-skin-color-hover": skin.colorHover, "--bdl-skin-icon-size": skin.iconSize, "--bdl-skin-bg": skin.bg, "--bdl-skin-bg-hover": skin.bgHover }; for (const [k, v] of Object.entries(map)) host.style.setProperty(k, v); } function applyBrand(host, brand) { if (!host) return; const map = { "--bdl-brand": brand.brand, "--bdl-brand-deep": brand.brandDeep, "--bdl-brand-accent": brand.brandAccent, "--bdl-brand-contrast": brand.brandContrast, "--bdl-brand-spark": brand.brandSpark ?? brand.brand }; for (const [k, v] of Object.entries(map)) host.style.setProperty(k, v); } var DARK_LUMINANCE_MAX = .4; function parseLuminance(color) { const m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,/\s]+([\d.]+))?/i.exec(color); if (!m) return null; if (m[4] !== void 0 && parseFloat(m[4]) === 0) return null; const r = parseFloat(m[1]) / 255; const g = parseFloat(m[2]) / 255; const b = parseFloat(m[3]) / 255; return .2126 * r + .7152 * g + .0722 * b; } function isDarkSite() { for (const el of [document.body, document.documentElement]) { if (!el) continue; const lum = parseLuminance(getComputedStyle(el).backgroundColor); if (lum !== null) return lum < DARK_LUMINANCE_MAX; } return false; } function applySurface(host, dark) { host?.classList.toggle("bdl-dark", dark); } var BILIBILI_PLAYER_SELECTORS = [ ".bpx-player-container", "#bilibili-player", "#playerWrap", ".player-wrap", ".bpx-player-video-wrap", ".player-container" ]; var DRAG_THRESHOLD = 5; function getItemAssets(item) { if (item.type === "image") return (item.images || []).map((url, index) => ({ thumb: url, label: `图 ${index + 1}` })); if (item.type === "live") return (item.live_photo || []).map((entry, index) => ({ thumb: entry.image, label: `实况 ${index + 1}` })); return []; } function getItemTypeLabel(item) { if (item.type === "image") return `图集${item.images?.length ? ` ${item.images.length}张` : ""}`; if (item.type === "live") return `实况${item.live_photo?.length ? ` ${item.live_photo.length}项` : ""}`; return "视频"; } function createAssetCell(asset, index, selected, onToggle) { const cell = document.createElement("button"); cell.type = "button"; cell.className = `bdl-short-asset${selected ? " selected" : ""}`; cell.title = asset.label; cell.dataset.index = String(index); cell.addEventListener("click", () => { const next = !cell.classList.contains("selected"); cell.classList.toggle("selected", next); onToggle(next); }); const badge = document.createElement("span"); badge.className = "bdl-short-asset-index"; badge.textContent = String(index + 1); if (asset.thumb) { const img = document.createElement("img"); img.className = "bdl-short-asset-thumb"; img.loading = "lazy"; img.referrerPolicy = "no-referrer"; img.alt = asset.label; img.src = asset.thumb; img.addEventListener("error", () => img.remove()); cell.appendChild(img); } cell.appendChild(badge); return cell; } function getPlatformLabel(platform) { return PLATFORMS[platform].label; } function clamp(value, min, max) { if (max < min) return min; return Math.max(min, Math.min(value, max)); } var htmlPolicy = null; var policyReady = false; function getHtmlPolicy() { if (policyReady) return htmlPolicy; policyReady = true; try { const tt = window.trustedTypes; if (tt?.createPolicy) htmlPolicy = tt.createPolicy("vdh-ui", { createHTML: (s) => s }); } catch { htmlPolicy = null; } return htmlPolicy; } function setHTML(el, html) { const policy = getHtmlPolicy(); try { el.innerHTML = policy ? policy.createHTML(html) : html; } catch { try { el.textContent = ""; const doc = new DOMParser().parseFromString(`${html}`, "text/html"); while (doc.body.firstChild) el.appendChild(doc.body.firstChild); } catch {} } } function makeToolbarButton() { return ``; } function makePopup() { return `
视频下载助手
加载中...
UP-- 时长--
内容选择
分P选择
合集选择
清晰度
编码格式
视频编码
音频编码
合并方式
JS 原生合并 推荐
直接在浏览器内合并,无需额外资源
就绪
FFmpeg 合并
使用 FFmpeg 进行更稳定的封装
加载中
分离下载
分别保存视频和音频文件
就绪
视频流 0%
音频流 0%
合并 0%
`; } function makeDiagModal() { return ``; } var UI = { elements: {}, pagesSectionEnabled: false, ugcSectionEnabled: false, shortSelectAllHandler: null, shortToolbarBound: false, multiSelectUnlocked: false, footerSecretClicks: 0, footerSecretLastClickAt: 0, currentMountMode: null, mountedAt: null, host: null, root: null, dragState: null, popupManualPosition: null, entryManualPosition: null, suppressNextEntryClick: false, circleProgressValue: 0, entryProgressActive: false, init() { this.createPanel(); this.bindDragHandlers(); this.ensureMounted(); }, createPanel() { document.getElementById("bdl-shadow-host")?.remove(); const host = document.createElement("div"); host.id = "bdl-shadow-host"; host.style.cssText = [ "position:fixed!important", "inset:0!important", "display:block!important", "width:100vw!important", "height:100vh!important", "overflow:visible!important", "pointer-events:none!important", "z-index:2147483647!important" ].join(";") + ";"; const root = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = STYLES; root.appendChild(style); const entry = document.createElement("div"); entry.id = "bdl-entry"; setHTML(entry, makeToolbarButton()); const panel = document.createElement("div"); panel.id = "bdl-panel"; setHTML(panel, makePopup()); const diagHost = document.createElement("div"); diagHost.id = "bdl-diag-host"; setHTML(diagHost, makeDiagModal()); root.appendChild(entry); root.appendChild(panel); root.appendChild(diagHost); document.body.appendChild(host); this.host = host; this.root = root; const inlineHost = document.createElement("div"); inlineHost.style.cssText = "display:inline-flex!important;align-items:center!important;pointer-events:auto!important;"; const inlineRoot = inlineHost.attachShadow({ mode: "open" }); const inlineStyle = document.createElement("style"); inlineStyle.textContent = STYLES; inlineRoot.appendChild(inlineStyle); this.inlineHost = inlineHost; this.inlineRoot = inlineRoot; const g = (id) => root.getElementById(id); this.elements = { entry, panel, btn: g("bdl-main-btn"), popup: g("bdl-popup"), close: g("bdl-close"), title: g("bdl-title"), author: g("bdl-author"), duration: g("bdl-duration"), vip: g("bdl-vip"), pagesSection: g("bdl-pages-section"), shortItemsSection: g("bdl-short-items-section"), shortItems: g("bdl-short-items"), shortItemsCount: g("bdl-short-items-count"), shortItemsToolbar: g("bdl-short-items-toolbar"), gifSection: g("bdl-gif-section"), gifFormats: g("bdl-gif-formats"), qualitySection: g("bdl-qualities")?.closest(".bdl-section") || null, codecSection: g("bdl-video-codec")?.closest(".bdl-section") || null, methodSection: g("bdl-methods")?.closest(".bdl-section") || null, pagesList: g("bdl-pages-list"), pagesCount: g("bdl-pages-count"), selectAll: g("bdl-select-all"), selectNone: g("bdl-select-none"), selectReverse: g("bdl-select-reverse"), ugcSection: g("bdl-ugc-section"), ugcList: g("bdl-ugc-list"), ugcCount: g("bdl-ugc-count"), ugcSelectAll: g("bdl-ugc-select-all"), ugcSelectNone: g("bdl-ugc-select-none"), ugcSelectReverse: g("bdl-ugc-select-reverse"), qualities: g("bdl-qualities"), videoCodec: g("bdl-video-codec"), audioCodec: g("bdl-audio-codec"), methods: g("bdl-methods"), extraDownloads: g("bdl-extra-downloads"), progress: g("bdl-progress"), progressVideo: g("bdl-progress-video"), progressVideoText: g("bdl-progress-video-text"), progressAudio: g("bdl-progress-audio"), progressAudioText: g("bdl-progress-audio-text"), progressAudioRow: g("bdl-progress-audio")?.closest(".bdl-progress-row") || null, progressMerge: g("bdl-progress-merge"), progressMergeText: g("bdl-progress-merge-text"), mergeRow: g("bdl-merge-row"), alert: g("bdl-alert"), download: g("bdl-download"), tips: g("bdl-tips"), tipsContent: g("bdl-tips-content"), progressCircle: g("bdl-progress-circle"), footer: g("bdl-footer"), diagTrigger: g("bdl-diag-trigger"), diagModal: g("bdl-diag-modal"), diagClose: g("bdl-diag-close"), diagNote: g("bdl-diag-note"), diagSubmit: g("bdl-diag-submit"), diagCopy: g("bdl-diag-copy"), diagDownload: g("bdl-diag-download"), diagClear: g("bdl-diag-clear"), diagToast: g("bdl-diag-toast"), diagLog: g("bdl-diag-log") }; this.bindDiagnosticHandlers(); }, diagUnsubscribe: null, diagToastTimer: null, bindDiagnosticHandlers() { const el = this.elements; const trigger = el.diagTrigger; const modal = el.diagModal; const close = el.diagClose; const submit = el.diagSubmit; const copy = el.diagCopy; const download = el.diagDownload; const clear = el.diagClear; trigger?.addEventListener("click", (event) => { event.stopPropagation(); this.showDiagModal(); }); close?.addEventListener("click", () => this.hideDiagModal()); modal?.addEventListener("click", (event) => { if (event.target === modal) this.hideDiagModal(); }); submit?.addEventListener("click", () => { const note = el.diagNote?.value || ""; const url = Diagnostics.buildIssueUrl(note); try { window.open(url, "_blank", "noopener"); this.showDiagToast("已在新标签页打开 GitHub Issue", "success"); } catch { this.showDiagToast("无法打开新标签页,请手动复制报告后到仓库提交 Issue", "error"); } }); copy?.addEventListener("click", async () => { const note = el.diagNote?.value || ""; const ok = await Diagnostics.copyReport(note); this.showDiagToast(ok ? "诊断报告已复制到剪贴板" : "复制失败,请尝试下载报告", ok ? "success" : "error"); }); download?.addEventListener("click", () => { const note = el.diagNote?.value || ""; Diagnostics.downloadReport(note); this.showDiagToast("已下载 Markdown 报告", "success"); }); clear?.addEventListener("click", () => { Diagnostics.clear(); this.showDiagToast("日志已清空", "success"); }); }, showDiagModal() { const modal = this.elements.diagModal; if (!modal) return; modal.classList.add("show"); this.showDiagToast("", "success"); if (!this.diagUnsubscribe) this.diagUnsubscribe = Diagnostics.subscribe((entries) => this.renderDiagLog(entries)); }, hideDiagModal() { const modal = this.elements.diagModal; if (!modal) return; modal.classList.remove("show"); if (this.diagUnsubscribe) { this.diagUnsubscribe(); this.diagUnsubscribe = null; } }, renderDiagLog(entries) { const log = this.elements.diagLog; if (!log) return; log.textContent = ""; if (entries.length === 0) { const empty = document.createElement("div"); empty.className = "bdl-diag-log-empty"; empty.textContent = "(暂无日志)"; log.appendChild(empty); return; } const frag = document.createDocumentFragment(); for (const entry of entries) { const line = document.createElement("span"); line.className = `bdl-diag-log-line level-${entry.level}`; line.textContent = `[${new Date(entry.time).toLocaleTimeString("zh-CN", { hour12: false })}] [${entry.level.toUpperCase()}] [${entry.scope}] ${entry.message}`; frag.appendChild(line); if (entry.detail) { const detail = document.createElement("span"); detail.className = "bdl-diag-log-detail"; detail.textContent = entry.detail; frag.appendChild(detail); } } log.appendChild(frag); log.scrollTop = log.scrollHeight; }, showDiagToast(message, kind) { const toast = this.elements.diagToast; if (!toast) return; toast.textContent = message; toast.className = `bdl-diag-toast${kind === "error" ? " error" : ""}`; if (this.diagToastTimer !== null) { clearTimeout(this.diagToastTimer); this.diagToastTimer = null; } if (message) this.diagToastTimer = window.setTimeout(() => { toast.textContent = ""; this.diagToastTimer = null; }, 3500); }, query(selector) { return this.root?.querySelector(selector) || null; }, queryAll(selector) { return Array.from(this.root?.querySelectorAll(selector) || []); }, bindDragHandlers() { const header = this.elements.popup?.querySelector(".bdl-header") || null; const entry = this.elements.entry; header?.addEventListener("pointerdown", (event) => { if (event.target?.closest(".bdl-close")) return; this.startDrag("popup", event); }); entry?.addEventListener("pointerdown", (event) => { if (this.currentMountMode !== "floating") return; this.startDrag("entry", event); }); }, startDrag(target, event) { if (event.button !== 0) return; const element = target === "popup" ? this.elements.popup : this.elements.entry; if (!element) return; const rect = element.getBoundingClientRect(); this.dragState = { target, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originLeft: rect.left, originTop: rect.top, hasMoved: false }; window.addEventListener("pointermove", this.handleDragMove, true); window.addEventListener("pointerup", this.handleDragEnd, true); window.addEventListener("pointercancel", this.handleDragEnd, true); }, handleDragMove: (event) => { UI.updateDrag(event); }, handleDragEnd: (event) => { UI.endDrag(event); }, updateDrag(event) { const state = this.dragState; if (!state || event.pointerId !== state.pointerId) return; const element = state.target === "popup" ? this.elements.popup : this.elements.entry; if (!element) return; const deltaX = event.clientX - state.startX; const deltaY = event.clientY - state.startY; if (!state.hasMoved && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD) return; if (!state.hasMoved) { state.hasMoved = true; element.classList.add("is-dragging"); element.setPointerCapture?.(event.pointerId); } const nextLeft = state.originLeft + deltaX; const nextTop = state.originTop + deltaY; const position = this.clampElementPosition(element, nextLeft, nextTop); if (state.target === "popup") { this.popupManualPosition = position; element.dataset.placement = "manual"; } else { this.entryManualPosition = position; element.style.right = "auto"; element.style.bottom = "auto"; } this.applyElementPosition(element, position); event.preventDefault(); event.stopPropagation(); }, endDrag(event) { const state = this.dragState; if (!state || event.pointerId !== state.pointerId) return; const element = state.target === "popup" ? this.elements.popup : this.elements.entry; if (element) { element.classList.remove("is-dragging"); element.releasePointerCapture?.(event.pointerId); } window.removeEventListener("pointermove", this.handleDragMove, true); window.removeEventListener("pointerup", this.handleDragEnd, true); window.removeEventListener("pointercancel", this.handleDragEnd, true); if (state.hasMoved && state.target === "entry") { this.suppressNextEntryClick = true; setTimeout(() => { this.suppressNextEntryClick = false; }, 0); } this.dragState = null; if (state.hasMoved) { event.preventDefault(); event.stopPropagation(); } }, consumeEntryClickSuppression() { const suppressed = this.suppressNextEntryClick; this.suppressNextEntryClick = false; return suppressed; }, clampElementPosition(element, left, top) { const viewportPadding = 8; const rect = element.getBoundingClientRect(); const width = rect.width || element.offsetWidth || 80; const height = rect.height || element.offsetHeight || 40; return { left: clamp(left, viewportPadding, window.innerWidth - width - viewportPadding), top: clamp(top, viewportPadding, window.innerHeight - height - viewportPadding) }; }, applyElementPosition(element, position) { element.style.left = `${Math.round(position.left)}px`; element.style.top = `${Math.round(position.top)}px`; }, resolveMountPoint(platform) { const config = PLATFORMS[platform].mount; if (!config?.selector) return null; if (config.urlPattern && !config.urlPattern.test(location.href)) return null; let matches; try { matches = Array.from(document.querySelectorAll(config.selector)); } catch { return null; } if (!matches.length) return null; const candidates = config.matchVisible ? this.sortByViewportOverlap(matches) : matches; const roots = config.matchIndex === void 0 ? candidates : [candidates[config.matchIndex < 0 ? candidates.length + config.matchIndex : config.matchIndex]]; for (const root of roots) { const resolved = root ? this.resolveMountPointAt(root, config) : null; if (resolved) return resolved; } return null; }, sortByViewportOverlap(matches) { const vw = window.innerWidth || document.documentElement.clientWidth; const vh = window.innerHeight || document.documentElement.clientHeight; const scored = matches.map((el) => { const r = el.getBoundingClientRect(); const w = Math.min(r.right, vw) - Math.max(r.left, 0); const h = Math.min(r.bottom, vh) - Math.max(r.top, 0); return { el, area: w > 0 && h > 0 ? w * h : 0 }; }).filter((item) => item.area > 0); if (!scored.length) return matches; return scored.sort((a, b) => b.area - a.area).map((item) => item.el); }, resolveMountPointAt(root, config) { let container = root; for (const step of config.childPath || []) { const kids = container.children; const child = kids[step < 0 ? kids.length + step : step]; if (!(child instanceof HTMLElement)) return null; container = child; } const natives = Array.from(container.children).filter((el) => el !== this.inlineHost && el !== this.inlineSkeleton); if (!natives.length) return null; let insertBefore; let sample; if (config.anchorSelector) { let anchor = null; try { anchor = container.querySelector(config.anchorSelector); } catch { anchor = null; } if (!anchor?.parentElement) return null; if (this.inlineHost?.contains(anchor) || this.inlineSkeleton?.contains(anchor)) return null; container = anchor.parentElement; insertBefore = anchor; sample = anchor; } else { const insertAt = config.insertAt ?? "start"; const insertIndex = insertAt === "start" ? 0 : insertAt === "end" ? natives.length : insertAt; insertBefore = natives[insertIndex] || null; sample = natives[clamp(config.sampleIndex ?? insertIndex, 0, natives.length - 1)]; } let skinSample = config.fixedSkin ? null : sample; if (config.skinSampleSelector) { let found = null; try { found = container.querySelector(config.skinSampleSelector); } catch { found = null; } skinSample = found && !this.inlineHost?.contains(found) ? found : null; } const hostStyle = {}; if (config.cloneSampleStyle) for (const name of Array.from(sample.style)) hostStyle[name] = sample.style.getPropertyValue(name); Object.assign(hostStyle, config.styleOverride || {}); let skeleton = null; if (config.cloneSampleSkeleton) { skeleton = this.buildSkeleton(sample, config.cloneSampleSkeleton, container); if (!skeleton) return null; } return { container, anchor: sample, mode: "native-bar", inline: true, sample, skinSample, insertBefore, hostStyle, skeleton, hideSample: config.hideSample ? sample : null }; }, buildSkeleton(sample, leafSelector, container) { const host = this.inlineHost; if (!host) return null; if (this.inlineSkeleton && this.inlineSkeleton.parentElement === container && this.inlineSkeleton.contains(host)) return this.inlineSkeleton; let clone; let leaf; try { clone = sample.cloneNode(true); leaf = clone.querySelector(leafSelector); } catch { return null; } if (!leaf) return null; host.className = leaf.className; host.setAttribute("aria-label", "下载"); leaf.replaceWith(host); const marked = [clone, ...Array.from(clone.querySelectorAll("[id],[data-testid]"))]; for (const el of marked) { el.removeAttribute("id"); el.removeAttribute("data-testid"); } return clone; }, getMountTarget() { const siteContext = Utils.getSiteContext(); if (siteContext.kind === "short-video") { const configured = this.resolveMountPoint(siteContext.platform); if (configured) return configured; return { container: document.body, anchor: null, mode: "floating" }; } const videoContainer = document.querySelector("#arc_toolbar_report .video-toolbar-left-main"); if (videoContainer) { const shareWrap = videoContainer.querySelector(".video-share-wrap")?.closest(".toolbar-left-item-wrap"); return { container: videoContainer, anchor: shareWrap, mode: "video", inline: true, sample: videoContainer.querySelector(".video-toolbar-left-item") || shareWrap, direction: "row" }; } const bangumiContainer = document.querySelector(".player-left-components .toolbar-left"); if (bangumiContainer) { const watchTogether = document.getElementById("watch_together_tab"); const anchor = watchTogether?.parentElement instanceof HTMLElement ? watchTogether.parentElement : null; return { container: bangumiContainer, anchor, mode: "bangumi", inline: true, sample: bangumiContainer.querySelector("[class*=\"toolbar-left-item\"]") || anchor, direction: "row" }; } return null; }, inlineHost: null, inlineRoot: null, inlineHostStyleKeys: [], inlineSkeleton: null, inlineSkeletonStyleKeys: [], hiddenSample: null, hiddenSampleDisplay: "", brandAppliedFor: null, attachHostInline(mount) { const entry = this.elements.entry; if (!entry || !this.inlineHost || !this.inlineRoot) return; this.setHiddenSample(mount.hideSample ?? null); if (entry.parentNode !== this.inlineRoot) this.inlineRoot.appendChild(entry); const skeleton = mount.skeleton || null; if (this.inlineSkeleton && this.inlineSkeleton !== skeleton) { this.applyOwnedStyle(this.inlineSkeleton, void 0, "inlineSkeletonStyleKeys"); this.inlineSkeleton.remove(); } this.inlineSkeleton = skeleton; if (skeleton) { this.applyInlineHostStyle(void 0); this.applyOwnedStyle(skeleton, mount.hostStyle, "inlineSkeletonStyleKeys"); } else { this.inlineHost.className = ""; this.inlineHost.removeAttribute("aria-label"); this.applyInlineHostStyle(mount.hostStyle); } const node = skeleton || this.inlineHost; const container = mount.container; const before = mount.insertBefore?.parentElement === container ? mount.insertBefore : null; if (node.parentElement === container) { if (!before || node.nextElementSibling === before) return; } if (before) container.insertBefore(node, before); else container.appendChild(node); }, setHiddenSample(el) { if (this.hiddenSample === el) return; if (this.hiddenSample) this.hiddenSample.style.display = this.hiddenSampleDisplay; this.hiddenSample = el; if (!el) return; this.hiddenSampleDisplay = el.style.display; el.style.display = "none"; }, applyOwnedStyle(el, style, keysField) { for (const key of this[keysField]) if (!style || !(key in style)) el.style.removeProperty(key); this[keysField] = style ? Object.keys(style) : []; if (!style) return; for (const [key, value] of Object.entries(style)) el.style.setProperty(key, value); }, applyInlineHostStyle(style) { const host = this.inlineHost; if (!host) return; this.applyOwnedStyle(host, style, "inlineHostStyleKeys"); }, detachHostInline() { const entry = this.elements.entry; if (!entry || !this.root) return; this.setHiddenSample(null); if (entry.parentNode !== this.root) { const panel = this.elements.panel; if (panel && panel.parentNode === this.root) this.root.insertBefore(entry, panel); else this.root.appendChild(entry); } this.applyInlineHostStyle(void 0); if (this.inlineHost) { this.inlineHost.className = ""; this.inlineHost.removeAttribute("aria-label"); } this.inlineHost?.remove(); if (this.inlineSkeleton) { this.applyOwnedStyle(this.inlineSkeleton, void 0, "inlineSkeletonStyleKeys"); this.inlineSkeleton.remove(); this.inlineSkeleton = null; } }, getSkinKey() { const ctx = Utils.getSiteContext(); if (ctx.kind === "bilibili") return ctx.sourceType === "bangumi" ? "bangumi" : "bilibili"; if (ctx.kind === "short-video") return ctx.platform; return null; }, getBaseSkin() { const key = this.getSkinKey(); return key && getSkin(key) || NEUTRAL_SKIN; }, getBaseBrand() { const key = this.getSkinKey(); return key ? getBrand(key) : NEUTRAL_BRAND; }, resolveDarkSurface(key) { if (key === null || key === "bilibili" || key === "bangumi") return false; const scheme = PLATFORMS[key].surface; if (scheme === "dark") return true; if (scheme === "light") return false; return isDarkSite(); }, syncBrand() { const key = this.getSkinKey(); if (key !== this.brandAppliedFor) { this.brandAppliedFor = key; const brand = this.getBaseBrand(); applyBrand(this.host, brand); applyBrand(this.inlineHost, brand); } applySurface(this.host, this.resolveDarkSurface(key)); }, ensureMounted() { const entry = this.elements.entry; const btn = this.elements.btn; if (!entry || !btn) return false; const mount = this.getMountTarget(); if (!mount) { this.detachHostInline(); entry.className = ""; entry.removeAttribute("data-mode"); entry.removeAttribute("data-mounted"); entry.style.left = ""; entry.style.top = ""; entry.style.right = ""; entry.style.bottom = ""; entry.style.visibility = ""; this.mountedAt = null; this.currentMountMode = null; this.hidePopup(); return false; } entry.dataset.mode = mount.mode; entry.dataset.mounted = "true"; this.syncBrand(); if (mount.inline) { entry.dataset.mode = "native-bar"; entry.style.left = ""; entry.style.top = ""; entry.style.right = ""; entry.style.bottom = ""; entry.style.visibility = ""; this.attachHostInline(mount); const skinSource = mount.skinSample === void 0 ? mount.sample ?? null : mount.skinSample; applySkin(this.inlineHost, sampleNativeStyle(skinSource, this.getBaseSkin())); this.mountedAt = mount.container; this.currentMountMode = mount.mode; entry.classList.toggle("is-downloading", this.entryProgressActive); if (!this.isPopupVisible()) this.positionPopup(); this.syncEntryProgressVisibility(); return true; } this.detachHostInline(); entry.classList.toggle("is-downloading", this.entryProgressActive); if (this.host) { const isBili = mount.mode === "video" || mount.mode === "bangumi"; this.host.style.setProperty("z-index", isBili ? "90" : "2147483647", "important"); } this.mountedAt = mount.container; this.currentMountMode = mount.mode; this.positionEntry(mount); if (!this.isPopupVisible()) this.positionPopup(); this.syncEntryProgressVisibility(); return true; }, positionEntry(mount) { const entry = this.elements.entry; if (!entry) return; const target = mount === void 0 ? this.getMountTarget() : mount; if (!target || target.mode === "floating") { if (target?.mode === "floating" && this.entryManualPosition) { const position = this.clampElementPosition(entry, this.entryManualPosition.left, this.entryManualPosition.top); this.entryManualPosition = position; this.applyElementPosition(entry, position); entry.style.right = "auto"; entry.style.bottom = "auto"; } else { entry.style.left = ""; entry.style.top = ""; entry.style.right = ""; entry.style.bottom = ""; } entry.style.visibility = ""; return; } const anchorRect = target.anchor?.getBoundingClientRect(); const containerRect = target.container.getBoundingClientRect(); const baseRect = anchorRect && anchorRect.width > 0 && anchorRect.height > 0 ? anchorRect : containerRect; if (baseRect.width <= 0 && baseRect.height <= 0) { entry.style.visibility = "hidden"; return; } entry.style.visibility = "hidden"; entry.style.left = "0px"; entry.style.top = "0px"; const entryRect = entry.getBoundingClientRect(); const width = entryRect.width || 74; const height = entryRect.height || 32; const gap = 8; const viewportPadding = 12; let left = baseRect.right + gap; if (left + width > window.innerWidth - viewportPadding) left = baseRect.left - width - gap; left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding)); const top = baseRect.top + (baseRect.height - height) / 2; const isAnchorHidden = baseRect.top + baseRect.height < 64 || baseRect.top > window.innerHeight; entry.style.left = `${Math.round(left)}px`; entry.style.top = `${Math.round(top)}px`; entry.style.right = "auto"; entry.style.bottom = "auto"; entry.style.visibility = isAnchorHidden ? "hidden" : "visible"; }, showPopup() { const popup = this.elements.popup; const btn = this.elements.btn; if (!popup || !btn) return; popup.classList.add("show"); btn.setAttribute("aria-expanded", "true"); this.popupManualPosition = null; this.positionPopup(); if (!this.popupManualPosition) this.popupManualPosition = { left: parseFloat(popup.style.left) || 0, top: parseFloat(popup.style.top) || 0 }; this.syncEntryProgressVisibility(); }, hidePopup() { const popup = this.elements.popup; const btn = this.elements.btn; if (popup) popup.classList.remove("show"); if (btn) btn.setAttribute("aria-expanded", "false"); this.popupManualPosition = null; this.syncEntryProgressVisibility(); }, isPopupVisible() { return this.elements.popup?.classList.contains("show") ?? false; }, togglePopup() { if (!this.ensureMounted()) return false; const popup = this.elements.popup; if (!popup) return false; const willShow = !popup.classList.contains("show"); if (willShow) this.showPopup(); else this.hidePopup(); return willShow; }, getBilibiliPlayerRect() { let bestRect = null; let bestArea = 0; for (const selector of BILIBILI_PLAYER_SELECTORS) document.querySelectorAll(selector).forEach((element) => { const rect = element.getBoundingClientRect(); const visibleWidth = Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0); const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0); if (rect.width < 320 || rect.height < 180 || visibleWidth <= 0 || visibleHeight <= 0) return; const area = rect.width * rect.height; if (area > bestArea) { bestRect = rect; bestArea = area; } }); return bestRect; }, setPopupMaxHeight(maxHeight) { const popup = this.elements.popup; if (!popup) return maxHeight; const safeMaxHeight = Math.max(220, Math.floor(maxHeight)); popup.style.maxHeight = `${safeMaxHeight}px`; const body = popup.querySelector(".bdl-body"); if (body) body.style.maxHeight = `${Math.max(120, safeMaxHeight - 108)}px`; return safeMaxHeight; }, setPopupWidth(width) { const popup = this.elements.popup; if (!popup) return width; const safeWidth = clamp(Math.floor(width), 300, window.innerWidth - 32); popup.style.width = `${safeWidth}px`; return safeWidth; }, positionPopup() { const popup = this.elements.popup; const btn = this.elements.btn; if (!popup || !btn || !popup.classList.contains("show")) return; const gap = 12; const viewportPadding = 16; const buttonRect = btn.getBoundingClientRect(); let popupWidth = this.setPopupWidth(Math.min(432, window.innerWidth - viewportPadding * 2)); const maxViewportHeight = this.setPopupMaxHeight(window.innerHeight - viewportPadding * 2); let popupHeight = Math.min(popup.offsetHeight || 620, maxViewportHeight); let placement = "bottom"; if (this.popupManualPosition) { const position = this.clampElementPosition(popup, this.popupManualPosition.left, this.popupManualPosition.top); this.popupManualPosition = position; popup.dataset.placement = "manual"; this.applyElementPosition(popup, position); return; } let left = buttonRect.right - popupWidth; left = Math.max(viewportPadding, Math.min(left, window.innerWidth - popupWidth - viewportPadding)); let top = buttonRect.bottom + gap; const playerRect = Utils.getSiteContext().kind === "bilibili" ? this.getBilibiliPlayerRect() : null; if (playerRect) { const rightLeft = playerRect.right + gap; const rightSpace = window.innerWidth - rightLeft - viewportPadding; if (rightSpace >= 300) { popupWidth = this.setPopupWidth(Math.min(432, rightSpace)); popupHeight = Math.min(popup.offsetHeight || 620, this.setPopupMaxHeight(window.innerHeight - viewportPadding * 2)); left = rightLeft; top = clamp(playerRect.top, viewportPadding, window.innerHeight - popupHeight - viewportPadding); placement = "side"; } else { const belowTop = Math.max(playerRect.bottom + gap, buttonRect.bottom + gap); const belowSpace = window.innerHeight - belowTop - viewportPadding; if (belowSpace >= 220) { popupHeight = Math.min(popup.offsetHeight || 620, this.setPopupMaxHeight(belowSpace)); top = belowTop; placement = "bottom"; } else { const topSpace = playerRect.top - gap - viewportPadding; if (topSpace >= 220) { popupHeight = Math.min(popup.offsetHeight || 620, this.setPopupMaxHeight(topSpace)); top = playerRect.top - popupHeight - gap; placement = "top"; } else { popupHeight = Math.min(popup.offsetHeight || 620, this.setPopupMaxHeight(window.innerHeight - viewportPadding * 2)); top = clamp(buttonRect.bottom + gap, viewportPadding, window.innerHeight - popupHeight - viewportPadding); placement = "bottom"; } } left = clamp(buttonRect.right - popupWidth, viewportPadding, window.innerWidth - popupWidth - viewportPadding); } } else if (top + popupHeight > window.innerHeight - viewportPadding && buttonRect.top > popupHeight + gap) { top = buttonRect.top - popupHeight - gap; placement = "top"; } top = Math.max(viewportPadding, Math.min(top, window.innerHeight - popupHeight - viewportPadding)); popup.dataset.placement = placement; popup.style.left = `${Math.round(left)}px`; popup.style.top = `${Math.round(top)}px`; }, updateVideoInfo(videoInfo, pageInfo, vipType) { this.setBilibiliMode(); let title = videoInfo.title; if (videoInfo.pages.length > 1 && pageInfo.part) title += " - " + pageInfo.part; this.elements.title.textContent = title; this.elements.title.title = title; setHTML(this.elements.author, `UP${videoInfo.owner.name}`); setHTML(this.elements.duration, `时长${Utils.formatDuration(videoInfo.duration)}`); let badge = ""; if (vipType === 0) badge = "游客"; else if (vipType === 1) badge = "会员"; else if (vipType === 2) badge = "大会员"; setHTML(this.elements.vip, badge); }, updateShortVideoInfo(data, platform) { this.setShortVideoMode(platform); const title = data.title || data.desc || "未获取到内容标题"; const authorName = data.author?.name || "--"; const typeLabel = { video: "短视频", image: "图集", live: "实况图", unknown: "内容" }[data.type || "unknown"] || "内容"; const durationText = data.duration ? Utils.formatDurationMs(data.duration) : null; this.elements.title.textContent = title; this.elements.title.title = title; setHTML(this.elements.author, `作者${authorName}`); setHTML(this.elements.duration, `类型${durationText ? `${typeLabel} · ${durationText}` : typeLabel}`); setHTML(this.elements.vip, `${getPlatformLabel(platform)}`); this.setPrimaryButtonLabel(this.getShortVideoPrimaryLabel(data)); }, prepareShortVideoItems(items, options) { const section = this.elements.shortItemsSection; const list = this.elements.shortItems; if (!section || !list) return; list.textContent = ""; this.shortSelectAllHandler = options.onSelectAll; this.bindShortItemsToolbar(); const assets = items.map((item) => getItemAssets(item)); if (items.length <= 1 && !assets.some((entries) => entries.length > 1)) { section.classList.remove("show"); return; } section.classList.add("show"); items.forEach((item, index) => { list.appendChild(this.createShortItemRow(item, index, assets[index], options)); }); }, createShortItemRow(item, index, assets, options) { const picked = options.selectedAssets.get(index); const row = document.createElement("div"); row.className = "bdl-short-item-row"; const head = document.createElement("div"); head.className = "bdl-short-item"; if (options.selectedItems.has(index)) head.classList.add("selected"); if (assets.length > 1 && !!picked && picked.size > 0 && picked.size < assets.length) head.classList.add("partial"); if (index === options.previewIndex) head.classList.add("previewing"); const mark = document.createElement("span"); mark.className = "bdl-short-item-mark"; mark.textContent = getItemTypeLabel(item); const title = document.createElement("span"); title.className = "bdl-short-item-title"; title.textContent = item.title || item.desc || item.itemLabel || `${mark.textContent} ${index + 1}`; head.title = title.textContent; head.append(mark, title); if (item.itemLabel && item.itemLabel !== title.textContent) { const label = document.createElement("span"); label.className = "bdl-short-item-label"; label.textContent = item.itemLabel; head.title = `${title.textContent} · ${item.itemLabel}`; head.append(label); } let grid = null; head.addEventListener("click", () => { const next = !head.classList.contains("selected"); head.classList.toggle("selected", next); head.classList.remove("partial"); grid?.querySelectorAll(".bdl-short-asset").forEach((cell) => { cell.classList.toggle("selected", next); }); options.onToggleItem(index, next); if (next && index !== options.previewIndex) options.onPreview(index); }); if (assets.length > 1) { grid = document.createElement("div"); grid.className = "bdl-short-assets"; assets.forEach((asset, assetIndex) => { const cell = createAssetCell(asset, assetIndex, picked?.has(assetIndex) ?? false, (selected) => { options.onToggleAsset(index, assetIndex, selected); const size = options.selectedAssets.get(index)?.size ?? 0; head.classList.toggle("selected", options.selectedItems.has(index)); head.classList.toggle("partial", size > 0 && size < assets.length); }); grid.appendChild(cell); }); } row.appendChild(head); if (grid) row.appendChild(grid); return row; }, bindShortItemsToolbar() { const toolbar = this.elements.shortItemsToolbar; if (!toolbar || this.shortToolbarBound) return; this.shortToolbarBound = true; toolbar.addEventListener("click", (event) => { const mode = (event.target?.closest("[data-mode]"))?.dataset.mode; if (mode === "all" || mode === "none" || mode === "invert") this.shortSelectAllHandler?.(mode); }); }, updateShortSelectionSummary(count, itemCount, zipThreshold) { const label = this.elements.shortItemsCount; if (!label) return; label.textContent = `共 ${itemCount} 项 · 已选 ${count} 个文件${count > zipThreshold ? " · 打包为 ZIP" : ""}`; }, hideShortVideoItems() { const section = this.elements.shortItemsSection; const list = this.elements.shortItems; const count = this.elements.shortItemsCount; this.shortSelectAllHandler = null; if (section) section.classList.remove("show"); if (list) list.textContent = ""; if (count) count.textContent = ""; }, setGifFormatVisible(visible) { const section = this.elements.gifSection; if (section) section.style.display = visible ? "" : "none"; if (!visible) this.setGifFormat("video"); }, setGifFormat(format) { const list = this.elements.gifFormats; if (!list) return; list.querySelectorAll(".bdl-method-item").forEach((item) => { item.classList.toggle("active", item.dataset.gif === format); }); }, getShortVideoPrimaryLabel(data, selectedUnits) { if (selectedUnits != null) { if (selectedUnits === 0) return "未选中内容"; if (selectedUnits > 1 || getItemAssets(data).length > 1) return `下载选中 (${selectedUnits})`; } if (data.type === "image") return `下载图集${data.images?.length ? ` (${data.images.length})` : ""}`; if (data.type === "live") return `下载实况图${data.live_photo?.length ? ` (${data.live_photo.length})` : ""}`; if (data.music?.url && !data.url) return "下载音频"; return "下载视频"; }, preparePagesSection(pages, currentIndex, onUpdate) { this.pagesSectionEnabled = true; this.resetFooterSecret(); this.elements.pagesSection.classList.remove("show"); this.elements.pagesCount.textContent = `共 ${pages.length} 个分P`; this.elements.pagesList.textContent = ""; pages.forEach((page, index) => { const item = document.createElement("div"); item.className = `bdl-page-item${index === currentIndex ? " active" : ""}`; const cb = document.createElement("input"); cb.type = "checkbox"; cb.className = "bdl-page-checkbox"; cb.dataset.index = String(index); cb.checked = index === currentIndex; const info = document.createElement("div"); info.className = "bdl-page-info"; const num = document.createElement("div"); num.className = "bdl-page-num"; num.textContent = `P${page.page}`; const pageTitle = document.createElement("div"); pageTitle.className = "bdl-page-title"; pageTitle.textContent = page.part || `第${page.page}话`; pageTitle.title = pageTitle.textContent; const duration = document.createElement("span"); duration.className = "bdl-page-duration"; duration.textContent = Utils.formatDuration(page.duration); info.appendChild(num); info.appendChild(pageTitle); item.appendChild(cb); item.appendChild(info); item.appendChild(duration); cb.addEventListener("change", onUpdate); item.addEventListener("click", (e) => { if (e.target !== cb) { cb.checked = !cb.checked; onUpdate(); } }); this.elements.pagesList.appendChild(item); }); onUpdate(); }, prepareUGCSection(episodes, onUpdate, currentCid) { const wasVisible = this.elements.ugcSection.classList.contains("show"); this.ugcSectionEnabled = true; this.resetFooterSecret(); this.elements.ugcCount.textContent = `共 ${episodes.length} 个视频`; this.elements.ugcList.textContent = ""; let currentItem = null; episodes.forEach((ep, index) => { const isCurrent = currentCid != null && ep.cid === currentCid; const item = document.createElement("div"); item.className = "bdl-page-item"; if (isCurrent) item.classList.add("active"); const cb = document.createElement("input"); cb.type = "checkbox"; cb.className = "bdl-page-checkbox"; cb.dataset.index = String(index); cb.checked = isCurrent; const info = document.createElement("div"); info.className = "bdl-page-info"; const num = document.createElement("div"); num.className = "bdl-page-num"; num.textContent = `E${index + 1}`; const epTitle = document.createElement("div"); epTitle.className = "bdl-page-title"; epTitle.textContent = ep.title; epTitle.title = ep.title; const duration = document.createElement("span"); duration.className = "bdl-page-duration"; duration.textContent = Utils.formatDuration(ep.arc.duration); info.appendChild(num); info.appendChild(epTitle); item.appendChild(cb); item.appendChild(info); item.appendChild(duration); cb.addEventListener("change", onUpdate); item.addEventListener("click", (e) => { if (e.target !== cb) { cb.checked = !cb.checked; onUpdate(); } }); this.elements.ugcList.appendChild(item); if (isCurrent) currentItem = item; }); onUpdate(); if (!wasVisible) { if (currentItem) { this.elements.ugcSection.classList.add("show"); requestAnimationFrame(() => { const list = this.elements.ugcList; const item = currentItem; list.scrollTop = item.offsetTop - list.clientHeight / 2 + item.clientHeight / 2; }); } } else { this.elements.ugcSection.classList.add("show"); if (currentItem) requestAnimationFrame(() => { const list = this.elements.ugcList; const item = currentItem; list.scrollTop = item.offsetTop - list.clientHeight / 2 + item.clientHeight / 2; }); } }, toggleExtendedSections() { const pagesVisible = this.elements.pagesSection.classList.contains("show"); const ugcVisible = this.elements.ugcSection.classList.contains("show"); if (pagesVisible || ugcVisible) { this.elements.pagesSection.classList.remove("show"); this.elements.ugcSection.classList.remove("show"); return; } if (this.pagesSectionEnabled) this.elements.pagesSection.classList.add("show"); if (this.ugcSectionEnabled) this.elements.ugcSection.classList.add("show"); }, resetFooterSecret() { this.multiSelectUnlocked = false; this.footerSecretClicks = 0; this.footerSecretLastClickAt = 0; }, handleFooterSecretClick() { if (!this.pagesSectionEnabled && !this.ugcSectionEnabled) return false; if (this.multiSelectUnlocked) { this.toggleExtendedSections(); return true; } const now = Date.now(); if (now - this.footerSecretLastClickAt > 1200) this.footerSecretClicks = 0; this.footerSecretLastClickAt = now; this.footerSecretClicks += 1; if (this.footerSecretClicks < 5) return false; this.multiSelectUnlocked = true; this.footerSecretClicks = 0; this.toggleExtendedSections(); return true; }, hidePagesSection() { this.pagesSectionEnabled = false; this.elements.pagesSection.classList.remove("show"); this.elements.pagesList.textContent = ""; this.elements.pagesCount.textContent = ""; }, hideUGCSection() { this.ugcSectionEnabled = false; this.elements.ugcSection.classList.remove("show"); this.elements.ugcList.textContent = ""; this.elements.ugcCount.textContent = ""; }, setBilibiliMode() { this.hideShortVideoItems(); this.setGifFormatVisible(false); this.elements.pagesSection.style.display = ""; this.elements.ugcSection.style.display = ""; if (this.elements.qualitySection) this.elements.qualitySection.style.display = ""; if (this.elements.codecSection) this.elements.codecSection.style.display = ""; if (this.elements.methodSection) this.elements.methodSection.style.display = ""; if (this.elements.progressAudioRow) this.elements.progressAudioRow.style.display = ""; this.elements.mergeRow.style.display = ""; this.setPrimaryProgressLabel("视频流"); this.setPrimaryButtonLabel("开始下载"); }, setShortVideoMode(platform) { this.hidePagesSection(); this.hideUGCSection(); this.hideShortVideoItems(); this.setGifFormatVisible(false); this.elements.pagesSection.style.display = "none"; this.elements.ugcSection.style.display = "none"; if (this.elements.qualitySection) this.elements.qualitySection.style.display = "none"; if (this.elements.codecSection) this.elements.codecSection.style.display = "none"; if (this.elements.methodSection) this.elements.methodSection.style.display = "none"; if (this.elements.progressAudioRow) this.elements.progressAudioRow.style.display = "none"; this.elements.mergeRow.style.display = "none"; this.hideTips(); this.setPrimaryProgressLabel(`${getPlatformLabel(platform)}下载`); this.elements.title.textContent = `${getPlatformLabel(platform)}内容待解析`; this.elements.title.title = this.elements.title.textContent; setHTML(this.elements.author, "状态打开具体内容页后可解析"); setHTML(this.elements.duration, "类型短视频/图集"); setHTML(this.elements.vip, `${getPlatformLabel(platform)}`); this.setPrimaryButtonLabel("解析内容"); }, resetSectionsVisibility() { this.elements.pagesSection.style.display = ""; this.elements.ugcSection.style.display = ""; if (this.elements.qualitySection) this.elements.qualitySection.style.display = ""; if (this.elements.codecSection) this.elements.codecSection.style.display = ""; if (this.elements.methodSection) this.elements.methodSection.style.display = ""; if (this.elements.progressAudioRow) this.elements.progressAudioRow.style.display = ""; this.elements.mergeRow.style.display = ""; }, updateQualities(qualities, currentQn, onSelect) { this.elements.qualities.textContent = ""; qualities.forEach((quality, index) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = `bdl-quality-btn${quality.available ? "" : " disabled"}`; if (quality.qn === currentQn && quality.available || index === 0 && quality.available) { btn.classList.add("active"); onSelect(quality.qn); } btn.textContent = quality.desc; btn.dataset.qn = String(quality.qn); btn.addEventListener("click", () => { if (!quality.available) { const vipTypeText = BiliAPI.userVipType === 0 ? "游客" : BiliAPI.userVipType === 1 ? "普通会员" : "大会员"; this.showAlert(`当前账号(${vipTypeText})无权限观看此清晰度`, "warning"); return; } this.elements.qualities.querySelectorAll(".bdl-quality-btn").forEach((button) => button.classList.remove("active")); btn.classList.add("active"); onSelect(quality.qn); }); this.elements.qualities.appendChild(btn); }); }, updateCodecSelectors(videoCodecs, audioCodecs) { this.elements.videoCodec.textContent = ""; videoCodecs.forEach((codec, index) => { const option = document.createElement("option"); option.value = codec.type; option.textContent = codec.name; if (index === 0) option.selected = true; this.elements.videoCodec.appendChild(option); }); this.elements.audioCodec.textContent = ""; audioCodecs.forEach((codec, index) => { const option = document.createElement("option"); option.value = String(codec.id); option.textContent = codec.name; if (index === 0) option.selected = true; this.elements.audioCodec.appendChild(option); }); }, updateExtraDownloads(hasSubtitles, hasDanmaku, hasCover, onCover, onSubtitles, onDanmaku) { this.elements.extraDownloads.textContent = ""; if (hasCover) { const button = document.createElement("button"); button.type = "button"; button.className = "bdl-extra-btn"; setHTML(button, "封面下载封面"); button.addEventListener("click", onCover); this.elements.extraDownloads.appendChild(button); } if (hasSubtitles) { const button = document.createElement("button"); button.type = "button"; button.className = "bdl-extra-btn"; setHTML(button, "字幕下载字幕"); button.addEventListener("click", onSubtitles); this.elements.extraDownloads.appendChild(button); } if (hasDanmaku) { const button = document.createElement("button"); button.type = "button"; button.className = "bdl-extra-btn"; setHTML(button, "弹幕下载弹幕"); button.addEventListener("click", onDanmaku); this.elements.extraDownloads.appendChild(button); } }, setExtraActions(actions) { this.elements.extraDownloads.textContent = ""; actions.forEach((action) => { const button = document.createElement("button"); button.type = "button"; button.className = "bdl-extra-btn"; setHTML(button, `${action.marker}${action.label}`); button.addEventListener("click", action.onClick); this.elements.extraDownloads.appendChild(button); }); }, showProgress(show) { if (show) { this.elements.progress.classList.add("show"); this.updateProgress("video", 0); this.updateProgress("audio", 0); this.updateProgress("merge", 0); return; } this.elements.progress.classList.remove("show"); }, updateProgress(type, percent, label) { const id = `progress${type.charAt(0).toUpperCase()}${type.slice(1)}`; const bar = this.elements[id]; const text = this.elements[`${id}Text`]; if (bar) bar.style.width = `${percent}%`; if (text) text.textContent = label || `${percent}%`; }, setPrimaryProgressLabel(label) { const progressLabel = this.elements.progressVideo?.closest(".bdl-progress-row")?.querySelector(".bdl-progress-label"); if (progressLabel) progressLabel.textContent = label; }, isPopupShown() { return Boolean(this.elements.popup?.classList.contains("show")); }, syncEntryProgressVisibility() { const circle = this.elements.progressCircle; const entry = this.elements.entry; if (!circle) return; const showOnEntry = !this.isPopupShown() && this.entryProgressActive; const visibleProgress = showOnEntry ? Math.max(4, this.circleProgressValue) : 0; circle.style.height = `${visibleProgress}%`; entry?.classList.toggle("has-entry-progress", showOnEntry); }, updateCircleProgress(percent) { this.circleProgressValue = clamp(percent, 0, 100); this.syncEntryProgressVisibility(); }, showAlert(message, type) { this.elements.alert.textContent = message; this.elements.alert.className = `bdl-alert show ${type}`; }, hideAlert() { this.elements.alert.className = "bdl-alert"; }, hideTips() { this.elements.tips.style.display = "none"; }, setPrimaryButtonLabel(label) { if (this.elements.download.disabled || this.elements.download.classList.contains("is-active")) return; this.renderDownloadButton(label); }, renderDownloadButton(label, progress = 0) { const safeProgress = clamp(progress, 0, 100); const download = this.elements.download; const fill = document.createElement("span"); const labelEl = document.createElement("span"); fill.className = "bdl-download-progress-fill"; labelEl.className = "bdl-download-label"; labelEl.textContent = label; download.style.setProperty("--bdl-download-progress", `${safeProgress}%`); download.replaceChildren(fill, labelEl); }, updateDownloadButtonProgress(percent, label) { const download = this.elements.download; if (!download?.classList.contains("is-active")) return; const queueMode = download.classList.contains("can-queue"); const safeProgress = clamp(percent, 0, 100); const text = label || `下载中 ${safeProgress}%`; this.renderDownloadButton(queueMode ? `${text} · 再点排队` : text, safeProgress); }, setDownloading(isDownloading, options = {}) { const allowQueue = Boolean(options.allowQueue); this.elements.download.disabled = isDownloading && !allowQueue; this.elements.download.classList.toggle("is-active", isDownloading); this.elements.download.classList.toggle("can-queue", isDownloading && allowQueue); this.renderDownloadButton(isDownloading ? options.label || "下载中..." : "开始下载", isDownloading ? 0 : 0); this.elements.btn.disabled = false; this.entryProgressActive = isDownloading; this.elements.entry.classList.toggle("is-downloading", isDownloading); if (!isDownloading) { this.circleProgressValue = 0; this.elements.entry.classList.remove("has-entry-progress"); } this.syncEntryProgressVisibility(); } }; var CACHE_MAX$1 = 30; var cache$1 = new Map(); function storeDetail(detail) { if (!detail?.aweme_id) return; if (cache$1.has(detail.aweme_id)) cache$1.delete(detail.aweme_id); else if (cache$1.size >= CACHE_MAX$1) cache$1.delete(cache$1.keys().next().value); cache$1.set(detail.aweme_id, detail); } function collectAwemeDetails(json) { const detail = json?.aweme_detail || json?.itemInfo?.itemStruct; if (detail) { storeDetail(detail); return; } const list = json?.aweme_list || json?.item_list; if (Array.isArray(list)) list.forEach(storeDetail); } function tryParseAwemeResponse(text) { try { collectAwemeDetails(JSON.parse(text)); } catch {} } function collectAwemeFromXhr(xhr) { if (xhr.responseType === "json") { collectAwemeDetails(xhr.response); return; } if (xhr.responseType === "" || xhr.responseType === "text") tryParseAwemeResponse(xhr.responseText || ""); } function isAwemeUrl(url) { return /douyin\.com.*\/aweme\/v\d+\//i.test(url); } var GEAR_HEIGHT = /_(\d{3,4})_/; function qualityHeight(item) { const fromDesc = item.quality_desc?.match(/(\d{3,4})\s*P/i)?.[1]; return Number(fromDesc || item.gear_name?.match(GEAR_HEIGHT)?.[1] || 0); } function qualityLabel(item) { const desc = item.quality_desc?.trim(); if (desc) return desc; const height = item.gear_name?.match(GEAR_HEIGHT)?.[1]; return height ? `${height}P` : `${Math.round(item.bit_rate / 1e3)}kbps`; } function pickHttpUrl(list) { if (!Array.isArray(list)) return ""; for (const item of list) if (typeof item === "string" && item.startsWith("http")) return item; return ""; } var DouyinInterceptor = { installed: false, install() { if (this.installed) return; this.installed = true; const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const origFetch = win.fetch; if (typeof origFetch !== "function") return; win.fetch = function(input, init) { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const promise = origFetch.call(this, input, init); if (!isAwemeUrl(url)) return promise; return promise.then((response) => { response.clone().text().then(tryParseAwemeResponse).catch(() => {}); return response; }); }; const origXhrOpen = win.XMLHttpRequest.prototype.open; const origXhrSend = win.XMLHttpRequest.prototype.send; win.XMLHttpRequest.prototype.open = function(method, url, ...rest) { this._bdl_url = url; return origXhrOpen.call(this, method, url, ...rest); }; win.XMLHttpRequest.prototype.send = function(...args) { if (isAwemeUrl(this._bdl_url || "")) this.addEventListener("load", function() { collectAwemeFromXhr(this); }); return origXhrSend.apply(this, args); }; }, scanWindowState() { const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const stateKeys = [ "__NEXT_DATA__", "_SSR_DATA_", "__INITIAL_STATE__", "__pinia", "odin" ]; const found = []; const walk = (value, depth, seen) => { if (depth > 8 || value == null || typeof value !== "object") return; if (seen.has(value)) return; seen.add(value); if (typeof value.aweme_id === "string" && value.video) { storeDetail(value); found.push(value); return; } const entries = Object.entries(value).slice(0, 200); for (const [, v] of entries) walk(v, depth + 1, seen); }; const seen = new WeakSet(); for (const key of stateKeys) try { walk(win[key], 0, seen); } catch {} cache$1.forEach((v) => found.push(v)); return found; }, getByAwemeId(awemeId) { if (cache$1.has(awemeId)) return cache$1.get(awemeId); return this.scanWindowState().find((d) => d.aweme_id === awemeId) || null; }, getByUrl(url) { const awemeId = url.match(/(?:video|note|modal_id=|aweme_id=)(\d{15,20})/i)?.[1]; if (awemeId && cache$1.has(awemeId)) return cache$1.get(awemeId); const all = this.scanWindowState(); if (awemeId) { const found = all.find((d) => d.aweme_id === awemeId); if (found) return found; } return all[all.length - 1] || null; }, extractQualities(detail) { const video = detail.video; if (!video) return []; const picked = new Map(); for (const item of video.bit_rate || []) { const url = pickHttpUrl(item?.play_addr?.url_list); if (!url) continue; const label = qualityLabel(item); const h265 = String(item.is_h265) === "1"; const prev = picked.get(label); if (prev && (prev.h265 === h265 ? prev.bitrate >= item.bit_rate : !prev.h265)) continue; picked.set(label, { label, url, bitrate: item.bit_rate, height: qualityHeight(item), h265 }); } const results = Array.from(picked.values()); if (results.length === 0) { const url = pickHttpUrl(video.play_addr?.url_list); if (url) results.push({ label: "原画", url, bitrate: 0, height: 0, h265: false }); } results.sort((a, b) => b.height - a.height || b.bitrate - a.bitrate); return results.map((item) => ({ label: item.label, url: item.url })); }, async fetchDetail(awemeId) { const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; try { const params = new URLSearchParams({ aweme_id: awemeId, aid: "6383", channel: "channel_pc_web", device_platform: "webapp", pc_client_type: "1", version_code: "170400", version_name: "17.4.0" }); const text = await (await win.fetch(`https://www.douyin.com/aweme/v1/web/aweme/detail/?${params}`, { headers: { Accept: "application/json, text/plain, */*", Referer: "https://www.douyin.com/" }, credentials: "include" })).text(); const detail = JSON.parse(text)?.aweme_detail; if (detail?.aweme_id) { storeDetail(detail); return detail; } } catch {} return null; }, getAwemeIdFromFeed() { const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const stateKeys = [ "__NEXT_DATA__", "_SSR_DATA_", "__INITIAL_STATE__", "__pinia", "odin", "__recommend__" ]; const AWEME_ID_RE = /^\d{15,20}$/; const extractFromValue = (value, depth, seen) => { if (depth > 12 || !value || typeof value !== "object") return null; if (seen.has(value)) return null; seen.add(value); if (Array.isArray(value.aweme_list) && value.aweme_list.length > 0) { const item = value.aweme_list[0]; if (item?.aweme_id && AWEME_ID_RE.test(String(item.aweme_id))) return String(item.aweme_id); } if (Array.isArray(value.item_list) && value.item_list.length > 0) { const item = value.item_list[0]; if (item?.aweme_id && AWEME_ID_RE.test(String(item.aweme_id))) return String(item.aweme_id); } const entries = Object.entries(value).slice(0, 200); for (const [, v] of entries) { const found = extractFromValue(v, depth + 1, seen); if (found) return found; } return null; }; const seen = new WeakSet(); for (const key of stateKeys) try { const result = extractFromValue(win[key], 0, seen); if (result) return result; } catch {} const arr = []; cache$1.forEach((v) => { if (v.aweme_id) arr.push(v.aweme_id); }); if (arr.length > 0) return arr[arr.length - 1]; return null; }, getFromDom() { const videos = Array.from(document.querySelectorAll("video")); const video = videos.map((v) => ({ v, area: (() => { const r = v.getBoundingClientRect(); return r.width * r.height; })() })).filter((item) => item.area > 1e3).sort((a, b) => b.area - a.area)[0]?.v || videos[0]; const src = video?.currentSrc || video?.src; if (!src || !src.startsWith("http")) return null; return { url: src, cover: video?.poster || "" }; } }; var CACHE_MAX = 30; var cache = new Map(); var PROGRESSIVE_URL = /\.(mp4|flv)(?:[?#]|$)/i; function isPlayableUrl(value) { return typeof value === "string" && /^https?:\/\//i.test(value) && PROGRESSIVE_URL.test(value); } function pickNumber(...values) { for (const value of values) if (typeof value === "number" && Number.isFinite(value) && value > 0) return value; return 0; } function pickString(...values) { for (const value of values) if (typeof value === "string" && value.trim()) return value.trim(); return ""; } function collectRepresentation(node, target) { if (!node || typeof node !== "object") return; const bitrate = pickNumber(node.avgBitrate, node.maxBitrate, node.bitrate); const height = pickNumber(node.height); const label = pickString(node.qualityLabel, node.shortName, node.name) || (height ? `${height}p` : "") || (bitrate ? `${Math.round(bitrate)}kbps` : "原画"); const candidates = [node.url]; if (Array.isArray(node.backupUrl)) candidates.push(...node.backupUrl); if (typeof node.backupUrl === "string") candidates.push(node.backupUrl); candidates.filter(isPlayableUrl).forEach((url) => { target.push({ url, label, bitrate }); }); } function collectManifestUrls(node, target, depth = 0) { if (!node || typeof node !== "object" || depth > 4) return; if (Array.isArray(node.adaptationSet)) { node.adaptationSet.forEach((set) => { if (Array.isArray(set?.representation)) set.representation.forEach((item) => collectRepresentation(item, target)); }); return; } Object.values(node).forEach((value) => collectManifestUrls(value, target, depth + 1)); } function isPhotoNode(value) { if (!value || typeof value !== "object") return false; if (typeof value.id !== "string" && typeof value.id !== "number") return false; return typeof value.photoUrl === "string" || value.videoResource != null || value.manifest != null; } function normalizePhoto(node, author) { const urls = []; if (isPlayableUrl(node.photoUrl)) urls.push({ url: node.photoUrl, label: "原画", bitrate: Number.MAX_SAFE_INTEGER }); collectManifestUrls(node.videoResource, urls); collectManifestUrls(node.manifest, urls); const seenUrl = new Set(); const deduped = urls.filter((item) => !seenUrl.has(item.url) && seenUrl.add(item.url)).sort((a, b) => b.bitrate - a.bitrate); if (deduped.length === 0) return null; return { id: String(node.id), caption: pickString(node.caption, node.originCaption, node.name), coverUrl: pickString(node.coverUrl, node.webpCoverUrl, node.animatedCoverUrl), duration: pickNumber(node.duration), authorName: pickString(author?.name, node.userName, node.author?.name), urls: deduped }; } function storePhoto(photo) { const prev = cache.get(photo.id); const merged = prev ? { id: photo.id, caption: photo.caption || prev.caption, coverUrl: photo.coverUrl || prev.coverUrl, duration: photo.duration || prev.duration, authorName: photo.authorName || prev.authorName, urls: photo.urls.length >= prev.urls.length ? photo.urls : prev.urls } : photo; if (prev) cache.delete(photo.id); else if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value); cache.set(merged.id, merged); } function walkForPhotos(value, depth, state) { if (depth > 8 || value == null || typeof value !== "object") return; if (state.visited.has(value)) return; state.visited.add(value); if (Array.isArray(value)) { const end = Math.min(value.length, 50); for (let index = 0; index < end; index += 1) walkForPhotos(value[index], depth + 1, state); return; } const node = isPhotoNode(value.photo) ? value.photo : isPhotoNode(value) ? value : null; if (node && !state.parsed.has(node)) { state.parsed.add(node); const photo = normalizePhoto(node, value.author || node.author); if (photo) storePhoto(photo); } let visited = 0; for (const key in value) { if (visited >= 200) return; visited += 1; walkForPhotos(value[key], depth + 1, state); } } function tryParseGraphqlResponse(text) { if (!text || text.length > 8e6) return; if (text.indexOf("photoUrl") < 0 && text.indexOf("adaptationSet") < 0) return; try { walkForPhotos(JSON.parse(text), 0, { visited: new WeakSet(), parsed: new WeakSet() }); } catch {} } function collectPhotosFromXhr(xhr) { if (xhr.responseType === "json") { walkForPhotos(xhr.response, 0, { visited: new WeakSet(), parsed: new WeakSet() }); return; } if (xhr.responseType === "" || xhr.responseType === "text") tryParseGraphqlResponse(xhr.responseText || ""); } function isGraphqlUrl(url) { if (!url) return false; try { const parsed = new URL(url, location.href); return /(^|\.)kuaishou\.com$/i.test(parsed.hostname) && /\/graphql/i.test(parsed.pathname); } catch { return /\/graphql/i.test(url); } } function coverKeys(value) { try { const parsed = new URL(value, location.href); const base = (parsed.pathname.split("/").pop() || "").replace(/\.[a-z0-9]+$/i, ""); const cacheKey = parsed.searchParams.get("clientCacheKey") || ""; return { base: base.toLowerCase(), cacheKey: cacheKey.replace(/\.[a-z0-9]+$/i, "").toLowerCase() }; } catch { return { base: "", cacheKey: "" }; } } var KuaishouInterceptor = { installed: false, install() { if (this.installed) return; this.installed = true; const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const origFetch = win.fetch; if (typeof origFetch === "function") win.fetch = function(input, init) { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const promise = origFetch.call(this, input, init); if (!isGraphqlUrl(url)) return promise; return promise.then((response) => { response.clone().text().then(tryParseGraphqlResponse).catch(() => {}); return response; }); }; const origXhrOpen = win.XMLHttpRequest?.prototype?.open; const origXhrSend = win.XMLHttpRequest?.prototype?.send; if (typeof origXhrOpen !== "function" || typeof origXhrSend !== "function") return; win.XMLHttpRequest.prototype.open = function(method, url, ...rest) { this._bdl_ks_url = url; return origXhrOpen.call(this, method, url, ...rest); }; win.XMLHttpRequest.prototype.send = function(...args) { if (isGraphqlUrl(this._bdl_ks_url || "")) this.addEventListener("load", function() { collectPhotosFromXhr(this); }); return origXhrSend.apply(this, args); }; }, getById(id) { return cache.get(id) || null; }, getByCover(poster) { const target = coverKeys(poster); if (!target.cacheKey && !target.base) return null; let byBase = null; for (const photo of cache.values()) { const keys = coverKeys(photo.coverUrl); if (target.cacheKey && keys.cacheKey === target.cacheKey) return photo; if (!byBase && target.base && keys.base === target.base) byBase = photo; } return byBase; }, getLatest() { let last = null; cache.forEach((photo) => { last = photo; }); return last; }, size() { return cache.size; } }; var WEB_BEARER = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"; var TWEET_QUERY_IDS = [ "zJvfJs3gSbrVhC0MKjt_OQ", "OoJd6A50cv8GsifjoOHGfg", "nBS-WpgA6ZG0CyNHD517JQ" ]; function extractTweetId(url) { try { const m = new URL(url).pathname.match(/\/status(?:es)?\/(\d{5,25})/); if (m?.[1]) return m[1]; } catch {} return null; } function getCookie(name) { const list = document.cookie.split(";"); for (const raw of list) { const [k, ...rest] = raw.trim().split("="); if (k === name) return decodeURIComponent(rest.join("=")); } return ""; } async function fetchGuestToken() { const ct0 = getCookie("ct0"); return new Promise((resolve, reject) => { globalThis.GM_xmlhttpRequest({ method: "POST", url: "https://api.x.com/1.1/guest/activate.json", headers: { Authorization: `Bearer ${WEB_BEARER}`, "Content-Type": "application/json", ...ct0 ? { "x-csrf-token": ct0 } : {} }, data: "", responseType: "json", onload(res) { try { let data = res.response; if (typeof data === "string") data = JSON.parse(data); if (data?.guest_token) resolve(String(data.guest_token)); else reject(new Error("未能获取 guest_token")); } catch (e) { reject(new Error("guest_token 解析失败: " + e.message)); } }, onerror: () => reject(new Error("guest_token 网络错误")), ontimeout: () => reject(new Error("guest_token 超时")) }); }); } function collectQueryIdsFromPage() { const ids = new Set(); TWEET_QUERY_IDS.forEach((id) => ids.add(id)); try { const scripts = Array.from(document.querySelectorAll("script")); for (const s of scripts) { const src = s.getAttribute("src") || ""; if (!/TweetResult|graphql/i.test(src + (s.textContent || ""))) continue; ((s.textContent || "").match(/queryId:"([A-Za-z0-9_-]{16,})"[^}]{0,120}operationName:"TweetResultByRestId"/g) || []).forEach((fragment) => { const idMatch = fragment.match(/queryId:"([A-Za-z0-9_-]+)"/); if (idMatch?.[1]) ids.add(idMatch[1]); }); } } catch {} return Array.from(ids); } async function fetchTweetGraphQL(tweetId, queryId, guestToken) { const variables = { tweetId, withCommunity: false, includePromotedContent: false, withVoice: false }; const url = `https://api.x.com/graphql/${queryId}/TweetResultByRestId?${new URLSearchParams({ variables: JSON.stringify(variables), features: JSON.stringify({ creator_subscriptions_tweet_preview_api_enabled: true, communities_web_enable_tweet_community_results_fetch: true, c9s_tweet_anatomy_moderator_badge_enabled: true, articles_preview_enabled: true, responsive_web_edit_tweet_api_enabled: true, graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, view_counts_everywhere_api_enabled: true, longform_notetweets_consumption_enabled: true, responsive_web_twitter_article_tweet_consumption_enabled: true, tweet_awards_web_tipping_enabled: false, creator_subscriptions_quote_tweet_preview_enabled: false, freedom_of_speech_not_reach_fetch_enabled: true, standardized_nudges_misinfo: true, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, rweb_video_timestamps_enabled: true, longform_notetweets_rich_text_read_enabled: true, longform_notetweets_inline_media_enabled: true, profile_label_improvements_pcf_label_in_post_enabled: false, rweb_tipjar_consumption_enabled: true, responsive_web_graphql_exclude_directive_enabled: true, verified_phone_label_enabled: false, responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, responsive_web_graphql_timeline_navigation_enabled: true, responsive_web_enhance_cards_enabled: false }), fieldToggles: JSON.stringify({ withArticleRichContentState: true, withArticlePlainText: false, withGrokAnalyze: false, withDisallowedReplyControls: false }) }).toString()}`; const ct0 = getCookie("ct0"); return new Promise((resolve, reject) => { globalThis.GM_xmlhttpRequest({ method: "GET", url, headers: { Authorization: `Bearer ${WEB_BEARER}`, "x-guest-token": guestToken, "Content-Type": "application/json", Referer: "https://x.com/", Origin: "https://x.com", ...ct0 ? { "x-csrf-token": ct0 } : {} }, responseType: "json", onload(res) { try { let data = res.response; if (typeof data === "string") data = JSON.parse(data); if (res.status >= 200 && res.status < 300) resolve(data); else reject(new Error(`TweetResultByRestId HTTP ${res.status}`)); } catch (e) { reject(new Error("TweetResult 解析失败: " + e.message)); } }, onerror: () => reject(new Error("TweetResult 网络错误")), ontimeout: () => reject(new Error("TweetResult 超时")) }); }); } function unwrapTweetResult(response) { const root = response?.data?.tweetResult?.result; if (!root) return null; if (root.__typename === "TweetWithVisibilityResults") return root.tweet || null; return root; } function pickBestVariant(variants) { const mp4 = variants.filter((v) => v.content_type === "video/mp4"); if (mp4.length === 0) return variants[0] || null; return mp4.slice().sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]; } function normalizeMediaItems(tweet, pageUrl) { const legacy = tweet.legacy; const media = legacy?.extended_entities?.media || []; if (media.length === 0) return []; const authorLegacy = tweet.core?.user_results?.result?.legacy; const author = { name: authorLegacy?.name || "", id: authorLegacy?.screen_name || "", avatar: authorLegacy?.profile_image_url_https || "" }; const text = (tweet.note_tweet?.note_tweet_results?.result?.text || legacy?.full_text || "").replace(/https:\/\/t\.co\/\w+/g, "").trim(); const results = []; media.forEach((m, index) => { const label = media.length > 1 ? ` #${index + 1}` : ""; if (m.type === "video" || m.type === "animated_gif") { const variants = m.video_info?.variants || []; const best = pickBestVariant(variants); if (!best?.url) return; const animated = m.type === "animated_gif" || /\/tweet_video\//.test(best.url); const backups = variants.filter((v) => v.content_type === "video/mp4" && v.url !== best.url).sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)).map((v) => v.url); results.push({ type: "video", title: text || `X ${animated ? "GIF" : "视频"}${label}`, desc: text, author, cover: m.media_url_https || "", url: best.url, video_backup: backups, duration: m.video_info?.duration_millis ? m.video_info.duration_millis / 1e3 : null, platform: "x", sourceUrl: pageUrl, animated, itemLabel: `${animated ? "GIF" : "视频"}${label} ${best.bitrate ? Math.round((best.bitrate || 0) / 1e3) + "kbps" : ""}`.trim() }); } else if (m.type === "photo" && m.media_url_https) results.push({ type: "image", title: text || `X 图片${label}`, desc: text, author, cover: m.media_url_https, url: "", images: [`${m.media_url_https}?name=orig`], platform: "x", sourceUrl: pageUrl, itemLabel: `图片${label}` }); }); return results; } function mergeIntoSingle(items, pageUrl) { const images = items.filter((i) => i.type === "image").flatMap((i) => i.images || []); const videos = items.filter((i) => i.type === "video"); if (videos.length > 0) { const first = videos[0]; return { ...first, sourceUrl: pageUrl, items: items.length > 1 ? items : void 0, images: images.length > 0 ? images : first.images }; } return { ...items[0], images, sourceUrl: pageUrl, items: items.length > 1 ? items : void 0 }; } async function parseFromDom(pageUrl) { const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const initial = win.__INITIAL_STATE__ || win.__META_DATA__ || null; if (!initial) return null; const walk = (value, depth, seen) => { if (depth > 8 || !value || typeof value !== "object") return null; if (seen.has(value)) return null; seen.add(value); if (value.legacy?.extended_entities?.media) return value; for (const v of Object.values(value)) { const r = walk(v, depth + 1, seen); if (r) return r; } return null; }; const found = walk(initial, 0, new WeakSet()); if (!found) return null; const items = normalizeMediaItems(found, pageUrl); if (items.length === 0) return null; return mergeIntoSingle(items, pageUrl); } var XAPI = { async parseUrl(pageUrl) { const tweetId = extractTweetId(pageUrl); if (!tweetId) { const dom = await parseFromDom(pageUrl); if (dom) return dom; throw new Error("未能识别推文 ID,请打开单条推文详情页"); } let guestToken = ""; try { guestToken = await fetchGuestToken(); } catch (e) { Diagnostics.warn("x", "guest_token 获取失败,尝试无 token 请求", e); } const queryIds = collectQueryIdsFromPage(); let lastError = null; for (const qid of queryIds) try { const response = await fetchTweetGraphQL(tweetId, qid, guestToken); const tweet = unwrapTweetResult(response); if (!tweet) { const reason = response?.errors?.[0]?.message || "推文数据为空"; lastError = new Error(reason); Diagnostics.debug("x", `queryId=${qid} 返回空: ${reason}`); continue; } const items = normalizeMediaItems(tweet, pageUrl); if (items.length === 0) throw new Error("该推文没有可下载的媒体"); Diagnostics.info("x", `queryId=${qid} 解析成功,媒体数=${items.length}`); return mergeIntoSingle(items, pageUrl); } catch (e) { lastError = e; Diagnostics.warn("x", `queryId=${qid} 请求失败`, e); } const dom = await parseFromDom(pageUrl); if (dom) return dom; throw lastError || new Error("无法获取 X 推文数据"); } }; var ITEM_ARRAY_KEYS = [ "items", "list", "data", "videos", "video_list", "videoList", "aweme_list", "awemeList", "statuses", "cards", "feeds" ]; function unique(values) { return values.filter((value, index) => Boolean(value) && values.indexOf(value) === index); } function isRecord(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function isHttpUrl(value) { return typeof value === "string" && /^(https?:)?\/\//i.test(value.trim()); } function normalizeUrl(value) { if (!isHttpUrl(value)) return ""; const url = value.trim(); return url.startsWith("//") ? `https:${url}` : url; } function pushUrl(target, value) { if (Array.isArray(value)) { value.forEach((item) => pushUrl(target, item)); return; } if (value && typeof value === "object") { Object.values(value).forEach((item) => pushUrl(target, item)); return; } const url = normalizeUrl(value); if (url && !target.includes(url)) target.push(url); } function normalizeAuthor(data) { const author = data.author; if (author && typeof author === "object") return { ...author, name: author.name || author.nickname || author.screen_name || author.user_name || data.nickname || data.author_name || "", avatar: author.avatar || author.avatar_url || data.avatar || data.author_avatar || "" }; return { name: typeof author === "string" ? author : data.nickname || data.author_name || data.screen_name || "", id: data.author_id || data.uid || data.user_id || "", avatar: data.avatar || data.author_avatar || "" }; } function normalizeLivePhotos(value) { if (!Array.isArray(value)) return []; return value.map((item) => { if (!item || typeof item !== "object") return null; return { image: normalizeUrl(item.image || item.cover || item.url), video: normalizeUrl(item.video || item.video_url || item.play_url) }; }).filter((item) => Boolean(item && (item.image || item.video))); } function getVisibleArea(element) { const rect = element.getBoundingClientRect(); return Math.max(0, Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0)) * Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)); } function getBestVisibleVideo() { const videos = Array.from(document.querySelectorAll("video")); return videos.map((video) => ({ video, area: getVisibleArea(video) })).filter((item) => item.area > 1e3).sort((a, b) => b.area - a.area)[0]?.video || videos[0] || null; } function getElementTreeSource(element, maxDepth = 6) { const parts = []; let current = element; let depth = 0; while (current && depth < maxDepth) { parts.push(current.outerHTML.slice(0, 6e4)); current = current.parentElement; depth += 1; } return parts.join("\n"); } function findNearbyText(element, selectors, maxLength, maxDepth = 7) { let current = element; let depth = 0; while (current && depth < maxDepth) { for (const selector of selectors) { const nodes = Array.from(current.querySelectorAll(selector)); for (const node of nodes) { const text = node.innerText?.replace(/\s+/g, " ").trim(); if (text && text.length <= maxLength) return text; } } current = current.parentElement; depth += 1; } return ""; } function getVideoCandidates(data) { const candidates = []; const qualityUrls = data.quality_urls || data.qualityUrls || null; if (qualityUrls && typeof qualityUrls === "object" && !Array.isArray(qualityUrls)) pushUrl(candidates, qualityUrls[data.default_quality || data.defaultQuality || "高清 720P"]); pushUrl(candidates, data.download_url || data.downloadUrl); pushUrl(candidates, data.url); pushUrl(candidates, data.video_url || data.videoUrl); pushUrl(candidates, data.play_url || data.playUrl); pushUrl(candidates, data.media_url || data.mediaUrl); pushUrl(candidates, data.hd_url || data.hdUrl); pushUrl(candidates, data.sd_url || data.sdUrl); pushUrl(candidates, data.mp4); pushUrl(candidates, data.src); pushUrl(candidates, data.video); pushUrl(candidates, data.video_backup || data.videoBackup); pushUrl(candidates, data.video_urls || data.videoUrls); pushUrl(candidates, data.downloads); pushUrl(candidates, qualityUrls); pushUrl(candidates, data.urls); pushUrl(candidates, data.download_urls || data.downloadUrls); return candidates; } function getImageCandidates(data) { const candidates = []; pushUrl(candidates, data.images || data.image_urls || data.imageUrls); pushUrl(candidates, data.pics || data.pictures || data.pic_urls || data.picUrls); pushUrl(candidates, data.photo || data.photos || data.photo_list || data.photoList); pushUrl(candidates, data.image || data.image_url || data.imageUrl); pushUrl(candidates, data.image_list || data.imageList || data.images_list || data.imagesList); pushUrl(candidates, data.album || data.album_images || data.albumImages); return candidates; } function unwrapRawItem(item) { if (!isRecord(item)) return null; if (isRecord(item.mblog)) return item.mblog; if (isRecord(item.status)) return item.status; if (isRecord(item.video)) return { ...item, ...item.video }; if (isRecord(item.aweme_detail)) return item.aweme_detail; return item; } function pickParentFallback(data) { return { title: data.title, desc: data.desc, author: data.author, nickname: data.nickname, author_name: data.author_name, screen_name: data.screen_name, avatar: data.avatar, author_avatar: data.author_avatar, cover: data.cover, cover_image: data.cover_image, coverImage: data.coverImage }; } function getNestedRawItems(data) { for (const key of ITEM_ARRAY_KEYS) { if (!Array.isArray(data[key])) continue; const fallback = pickParentFallback(data); const items = data[key].map(unwrapRawItem).filter((item) => Boolean(item)).map((item) => ({ ...fallback, ...item })); if (items.length > 0) return items; } return []; } function collectRawItems(raw) { const sourceItems = Array.isArray(raw) ? raw : [raw || {}]; const items = []; sourceItems.forEach((item) => { const rawItem = unwrapRawItem(item); if (!rawItem) return; const nested = getNestedRawItems(rawItem); if (nested.length > 0) items.push(...nested); else items.push(rawItem); }); return items.length > 0 ? items : [{}]; } function getShortVideoItemKey(item) { return [ item.url, item.images?.join("|"), item.live_photo?.map((live) => `${live.image || ""}:${live.video || ""}`).join("|"), item.music?.url, item.sourceUrl, item.title ].find(Boolean) || ""; } function dedupeShortVideoItems(items) { const seen = new Set(); return items.filter((item) => { const key = getShortVideoItemKey(item); if (!key) return true; if (seen.has(key)) return false; seen.add(key); return true; }); } function combineShortVideoItems(items, platform, url) { const deduped = dedupeShortVideoItems(items); return { ...deduped[0] || { type: "unknown", title: "", platform, sourceUrl: url }, items: deduped }; } function getDocumentSource() { return [ document.querySelector("meta[property=\"og:video:url\"]")?.content || "", document.querySelector("meta[property=\"og:url\"]")?.content || "", document.querySelector("meta[name=\"twitter:url\"]")?.content || "", document.documentElement?.innerHTML || "" ].join("\n"); } function normalizeWeiboFid(value) { return value.replace(/%253A|%3A|\\u003a/gi, ":"); } function extractWeiboFids(source) { return unique((source.match(/1034(?::|%3A|%253A|\\u003a)\d{6,}/gi) || []).map(normalizeWeiboFid)); } function getPageWindow() { if (typeof unsafeWindow !== "undefined") return unsafeWindow; return window; } function extractKuaishouPhotoIds(source) { const ids = []; [ /\/short-video\/([A-Za-z0-9_-]+)/gi, /(?:VisionVideoDetailPhoto|VisionPhoto|RecoPhoto|FeedPhoto|PhotoEntity|Photo):([A-Za-z0-9_-]{6,})/gi, /(?:photoId|photo_id|photoIdStr)["']?\s*[:=]\s*["']?([A-Za-z0-9_-]{6,})/gi, /(?:[?&]|&)(?:photoId|photo_id)=([A-Za-z0-9_-]{6,})/gi, /clientCacheKey=([A-Za-z0-9-]+?)(?:_[A-Za-z0-9]+)?\.(?:mp4|jpg|jpeg|kvif|webp)/gi ].forEach((pattern) => { let match; while (match = pattern.exec(source)) ids.push(match[1]); }); return unique(ids); } function getKuaishouActiveSource() { const video = getBestVisibleVideo(); if (!video) return ""; return [ video.currentSrc || "", video.src || "", video.poster || "", getElementTreeSource(video) ].join("\n"); } function toKuaishouShortVideo(photo, url, label) { return { type: "video", title: photo.caption || getKuaishouDomTitle(getBestVisibleVideo()), desc: photo.caption, author: photo.authorName ? { name: photo.authorName } : {}, cover: photo.coverUrl, url: photo.urls[0].url, video_backup: photo.urls.slice(1).map((item) => item.url), duration: photo.duration || null, video_id: photo.id, platform: "kuaishou", sourceUrl: url, itemLabel: label }; } function getKuaishouCorrelatedItem(url) { if (KuaishouInterceptor.size() === 0) return null; for (const id of extractKuaishouPhotoIds(url)) { const photo = KuaishouInterceptor.getById(id); if (photo) return toKuaishouShortVideo(photo, url, "当前播放视频"); } for (const id of extractKuaishouPhotoIds(getKuaishouActiveSource())) { const photo = KuaishouInterceptor.getById(id); if (photo) return toKuaishouShortVideo(photo, url, "当前播放视频"); } const poster = normalizeUrl(getBestVisibleVideo()?.poster); const byCover = poster ? KuaishouInterceptor.getByCover(poster) : null; return byCover ? toKuaishouShortVideo(byCover, url, "当前播放视频") : null; } var KUAISHOU_MEDIA_HOSTS = /(kwaicdn\.com|kwimgs\.com|yximgs\.com|chenzhongtech\.com|ndcimgs\.com)/i; var KUAISHOU_NON_MEDIA_EXT = /\.(m3u8|mpd|ts|jpe?g|png|webp|gif|svg|ico|css|js|json|html?)(?:[?#]|$)/i; function isKuaishouPlayableUrl(value) { const url = normalizeUrl(value); return Boolean(url && !KUAISHOU_NON_MEDIA_EXT.test(url)); } function isKuaishouVideoUrl(value) { const url = normalizeUrl(value); return Boolean(url && /\.(mp4|flv)(?:[?#]|$)/i.test(url) && KUAISHOU_MEDIA_HOSTS.test(url)); } function describeMediaUrl(value) { const raw = typeof value === "string" ? value.trim() : ""; if (!raw) return "empty"; if (raw.startsWith("blob:")) return "blob"; if (raw.startsWith("data:")) return "data"; try { const parsed = new URL(raw, location.href); const ext = (parsed.pathname.match(/\.([a-z0-9]{2,5})$/i) || [])[1] || "none"; return `${parsed.hostname}/.${ext}`; } catch { return "invalid"; } } function describeKuaishouSources() { const best = getBestVisibleVideo(); const win = getPageWindow(); const stateKeys = [ "__APOLLO_STATE__", "__INITIAL_STATE__", "__NUXT__", "__PRELOADED_STATE__", "pageData" ].filter((key) => { try { return win?.[key] != null; } catch { return false; } }); let cdn = []; try { cdn = unique(performance.getEntriesByType("resource").map((entry) => entry.name).filter((name) => KUAISHOU_MEDIA_HOSTS.test(name)).map(describeMediaUrl)).slice(0, 8); } catch {} return [ `videos=${document.querySelectorAll("video").length}`, `currentSrc=${describeMediaUrl(best?.currentSrc)}`, `src=${describeMediaUrl(best?.getAttribute("src"))}`, `poster=${best?.poster ? "yes" : "no"}`, `gql=${KuaishouInterceptor.installed ? KuaishouInterceptor.size() : "off"}`, `state=[${stateKeys.join(",") || "none"}]`, `cdn=[${cdn.join(" ") || "none"}]` ].join(" "); } function getKuaishouPerformanceVideoUrls() { const urls = []; const seen = new Set(); try { for (const entry of performance.getEntriesByType("resource")) { const name = entry.name; if (seen.has(name) || !isKuaishouVideoUrl(name)) continue; seen.add(name); urls.push(name); } } catch {} return urls; } function collectKuaishouMediaUrls(video) { return unique([...[normalizeUrl(video?.currentSrc), normalizeUrl(video?.src)].filter(isKuaishouPlayableUrl), ...getKuaishouPerformanceVideoUrls()]); } function getKuaishouDomTitle(video) { const nearbyTitle = findNearbyText(video?.parentElement || null, [ "[class*=\"photo-desc\"]", "[class*=\"video-desc\"]", "[class*=\"caption\"]", "[class*=\"desc\"]", "[class*=\"title\"]", "[data-e2e*=\"desc\"]", "h1", "h2" ], 180); if (nearbyTitle) return nearbyTitle; return document.title.replace(/[-_||]?\s*快手.*$/i, "").trim() || "快手视频"; } function getKuaishouDomAuthor(video) { const name = findNearbyText(video?.parentElement || null, [ "a[href*=\"/profile/\"]", "a[href*=\"/user/\"]", "[class*=\"author\"]", "[class*=\"nickname\"]", "[class*=\"user\"]", "[class*=\"name\"]", "[data-e2e*=\"author\"]", "[data-e2e*=\"user\"]" ], 80); return name ? { name } : {}; } function isKuaishouFeedPage(url) { const pathname = new URL(url).pathname.replace(/\/+$/, "") || "/"; return pathname === "/new-reco" || pathname === "/"; } function isWeiboNonContentPage(url, candidates) { if (candidates.some((candidate) => /weibo\.com\/tv\/show\/1034:/i.test(candidate))) return false; const pathname = new URL(url).pathname.replace(/\/+$/, "") || "/"; return pathname === "/" || /^\/newlogin/i.test(pathname) || /^\/login/i.test(pathname) || /^\/u\/?\d*$/i.test(pathname); } async function parseKuaishouLocal(url, itemLabel) { for (let attempt = 0; attempt < 9; attempt += 1) { if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 250)); const video = getBestVisibleVideo(); const videoUrls = collectKuaishouMediaUrls(video); if (videoUrls.length === 0) continue; const title = getKuaishouDomTitle(video); return { type: "video", title, desc: title, author: getKuaishouDomAuthor(video), cover: normalizeUrl(video?.poster) || "", url: videoUrls[0], video_backup: videoUrls.slice(1), platform: "kuaishou", sourceUrl: url, itemLabel }; } return null; } function parseXhsWindowState(url) { const win = getPageWindow(); const stateKeys = [ "__INITIAL_SSR_STATE__", "__NUXT__", "__data", "initialState", "__REDUX_STATE__" ]; const walk = (value, depth, seen) => { if (depth > 8 || !value || typeof value !== "object") return null; if (seen.has(value)) return null; seen.add(value); if (value.note || value.noteDetail) { const note = value.note || value.noteDetail; if (note?.video?.media?.stream) { const streams = note.video.media.stream; const h264 = streams.h264?.[0] || streams.av1?.[0] || streams.h265?.[0]; if (h264?.masterUrl) { const videoUrl = h264.masterUrl; const images = note.imageList?.map((img) => img?.urlDefault || img?.url).filter(Boolean) || []; const isImageNote = images.length > 0 && !videoUrl; return { type: isImageNote ? "image" : "video", title: note.title || note.desc || "小红书内容", desc: note.desc || "", author: { name: note.user?.nickname || note.author?.nickname || "" }, cover: note.cover?.urlDefault || note.cover?.url || "", url: videoUrl || "", images: isImageNote ? images : [], platform: "xiaohongshu", sourceUrl: url }; } } if (note?.imageList?.length > 0) { const images = note.imageList.map((img) => img?.urlDefault || img?.infoList?.[0]?.url || img?.url).filter(Boolean); if (images.length > 0) return { type: "image", title: note.title || note.desc || "小红书图集", desc: note.desc || "", author: { name: note.user?.nickname || "" }, cover: images[0], url: "", images, platform: "xiaohongshu", sourceUrl: url }; } } const entries = Object.entries(value).slice(0, 100); for (const [, v] of entries) { const found = walk(v, depth + 1, seen); if (found) return found; } return null; }; const seen = new WeakSet(); for (const key of stateKeys) try { const result = walk(win[key], 0, seen); if (result) return result; } catch {} try { const videoUrls = performance.getEntriesByType("resource").map((e) => e.name).filter((n) => /xhscdn\.com.*\.mp4/i.test(n) || /sns-video.*\.mp4/i.test(n)); if (videoUrls.length > 0) { const video = getBestVisibleVideo(); return { type: "video", title: document.title.replace(/[-_||]?\s*小红书.*$/i, "").trim() || "小红书视频", desc: "", author: {}, cover: video?.poster || "", url: videoUrls[0], platform: "xiaohongshu", sourceUrl: url }; } } catch {} return null; } async function parseXhsLocal(url) { const immediate = parseXhsWindowState(url); if (immediate?.url || immediate?.images && immediate.images.length > 0) return immediate; for (let i = 0; i < 8; i++) { await new Promise((r) => setTimeout(r, 300)); const result = parseXhsWindowState(url); if (result?.url || result?.images && result.images.length > 0) return result; } const video = getBestVisibleVideo(); const src = video?.currentSrc || video?.src; if (src && src.startsWith("http")) return { type: "video", title: document.title.replace(/[-_||]?\s*小红书.*$/i, "").trim() || "小红书视频", desc: "", author: {}, cover: video?.poster || "", url: src, platform: "xiaohongshu", sourceUrl: url }; return null; } async function parseWeiboLocal(url) { const win = getPageWindow(); const stateAttempt = (() => { try { for (const k of [ "__INITIAL_STATE__", "__preloadData", "__wb_data__", "WEIBO_DATA" ]) { const val = win[k]; if (!val) continue; const walk = (v, d, seen) => { if (d > 6 || !v || typeof v !== "object") return null; if (seen.has(v)) return null; seen.add(v); if (v.video_sources && Array.isArray(v.video_sources)) return v; if (v.media_info?.stream_url) return v; if (Array.isArray(v.pic_ids) && v.pic_ids.length > 0 && v.pic_infos) return v; for (const [, child] of Object.entries(v).slice(0, 100)) { const r = walk(child, d + 1, seen); if (r) return r; } return null; }; const found = walk(val, 0, new WeakSet()); if (found) { const cleanText = found.text?.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim() || ""; const title = cleanText || "微博内容"; const authorName = found.user?.screen_name || ""; if (Array.isArray(found.pic_ids) && found.pic_ids.length > 0 && found.pic_infos) { const images = found.pic_ids.map((pid) => { const info = found.pic_infos[pid]; return info?.original?.url || info?.large?.url || info?.bmiddle?.url || ""; }).filter(Boolean); if (images.length > 0) return { type: "image", title, desc: cleanText, author: { name: authorName }, cover: images[0], url: "", images, platform: "weibo", sourceUrl: url }; } const sources = found.video_sources || []; const videoUrl = (sources.find((s) => s.quality === "original" || s.quality === "HD") || sources[0])?.url || found.media_info?.stream_url; if (videoUrl) return { type: "video", title, desc: cleanText, author: { name: authorName }, cover: found.thumbnail_pic || found.original_pic || "", url: videoUrl, platform: "weibo", sourceUrl: url }; } } } catch {} return null; })(); if (stateAttempt) return stateAttempt; const fids = extractWeiboFids(`${url}\n${getDocumentSource()}`); for (const fid of fids) try { const tvUrl = `https://weibo.com/tv/show/${fid}?from=api`; const json = await (await getPageWindow().fetch(tvUrl, { headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" }, credentials: "include" })).json(); const urls = json?.data?.urls; if (urls && typeof urls === "object") { const sorted = Object.entries(urls).sort(([a], [b]) => { const rank = (k) => k.includes("ld") ? 0 : k.includes("hd") ? 1 : k.includes("ori") ? 2 : 0; return rank(b) - rank(a); }); if (sorted.length > 0) return { type: "video", title: json.data?.title || "微博视频", desc: "", author: {}, cover: "", url: sorted[0][1], video_backup: sorted.slice(1).map((e) => e[1]), platform: "weibo", sourceUrl: url }; } } catch {} const video = getBestVisibleVideo(); const directSrc = (() => { const src = video?.currentSrc || video?.src; if (src && src.startsWith("http") && !src.includes("blob:")) return src; try { const perf = performance.getEntriesByType("resource").map((e) => e.name).filter((n) => /\.mp4/i.test(n) && /weibo|sinaimg|weibocdn|sinastorage|scloud/i.test(n)); if (perf.length > 0) return perf[perf.length - 1]; } catch {} return null; })(); if (directSrc) return { type: "video", title: "微博视频", desc: "", author: {}, cover: video?.poster || "", url: directSrc, platform: "weibo", sourceUrl: url }; return null; } function parseToutiaoWindowState(url) { const win = getPageWindow(); const stateKeys = [ "__INITIAL_STATE__", "SSR_HYDRATION_DATA", "__pageData", "detailSSRData", "__tt_spa_initial_state__", "APP_INITIAL_DATA", "PAGE_SSR_DATA" ]; const extract = (value, depth, seen) => { if (depth > 8 || !value || typeof value !== "object") return null; if (seen.has(value)) return null; seen.add(value); if (value.video_list && typeof value.video_list === "object" && !Array.isArray(value.video_list)) { const best = Object.values(value.video_list).sort((a, b) => (b.definition || 0) - (a.definition || 0))[0]; if (best?.main_url || best?.play_url) { const videoUrl = best.main_url || best.play_url; try { return { type: "video", title: value.title || value.abstract || document.title.replace(/[-|]?\s*今日头条.*$/i, "").trim() || "头条视频", desc: value.abstract || "", author: { name: value.user_info?.name || value.source || "" }, cover: value.detail_video_large_image?.url || value.thumb_image_list?.[0]?.url || "", url: decodeURIComponent(videoUrl), platform: "toutiao", sourceUrl: url }; } catch {} } } const mediaInfo = value.mediaInfo || value.videoInfo; if (mediaInfo?.mp4_url || mediaInfo?.mp4_play_url || mediaInfo?.video_url) { const videoUrl = mediaInfo.mp4_url || mediaInfo.mp4_play_url || mediaInfo.video_url; return { type: "video", title: mediaInfo?.title || value.title || "头条视频", desc: "", author: { name: mediaInfo?.user_info?.name || "" }, cover: mediaInfo?.poster || mediaInfo?.cover?.url || "", url: videoUrl, platform: "toutiao", sourceUrl: url }; } if ((value.play_url || value.mp4_url) && (value.title || value.abstract)) { const videoUrl = value.play_url || value.mp4_url; return { type: "video", title: value.title || value.abstract || "头条视频", desc: value.abstract || "", author: { name: value.user_info?.name || value.source || "" }, cover: value.cover?.url || value.thumb_image_list?.[0]?.url || "", url: videoUrl, platform: "toutiao", sourceUrl: url }; } const entries2 = Object.entries(value).slice(0, 100); for (const [, v] of entries2) { const r = extract(v, depth + 1, seen); if (r) return r; } return null; }; const seen = new WeakSet(); for (const key of stateKeys) try { const r = extract(win[key], 0, seen); if (r) return r; } catch {} try { const scripts = Array.from(document.querySelectorAll("script:not([src])")); for (const script of scripts) { const text = script.textContent || ""; if (!text.includes("video_list") && !text.includes("videoInfo")) continue; const match = text.match(/window\.__INITIAL_STATE__\s*=\s*(\{.+?\});?\s*(?:window|$)/s); if (match?.[1]) try { const r = extract(JSON.parse(match[1]), 0, new WeakSet()); if (r) return r; } catch {} } } catch {} return null; } async function parseToutiaoLocal(url) { const immediate = parseToutiaoWindowState(url); if (immediate) return immediate; for (let i = 0; i < 8; i++) { await new Promise((r) => setTimeout(r, 300)); const result = parseToutiaoWindowState(url); if (result) return result; const v = getBestVisibleVideo(); const src = v?.currentSrc; if (src && src.startsWith("http") && !src.includes("blob:")) return { type: "video", title: document.title.replace(/[-|]?\s*今日头条.*$/i, "").trim() || "头条视频", desc: "", author: {}, cover: v?.poster || "", url: src, platform: "toutiao", sourceUrl: url }; } const v = getBestVisibleVideo(); const src = v?.currentSrc || v?.src; if (src && src.startsWith("http") && !src.includes("blob:")) return { type: "video", title: document.title.replace(/[-|]?\s*今日头条.*$/i, "").trim() || "头条视频", desc: "", author: {}, cover: v?.poster || "", url: src, platform: "toutiao", sourceUrl: url }; return null; } var PARSERS = { douyin: (url) => ShortVideoAPI.parseDouyinLocal(url), kuaishou: async (url) => { const correlated = getKuaishouCorrelatedItem(url); if (correlated) return combineShortVideoItems([correlated], "kuaishou", url); const local = await parseKuaishouLocal(url, isKuaishouFeedPage(url) ? "当前播放视频" : void 0); if (local) return combineShortVideoItems([local], "kuaishou", url); if (!isKuaishouFeedPage(url)) { const latest = KuaishouInterceptor.getLatest(); if (latest) return combineShortVideoItems([toKuaishouShortVideo(latest, url, "")], "kuaishou", url); } Diagnostics.debug("kuaishou", `未取到媒体源: ${describeKuaishouSources()}`); return null; }, xiaohongshu: async (url) => { const local = await parseXhsLocal(url); return local ? combineShortVideoItems([local], "xiaohongshu", url) : null; }, weibo: async (url) => { const local = await parseWeiboLocal(url); if (local) return combineShortVideoItems([local], "weibo", url); if (isWeiboNonContentPage(url, [url])) throw new Error("请打开微博视频详情页后再试"); return null; }, toutiao: async (url) => { const local = await parseToutiaoLocal(url); return local ? combineShortVideoItems([local], "toutiao", url) : null; }, x: async (url) => { const data = await XAPI.parseUrl(url); if (!data) return null; return combineShortVideoItems(data.items?.length ? data.items : [data], "x", url); } }; var ShortVideoAPI = { getPlatformConfig(platform) { return PLATFORMS[platform]; }, getPlatformLabel(platform) { return this.getPlatformConfig(platform).label; }, getMediaHeaders(platform) { const config = this.getPlatformConfig(platform); return { Referer: config.mediaReferer, Origin: config.mediaOrigin, "User-Agent": navigator.userAgent }; }, normalizeDouyinDetail(detail, url) { const qualities = DouyinInterceptor.extractQualities(detail); const primaryUrl = qualities[0]?.url || ""; const backupUrls = qualities.slice(1).map((q) => q.url); const video = detail.video; const cover = pickHttpUrl(video?.cover?.url_list); const avatarUrl = pickHttpUrl(detail.author?.avatar_thumb?.url_list); const musicUrl = pickHttpUrl(detail.music?.play_url?.url_list) || detail.music?.play_url?.uri || ""; const musicCover = pickHttpUrl(detail.music?.cover_medium?.url_list); const items = qualities.map((q) => ({ type: "video", title: detail.desc || "抖音视频", desc: detail.desc || "", author: { name: detail.author?.nickname || "", id: detail.author?.uid || "", avatar: avatarUrl }, cover, url: q.url, video_backup: [], duration: video?.duration ? video.duration / 1e3 : null, music: musicUrl ? { title: detail.music?.title, author: detail.music?.author, url: musicUrl, cover: musicCover } : void 0, platform: "douyin", sourceUrl: url, itemLabel: q.label })); return { type: "video", title: detail.desc || "抖音视频", desc: detail.desc || "", author: { name: detail.author?.nickname || "", id: detail.author?.uid || "", avatar: avatarUrl }, cover, url: primaryUrl, video_backup: backupUrls, duration: video?.duration ? video.duration / 1e3 : null, music: musicUrl ? { title: detail.music?.title, author: detail.music?.author, url: musicUrl, cover: musicCover } : void 0, platform: "douyin", sourceUrl: url, items: items.length > 1 ? items : void 0, alternativeSources: items.length > 1 }; }, async parseDouyinLocal(url, waitMs = 3e3) { const extractAwemeId = (u) => { try { const p = new URL(u); return p.searchParams.get("modal_id") || p.searchParams.get("aweme_id") || p.pathname.match(/\/(?:video|note)\/(\d{15,20})/)?.[1] || null; } catch { return null; } }; const AWEME_ID_RE = /\b(\d{15,20})\b/; const scanFiberChain = (fiber) => { for (let i = 0; i < 60 && fiber; i++) { const props = fiber.memoizedProps || fiber.pendingProps; if (props) { const id = props.aweme_id || props.awemeId || props.itemId || props.videoId || props.item?.aweme_id || props.item?.awemeId || props.aweme?.aweme_id || props.video?.aweme_id || props.data?.aweme_id || props.awemeInfo?.aweme_id || props.feedItem?.aweme_id || props.videoData?.aweme_id; if (id && AWEME_ID_RE.test(String(id))) return String(id).match(AWEME_ID_RE)[1]; } fiber = fiber.return; } return null; }; const extractFromDomFibers = (startEl) => { let el = startEl; while (el && el !== document.documentElement) { const fiberKey = Object.keys(el).find((k) => k.startsWith("__reactFiber") || k.startsWith("__reactInternalInstance")); if (fiberKey) { const found = scanFiberChain(el[fiberKey]); if (found) return found; } el = el.parentElement; } return null; }; const extractAwemeIdFromDom = () => { const video = getBestVisibleVideo(); let el2 = video; while (el2 && el2 !== document.documentElement) { const m = el2.className?.match?.(/\bvideo_(\d{15,20})\b/); if (m?.[1]) return m[1]; el2 = el2.parentElement; } const classMatches = Array.from(document.querySelectorAll("[class*=\"video_\"]")); let bestClassEl = null; let bestArea = 0; for (const el of classMatches) { if (!el.className.match(/\bvideo_(\d{15,20})\b/)?.[1]) continue; const r = el.getBoundingClientRect(); const area = Math.max(0, Math.min(r.right, window.innerWidth) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, window.innerHeight) - Math.max(r.top, 0)); if (area > bestArea) { bestArea = area; bestClassEl = el; } } if (bestClassEl) { const m = bestClassEl.className.match(/\bvideo_(\d{15,20})\b/); if (m?.[1]) return m[1]; } const eVidEls = Array.from(document.querySelectorAll("[data-e2e-vid]")); let bestEVid = null; let bestEArea = 0; for (const el of eVidEls) { const r = el.getBoundingClientRect(); const area = Math.max(0, Math.min(r.right, window.innerWidth) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, window.innerHeight) - Math.max(r.top, 0)); if (area > bestEArea) { bestEArea = area; bestEVid = el; } } if (bestEVid) { const vid = bestEVid.getAttribute("data-e2e-vid"); if (vid && AWEME_ID_RE.test(vid)) return vid.match(AWEME_ID_RE)[1]; } const fiberId = extractFromDomFibers(video); if (fiberId) return fiberId; const dataAttrs = [ "data-aweme-id", "data-item-id", "data-awemeid", "data-id", "data-video-id" ]; const allElements = Array.from(document.querySelectorAll("[data-aweme-id],[data-item-id],[data-awemeid]")); for (const el of allElements) for (const attr of dataAttrs) { const val = el.getAttribute(attr); if (val && AWEME_ID_RE.test(val)) return val.match(AWEME_ID_RE)[1]; } let el = video; for (let depth = 0; depth < 15 && el; depth++) { for (const attr of dataAttrs) { const val = el.getAttribute(attr); if (val && AWEME_ID_RE.test(val)) return val.match(AWEME_ID_RE)[1]; } const link = el.querySelector("a[href*=\"/video/\"],a[href*=\"/note/\"]"); if (link) { const m = link.getAttribute("href")?.match(/\/(?:video|note)\/(\d{15,20})/); if (m?.[1]) return m[1]; } el = el.parentElement; } const allLinks = Array.from(document.querySelectorAll("a[href*=\"/video/\"],a[href*=\"/note/\"]")); for (const link of allLinks) { const m = link.getAttribute("href")?.match(/\/(?:video|note)\/(\d{15,20})/); if (m?.[1]) return m[1]; } const feedId = DouyinInterceptor.getAwemeIdFromFeed(); if (feedId) return feedId; return null; }; const awemeIdFromUrl = extractAwemeId(url); if (awemeIdFromUrl) { const detail = await DouyinInterceptor.fetchDetail(awemeIdFromUrl); if (detail) { const data = this.normalizeDouyinDetail(detail, url); if (data.url) return data; } } if (!awemeIdFromUrl) { const domId = extractAwemeIdFromDom(); if (domId) { const cached = DouyinInterceptor.getByAwemeId(domId); if (cached) { const data = this.normalizeDouyinDetail(cached, url); if (data.url) return data; } const detail = await DouyinInterceptor.fetchDetail(domId); if (detail) { const data = this.normalizeDouyinDetail(detail, url); if (data.url) return data; } } } const interval = 300; const maxTries = Math.ceil(waitMs / interval); let lastDomIdFetched = null; for (let i = 0; i < maxTries; i++) { const detail = DouyinInterceptor.getByUrl(url); if (detail) { const data = this.normalizeDouyinDetail(detail, url); if (data.url) return data; } const dom = DouyinInterceptor.getFromDom(); if (dom?.url) return { type: "video", title: document.title.replace(/[-_||]?\s*抖音.*$/i, "").trim() || "抖音视频", desc: "", cover: dom.cover, url: dom.url, video_backup: [], platform: "douyin", sourceUrl: url, itemLabel: "当前播放" }; if (!awemeIdFromUrl) { const domId = extractAwemeIdFromDom(); if (domId && domId !== lastDomIdFetched) { lastDomIdFetched = domId; const detail2 = await DouyinInterceptor.fetchDetail(domId); if (detail2) { const data = this.normalizeDouyinDetail(detail2, location.href); if (data.url) return data; } } } if (i < maxTries - 1) await new Promise((resolve) => setTimeout(resolve, interval)); } if (!awemeIdFromUrl) { const fallbackId = extractAwemeId(location.href); if (fallbackId) { const detail = await DouyinInterceptor.fetchDetail(fallbackId); if (detail) { const data = this.normalizeDouyinDetail(detail, location.href); if (data.url) return data; } } } return null; }, async parseUrl(url, platform) { const parse = PARSERS[platform]; if (!parse) throw new Error("不支持的平台"); const data = await parse(url); if (data) return data; throw new Error(`解析失败,无法获取${this.getPlatformLabel(platform)}内容`); }, normalizeResponse(raw, platform, url) { return combineShortVideoItems(collectRawItems(raw).map((data, index) => this.normalizeRawItem(data, platform, url, index)), platform, url); }, normalizeRawItem(data, platform, url, index = 0) { const videoCandidates = getVideoCandidates(data); const images = getImageCandidates(data); const livePhotos = normalizeLivePhotos(data.live_photo || data.livePhoto || data.live_photos || data.livePhotos); const author = normalizeAuthor(data); const rawType = typeof data.type === "string" ? data.type : ""; const type = [ "video", "image", "live" ].includes(rawType) ? rawType : livePhotos.length > 0 ? "live" : images.length > 0 && videoCandidates.length === 0 ? "image" : videoCandidates.length > 0 ? "video" : "unknown"; const normalized = { ...data, type, title: data.title || data.desc || this.getPlatformLabel(platform), desc: data.desc || data.title || "", author, cover: normalizeUrl(data.cover || data.cover_image || data.coverImage || data.pic || data.thumbnail || data.avatar) || "", url: data.url || "", duration: typeof data.duration === "number" ? data.duration : null, video_backup: videoCandidates.slice(1), images, live_photo: livePhotos, music: data.music && typeof data.music === "object" ? { ...data.music, url: normalizeUrl(data.music.url || data.music.play_url || data.music.playUrl), cover: normalizeUrl(data.music.cover || data.music.cover_url || data.music.coverUrl) } : {}, platform, sourceUrl: url, itemLabel: data.itemLabel || data.label || data.quality || `${type === "image" ? "图集" : "视频"} ${index + 1}` }; normalized.url = videoCandidates[0] || normalizeUrl(data.url) || ""; if (!normalized.title) normalized.title = this.getPlatformLabel(platform); return normalized; } }; var JSMerger = { name: "JS原生合并", status: "ready", readBox(buffer, offset) { const view = new DataView(buffer); if (offset + 8 > buffer.byteLength) return null; let size = view.getUint32(offset); const type = String.fromCharCode(view.getUint8(offset + 4), view.getUint8(offset + 5), view.getUint8(offset + 6), view.getUint8(offset + 7)); let headerSize = 8; if (size === 1 && offset + 16 <= buffer.byteLength) { size = Number(view.getBigUint64(offset + 8)); headerSize = 16; } else if (size === 0) size = buffer.byteLength - offset; return { size, type, headerSize, offset }; }, parseBoxes(buffer) { const boxes = []; let offset = 0; while (offset < buffer.byteLength) { const box = this.readBox(buffer, offset); if (!box || box.size < 8) break; boxes.push({ size: box.size, type: box.type, headerSize: box.headerSize, offset: box.offset, data: new Uint8Array(buffer, offset, box.size) }); offset += box.size; } return boxes; }, findBox(boxes, type) { return boxes.find((b) => b.type === type) || null; }, findAllBoxes(boxes, type) { return boxes.filter((b) => b.type === type); }, parseContainerBox(boxData, headerOffset = 8) { const childBoxes = []; let offset = headerOffset; while (offset < boxData.length) { if (offset + 8 > boxData.length) break; let size = new DataView(boxData.buffer, boxData.byteOffset + offset).getUint32(0); const type = String.fromCharCode(boxData[offset + 4], boxData[offset + 5], boxData[offset + 6], boxData[offset + 7]); if (size === 0) size = boxData.length - offset; if (size < 8 || offset + size > boxData.length) break; childBoxes.push({ size, type, offset, data: boxData.slice(offset, offset + size) }); offset += size; } return childBoxes; }, createBox(type, content) { const size = 8 + content.length; const box = new Uint8Array(size); new DataView(box.buffer).setUint32(0, size); box[4] = type.charCodeAt(0); box[5] = type.charCodeAt(1); box[6] = type.charCodeAt(2); box[7] = type.charCodeAt(3); box.set(content, 8); return box; }, concat(...arrays) { const totalLen = arrays.reduce((acc, a) => acc + a.length, 0); const result = new Uint8Array(totalLen); let offset = 0; for (const a of arrays) { result.set(a, offset); offset += a.length; } return result; }, toArrayBuffer(data) { const buffer = new ArrayBuffer(data.byteLength); new Uint8Array(buffer).set(data); return buffer; }, modifyTrackId(trakData, newId) { const result = new Uint8Array(trakData); for (const box of this.parseContainerBox(result)) if (box.type === "tkhd") { const version = result[box.offset + 8]; const trackIdOffset = box.offset + 8 + (version === 0 ? 12 : 20); new DataView(result.buffer, result.byteOffset + trackIdOffset).setUint32(0, newId); } return result; }, modifyTrexTrackId(trexData, newId) { const result = new Uint8Array(trexData); new DataView(result.buffer, result.byteOffset + 12).setUint32(0, newId); return result; }, getTrackType(trakData) { for (const box of this.parseContainerBox(trakData)) if (box.type === "mdia") { for (const mdiaBox of this.parseContainerBox(box.data)) if (mdiaBox.type === "hdlr") return String.fromCharCode(mdiaBox.data[16], mdiaBox.data[17], mdiaBox.data[18], mdiaBox.data[19]); } return null; }, buildMvex(videoMvex, audioMvex) { const mvexParts = []; if (videoMvex) { for (const box of this.parseContainerBox(videoMvex.data)) if (box.type === "trex") mvexParts.push(this.modifyTrexTrackId(box.data, 1)); else if (box.type === "mehd") mvexParts.push(box.data); } if (audioMvex) { for (const box of this.parseContainerBox(audioMvex.data)) if (box.type === "trex") mvexParts.push(this.modifyTrexTrackId(box.data, 2)); } if (mvexParts.length > 0) return this.createBox("mvex", this.concat(...mvexParts)); return null; }, buildUdta(metadata) { const encoder = new TextEncoder(); const buildDataBox = (value) => { if (!value) return null; const valueBytes = encoder.encode(value); const payload = new Uint8Array(8 + valueBytes.length); const view = new DataView(payload.buffer); view.setUint32(0, 1); view.setUint32(4, 0); payload.set(valueBytes, 8); return this.createBox("data", payload); }; const buildMetaTag = (tag, value) => { if (!value) return new Uint8Array(0); const dataBox = buildDataBox(value); if (!dataBox) return new Uint8Array(0); return this.createBox(tag, dataBox); }; const commentText = metadata.description ? metadata.description + "\n\n" + LEARNING_DISCLAIMER : LEARNING_DISCLAIMER; const ilstContent = this.concat(buildMetaTag("©nam", metadata.title), buildMetaTag("©ART", metadata.author), buildMetaTag("©alb", "Bilibili"), buildMetaTag("©day", new Date().getFullYear().toString()), buildMetaTag("©cmt", commentText), buildMetaTag("©too", "Bilibili Video Downloader")); const ilstBox = this.createBox("ilst", ilstContent); const hdlrContent = new Uint8Array(24); const hdlrView = new DataView(hdlrContent.buffer); hdlrView.setUint32(0, 0); hdlrView.setUint32(4, 0); hdlrContent.set([ 109, 100, 105, 114 ], 8); hdlrContent.set([ 97, 112, 112, 108 ], 12); hdlrView.setUint32(16, 0); hdlrView.setUint32(20, 0); const hdlrBox = this.createBox("hdlr", hdlrContent); const metaContent = this.concat(new Uint8Array([ 0, 0, 0, 0 ]), hdlrBox, ilstBox); return this.createBox("udta", this.createBox("meta", metaContent)); }, buildMoov(videoMoov, audioMoov, metadata) { const videoMoovBoxes = this.parseContainerBox(videoMoov.data); const audioMoovBoxes = this.parseContainerBox(audioMoov.data); const mvhd = this.findBox(videoMoovBoxes, "mvhd"); if (!mvhd) throw new Error("找不到mvhd box"); const videoTrak = videoMoovBoxes.find((b) => b.type === "trak" && this.getTrackType(b.data) === "vide") || null; const audioTrak = audioMoovBoxes.find((b) => b.type === "trak" && this.getTrackType(b.data) === "soun") || null; if (!videoTrak) throw new Error("找不到视频轨道"); const videoTrakData = this.modifyTrackId(videoTrak.data, 1); const audioTrakData = audioTrak ? this.modifyTrackId(audioTrak.data, 2) : null; const mvhdData = new Uint8Array(mvhd.data); const nextTrackIdOffset = mvhdData[8] === 0 ? 104 : 116; new DataView(mvhdData.buffer, mvhdData.byteOffset + nextTrackIdOffset - 4).setUint32(0, audioTrakData ? 3 : 2); const videoMvex = this.findBox(videoMoovBoxes, "mvex"); const audioMvex = this.findBox(audioMoovBoxes, "mvex"); const mvexData = videoMvex || audioMvex ? this.buildMvex(videoMvex, audioMvex) : null; const udtaContent = this.buildUdta(metadata); const moovParts = [mvhdData, videoTrakData]; if (audioTrakData) moovParts.push(audioTrakData); if (mvexData) moovParts.push(mvexData); moovParts.push(udtaContent); return this.createBox("moov", this.concat(...moovParts)); }, modifyMoofTrackId(moofData, newId) { const result = new Uint8Array(moofData); for (const box of this.parseContainerBox(result)) if (box.type === "traf") { for (const trafBox of this.parseContainerBox(box.data)) if (trafBox.type === "tfhd") new DataView(result.buffer, result.byteOffset + box.offset + trafBox.offset + 12).setUint32(0, newId); } return result; }, merge(videoBuffer, audioBuffer, metadata) { return new Promise((resolve, reject) => { try { const videoBoxes = this.parseBoxes(videoBuffer); const audioBoxes = this.parseBoxes(audioBuffer); const videoFtyp = this.findBox(videoBoxes, "ftyp"); const videoMoov = this.findBox(videoBoxes, "moov"); const videoMdat = this.findAllBoxes(videoBoxes, "mdat"); const videoMoof = this.findAllBoxes(videoBoxes, "moof"); const audioMoov = this.findBox(audioBoxes, "moov"); const audioMdat = this.findAllBoxes(audioBoxes, "mdat"); const audioMoof = this.findAllBoxes(audioBoxes, "moof"); if (!videoFtyp || !videoMoov) throw new Error("视频文件结构不完整"); if (videoMoof.length > 0 || audioMoof.length > 0) { const parts = [videoFtyp.data]; parts.push(audioMoov ? this.buildMoov(videoMoov, audioMoov, metadata) : videoMoov.data); for (let i = 0; i < videoMoof.length; i++) { parts.push(videoMoof[i].data); if (videoMdat[i]) parts.push(videoMdat[i].data); } for (let j = 0; j < audioMoof.length; j++) { parts.push(this.modifyMoofTrackId(audioMoof[j].data, 2)); if (audioMdat[j]) parts.push(audioMdat[j].data); } resolve(this.toArrayBuffer(this.concat(...parts))); } else { const mergedMoov = audioMoov ? this.buildMoov(videoMoov, audioMoov, metadata) : videoMoov.data; const allMdat = [...videoMdat, ...audioMdat].map((m) => m.data.slice(8)); const mdatContent = this.concat(...allMdat); const mergedMdat = this.createBox("mdat", mdatContent); resolve(this.toArrayBuffer(this.concat(videoFtyp.data, mergedMoov, mergedMdat))); } } catch (error) { console.error("JS合并失败:", error); reject(error); } }); } }; var FFMPEG_CORE_JS = "https://unpkg.com/@ffmpeg/core-st@0.11.1/dist/ffmpeg-core.js"; var FFMPEG_CORE_WASM = "https://unpkg.com/@ffmpeg/core-st@0.11.1/dist/ffmpeg-core.wasm"; var FFMPEG_LOADER_JS = "https://cdnjs.cloudflare.com/ajax/libs/ffmpeg/0.11.6/ffmpeg.min.js"; function getFFmpegGlobal() { if (typeof FFmpeg !== "undefined") return FFmpeg; if (typeof unsafeWindow !== "undefined" && unsafeWindow.FFmpeg) { globalThis.FFmpeg = unsafeWindow.FFmpeg; return unsafeWindow.FFmpeg; } return null; } var loaderPromise = null; function loadFFmpegLoader() { if (getFFmpegGlobal()) return Promise.resolve(); if (loaderPromise) return loaderPromise; Diagnostics.info("ffmpeg", "开始加载 FFmpeg (0.11.6 loader / core-st)"); loaderPromise = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = FFMPEG_LOADER_JS; script.async = true; script.onload = () => { if (getFFmpegGlobal()) { Diagnostics.info("ffmpeg", "FFmpeg 脚本加载完成"); resolve(); } else { Diagnostics.error("ffmpeg", "FFmpeg 脚本已加载但全局对象不可用"); reject(new Error("FFmpeg 加载后不可用")); } }; script.onerror = () => { Diagnostics.error("ffmpeg", "FFmpeg 脚本加载失败(网络或 CDN 被拦截)"); reject(new Error("FFmpeg 加载失败")); }; (document.head || document.documentElement).appendChild(script); }).catch((err) => { loaderPromise = null; throw err; }); return loaderPromise; } function fetchAsBlobUrl(url, mime) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("GM_xmlhttpRequest 不可用")); return; } GM_xmlhttpRequest({ method: "GET", url, responseType: "blob", onload(res) { if (res.status >= 200 && res.status < 300) { const blob = res.response instanceof Blob ? res.response : new Blob([res.response], { type: mime }); const typed = blob.type ? blob : new Blob([blob], { type: mime }); resolve(URL.createObjectURL(typed)); } else reject(new Error(`HTTP ${res.status} @ ${url}`)); }, onerror() { reject(new Error(`网络错误 @ ${url}`)); }, ontimeout() { reject(new Error(`超时 @ ${url}`)); } }); }); } var corePathPromise = null; function prepareCorePath() { if (corePathPromise) return corePathPromise; corePathPromise = Promise.all([fetchAsBlobUrl(FFMPEG_CORE_WASM, "application/wasm")]).then(([wasmBlobUrl]) => { return fetchAsBlobUrl(FFMPEG_CORE_JS, "text/javascript").then((coreJsBlobUrl) => { return fetch(coreJsBlobUrl).then((r) => r.text()).then((coreJsText) => { URL.revokeObjectURL(coreJsBlobUrl); const patched = coreJsText.replace(/ffmpeg-core\.wasm/g, wasmBlobUrl); const patchedBlob = new Blob([patched], { type: "text/javascript" }); return URL.createObjectURL(patchedBlob); }); }); }).catch((err) => { corePathPromise = null; throw err; }); return corePathPromise; } function createInstance() { return loadFFmpegLoader().then(() => prepareCorePath()).then((corePath) => { const ns = getFFmpegGlobal(); if (!ns) throw new Error("FFmpeg未加载"); const instance = ns.createFFmpeg({ log: false, corePath, mainName: "main" }); return instance.load().then(() => instance); }); } function runOnce(owner, inputs, argv, outFile) { const prepared = owner.ffmpeg && owner.ffmpeg.isLoaded() ? Promise.resolve(owner.ffmpeg) : createInstance(); owner.ffmpeg = null; return prepared.then((instance) => { inputs.forEach((file) => instance.FS("writeFile", file.name, file.data)); return instance.run(...argv).catch((e) => { if (e && e.name === "ExitStatus" && e.status === 0) return; throw e; }).then(() => instance.FS("readFile", outFile)); }); } var FFmpegMerger = { name: "FFmpeg合并", status: "loading", unavailableReason: "", ffmpeg: null, isEnvSupported() { return true; }, getUnavailableReason() { return this.unavailableReason; }, init() { if (this.ffmpeg && this.ffmpeg.isLoaded()) { this.status = "ready"; return Promise.resolve(true); } this.status = "loading"; return createInstance().then((instance) => { this.ffmpeg = instance; this.status = "ready"; return true; }).catch((e) => { this.status = "error"; this.unavailableReason = e?.message || String(e); throw e; }); }, merge(videoBuffer, audioBuffer, metadata) { return this.init().then(() => runOnce(this, [{ name: "video.mp4", data: new Uint8Array(videoBuffer) }, { name: "audio.m4a", data: new Uint8Array(audioBuffer) }], [ "-i", "video.mp4", "-i", "audio.m4a", "-c", "copy", "-map", "0:v:0", "-map", "1:a:0", "-metadata", "title=" + (metadata.title || ""), "-metadata", "artist=" + (metadata.author || ""), "-metadata", "comment=" + LEARNING_DISCLAIMER, "output.mp4" ], "output.mp4")).then((data) => data.buffer); } }; var GifConverter = { convert(source, options = {}) { const scale = `fps=${options.fps ?? 15},scale=min(${options.maxWidth ?? 640}\\,iw):-1:flags=lanczos`; const input = new Uint8Array(source); return FFmpegMerger.init().then(() => runOnce(FFmpegMerger, [{ name: "in.mp4", data: input }], [ "-i", "in.mp4", "-vf", `${scale},palettegen=max_colors=256`, "pal.png" ], "pal.png")).then((palette) => runOnce(FFmpegMerger, [{ name: "in.mp4", data: input }, { name: "pal.png", data: palette }], [ "-i", "in.mp4", "-i", "pal.png", "-filter_complex", `${scale}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3`, "-loop", "0", "out.gif" ], "out.gif")).then((data) => data.buffer); } }; var MergeManager = { currentMethod: CONFIG.MERGE_METHODS.JSMERGE, methods: { "js-merge": { name: "JS原生合并", desc: "浏览器内直接合并,兼容性好", handler: JSMerger, recommended: true }, "ffmpeg-merge": { name: "FFmpeg合并", desc: "使用FFmpeg进行专业合并", handler: FFmpegMerger, recommended: false }, "separate": { name: "分离下载", desc: "分别保存视频和音频文件", handler: null, recommended: false } }, setMethod(method) { this.currentMethod = method; }, getMethodStatus(method) { if (method === CONFIG.MERGE_METHODS.SEPARATE) return "ready"; const handler = this.methods[method]?.handler; return handler ? handler.status : "unavailable"; }, merge(videoBuffer, audioBuffer, metadata) { const method = this.methods[this.currentMethod]; if (this.currentMethod === CONFIG.MERGE_METHODS.SEPARATE) return Promise.resolve({ separate: true, video: videoBuffer, audio: audioBuffer }); if (!method.handler) return Promise.reject(new Error("未找到合并处理器")); return method.handler.merge(videoBuffer, audioBuffer, metadata).then((result) => ({ separate: false, data: result })).catch((error) => { console.error(method.name + " 合并失败:", error); throw error; }); } }; var PALETTE_SIZE = 256; var HIST_SIZE = 32768; var PALETTE_SAMPLES = 24; var MAX_FRAMES = 240; var SEEK_TIMEOUT = 8e3; var BAYER_4X4 = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 ]; var DITHER_STRENGTH = 18; var ByteWriter = class { buffer = new Uint8Array(65536); length = 0; ensure(extra) { if (this.length + extra <= this.buffer.length) return; let size = this.buffer.length * 2; while (size < this.length + extra) size *= 2; const grown = new Uint8Array(size); grown.set(this.buffer.subarray(0, this.length)); this.buffer = grown; } byte(value) { this.ensure(1); this.buffer[this.length++] = value & 255; } short(value) { this.byte(value); this.byte(value >> 8); } bytes(values) { this.ensure(values.length); this.buffer.set(values, this.length); this.length += values.length; } ascii(text) { for (let i = 0; i < text.length; i++) this.byte(text.charCodeAt(i)); } toUint8Array() { return this.buffer.slice(0, this.length); } }; var LZW_MAX_CODES = 4096; function writeLzw(out, indices, minCodeSize) { const clearCode = 1 << minCodeSize; const endCode = clearCode + 1; const dict = new Int32Array(LZW_MAX_CODES * 256); let codeSize = minCodeSize + 1; let nextCode = endCode + 1; const block = []; let bitBuffer = 0; let bitCount = 0; const flushBlock = () => { if (!block.length) return; out.byte(block.length); out.bytes(block); block.length = 0; }; const emit = (code) => { bitBuffer |= code << bitCount; bitCount += codeSize; while (bitCount >= 8) { block.push(bitBuffer & 255); bitBuffer >>= 8; bitCount -= 8; if (block.length === 255) flushBlock(); } }; out.byte(minCodeSize); emit(clearCode); let prefix = indices[0]; for (let i = 1; i < indices.length; i++) { const next = indices[i]; const key = prefix * 256 + next; const found = dict[key]; if (found) { prefix = found - 1; continue; } emit(prefix); if (nextCode < LZW_MAX_CODES) { dict[key] = nextCode + 1; nextCode++; if (nextCode === (1 << codeSize) + 1 && codeSize < 12) codeSize++; } else { emit(clearCode); dict.fill(0); codeSize = minCodeSize + 1; nextCode = endCode + 1; } prefix = next; } emit(prefix); emit(endCode); if (bitCount > 0) { block.push(bitBuffer & 255); if (block.length === 255) flushBlock(); } flushBlock(); out.byte(0); } function expand5(value) { return value << 3 | value >> 2; } function keyToChannels(key) { return [ key >> 10 & 31, key >> 5 & 31, key & 31 ]; } function buildPalette(histogram) { const keys = []; let total = 0; for (let key = 0; key < HIST_SIZE; key++) { const count = histogram[key]; if (!count) continue; keys.push(key); total += count; } const boxes = [{ keys, count: total }]; while (boxes.length < PALETTE_SIZE) { let target = -1; let best = 0; for (let i = 0; i < boxes.length; i++) if (boxes[i].keys.length > 1 && boxes[i].count > best) { best = boxes[i].count; target = i; } if (target < 0) break; const parts = splitBox(boxes[target], histogram); if (!parts) break; boxes.splice(target, 1, parts[0], parts[1]); } const palette = new Uint8Array(PALETTE_SIZE * 3); for (let i = 0; i < boxes.length; i++) { const [r, g, b] = averageColor(boxes[i], histogram); palette[i * 3] = r; palette[i * 3 + 1] = g; palette[i * 3 + 2] = b; } for (let i = boxes.length; i < PALETTE_SIZE; i++) { palette[i * 3] = palette[0]; palette[i * 3 + 1] = palette[1]; palette[i * 3 + 2] = palette[2]; } return palette; } function splitBox(box, histogram) { const min = [ 31, 31, 31 ]; const max = [ 0, 0, 0 ]; for (const key of box.keys) { const channels = keyToChannels(key); for (let c = 0; c < 3; c++) { if (channels[c] < min[c]) min[c] = channels[c]; if (channels[c] > max[c]) max[c] = channels[c]; } } let axis = 0; for (let c = 1; c < 3; c++) if (max[c] - min[c] > max[axis] - min[axis]) axis = c; if (max[axis] === min[axis]) return null; const shift = axis === 0 ? 10 : axis === 1 ? 5 : 0; const sorted = box.keys.slice().sort((a, b) => (a >> shift & 31) - (b >> shift & 31)); const half = box.count / 2; let taken = 0; let cut = 0; while (cut < sorted.length - 1 && taken < half) { taken += histogram[sorted[cut]]; cut++; } const left = sorted.slice(0, cut); const right = sorted.slice(cut); if (!left.length || !right.length) return null; return [{ keys: left, count: taken }, { keys: right, count: box.count - taken }]; } function averageColor(box, histogram) { let r = 0; let g = 0; let b = 0; let count = 0; for (const key of box.keys) { const weight = histogram[key]; const channels = keyToChannels(key); r += expand5(channels[0]) * weight; g += expand5(channels[1]) * weight; b += expand5(channels[2]) * weight; count += weight; } if (!count) return [ 0, 0, 0 ]; return [ Math.round(r / count), Math.round(g / count), Math.round(b / count) ]; } function mapToIndices(rgba, width, palette, cache) { const pixels = rgba.length >> 2; const indices = new Uint8Array(pixels); for (let i = 0; i < pixels; i++) { const x = i % width; const bias = (BAYER_4X4[((i - x) / width & 3) * 4 + (x & 3)] / 15 - .5) * DITHER_STRENGTH; const r = clamp255(rgba[i * 4] + bias); const g = clamp255(rgba[i * 4 + 1] + bias); const b = clamp255(rgba[i * 4 + 2] + bias); const key = r >> 3 << 10 | g >> 3 << 5 | b >> 3; let index = cache[key]; if (index < 0) { index = nearestColor(palette, r, g, b); cache[key] = index; } indices[i] = index; } return indices; } function clamp255(value) { if (value <= 0) return 0; if (value >= 255) return 255; return value | 0; } function nearestColor(palette, r, g, b) { let best = 0; let bestDistance = Infinity; for (let i = 0; i < PALETTE_SIZE; i++) { const dr = r - palette[i * 3]; const dg = g - palette[i * 3 + 1]; const db = b - palette[i * 3 + 2]; const distance = dr * dr + dg * dg + db * db; if (distance < bestDistance) { bestDistance = distance; best = i; } } return best; } function writeHeader(out, width, height, palette) { out.ascii("GIF89a"); out.short(width); out.short(height); out.byte(247); out.byte(0); out.byte(0); out.bytes(palette); out.byte(33); out.byte(255); out.byte(11); out.ascii("NETSCAPE2.0"); out.byte(3); out.byte(1); out.short(0); out.byte(0); } function writeFrame(out, indices, width, height, delay) { out.byte(33); out.byte(249); out.byte(4); out.byte(4); out.short(delay); out.byte(0); out.byte(0); out.byte(44); out.short(0); out.short(0); out.short(width); out.short(height); out.byte(0); writeLzw(out, indices, 8); } function loadVideo(source) { const url = URL.createObjectURL(new Blob([source], { type: "video/mp4" })); const video = document.createElement("video"); video.muted = true; video.playsInline = true; video.preload = "auto"; video.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none;"; const release = () => { video.removeAttribute("src"); video.load(); video.remove(); URL.revokeObjectURL(url); }; return new Promise((resolve, reject) => { const timer = setTimeout(() => { release(); reject(new Error("视频解码超时")); }, SEEK_TIMEOUT); video.addEventListener("loadeddata", () => { clearTimeout(timer); resolve({ video, release }); }, { once: true }); video.addEventListener("error", () => { clearTimeout(timer); release(); reject(new Error("浏览器无法解码该视频")); }, { once: true }); video.src = url; (document.body || document.documentElement).appendChild(video); }); } function seekTo(video, time) { if (Math.abs(video.currentTime - time) < .001 && video.readyState >= 2) return Promise.resolve(); return new Promise((resolve, reject) => { const cleanup = () => { clearTimeout(timer); video.removeEventListener("seeked", done); }; const done = () => { cleanup(); resolve(); }; const timer = setTimeout(() => { cleanup(); reject(new Error("取帧超时")); }, SEEK_TIMEOUT); video.addEventListener("seeked", done); video.currentTime = time; }); } var JsGifEncoder = { name: "JS原生转码", isEnvSupported() { return typeof document !== "undefined" && typeof HTMLCanvasElement !== "undefined"; }, async convert(source, options = {}) { const fps = Math.min(30, Math.max(2, options.fps ?? 12)); const delay = Math.max(2, Math.round(100 / fps)); const step = delay / 100; const maxWidth = options.maxWidth ?? 480; const report = options.onProgress ?? (() => {}); const { video, release } = await loadVideo(source); try { const duration = video.duration; if (!isFinite(duration) || duration <= 0) throw new Error("无法读取视频时长"); const sourceWidth = video.videoWidth; const sourceHeight = video.videoHeight; if (!sourceWidth || !sourceHeight) throw new Error("无法读取视频尺寸"); const width = Math.max(1, Math.min(maxWidth, sourceWidth)); const height = Math.max(1, Math.round(sourceHeight * width / sourceWidth)); const frameCount = Math.max(1, Math.min(MAX_FRAMES, Math.round(duration / step))); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) throw new Error("无法创建画布上下文"); const grab = async (index) => { await seekTo(video, Math.min(index * step, Math.max(0, duration - .001))); ctx.drawImage(video, 0, 0, width, height); return ctx.getImageData(0, 0, width, height).data; }; const histogram = new Uint32Array(HIST_SIZE); const samples = Math.min(PALETTE_SAMPLES, frameCount); for (let i = 0; i < samples; i++) { const rgba = await grab(samples === 1 ? 0 : Math.round(i * (frameCount - 1) / (samples - 1))); for (let p = 0; p < rgba.length; p += 4) histogram[rgba[p] >> 3 << 10 | rgba[p + 1] >> 3 << 5 | rgba[p + 2] >> 3]++; report(.25 * (i + 1) / samples); } const palette = buildPalette(histogram); const cache = new Int16Array(HIST_SIZE).fill(-1); const out = new ByteWriter(); writeHeader(out, width, height, palette); for (let frame = 0; frame < frameCount; frame++) { writeFrame(out, mapToIndices(await grab(frame), width, palette, cache), width, height, delay); report(.25 + .75 * (frame + 1) / frameCount); } out.byte(59); return out.toUint8Array().buffer; } finally { release(); } } }; var GifManager = { currentMethod: CONFIG.GIF_METHODS.JS, methods: { "js-gif": { name: "JS 原生转码", desc: "浏览器内直接转码,无需下载 FFmpeg", convert: (source, onProgress) => JsGifEncoder.convert(source, { onProgress }) }, "ffmpeg-gif": { name: "FFmpeg 转码", desc: "调色板更准,需先加载 FFmpeg 核心", convert: (source) => GifConverter.convert(source) } }, setMethod(method) { this.currentMethod = method; }, convert(source, method, onProgress) { const entry = this.methods[method ?? this.currentMethod]; if (!entry) return Promise.reject(new Error("未找到 GIF 转码处理器")); return entry.convert(source, onProgress); } }; var CompleteEffect = { show(root, progressCircle) { const overlay = document.createElement("div"); overlay.className = "bdl-complete-overlay"; setHTML(overlay, "
✨ 下载完成 ✨
"); (root || document.body).appendChild(overlay); this.createParticles(overlay.querySelector(".bdl-complete-particles")); this.createSparkles(overlay.querySelector(".bdl-complete-sparkles")); this.addBubbles(progressCircle); setTimeout(() => { overlay.style.animation = "bdlFadeOut 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards"; setTimeout(() => { overlay.parentNode?.removeChild(overlay); }, 500); }, 2500); }, createParticles(container) { const tints = [ "", "is-light", "is-spark" ]; for (let i = 0; i < 30; i++) { const particle = document.createElement("div"); particle.className = ("bdl-particle " + tints[i % tints.length]).trim(); const angle = Math.PI * 2 * i / 30; const velocity = 120 + Math.random() * 80; const size = 6 + Math.random() * 10; particle.style.width = size + "px"; particle.style.height = size + "px"; particle.style.setProperty("--tx", Math.cos(angle) * velocity + "px"); particle.style.setProperty("--ty", Math.sin(angle) * velocity + "px"); particle.style.animationDelay = Math.random() * .3 + "s"; container.appendChild(particle); } }, createSparkles(container) { for (const pos of [ { top: "10%", left: "15%", delay: .2, duration: 1.5 }, { top: "20%", right: "20%", delay: .4, duration: 1.8 }, { top: "40%", left: "10%", delay: .6, duration: 1.6 }, { top: "60%", right: "15%", delay: .3, duration: 1.7 }, { bottom: "30%", left: "25%", delay: .5, duration: 1.9 }, { bottom: "20%", right: "25%", delay: .7, duration: 1.4 }, { top: "30%", left: "50%", delay: .1, duration: 2 }, { bottom: "40%", right: "50%", delay: .8, duration: 1.3 } ]) { const sparkle = document.createElement("div"); sparkle.className = "bdl-sparkle"; if (pos.top) sparkle.style.top = pos.top; if (pos.bottom) sparkle.style.bottom = pos.bottom; if (pos.left) sparkle.style.left = pos.left; if (pos.right) sparkle.style.right = pos.right; sparkle.style.animationDelay = pos.delay + "s"; sparkle.style.animationDuration = pos.duration + "s"; container.appendChild(sparkle); } }, addBubbles(progressCircle) { const circleProgress = progressCircle || document.getElementById("bdl-progress-circle"); if (!circleProgress) return; for (let i = 1; i <= 3; i++) { const bubble = document.createElement("div"); bubble.className = "bdl-progress-bubble"; circleProgress.appendChild(bubble); } } }; var ZIP32_LIMIT = 4294967295; var crcTable = null; function getCrcTable() { if (crcTable) return crcTable; const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1; table[i] = c >>> 0; } crcTable = table; return table; } function crc32(data) { const table = getCrcTable(); let c = 4294967295; for (let i = 0; i < data.length; i++) c = table[(c ^ data[i]) & 255] ^ c >>> 8; return (c ^ 4294967295) >>> 0; } function dosDateTime(date) { const year = Math.max(1980, date.getFullYear()); return { time: date.getHours() << 11 | date.getMinutes() << 5 | date.getSeconds() >> 1, date: year - 1980 << 9 | date.getMonth() + 1 << 5 | date.getDate() }; } function normalizeName(name) { return name.replace(/\\/g, "/").split("/").filter((seg) => seg && seg !== "." && seg !== "..").join("/") || "file"; } function dedupe(names) { const seen = new Map(); return names.map((name) => { const lower = name.toLowerCase(); const hit = seen.get(lower); if (hit === void 0) { seen.set(lower, 1); return name; } seen.set(lower, hit + 1); const dot = name.lastIndexOf("."); return dot > 0 ? `${name.slice(0, dot)}_${hit + 1}${name.slice(dot)}` : `${name}_${hit + 1}`; }); } function localHeader(record, dos) { const buf = new Uint8Array(30 + record.nameBytes.length); const view = new DataView(buf.buffer); view.setUint32(0, 67324752, true); view.setUint16(4, 20, true); view.setUint16(6, 2048, true); view.setUint16(8, 0, true); view.setUint16(10, dos.time, true); view.setUint16(12, dos.date, true); view.setUint32(14, record.crc, true); view.setUint32(18, record.size, true); view.setUint32(22, record.size, true); view.setUint16(26, record.nameBytes.length, true); view.setUint16(28, 0, true); buf.set(record.nameBytes, 30); return buf; } function centralHeader(record, dos) { const buf = new Uint8Array(46 + record.nameBytes.length); const view = new DataView(buf.buffer); view.setUint32(0, 33639248, true); view.setUint16(4, 20, true); view.setUint16(6, 20, true); view.setUint16(8, 2048, true); view.setUint16(10, 0, true); view.setUint16(12, dos.time, true); view.setUint16(14, dos.date, true); view.setUint32(16, record.crc, true); view.setUint32(20, record.size, true); view.setUint32(24, record.size, true); view.setUint16(28, record.nameBytes.length, true); view.setUint16(30, 0, true); view.setUint16(32, 0, true); view.setUint16(34, 0, true); view.setUint16(36, 0, true); view.setUint32(38, 0, true); view.setUint32(42, record.offset, true); buf.set(record.nameBytes, 46); return buf; } function endOfCentralDirectory(count, cdSize, cdOffset) { const buf = new Uint8Array(22); const view = new DataView(buf.buffer); view.setUint32(0, 101010256, true); view.setUint16(4, 0, true); view.setUint16(6, 0, true); view.setUint16(8, count, true); view.setUint16(10, count, true); view.setUint32(12, cdSize, true); view.setUint32(16, cdOffset, true); view.setUint16(20, 0, true); return buf; } function createZip(entries, modified = new Date()) { const dos = dosDateTime(modified); const encoder = new TextEncoder(); const names = dedupe(entries.map((entry) => normalizeName(entry.name))); const parts = []; const records = []; let offset = 0; entries.forEach((entry, index) => { if (entry.data.length > ZIP32_LIMIT) throw new Error(`${names[index]} 超过 4GB,无法打包`); const record = { nameBytes: encoder.encode(names[index]), crc: crc32(entry.data), size: entry.data.length, offset }; const header = localHeader(record, dos); parts.push(header, entry.data); offset += header.length + record.size; if (offset > ZIP32_LIMIT) throw new Error("打包体积超过 4GB,请减少选中项"); records.push(record); }); const cdOffset = offset; let cdSize = 0; for (const record of records) { const header = centralHeader(record, dos); parts.push(header); cdSize += header.length; } parts.push(endOfCentralDirectory(records.length, cdSize, cdOffset)); return new Blob(parts, { type: "application/zip" }); } var Downloader = { isDownloading: false, videoInfo: null, shortVideoInfo: null, shortVideoItems: [], selectedShortVideoIndex: 0, shortItemsAreAlternatives: false, shortSelectedItems: new Set(), shortSelectedAssets: new Map(), shortVideoPageUrl: null, shortVideoQueue: [], activeShortVideoTask: null, playData: null, selectedQuality: 80, selectedPages: [], selectedUGCEpisodes: [], videoType: "video", selectedVideoCodec: null, selectedAudioCodec: null, availableSubtitles: [], hasDanmaku: false, coverUrl: null, currentPlatform: null, gifMethod: "video", isShortVideoMode() { return Utils.getSiteContext().kind === "short-video"; }, refreshInfo(options = {}) { const siteContext = Utils.getSiteContext(); if (siteContext.kind === "bilibili") return this.refreshBilibiliInfo(); if (siteContext.kind === "short-video") return this.refreshShortVideoInfo(siteContext.platform, options).then(() => void 0); UI.showAlert("当前页面暂不支持", "warning"); return Promise.resolve(); }, refreshBilibiliInfo() { const videoId = Utils.getVideoId(); if (!videoId) { UI.showAlert("无法识别视频ID", "error"); return Promise.resolve(); } this.shortVideoInfo = null; this.shortVideoItems = []; this.selectedShortVideoIndex = 0; this.initShortVideoSelection(); this.shortVideoPageUrl = null; this.currentPlatform = null; this.videoType = videoId.type; return BiliAPI.getUserInfo().then(() => { return (videoId.type === "bangumi" ? BiliAPI.getBangumiInfo(videoId.id) : BiliAPI.getVideoInfo(videoId.id)).then((videoInfo) => { this.videoInfo = videoInfo; this.coverUrl = videoInfo.pic || videoInfo.cover || null; return (videoId.type === "video" ? BiliAPI.getUGCSeasonInfo(videoId.id) : Promise.resolve({ hasUGC: false })).then((ugcInfo) => { const page = videoInfo.currentPage || Utils.getCurrentPage(); const pageInfo = videoInfo.pages[page - 1]; if (!pageInfo) { UI.showAlert("无法获取分P信息", "error"); return; } const playParams = { type: videoId.type, cid: pageInfo.cid, qn: 127 }; if (videoId.type === "bangumi") playParams.ep_id = pageInfo.ep_id; else playParams.bvid = videoId.id; return Promise.all([BiliAPI.getPlayUrl(playParams), BiliAPI.getSubtitles(videoId.id, pageInfo.cid)]).then(([playData, subtitles]) => { this.playData = playData; this.availableSubtitles = subtitles; this.hasDanmaku = true; const qualities = BiliAPI.getAvailableQualities(playData); const videoCodecs = BiliAPI.getVideoCodecs(playData); const audioCodecs = BiliAPI.getAudioCodecs(playData); UI.updateVideoInfo(videoInfo, pageInfo, BiliAPI.userVipType); UI.updateQualities(qualities, playData.quality ?? this.selectedQuality, (qn) => { this.selectedQuality = qn; }); UI.updateCodecSelectors(videoCodecs, audioCodecs); if (videoCodecs.length > 0) this.selectedVideoCodec = videoCodecs[0].type; if (audioCodecs.length > 0) this.selectedAudioCodec = audioCodecs[0].id; if (videoInfo.pages.length > 1) { UI.preparePagesSection(videoInfo.pages, page - 1, () => this.updateSelectedPages()); this.selectedPages = [page - 1]; } else { UI.hidePagesSection(); this.selectedPages = [0]; } if (ugcInfo.hasUGC && ugcInfo.episodes) { this.videoInfo.ugcEpisodes = ugcInfo.episodes; UI.prepareUGCSection(ugcInfo.episodes, () => this.updateSelectedUGC(), pageInfo.cid); this.selectedUGCEpisodes = []; } else UI.hideUGCSection(); UI.updateExtraDownloads(subtitles.length > 0, this.hasDanmaku, Boolean(this.coverUrl), () => this.downloadCover(), () => this.downloadSubtitles(), () => this.downloadDanmaku()); if (qualities.length > 0) { for (const quality of qualities) if (quality.available) { this.selectedQuality = quality.qn; break; } } UI.positionPopup(); }); }); }); }).catch((error) => { console.error("获取视频信息失败:", error); Diagnostics.error("downloader", "获取视频信息失败", error); UI.showAlert("获取视频信息失败: " + error.message, "error"); UI.positionPopup(); }); }, refreshShortVideoInfo(platform, options = {}) { this.videoInfo = null; this.playData = null; this.availableSubtitles = []; this.hasDanmaku = false; this.currentPlatform = platform; this.coverUrl = null; return ShortVideoAPI.parseUrl(location.href, platform).then((data) => { this.shortVideoItems = data.items?.length ? data.items : [data]; this.shortItemsAreAlternatives = !!data.alternativeSources && this.shortVideoItems.length > 1; this.selectedShortVideoIndex = 0; this.initShortVideoSelection(); this.shortVideoInfo = this.shortVideoItems[0] || data; this.shortVideoPageUrl = location.href; this.coverUrl = this.shortVideoInfo.cover || this.shortVideoInfo.music?.cover || null; this.renderShortVideoInfo(platform); UI.hideAlert(); UI.positionPopup(); return true; }).catch((error) => { if (options.silent) { console.warn("短视频解析跳过:", error); Diagnostics.debug("short-video", `解析跳过 (${platform})`, error); } else { console.error("短视频解析失败:", error); Diagnostics.error("short-video", `解析失败 (${platform})`, error); } this.shortVideoInfo = null; this.shortVideoItems = []; this.shortItemsAreAlternatives = false; this.selectedShortVideoIndex = 0; this.initShortVideoSelection(); this.shortVideoPageUrl = null; UI.setShortVideoMode(platform); UI.setExtraActions([]); if (!options.silent) UI.showAlert("解析失败: " + error.message, "error"); else UI.hideAlert(); UI.positionPopup(); return false; }); }, renderShortVideoInfo(platform) { if (!this.shortVideoInfo) return; UI.updateShortVideoInfo(this.shortVideoInfo, platform); UI.prepareShortVideoItems(this.shortVideoItems, { previewIndex: this.selectedShortVideoIndex, selectedItems: this.shortSelectedItems, selectedAssets: this.shortSelectedAssets, onPreview: (index) => this.selectShortVideoItem(index), onToggleItem: (index, selected) => this.toggleShortItem(index, selected), onToggleAsset: (itemIndex, assetIndex, selected) => this.toggleShortAsset(itemIndex, assetIndex, selected), onSelectAll: (mode) => this.setShortSelection(mode) }); UI.setExtraActions(this.getShortVideoActions(this.shortVideoInfo, platform)); this.syncShortSelectionLabels(); }, syncGifFormatSection() { const hasAnimated = this.shortVideoItems.some((item, index) => !!item.animated && this.shortSelectedItems.has(index)); if (!hasAnimated) this.gifMethod = "video"; UI.setGifFormatVisible(hasAnimated); }, setGifMethod(output) { this.gifMethod = output; if (output !== "video") GifManager.setMethod(output); UI.setGifFormat(output); }, selectShortVideoItem(index) { if (!this.currentPlatform || !this.shortVideoItems[index]) return; this.selectedShortVideoIndex = index; this.shortVideoInfo = this.shortVideoItems[index]; this.coverUrl = this.shortVideoInfo.cover || this.shortVideoInfo.music?.cover || null; this.renderShortVideoInfo(this.currentPlatform); UI.hideAlert(); UI.positionPopup(); }, initShortVideoSelection() { this.selectAllShortItems(); if (this.shortItemsAreAlternatives) this.shortSelectedItems = new Set(this.shortVideoItems.length ? [0] : []); }, selectAllShortItems() { this.shortSelectedItems = new Set(this.shortVideoItems.map((_, index) => index)); this.shortSelectedAssets = new Map(); this.shortVideoItems.forEach((item, index) => { const total = this.getItemAssetCount(item); if (total > 0) this.shortSelectedAssets.set(index, new Set(Array.from({ length: total }, (_, i) => i))); }); }, getItemAssetCount(item) { if (item.type === "image") return item.images?.length || 0; if (item.type === "live") return item.live_photo?.length || 0; return 0; }, toggleShortItem(index, selected) { const item = this.shortVideoItems[index]; const total = item ? this.getItemAssetCount(item) : 0; if (selected) { this.shortSelectedItems.add(index); if (total > 0) this.shortSelectedAssets.set(index, new Set(Array.from({ length: total }, (_, i) => i))); } else { this.shortSelectedItems.delete(index); this.shortSelectedAssets.get(index)?.clear(); } this.syncShortSelectionLabels(); }, toggleShortAsset(itemIndex, assetIndex, selected) { let picked = this.shortSelectedAssets.get(itemIndex); if (!picked) { picked = new Set(); this.shortSelectedAssets.set(itemIndex, picked); } if (selected) { picked.add(assetIndex); this.shortSelectedItems.add(itemIndex); } else { picked.delete(assetIndex); if (picked.size === 0) this.shortSelectedItems.delete(itemIndex); } this.syncShortSelectionLabels(); }, setShortSelection(mode) { if (mode === "all") this.selectAllShortItems(); else if (mode === "none") { this.shortSelectedItems = new Set(); this.shortSelectedAssets = new Map(); } else { const nextItems = new Set(); const nextAssets = new Map(); this.shortVideoItems.forEach((item, index) => { const total = this.getItemAssetCount(item); if (total > 0) { const picked = this.shortSelectedAssets.get(index); const inverted = new Set(); for (let i = 0; i < total; i++) if (!picked?.has(i)) inverted.add(i); nextAssets.set(index, inverted); if (inverted.size > 0) nextItems.add(index); } else if (!this.shortSelectedItems.has(index)) nextItems.add(index); }); this.shortSelectedItems = nextItems; this.shortSelectedAssets = nextAssets; } if (this.currentPlatform) this.renderShortVideoInfo(this.currentPlatform); }, syncShortSelectionLabels() { this.syncGifFormatSection(); const count = this.buildMediaUnits().length; UI.updateShortSelectionSummary(count, this.shortVideoItems.length, CONFIG.ZIP_THRESHOLD); if (!this.isDownloading && this.shortVideoInfo) UI.setPrimaryButtonLabel(UI.getShortVideoPrimaryLabel(this.shortVideoInfo, count)); }, buildMediaUnits() { const multi = this.shortVideoItems.length > 1; const units = []; this.shortVideoItems.forEach((item, index) => { if (!this.shortSelectedItems.has(index)) return; const base = this.getItemBaseFilename(item); const scoped = multi ? `${base}_${String(index + 1).padStart(2, "0")}` : base; units.push(...this.buildItemUnits(item, scoped, this.shortSelectedAssets.get(index))); }); return units; }, getItemBaseFilename(item) { const platform = item.platform || this.currentPlatform; const fallback = platform ? ShortVideoAPI.getPlatformLabel(platform) : "媒体"; return Utils.getShortVideoFilename(item.title || item.desc || item.itemLabel || fallback, item.author?.name); }, buildItemUnits(item, base, picked) { if (item.type === "image" && item.images?.length) { const total = item.images.length; return item.images.map((url, assetIndex) => ({ url, assetIndex })).filter((entry) => entry.url && (!picked || picked.has(entry.assetIndex))).map((entry) => ({ candidates: [entry.url], filename: `${base}${total > 1 ? `_${String(entry.assetIndex + 1).padStart(2, "0")}` : ""}.${Utils.inferExtension(entry.url, "jpg")}`, label: `图片 ${entry.assetIndex + 1}` })); } if (item.type === "live" && item.live_photo?.length) { const units = []; item.live_photo.forEach((entry, assetIndex) => { if (picked && !picked.has(assetIndex)) return; const serial = String(assetIndex + 1).padStart(2, "0"); if (entry.image) units.push({ candidates: [entry.image], filename: `${base}_${serial}.${Utils.inferExtension(entry.image, "jpg")}`, label: `实况图 ${assetIndex + 1}` }); if (entry.video) units.push({ candidates: [entry.video], filename: `${base}_${serial}_live.${Utils.inferExtension(entry.video, "mp4")}`, label: `实况视频 ${assetIndex + 1}` }); }); return units; } const candidates = [item.url, ...item.video_backup || []].filter(Boolean); if (candidates.length > 0) { const convert = item.animated && this.gifMethod !== "video" ? this.gifMethod : null; return [{ candidates, filename: `${base}.${convert ? "gif" : Utils.inferExtension(candidates[0], "mp4")}`, label: item.animated ? "GIF" : "视频", ...convert ? { convert } : {} }]; } if (item.music?.url) return [{ candidates: [item.music.url], filename: `${base}_music.${Utils.inferExtension(item.music.url, "mp3")}`, label: "音频" }]; return []; }, getShortVideoActions(data, platform) { const actions = []; if (data.cover) actions.push({ marker: "封面", label: "下载封面", onClick: () => this.downloadCover() }); if (data.music?.url) actions.push({ marker: "音频", label: "下载音频", onClick: () => this.downloadShortVideoMusic() }); if (data.type !== "image" && data.images && data.images.length > 0) actions.push({ marker: "图片", label: `下载图片 (${data.images.length})`, onClick: () => this.downloadShortVideoImages() }); if (data.type !== "live" && data.live_photo && data.live_photo.length > 0) actions.push({ marker: "实况", label: `下载实况图 (${data.live_photo.length})`, onClick: () => this.downloadShortVideoLivePhotos() }); return actions; }, updateSelectedPages() { const checkboxes = UI.elements.pagesList.querySelectorAll(".bdl-page-checkbox"); this.selectedPages = []; checkboxes.forEach((cb) => { if (cb.checked) this.selectedPages.push(parseInt(cb.dataset.index)); }); }, updateSelectedUGC() { const checkboxes = UI.elements.ugcList.querySelectorAll(".bdl-page-checkbox"); this.selectedUGCEpisodes = []; checkboxes.forEach((cb) => { if (cb.checked) this.selectedUGCEpisodes.push(parseInt(cb.dataset.index)); }); }, start() { if (this.isShortVideoMode()) { this.startShortVideoDownload(); return; } if (this.isDownloading) return; this.startBilibiliDownload(); }, startBilibiliDownload() { if (this.selectedPages.length + this.selectedUGCEpisodes.length === 0) { UI.showAlert("请至少选择一个分P或合集视频", "warning"); return; } this.isDownloading = true; UI.setDownloading(true); UI.showProgress(true); UI.hideAlert(); const allTasks = []; for (const idx of this.selectedPages) allTasks.push({ type: "page", index: idx, data: this.videoInfo.pages[idx] }); for (const idx of this.selectedUGCEpisodes) allTasks.push({ type: "ugc", index: idx, data: this.videoInfo.ugcEpisodes[idx] }); const downloadNext = (index) => { if (index >= allTasks.length) { this.finishDownload("全部下载完成!"); return; } const task = allTasks[index]; if (allTasks.length > 1) { const name = task.type === "page" ? task.data.part : task.data.title; UI.showAlert("正在下载 " + (index + 1) + "/" + allTasks.length + ": " + (name || task.data.page), "info"); } this.downloadSinglePage(task.data).then(() => { if (index < allTasks.length - 1) Utils.delay(1e3).then(() => downloadNext(index + 1)); else downloadNext(index + 1); }).catch((error) => { console.error("下载失败:", error); Diagnostics.error("downloader", `下载失败 (task ${index + 1}/${allTasks.length}): ${error?.message ?? error}`, error); UI.showAlert("下载失败: " + error.message, "error"); this.resetDownloadingState(); UI.updateCircleProgress(0); setTimeout(() => UI.showProgress(false), 2e3); }); }; downloadNext(0); }, startShortVideoDownload() { const siteContext = Utils.getSiteContext(); const platform = siteContext.kind === "short-video" ? siteContext.platform : this.currentPlatform; if (this.isDownloading) { if (!platform) { UI.showAlert("当前页面暂不支持", "warning"); return; } UI.showAlert("正在解析并加入等待队列...", "info"); this.prepareShortVideoTask(platform).then((task) => { if (!task) return; this.enqueueShortVideoTask(task); }); return; } if (!this.shortVideoInfo || !platform) { if (!platform) { UI.showAlert("当前页面暂不支持", "warning"); return; } UI.setShortVideoMode(platform); UI.setExtraActions([]); UI.showAlert("正在解析当前页面...", "info"); this.refreshShortVideoInfo(platform, { silent: false }).then((success) => { if (success) UI.showAlert("解析完成,请确认内容后再点击下载", "success"); }); return; } const task = this.createShortVideoTask(platform, this.shortVideoPageUrl || location.href); if (!task) { UI.showAlert("没有选中可下载的内容", "warning"); return; } this.runShortVideoTask(task); }, prepareShortVideoTask(platform) { if (this.shortVideoInfo) return Promise.resolve(this.createShortVideoTask(platform, this.shortVideoPageUrl || location.href)); return this.refreshShortVideoInfo(platform, { silent: false }).then((success) => { if (!success || !this.shortVideoInfo) return null; return this.createShortVideoTask(platform, this.shortVideoPageUrl || location.href); }); }, createShortVideoTask(platform, pageUrl) { const units = this.buildMediaUnits(); if (units.length === 0) return null; const primary = this.shortVideoItems[this.selectedShortVideoIndex] || this.shortVideoItems[0] || this.shortVideoInfo; const label = primary?.title || primary?.desc || primary?.itemLabel || ShortVideoAPI.getPlatformLabel(platform); return { units, zipName: primary ? this.getItemBaseFilename(primary) : Utils.getShortVideoFilename(label), platform, pageUrl, label: units.length > 1 ? `${label}(${units.length} 个文件)` : label }; }, getShortVideoTaskKey(task) { return [ task.platform, task.pageUrl, ...task.units.map((unit) => unit.candidates[0]) ].join("::"); }, maxQueueLength: 8, enqueueShortVideoTask(task) { const taskKey = this.getShortVideoTaskKey(task); if ((this.activeShortVideoTask ? this.getShortVideoTaskKey(this.activeShortVideoTask) : "") === taskKey || this.shortVideoQueue.some((item) => this.getShortVideoTaskKey(item) === taskKey)) { UI.showAlert("当前内容已在下载或等待队列中", "warning"); return; } if (this.shortVideoQueue.length >= this.maxQueueLength) { UI.showAlert(`等待队列已满(${this.maxQueueLength}),请等当前任务完成后再添加`, "warning"); return; } this.shortVideoQueue.push(task); UI.showAlert(`已加入等待队列(${this.shortVideoQueue.length}): ${task.label}`, "success"); UI.updateDownloadButtonProgress(0, `下载中 · 队列 ${this.shortVideoQueue.length}`); }, updateShortVideoDownloadProgress(percent, label) { const queueText = this.shortVideoQueue.length > 0 ? ` · 队列 ${this.shortVideoQueue.length}` : ""; UI.updateDownloadButtonProgress(percent, `${label}${queueText}`); }, runShortVideoTask(task) { this.isDownloading = true; this.activeShortVideoTask = task; UI.setDownloading(true, { allowQueue: true, label: "下载中 0% · 再点排队" }); UI.showProgress(true); UI.hideAlert(); UI.updateProgress("video", 0, "准备下载..."); UI.updateCircleProgress(0); this.updateShortVideoDownloadProgress(0, "准备下载"); this.downloadUnits(task.units, task.platform, task.zipName).then(() => { this.finishShortVideoTask("下载完成!"); }).catch((error) => { console.error("下载失败:", error); Diagnostics.error("short-video-download", "短视频下载失败", error); UI.showAlert("下载失败: " + error.message, "error"); const hasQueuedTask = this.shortVideoQueue.length > 0; this.resetDownloadingState(); UI.updateCircleProgress(0); if (!hasQueuedTask) setTimeout(() => UI.showProgress(false), 2e3); this.startNextShortVideoTask(); }); }, finishShortVideoTask(message) { UI.showAlert(this.shortVideoQueue.length > 0 ? `${message} 继续下载队列...` : message, "success"); CompleteEffect.show(UI.root, UI.elements.progressCircle); this.resetDownloadingState(); setTimeout(() => UI.updateCircleProgress(0), 1e3); if (this.shortVideoQueue.length === 0) setTimeout(() => UI.showProgress(false), 3e3); this.startNextShortVideoTask(); }, startNextShortVideoTask() { const next = this.shortVideoQueue.shift(); if (!next) return; setTimeout(() => { UI.showAlert(`开始队列任务(剩余 ${this.shortVideoQueue.length}): ${next.label}`, "info"); this.runShortVideoTask(next); }, 600); }, downloadSinglePage(pageInfo) { const videoId = Utils.getVideoId(); UI.updateProgress("video", 0, "获取下载地址..."); UI.updateProgress("audio", 0); UI.updateProgress("merge", 0); const playParams = { type: this.videoType, cid: pageInfo.cid, qn: this.selectedQuality }; if (this.videoType === "bangumi") playParams.ep_id = pageInfo.ep_id; else playParams.bvid = videoId.id; return BiliAPI.getPlayUrl(playParams).then((playData) => { const streams = BiliAPI.getStreams(playData, this.selectedQuality, this.selectedVideoCodec, this.selectedAudioCodec); if (!streams.video) throw new Error("无法获取视频流"); const videoUrls = [streams.video.baseUrl || streams.video.base_url, ...streams.video.backupUrl || streams.video.backup_url || []].filter(Boolean); const audioMainUrl = streams.audio ? streams.audio.baseUrl || streams.audio.base_url : null; const audioBackups = streams.audio ? streams.audio.backupUrl || streams.audio.backup_url || [] : []; const audioUrls = audioMainUrl ? [audioMainUrl, ...audioBackups].filter(Boolean) : null; UI.updateProgress("video", 0, "下载视频..."); UI.updateCircleProgress(0); return ThreadManager.downloadWithThread(videoUrls, audioUrls, (loaded, total) => { const percent = Math.round(loaded / total * 100); UI.updateProgress("video", percent); UI.updateCircleProgress(audioUrls ? percent * .4 : percent); }, (loaded, total) => { const percent = Math.round(loaded / total * 100); UI.updateProgress("audio", percent); UI.updateCircleProgress(40 + percent * .4); }).then((buffers) => { UI.updateProgress("video", 100); if (buffers.audioBuffer) UI.updateProgress("audio", 100); return buffers; }); }).then((buffers) => { const metadata = { title: this.videoInfo.title + (pageInfo.part ? " - " + pageInfo.part : ""), author: this.videoInfo.owner.name, description: this.videoInfo.desc, duration: pageInfo.duration }; let filename = this.videoInfo.title; if (this.videoInfo.pages.length > 1 && pageInfo.part) filename += " - " + pageInfo.part; filename += " - " + this.videoInfo.owner.name; filename = Utils.sanitizeFilename(filename); const mergeBytes = buffers.videoBuffer.byteLength + (buffers.audioBuffer?.byteLength || 0); const mergeLimit = MergeManager.currentMethod === CONFIG.MERGE_METHODS.FFMPEG ? CONFIG.MAX_FFMPEG_MERGE_BYTES : CONFIG.MAX_MERGE_BYTES; if (buffers.audioBuffer && mergeBytes > mergeLimit) { Diagnostics.warn("merge", `文件过大(${Utils.formatBytes(mergeBytes)} > ${Utils.formatBytes(mergeLimit)}),跳过合并`); UI.showAlert(`文件较大(${Utils.formatBytes(mergeBytes)}),为避免浏览器内存溢出已分别保存`, "warning"); this.saveSeparate(buffers.videoBuffer, buffers.audioBuffer, filename); } else if (buffers.audioBuffer && MergeManager.currentMethod !== CONFIG.MERGE_METHODS.SEPARATE) { UI.updateProgress("merge", 0, "合并中..."); return MergeManager.merge(buffers.videoBuffer, buffers.audioBuffer, metadata).then((result) => { if (result.separate) this.saveSeparate(buffers.videoBuffer, buffers.audioBuffer, filename); else { UI.updateProgress("merge", 100); UI.updateCircleProgress(100); this.saveFile(result.data, filename + ".mp4"); } }).catch((mergeError) => { console.error("合并失败:", mergeError); Diagnostics.error("merge", `合并失败(method=${MergeManager.currentMethod}),已回退分离保存`, mergeError); UI.showAlert("合并失败,已分别保存。错误: " + mergeError.message, "warning"); this.saveSeparate(buffers.videoBuffer, buffers.audioBuffer, filename); }); } else if (buffers.audioBuffer) this.saveSeparate(buffers.videoBuffer, buffers.audioBuffer, filename); else this.saveFile(buffers.videoBuffer, filename + ".mp4"); }); }, finishDownload(message) { UI.showAlert(message, "success"); CompleteEffect.show(UI.root, UI.elements.progressCircle); this.resetDownloadingState(); setTimeout(() => UI.updateCircleProgress(0), 1e3); setTimeout(() => UI.showProgress(false), 3e3); }, resetDownloadingState() { this.isDownloading = false; this.activeShortVideoTask = null; UI.setDownloading(false); if (this.isShortVideoMode() && this.shortVideoInfo && this.currentPlatform) UI.setPrimaryButtonLabel(UI.getShortVideoPrimaryLabel(this.shortVideoInfo)); }, fetchBlobCandidates(urls, platform, onProgress) { const candidates = [...new Set(urls.filter(Boolean))]; if (candidates.length === 0) return Promise.reject(new Error("未找到可下载地址")); const headers = ShortVideoAPI.getMediaHeaders(platform); let lastError = null; const pattern = PLATFORMS[platform].pageContextMedia; const needsPageContext = (url) => { if (!pattern) return false; try { return pattern.test(new URL(url).hostname); } catch { return false; } }; const tryDirect = (index) => { if (index >= candidates.length) return Promise.reject(lastError || new Error("所有下载地址均不可用")); const currentUrl = candidates[index]; const progressCallback = (loaded, total) => { if (!total) return; onProgress(Math.round(loaded / total * 100), index + 1, candidates.length); }; onProgress(0, index + 1, candidates.length); return (needsPageContext(currentUrl) ? Network.downloadBlobInPageContext(currentUrl, progressCallback).catch((err) => { return Network.downloadBlob(currentUrl, progressCallback, headers).catch((gmErr) => { throw gmErr instanceof Error ? gmErr : err; }); }) : Network.downloadBlob(currentUrl, progressCallback, headers)).catch((error) => { lastError = error; return tryDirect(index + 1); }); }; return tryDirect(0); }, downloadBlobCandidates(urls, filename, platform, progressLabel, scope = { base: 0, span: 100 }) { const getScopedPercent = (percent) => Math.min(100, Math.round(scope.base + percent * scope.span / 100)); return this.fetchBlobCandidates(urls, platform, (percent, attempt, attemptCount) => { const suffix = attemptCount > 1 ? ` (${attempt}/${attemptCount})` : ""; const scopedPercent = getScopedPercent(percent); UI.updateProgress("video", percent, `${progressLabel}${suffix} ${percent}%`); UI.updateCircleProgress(scopedPercent); this.updateShortVideoDownloadProgress(scopedPercent, `${progressLabel} ${percent}%`); }).then((blob) => { UI.updateProgress("video", 100, "100%"); const scopedPercent = getScopedPercent(100); UI.updateCircleProgress(scopedPercent); this.updateShortVideoDownloadProgress(scopedPercent, `${progressLabel} 100%`); this.saveFile(blob, filename); }); }, downloadUnits(units, platform, zipName) { if (units.length === 0) return Promise.reject(new Error("没有选中可下载的内容")); const total = units.length; const shouldZip = total > CONFIG.ZIP_THRESHOLD; const percents = new Array(total).fill(0); let done = 0; let saveChain = Promise.resolve(); let convertChain = Promise.resolve(); let convertLabel = ""; const report = () => { const overall = Math.round(percents.reduce((sum, value) => sum + value, 0) / total); const label = convertLabel || (total > 1 ? `${shouldZip ? "打包下载" : "下载"} ${done}/${total}` : units[0].label); UI.updateProgress("video", overall, `${label} · ${overall}%`); UI.updateCircleProgress(overall); this.updateShortVideoDownloadProgress(overall, label); }; report(); const convert = (unit, blob) => { const method = unit.convert; if (!method) return Promise.resolve(blob); const task = convertChain.then(() => { convertLabel = "转换 GIF"; report(); return blob.arrayBuffer().then((buffer) => GifManager.convert(buffer, method, (ratio) => { convertLabel = `转换 GIF ${Math.round(ratio * 100)}%`; report(); })).then((gif) => new Blob([gif], { type: "image/gif" })); }); convertChain = task.then(() => { convertLabel = ""; report(); }, () => { convertLabel = ""; }); return task; }; const tasks = units.map((unit, index) => ThreadManager.runTask(() => this.fetchBlobCandidates(unit.candidates, platform, (percent) => { percents[index] = percent; report(); })).then((blob) => convert(unit, blob)).then((blob) => { percents[index] = 100; done += 1; report(); if (!shouldZip) saveChain = saveChain.then(() => { this.saveFile(blob, unit.filename); return Utils.delay(150); }); return blob; })); return Promise.all(tasks).then((blobs) => shouldZip ? this.saveUnitsAsZip(units, blobs, zipName) : saveChain); }, saveUnitsAsZip(units, blobs, zipName) { UI.updateProgress("video", 100, "正在打包..."); this.updateShortVideoDownloadProgress(100, "正在打包"); return Promise.all(blobs.map((blob) => blob.arrayBuffer())).then((buffers) => { const zip = createZip(buffers.map((buffer, index) => ({ name: units[index].filename, data: new Uint8Array(buffer) }))); this.saveFile(zip, `${zipName}.zip`); }); }, downloadShortVideoImages() { if (!this.shortVideoInfo || !this.currentPlatform || !this.shortVideoInfo.images?.length) { UI.showAlert("当前内容没有可下载的图片", "warning"); return; } const baseFilename = Utils.getShortVideoFilename(this.shortVideoInfo.title || this.shortVideoInfo.desc || "图集", this.shortVideoInfo.author?.name); UI.showAlert("正在下载图片...", "info"); UI.showProgress(true); UI.updateCircleProgress(0); this.downloadUnits(this.buildItemUnits(this.shortVideoInfo, baseFilename), this.currentPlatform, baseFilename).then(() => { UI.showAlert("图片下载完成", "success"); setTimeout(() => UI.showProgress(false), 1200); }).catch((error) => UI.showAlert("下载图片失败: " + error.message, "error")); }, downloadShortVideoLivePhotos() { if (!this.shortVideoInfo || !this.currentPlatform || !this.shortVideoInfo.live_photo?.length) { UI.showAlert("当前内容没有可下载的实况图", "warning"); return; } const baseFilename = Utils.getShortVideoFilename(this.shortVideoInfo.title || this.shortVideoInfo.desc || "实况图", this.shortVideoInfo.author?.name); UI.showAlert("正在下载实况图...", "info"); UI.showProgress(true); UI.updateCircleProgress(0); this.downloadUnits(this.buildItemUnits(this.shortVideoInfo, baseFilename), this.currentPlatform, baseFilename).then(() => { UI.showAlert("实况图下载完成", "success"); setTimeout(() => UI.showProgress(false), 1200); }).catch((error) => UI.showAlert("下载实况图失败: " + error.message, "error")); }, downloadCover() { if (this.isShortVideoMode()) { this.downloadShortVideoCover(); return; } if (!this.coverUrl) { UI.showAlert("没有找到封面", "warning"); return; } UI.showAlert("正在下载封面...", "info"); fetch(this.coverUrl).then((r) => r.blob()).then((blob) => { const ext = this.coverUrl.match(/\.(jpg|jpeg|png|webp)($|\?)/i); this.saveFile(blob, Utils.sanitizeFilename(this.videoInfo.title) + "_cover." + (ext ? ext[1] : "jpg")); UI.showAlert("封面下载完成", "success"); }).catch((e) => UI.showAlert("下载封面失败: " + e.message, "error")); }, downloadShortVideoCover() { if (!this.shortVideoInfo?.cover || !this.currentPlatform) { UI.showAlert("没有找到封面", "warning"); return; } const ext = Utils.inferExtension(this.shortVideoInfo.cover, "jpg"); const filename = `${Utils.getShortVideoFilename(this.shortVideoInfo.title || this.shortVideoInfo.desc || "cover", this.shortVideoInfo.author?.name)}_cover.${ext}`; UI.showAlert("正在下载封面...", "info"); UI.showProgress(true); UI.updateCircleProgress(0); this.downloadBlobCandidates([this.shortVideoInfo.cover], filename, this.currentPlatform, "下载封面").then(() => { UI.showAlert("封面下载完成", "success"); setTimeout(() => UI.showProgress(false), 1200); }).catch((error) => UI.showAlert("下载封面失败: " + error.message, "error")); }, downloadSubtitles() { if (this.availableSubtitles.length === 0) { UI.showAlert("没有可用的字幕", "warning"); return; } UI.showAlert("正在下载字幕...", "info"); const promises = this.availableSubtitles.map((sub) => { let url = sub.subtitle_url; if (!url.startsWith("http")) url = "https:" + url; return fetch(url).then((r) => r.json()).then((data) => ({ lan: sub.lan_doc || sub.lan, data })); }); Promise.all(promises).then((results) => { results.forEach((result) => { const srt = this.convertJsonToSrt(result.data); this.saveFile(new Blob([srt], { type: "text/plain;charset=utf-8" }), Utils.sanitizeFilename(this.videoInfo.title) + "_" + result.lan + ".srt"); }); UI.showAlert("字幕下载完成", "success"); }).catch((error) => UI.showAlert("下载字幕失败: " + error.message, "error")); }, downloadShortVideoMusic() { if (!this.shortVideoInfo?.music?.url || !this.currentPlatform) { UI.showAlert("当前内容没有可下载的音频", "warning"); return; } const ext = Utils.inferExtension(this.shortVideoInfo.music.url, "mp3"); const filename = `${Utils.getShortVideoFilename(this.shortVideoInfo.title || this.shortVideoInfo.desc || "audio", this.shortVideoInfo.author?.name)}_music.${ext}`; UI.showAlert("正在下载音频...", "info"); UI.showProgress(true); UI.updateCircleProgress(0); this.downloadBlobCandidates([this.shortVideoInfo.music.url], filename, this.currentPlatform, "下载音频").then(() => { UI.showAlert("音频下载完成", "success"); setTimeout(() => UI.showProgress(false), 1200); }).catch((error) => UI.showAlert("下载音频失败: " + error.message, "error")); }, downloadDanmaku() { const page = this.videoInfo.currentPage || Utils.getCurrentPage(); const pageInfo = this.videoInfo.pages[page - 1]; UI.showAlert("正在下载弹幕...", "info"); BiliAPI.getDanmaku(pageInfo.cid).then((xmlData) => { let filename = Utils.sanitizeFilename(this.videoInfo.title); if (this.videoInfo.pages.length > 1 && pageInfo.part) filename += " - " + pageInfo.part; this.saveFile(new Blob([xmlData], { type: "text/xml;charset=utf-8" }), filename + ".xml"); UI.showAlert("弹幕下载完成", "success"); }).catch((error) => UI.showAlert("下载弹幕失败: " + error.message, "error")); }, convertJsonToSrt(jsonData) { let srt = ""; if (jsonData.body && Array.isArray(jsonData.body)) jsonData.body.forEach((item, index) => { srt += index + 1 + "\n" + this.formatSrtTime(item.from) + " --> " + this.formatSrtTime(item.to) + "\n" + item.content + "\n\n"; }); return srt; }, formatSrtTime(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor(seconds % 3600 / 60); const s = Math.floor(seconds % 60); const ms = Math.floor(seconds % 1 * 1e3); return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0") + "," + String(ms).padStart(3, "0"); }, saveSeparate(videoBuffer, audioBuffer, filename) { this.saveFile(videoBuffer, filename + "_video.mp4"); setTimeout(() => this.saveFile(audioBuffer, filename + "_audio.m4a"), 500); }, saveFile(buffer, filename) { const blob = buffer instanceof Blob ? buffer : new Blob([buffer], { type: "video/mp4" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.style.display = "none"; document.body.appendChild(anchor); anchor.click(); const graceMs = Math.min(3e4, Math.max(2e3, Math.round(blob.size / (8 * 1024 * 1024)) * 1e3)); setTimeout(() => { if (anchor.parentNode) document.body.removeChild(anchor); URL.revokeObjectURL(url); }, graceMs); } }; Diagnostics.init(); var _earlyCtx = Utils.getSiteContext(); if (_earlyCtx.kind === "short-video") { if (_earlyCtx.platform === "douyin") DouyinInterceptor.install(); if (_earlyCtx.platform === "kuaishou") KuaishouInterceptor.install(); } var initialized = false; var initTimer = null; function bindUIEvents() { const el = UI.elements; let mountQueued = false; const syncMount = () => { if (mountQueued) return; mountQueued = true; requestAnimationFrame(() => { mountQueued = false; UI.ensureMounted(); if (!UI.isPopupVisible()) UI.positionPopup(); }); }; syncMount(); el.btn.addEventListener("click", (event) => { event.stopPropagation(); if (UI.consumeEntryClickSuppression()) return; if (UI.togglePopup() && !Downloader.isDownloading) Downloader.refreshInfo(); }); el.close.addEventListener("click", () => UI.hidePopup()); document.addEventListener("click", (e) => { const path = e.composedPath(); if (!path.includes(el.panel) && !path.includes(el.entry)) UI.hidePopup(); }); el.methods.addEventListener("click", (e) => { const item = e.target.closest(".bdl-method-item"); if (!item) return; const method = item.dataset.method; const status = item.querySelector(".bdl-method-status"); if (method === "ffmpeg-merge" && status && !status.classList.contains("ready") && !status.classList.contains("loading")) { const reason = status.title || "当前页面无法使用 FFmpeg 合并,请改用「JS 原生合并」"; UI.showAlert(reason, "warning"); return; } UI.queryAll("#bdl-methods .bdl-method-item").forEach((i) => i.classList.remove("active")); item.classList.add("active"); MergeManager.setMethod(method); UI.hideTips(); el.mergeRow.style.display = method === "separate" ? "none" : "block"; }); el.gifFormats.addEventListener("click", (e) => { const item = e.target.closest(".bdl-method-item"); if (!item || !item.dataset.gif) return; Downloader.setGifMethod(item.dataset.gif); }); el.download.addEventListener("click", () => Downloader.start()); el.footer.addEventListener("click", (event) => { if (event.target?.closest(".bdl-diag-trigger")) return; if (UI.handleFooterSecretClick()) UI.positionPopup(); }); el.selectAll.addEventListener("click", () => { el.pagesList.querySelectorAll(".bdl-page-checkbox").forEach((cb) => cb.checked = true); Downloader.updateSelectedPages(); }); el.selectNone.addEventListener("click", () => { el.pagesList.querySelectorAll(".bdl-page-checkbox").forEach((cb) => cb.checked = false); Downloader.updateSelectedPages(); }); el.selectReverse.addEventListener("click", () => { el.pagesList.querySelectorAll(".bdl-page-checkbox").forEach((cb) => cb.checked = !cb.checked); Downloader.updateSelectedPages(); }); el.ugcSelectAll.addEventListener("click", () => { el.ugcList.querySelectorAll(".bdl-page-checkbox").forEach((cb) => cb.checked = true); Downloader.updateSelectedUGC(); }); el.ugcSelectNone.addEventListener("click", () => { el.ugcList.querySelectorAll(".bdl-page-checkbox").forEach((cb) => cb.checked = false); Downloader.updateSelectedUGC(); }); el.ugcSelectReverse.addEventListener("click", () => { el.ugcList.querySelectorAll(".bdl-page-checkbox").forEach((cb) => cb.checked = !cb.checked); Downloader.updateSelectedUGC(); }); el.videoCodec.addEventListener("change", function() { Downloader.selectedVideoCodec = this.value; }); el.audioCodec.addEventListener("change", function() { Downloader.selectedAudioCodec = parseInt(this.value); }); window.addEventListener("resize", () => { UI.positionEntry(); UI.positionPopup(); }); document.addEventListener("scroll", () => { UI.positionEntry(); if (!UI.isPopupVisible()) UI.positionPopup(); }, true); const ctx = Utils.getSiteContext(); installWatchers(syncMount, ctx.kind === "short-video" ? PLATFORMS[ctx.platform].watch : void 0); } function installWatchers(syncMount, watch) { const urlChangeDelay = watch?.urlChangeDelayMs ?? 1500; let lastUrl = location.href; new MutationObserver(() => { syncMount(); if (location.href === lastUrl) return; lastUrl = location.href; UI.hidePopup(); UI.ensureMounted(); setTimeout(() => { UI.ensureMounted(); Downloader.refreshInfo({ silent: true }); }, urlChangeDelay); }).observe(document.body, { childList: true, subtree: true }); if (!watch) return; const debounceMs = watch.debounceMs ?? 300; let refreshTimer = null; const onVideoChange = () => { if (refreshTimer !== null) clearTimeout(refreshTimer); refreshTimer = window.setTimeout(() => { refreshTimer = null; UI.ensureMounted(); Downloader.refreshInfo({ silent: true }); }, debounceMs); }; const observedContainers = new WeakSet(); const observeClassId = (el, pattern) => { if (observedContainers.has(el)) return; observedContainers.add(el); const readId = () => (el.getAttribute("class")?.match(pattern) || [])[1] || ""; let lastId = readId(); new MutationObserver(() => { const id = readId(); if (id && id !== lastId) { lastId = id; onVideoChange(); } }).observe(el, { attributes: true, attributeFilter: ["class"] }); }; const bindVideoPlay = (video) => { if (video.__bdl_play_bound) return; video.__bdl_play_bound = true; video.addEventListener("play", onVideoChange); }; const scan = () => { const { classIdSelector, classIdPattern } = watch; if (classIdSelector && classIdPattern) document.querySelectorAll(classIdSelector).forEach((el) => observeClassId(el, classIdPattern)); if (watch.videoPlay) document.querySelectorAll("video").forEach(bindVideoPlay); }; scan(); new MutationObserver(scan).observe(document.body, { childList: true, subtree: true }); } function checkFFmpegAvailability() { const updateStatus = (ready, label, tooltip) => { const el = UI.query("[data-method=\"ffmpeg-merge\"] .bdl-method-status"); if (!el) return; el.textContent = label || (ready ? "就绪" : "不可用"); el.className = ready ? "bdl-method-status ready" : "bdl-method-status"; el.style.background = ready ? "" : "var(--bdl-danger-bg)"; el.style.color = ready ? "" : "var(--bdl-danger-text)"; if (tooltip) el.title = tooltip; }; const hasSAB = typeof SharedArrayBuffer !== "undefined"; const isolated = typeof globalThis.crossOriginIsolated === "boolean" ? globalThis.crossOriginIsolated : false; Diagnostics.info("ffmpeg", `环境: hasSAB=${hasSAB} crossOriginIsolated=${isolated}(使用单线程核心,无需跨源隔离)`); if (getFFmpegGlobal()) { FFmpegMerger.init().then(() => { Diagnostics.info("ffmpeg", "FFmpeg 初始化成功(本地已存在)"); updateStatus(true); }).catch((err) => { Diagnostics.error("ffmpeg", "FFmpeg 初始化失败", err); updateStatus(false); }); return; } loadFFmpegLoader().then(() => { if (!getFFmpegGlobal()) { Diagnostics.warn("ffmpeg", "FFmpeg 脚本加载后仍不可用"); updateStatus(false); return; } return FFmpegMerger.init().then(() => { Diagnostics.info("ffmpeg", "FFmpeg 初始化成功"); updateStatus(true); }).catch((err) => { Diagnostics.error("ffmpeg", "FFmpeg 初始化失败", err); updateStatus(false); }); }).catch((err) => { Diagnostics.error("ffmpeg", "FFmpeg 脚本加载失败", err); updateStatus(false); }); } function init() { if (initialized) return; if (!document.body) { scheduleInit(50); return; } const siteContext = Utils.getSiteContext(); if (siteContext.kind === "unsupported") return; initialized = true; Diagnostics.info("main", `站点识别: ${siteContext.kind} / ${siteContext.platform ?? "unknown"}`); ThreadManager.init(); UI.init(); if (siteContext.kind === "short-video") UI.setShortVideoMode(siteContext.platform); bindUIEvents(); if (siteContext.kind === "bilibili") checkFFmpegAvailability(); setTimeout(() => Downloader.refreshInfo({ silent: siteContext.kind === "short-video" }), siteContext.kind === "short-video" ? 900 : 500); console.log(`%c ██╗ ██╗██████╗ ██╗ ██╗ ██║ ██║██╔══██╗██║ ██║ ██║ ██║██║ ██║███████║ ╚██╗ ██╔╝██║ ██║██╔══██║ ╚████╔╝ ██████╔╝██║ ██║ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ Video Download Helper ${GM_info.script.version}\n threads: ` + ThreadManager.maxThreads + " cores: " + Utils.getCPUCores() + "\n", "color:#00c8ff;font-family:monospace;font-size:11px;line-height:1.2"); } function scheduleInit(delay) { if (initialized || initTimer !== null) return; initTimer = window.setTimeout(() => { initTimer = null; init(); }, delay); } init(); scheduleInit(100); document.addEventListener("DOMContentLoaded", init, { once: true }); window.addEventListener("load", init, { once: true }); })();