// ==UserScript== // @name BiliFetch - B站下载助手(审核稳版) // @namespace https://bilifetch.local/scriptcat-stable // @version 1.3.0 // @description 无外部合并库、少权限版:导出视频流、音频、字幕、弹幕、封面、信息,并复制 aria2/ffmpeg 命令 // @author BiliFetch // @license MIT // @homepageURL https://scriptcat.org/ // @supportURL https://scriptcat.org/ // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/list/* // @grant unsafeWindow // @grant GM_download // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @grant GM_registerMenuCommand // @connect api.bilibili.com // @connect comment.bilibili.com // @connect *.hdslb.com // @connect *.bilivideo.com // @run-at document-idle // ==/UserScript== (function () { "use strict"; const pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const VERSION = "1.3.0"; const PANEL_ID = "bilifetch-stable-panel"; const STATUS_ID = "bilifetch-stable-status"; const MODE_ID = "bilifetch-stable-mode"; const SCOPE_ID = "bilifetch-stable-scope"; const STORAGE_MODE_KEY = "bilifetch-stable-last-mode"; const STORAGE_SCOPE_KEY = "bilifetch-stable-last-scope"; const modes = [ ["commands", "复制ffmpeg/aria2命令"], ["video", "只下载视频流"], ["audio", "只下载音频"], ["subtitles", "只下载字幕"], ["danmaku", "只下载弹幕"], ["cover", "只下载封面"], ["info", "只下载信息JSON"], ["copy", "复制直链和信息"], ]; const scopes = [ ["current", "当前分P"], ["all", "全部分P"], ]; let running = false; let lastError = null; let recentStatus = []; function installPanel() { if (document.getElementById(PANEL_ID)) { return; } const style = document.createElement("style"); style.textContent = ` #${PANEL_ID} { position: fixed; right: 18px; bottom: 18px; z-index: 2147483647; width: min(380px, calc(100vw - 28px)); color: #172033; background: #fff; border: 1px solid rgba(23, 32, 51, 0.16); border-radius: 8px; box-shadow: 0 14px 42px rgba(20, 28, 44, 0.22); overflow: hidden; font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID}.is-collapsed { width: auto; } #${PANEL_ID}.is-collapsed .bf-body { display: none; } #${PANEL_ID} .bf-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; color: #fff; background: #00a1d6; } #${PANEL_ID} .bf-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 750; } #${PANEL_ID} .bf-subtitle { display: block; margin-top: 2px; font-size: 11px; font-weight: 500; opacity: 0.9; } #${PANEL_ID} .bf-toggle { width: 30px; height: 30px; color: #fff; background: rgba(255,255,255,0.18); border: 0; border-radius: 6px; cursor: pointer; font-weight: 800; } #${PANEL_ID} .bf-body { display: grid; gap: 10px; padding: 12px; } #${PANEL_ID} .bf-note { color: #5a6478; } #${PANEL_ID} .bf-grid { display: grid; grid-template-columns: 1fr 118px; gap: 8px; } #${PANEL_ID} .bf-row { display: grid; gap: 6px; } #${PANEL_ID} label { font-weight: 680; } #${PANEL_ID} select, #${PANEL_ID} button { min-height: 34px; border-radius: 6px; border: 1px solid rgba(23, 32, 51, 0.18); background: #fff; color: #172033; font: inherit; } #${PANEL_ID} select { width: 100%; padding: 6px 8px; } #${PANEL_ID} .bf-actions { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; } #${PANEL_ID} .bf-primary { color: #fff; border-color: #00a1d6; background: #00a1d6; font-weight: 750; cursor: pointer; } #${PANEL_ID} .bf-secondary { cursor: pointer; } #${PANEL_ID} button:disabled, #${PANEL_ID} select:disabled { cursor: wait; opacity: 0.68; } #${STATUS_ID} { min-height: 110px; max-height: 220px; margin: 0; padding: 8px; overflow: auto; color: #263248; background: #f5f7fb; border: 1px solid rgba(23, 32, 51, 0.1); border-radius: 6px; white-space: pre-wrap; word-break: break-word; } @media (max-width: 520px) { #${PANEL_ID} { right: 10px; bottom: 10px; width: calc(100vw - 20px); } #${PANEL_ID} .bf-grid, #${PANEL_ID} .bf-actions { grid-template-columns: 1fr; } } `; document.documentElement.appendChild(style); const panel = document.createElement("section"); panel.id = PANEL_ID; panel.innerHTML = `
BiliFetch 审核稳版无外部合并库,少权限
稳版不在浏览器内自动合并。推荐复制命令后在电脑端用 aria2/ffmpeg 下载合并。
准备好了。稳版适合先过审核;需要浏览器自动合并请使用完整版。
`; document.documentElement.appendChild(panel); fillSelect(panel.querySelector(`#${MODE_ID}`), modes, localStorage.getItem(STORAGE_MODE_KEY) || "commands"); fillSelect(panel.querySelector(`#${SCOPE_ID}`), scopes, localStorage.getItem(STORAGE_SCOPE_KEY) || "current"); panel.querySelector(`#${MODE_ID}`).addEventListener("change", (event) => { localStorage.setItem(STORAGE_MODE_KEY, event.target.value); }); panel.querySelector(`#${SCOPE_ID}`).addEventListener("change", (event) => { localStorage.setItem(STORAGE_SCOPE_KEY, event.target.value); }); panel.querySelector(".bf-toggle").addEventListener("click", () => { panel.classList.toggle("is-collapsed"); panel.querySelector(".bf-toggle").textContent = panel.classList.contains("is-collapsed") ? "+" : "-"; }); panel.querySelector("#bilifetch-stable-preview").addEventListener("click", () => { preview().catch(showError); }); panel.querySelector("#bilifetch-stable-start").addEventListener("click", () => { run().catch(showError); }); panel.querySelector("#bilifetch-stable-diagnose").addEventListener("click", () => { copyDiagnostics().catch(showError); }); } function fillSelect(select, items, selectedValue) { for (const [value, label] of items) { const option = document.createElement("option"); option.value = value; option.textContent = label; option.selected = value === selectedValue; select.appendChild(option); } } function selectedMode() { const select = document.getElementById(MODE_ID); return select ? select.value : "commands"; } function selectedScope() { const select = document.getElementById(SCOPE_ID); return select ? select.value : "current"; } function setBusy(value) { running = value; const panel = document.getElementById(PANEL_ID); if (!panel) { return; } panel.querySelectorAll("button, select").forEach((element) => { element.disabled = value; }); } function setStatus(message) { recentStatus = [message]; const status = document.getElementById(STATUS_ID); if (status) { status.textContent = message; } } function appendStatus(message) { recentStatus.push(message); if (recentStatus.length > 60) { recentStatus = recentStatus.slice(-60); } const status = document.getElementById(STATUS_ID); if (!status) { return; } const prefix = status.textContent.trim() ? "\n" : ""; status.textContent += `${prefix}${message}`; status.scrollTop = status.scrollHeight; } function showError(error) { lastError = { message: error && error.message ? error.message : String(error), stack: error && error.stack ? error.stack : "", time: new Date().toISOString(), }; appendStatus(`失败:${lastError.message}`); appendStatus(`建议:\n- ${diagnoseError(error).join("\n- ")}`); } function diagnoseError(error) { const message = `${error && error.message ? error.message : String(error || "")}\n${error && error.stack ? error.stack : ""}`; const tips = []; if (/BV|视频页|识别/i.test(message)) { tips.push("确认打开的是普通 B 站视频页,地址里最好包含 BV 号。"); } if (/401|403|登录|会员|权限|地区|forbidden/i.test(message)) { tips.push("先登录 B 站,并确认当前账号能正常播放这个清晰度。"); } if (/timeout|超时|network|fetch|网络/i.test(message)) { tips.push("网络请求超时,可以刷新页面重试,或检查脚本猫跨域权限。"); } if (/GM_download|下载失败|download/i.test(message)) { tips.push("检查浏览器是否禁止多个文件下载,脚本猫是否允许下载权限。"); } if (!tips.length) { tips.push("点击“诊断”,把复制内容和视频链接一起发给作者排查。"); } return Array.from(new Set(tips)); } function getBvid() { const pathMatch = location.pathname.match(/\/video\/(BV[a-zA-Z0-9]+)/); if (pathMatch) { return pathMatch[1]; } const queryBvid = new URLSearchParams(location.search).get("bvid"); if (queryBvid && /^BV[a-zA-Z0-9]+$/.test(queryBvid)) { return queryBvid; } const state = pageWindow.__INITIAL_STATE__ || {}; const stateBvid = state.bvid || (state.videoData && state.videoData.bvid); if (stateBvid && /^BV[a-zA-Z0-9]+$/.test(stateBvid)) { return stateBvid; } throw new Error("没有识别到 BV 号。请打开普通 B 站视频页。"); } function getCurrentPageNumber() { const page = Number(new URLSearchParams(location.search).get("p") || "1"); return Number.isFinite(page) && page > 0 ? page : 1; } async function requestText(url, options = {}) { const headers = Object.assign({ Referer: location.href }, options.headers || {}); if (typeof GM_xmlhttpRequest === "function") { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || "GET", url, headers, responseType: "text", timeout: options.timeout || 25000, onload(response) { if (response.status >= 200 && response.status < 300) { resolve(response.responseText || ""); return; } reject(new Error(`接口返回 HTTP ${response.status}`)); }, onerror() { reject(new Error("网络请求失败")); }, ontimeout() { reject(new Error("网络请求超时")); }, }); }); } const response = await fetch(url, { credentials: "include", headers }); if (!response.ok) { throw new Error(`接口返回 HTTP ${response.status}`); } return response.text(); } async function requestJson(url, options = {}) { const text = await requestText(url, options); return JSON.parse(text); } function unwrapBiliData(payload, label) { if (!payload) { throw new Error(`${label}为空`); } if (Object.prototype.hasOwnProperty.call(payload, "code") && payload.code !== 0) { throw new Error(`${label}失败:${payload.message || payload.msg || payload.code}`); } return payload.data || payload.result || payload; } async function getViewInfo(bvid) { const json = await requestJson(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`); return unwrapBiliData(json, "视频信息接口"); } async function getPlayInfo(bvid, cid) { const pagePlayInfo = pageWindow.__playinfo__ && (pageWindow.__playinfo__.data || pageWindow.__playinfo__.result || pageWindow.__playinfo__); if (pagePlayInfo && (pagePlayInfo.dash || pagePlayInfo.durl)) { return pagePlayInfo; } const url = new URL("https://api.bilibili.com/x/player/playurl"); url.searchParams.set("bvid", bvid); url.searchParams.set("cid", cid); url.searchParams.set("qn", "127"); url.searchParams.set("fnval", "4048"); url.searchParams.set("fnver", "0"); url.searchParams.set("fourk", "1"); url.searchParams.set("high_quality", "1"); return unwrapBiliData(await requestJson(url.toString()), "播放地址接口"); } async function getPlayerInfo(bvid, cid) { const url = new URL("https://api.bilibili.com/x/player/v2"); url.searchParams.set("bvid", bvid); url.searchParams.set("cid", cid); return unwrapBiliData(await requestJson(url.toString()), "播放器信息接口"); } function getTargetPages(view, scope) { const pages = Array.isArray(view.pages) && view.pages.length ? view.pages : [{ cid: view.cid, page: 1, part: view.title || "video" }]; if (scope === "all") { return pages; } const current = getCurrentPageNumber(); return [pages.find((item) => Number(item.page) === current) || pages[0]]; } async function buildBase() { const bvid = getBvid(); appendStatus(`识别到:${bvid}`); const view = await getViewInfo(bvid); return { bvid, view }; } async function buildContext(base, page, needPlayer) { const context = { bvid: base.bvid, cid: page.cid, view: base.view, page, baseName: makeBaseName(base.view, page), playInfo: await getPlayInfo(base.bvid, page.cid), playerInfo: null, }; if (needPlayer) { context.playerInfo = await getPlayerInfo(base.bvid, page.cid).catch((error) => { appendStatus(`字幕信息读取失败:${error.message}`); return null; }); } return context; } async function preview() { if (running) { return; } setBusy(true); setStatus("正在生成预览..."); try { const base = await buildBase(); const pages = getTargetPages(base.view, selectedScope()); const lines = [ "下载前预览", `模式:${labelOf(modes, selectedMode())}`, `范围:${labelOf(scopes, selectedScope())},共 ${pages.length} 个分P`, "", ]; pages.forEach((page, index) => { const baseName = makeBaseName(base.view, page); lines.push(`P${page.page || index + 1}:${page.part || "未命名"}`); lines.push(` - ${baseName}.commands.txt 或所选导出文件`); }); setStatus(lines.join("\n")); } finally { setBusy(false); } } async function run() { if (running) { return; } setBusy(true); setStatus("开始处理..."); try { const mode = selectedMode(); const base = await buildBase(); const pages = getTargetPages(base.view, selectedScope()); appendStatus(`任务范围:${pages.length} 个分P。`); for (let index = 0; index < pages.length; index += 1) { const page = pages[index]; appendStatus(`\n[${index + 1}/${pages.length}] ${page.part || "未命名"}`); const context = await buildContext(base, page, ["subtitles", "copy"].includes(mode)); await runMode(context, mode); } appendStatus("完成。"); } finally { setBusy(false); } } async function runMode(context, mode) { if (mode === "commands") { await copyCommandReport(context); } else if (mode === "video") { startBestVideoDownload(context); } else if (mode === "audio") { startBestAudioDownload(context); } else if (mode === "subtitles") { await exportSubtitles(context); } else if (mode === "danmaku") { await exportDanmaku(context); } else if (mode === "cover") { await exportCover(context); } else if (mode === "info") { exportInfo(context); } else if (mode === "copy") { await copyLinkReport(context); } } function collectStreams(playInfo) { const result = { videos: [], audios: [], durls: [], bestVideo: null, bestAudio: null }; if (!playInfo) { return result; } if (Array.isArray(playInfo.durl)) { result.durls = playInfo.durl.map((item, index) => ({ url: item.url || item.base_url || item.baseUrl, size: item.size || 0, order: index + 1, })).filter((item) => item.url); } const dash = playInfo.dash || {}; if (Array.isArray(dash.video)) { result.videos = dash.video.map((item) => ({ raw: item, url: item.baseUrl || item.base_url || item.url, id: item.id || item.quality, codecs: item.codecs || "", width: Number(item.width || 0), height: Number(item.height || 0), bandwidth: Number(item.bandwidth || 0), })).filter((item) => item.url).sort((a, b) => streamScore(b) - streamScore(a)); result.bestVideo = result.videos[0] || null; } if (Array.isArray(dash.audio)) { result.audios = dash.audio.map((item) => ({ raw: item, url: item.baseUrl || item.base_url || item.url, id: item.id || item.quality, codecs: item.codecs || "", bandwidth: Number(item.bandwidth || 0), })).filter((item) => item.url).sort((a, b) => Number(b.bandwidth || 0) - Number(a.bandwidth || 0)); result.bestAudio = result.audios[0] || null; } return result; } function streamScore(item) { return Number(item.height || 0) * 100000000 + Number(item.width || 0) * 100000 + Number(item.bandwidth || 0); } function estimateDownloadSize(context) { const streams = collectStreams(context.playInfo); const duration = Number(context.playInfo && context.playInfo.timelength) / 1000 || Number(context.page.duration || context.view.duration || 0); const videoBytes = streams.bestVideo ? Math.round((Number(streams.bestVideo.bandwidth || 0) * duration) / 8) : 0; const audioBytes = streams.bestAudio ? Math.round((Number(streams.bestAudio.bandwidth || 0) * duration) / 8) : 0; const durlBytes = streams.durls.reduce((sum, item) => sum + Number(item.size || 0), 0); return durlBytes || videoBytes + audioBytes; } function formatBytes(bytes) { const value = Number(bytes || 0); if (!value) { return "暂时无法估算"; } const units = ["B", "KB", "MB", "GB"]; let size = value; let index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; } return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`; } function startBestVideoDownload(context) { const streams = collectStreams(context.playInfo); if (streams.durls.length) { streams.durls.forEach((item) => startRemoteDownload(item.url, `${context.baseName}.mp4`, "完整视频")); return; } if (!streams.bestVideo) { appendStatus("没有拿到视频流。"); return; } startRemoteDownload(streams.bestVideo.url, `${context.baseName}.video-${makeVideoLabel(streams.bestVideo)}.m4s`, "视频流"); } function startBestAudioDownload(context) { const streams = collectStreams(context.playInfo); if (!streams.bestAudio) { appendStatus("没有拿到音频流。"); return; } startRemoteDownload(streams.bestAudio.url, `${context.baseName}.audio-${makeAudioLabel(streams.bestAudio)}.m4s`, "音频"); } function startRemoteDownload(url, filename, label) { if (typeof GM_download === "function") { try { GM_download({ url, name: sanitizeDownloadFilename(filename), headers: { Referer: location.href, Origin: "https://www.bilibili.com", }, saveAs: false, onload() { appendStatus(`${label}下载完成。`); }, onerror() { appendStatus(`${label}下载失败,已复制直链。`); copyText(url); }, }); appendStatus(`${label}已交给浏览器下载。`); return; } catch (error) { appendStatus(`${label}下载接口不可用,改为复制直链。`); } } copyText(url); window.open(url, "_blank", "noopener,noreferrer"); } function exportInfo(context) { const streams = collectStreams(context.playInfo); const payload = { version: VERSION, exported_at: new Date().toISOString(), source_url: location.href, bvid: context.bvid, cid: context.cid, title: context.view.title || "", page: context.page, estimate: formatBytes(estimateDownloadSize(context)), selected_streams: { video: streams.bestVideo ? streams.bestVideo.url : "", audio: streams.bestAudio ? streams.bestAudio.url : "", complete: streams.durls.map((item) => item.url), }, raw_view: context.view, }; downloadText(`${context.baseName}.info.json`, JSON.stringify(payload, null, 2), "application/json;charset=utf-8"); } async function exportCover(context) { const url = normalizeUrl(context.view.pic || ""); if (!url) { appendStatus("没有找到封面。"); return; } startRemoteDownload(url, `${context.baseName}.cover.${getExtensionFromUrl(url, "jpg")}`, "封面"); } async function exportDanmaku(context) { const xml = await requestText(`https://comment.bilibili.com/${encodeURIComponent(context.cid)}.xml`); downloadText(`${context.baseName}.danmaku.xml`, xml, "application/xml;charset=utf-8"); appendStatus("弹幕 XML 已保存。"); } async function exportSubtitles(context) { const playerInfo = context.playerInfo || await getPlayerInfo(context.bvid, context.cid); const subtitles = playerInfo && playerInfo.subtitle && Array.isArray(playerInfo.subtitle.subtitles) ? playerInfo.subtitle.subtitles : []; if (!subtitles.length) { appendStatus("没有找到字幕。"); return; } for (let index = 0; index < subtitles.length; index += 1) { const subtitle = subtitles[index]; const url = normalizeUrl(subtitle.subtitle_url || ""); if (!url) { continue; } const label = sanitizeFilename(subtitle.lan_doc || subtitle.lan || `subtitle-${index + 1}`); const data = await requestJson(url); downloadText(`${context.baseName}.${label}.subtitle.json`, JSON.stringify(data, null, 2), "application/json;charset=utf-8"); if (Array.isArray(data.body)) { downloadText(`${context.baseName}.${label}.srt`, subtitleToSrt(data.body), "application/x-subrip;charset=utf-8"); } } } async function copyCommandReport(context) { const report = buildCommandReport(context); await copyText(report); downloadText(`${context.baseName}.commands.txt`, report, "text/plain;charset=utf-8"); appendStatus("命令已复制并保存。"); } function buildCommandReport(context) { const streams = collectStreams(context.playInfo); const lines = [ "BiliFetch 审核稳版命令", "", `标题:${context.view.title || ""}`, `页面:${location.href}`, `BV号:${context.bvid}`, `CID:${context.cid}`, `估算大小:${formatBytes(estimateDownloadSize(context))}`, "", ]; if (streams.durls.length) { lines.push("完整视频下载命令:"); streams.durls.forEach((item, index) => { lines.push(buildAria2Command(item.url, `${context.baseName}.part${index + 1}.mp4`)); }); return lines.join("\n"); } const video = streams.bestVideo; const audio = streams.bestAudio; const videoName = `${context.baseName}.video-${video ? makeVideoLabel(video) : "best"}.m4s`; const audioName = `${context.baseName}.audio-${audio ? makeAudioLabel(audio) : "best"}.m4s`; const outputName = `${context.baseName}.merged.mp4`; lines.push("视频下载:"); lines.push(video ? buildAria2Command(video.url, videoName) : "未获取到视频流。"); lines.push(""); lines.push("音频下载:"); lines.push(audio ? buildAria2Command(audio.url, audioName) : "未获取到音频流。"); lines.push(""); lines.push("合并命令:"); lines.push(`ffmpeg -i "${escapeCommandArg(videoName)}" -i "${escapeCommandArg(audioName)}" -c copy "${escapeCommandArg(outputName)}"`); return lines.join("\n"); } function buildAria2Command(url, filename) { return `aria2c --referer="https://www.bilibili.com/" --header="Origin: https://www.bilibili.com" -o "${escapeCommandArg(filename)}" "${escapeCommandArg(url)}"`; } async function copyLinkReport(context) { const report = [ "BiliFetch 链接信息", buildCommandReport(context), `封面:${normalizeUrl(context.view.pic || "")}`, `弹幕XML:https://comment.bilibili.com/${context.cid}.xml`, ].join("\n"); await copyText(report); appendStatus("链接信息已复制。"); } async function copyDiagnostics() { const payload = { name: "BiliFetch ScriptCat stable diagnostics", version: VERSION, time: new Date().toISOString(), url: location.href, userAgent: navigator.userAgent, bvid: safeCall(getBvid), mode: selectedMode(), scope: selectedScope(), hasGMDownload: typeof GM_download === "function", hasGMXmlhttpRequest: typeof GM_xmlhttpRequest === "function", hasGMClipboard: typeof GM_setClipboard === "function", lastError, suggestions: lastError ? diagnoseError(lastError) : [], recentStatus, }; await copyText(JSON.stringify(payload, null, 2)); setStatus("诊断信息已复制。"); } function subtitleToSrt(items) { return items.map((item, index) => { const start = formatSrtTime(Number(item.from || 0)); const end = formatSrtTime(Number(item.to || item.from || 0)); return `${index + 1}\n${start} --> ${end}\n${String(item.content || "").trim()}\n`; }).join("\n"); } function downloadText(filename, content, mimeType) { const blob = new Blob([content], { type: mimeType || "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = sanitizeDownloadFilename(filename); anchor.style.display = "none"; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); appendStatus(`已保存:${anchor.download}`); } async function copyText(text) { if (typeof GM_setClipboard === "function") { GM_setClipboard(text, "text"); return; } if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); textarea.focus(); textarea.select(); document.execCommand("copy"); textarea.remove(); } function makeBaseName(view, page) { const title = view.title || view.bvid || "bilibili"; const pages = Array.isArray(view.pages) ? view.pages : []; const pageLabel = pages.length > 1 ? `P${page.page}-${page.part || "part"}` : ""; return sanitizeFilename([title, pageLabel].filter(Boolean).join(" ")); } function sanitizeFilename(value) { const cleaned = String(value || "") .replace(/[\\/:*?"<>|]/g, "_") .replace(/\s+/g, " ") .trim(); return (cleaned || "bilibili").slice(0, 120); } function sanitizeDownloadFilename(value) { const text = String(value || "bilibili"); const extensionMatch = text.match(/(\.[a-zA-Z0-9]{2,5})$/); const extension = extensionMatch ? extensionMatch[1] : ""; const stem = extension ? text.slice(0, -extension.length) : text; return `${sanitizeFilename(stem).slice(0, 112)}${extension}`; } function makeVideoLabel(video) { const resolution = video.height ? `${video.height}p` : "best"; const codec = video.codecs ? video.codecs.split(".")[0] : "video"; return sanitizeFilename(`${resolution}-${codec}`); } function makeAudioLabel(audio) { const bitrate = audio.bandwidth ? `${Math.round(audio.bandwidth / 1000)}k` : "best"; const codec = audio.codecs ? audio.codecs.split(".")[0] : "audio"; return sanitizeFilename(`${bitrate}-${codec}`); } function normalizeUrl(url) { if (!url) { return ""; } if (url.startsWith("//")) { return `https:${url}`; } return new URL(url, location.href).toString(); } function getExtensionFromUrl(url, fallback) { try { const match = new URL(url).pathname.toLowerCase().match(/\.([a-z0-9]{2,5})$/); return match ? match[1] : fallback; } catch (error) { return fallback; } } function formatSrtTime(seconds) { const totalMs = Math.max(0, Math.round(seconds * 1000)); const ms = totalMs % 1000; const totalSeconds = Math.floor(totalMs / 1000); const s = totalSeconds % 60; const totalMinutes = Math.floor(totalSeconds / 60); const m = totalMinutes % 60; const h = Math.floor(totalMinutes / 60); return `${pad(h)}:${pad(m)}:${pad(s)},${String(ms).padStart(3, "0")}`; } function pad(value) { return String(value).padStart(2, "0"); } function escapeCommandArg(value) { return String(value || "").replace(/"/g, '\\"'); } function labelOf(items, value) { const item = items.find(([itemValue]) => itemValue === value); return item ? item[1] : value; } function safeCall(fn) { try { return fn(); } catch (error) { return `读取失败:${error.message}`; } } if (typeof GM_registerMenuCommand === "function") { GM_registerMenuCommand("打开 BiliFetch 审核稳版面板", installPanel); GM_registerMenuCommand("复制 BiliFetch 审核稳版诊断", () => copyDiagnostics().catch(showError)); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", installPanel, { once: true }); } else { installPanel(); } })();