// ==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();
}
})();