// ==UserScript==
// @name BiliFetch - B站下载助手
// @namespace https://bilifetch.local/scriptcat
// @version 1.2.0
// @description 在B站视频页选择下载/导出视频、音频、字幕、弹幕、封面和信息,支持分P批量、命令复制、设置页、错误诊断和实验性浏览器内合并
// @author BiliFetch
// @license MIT
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/list/*
// @require https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/umd/ffmpeg.js
// @require https://unpkg.com/@ffmpeg/util@0.12.1/dist/umd/index.js
// @grant unsafeWindow
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant GM_notification
// @grant GM_registerMenuCommand
// @connect api.bilibili.com
// @connect comment.bilibili.com
// @connect i0.hdslb.com
// @connect i1.hdslb.com
// @connect i2.hdslb.com
// @connect *.hdslb.com
// @connect *.bilivideo.com
// @connect unpkg.com
// @connect cdn.jsdelivr.net
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
const SCRIPT_VERSION = "1.2.0";
const PANEL_ID = "bilifetch-scriptcat-panel";
const STATUS_ID = "bilifetch-scriptcat-status";
const MODE_ID = "bilifetch-scriptcat-mode";
const SCOPE_ID = "bilifetch-scriptcat-scope";
const PROGRESS_ID = "bilifetch-scriptcat-progress";
const PROGRESS_TEXT_ID = "bilifetch-scriptcat-progress-text";
const STORAGE_MODE_KEY = "bilifetch-scriptcat-last-mode";
const STORAGE_SCOPE_KEY = "bilifetch-scriptcat-last-scope";
const STORAGE_SETTINGS_KEY = "bilifetch-scriptcat-settings-v1";
const STORAGE_SEEN_VERSION_KEY = "bilifetch-scriptcat-seen-version";
const DEFAULT_SETTINGS = {
mergeWarnMB: 900,
showChangelog: true,
saveCommandsOnMergeFail: true,
detailedErrorTips: true,
};
const FFMPEG_CORE_BASES = [
"https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd",
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/umd",
];
const modes = [
["full_merge", "完整导出并自动合并"],
["merge", "只合并视频+音频MP4"],
["full", "完整导出不合并"],
["video", "只下载视频流"],
["audio", "只下载音频"],
["subtitles", "只下载字幕"],
["danmaku", "只下载弹幕"],
["cover", "只下载封面"],
["info", "只下载信息JSON"],
["commands", "复制ffmpeg/aria2命令"],
["copy", "复制直链和信息"],
];
const scopes = [
["current", "当前分P"],
["all", "全部分P"],
];
let running = false;
let ffmpegInstance = null;
let ffmpegLoaded = false;
let lastError = null;
let lastDiagnostics = [];
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(390px, calc(100vw - 28px));
box-sizing: border-box;
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172033;
background: #ffffff;
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;
}
#${PANEL_ID}.is-collapsed {
width: auto;
}
#${PANEL_ID} * {
box-sizing: border-box;
}
#${PANEL_ID} .bf-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
color: #ffffff;
background: #00a1d6;
}
#${PANEL_ID} .bf-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 750;
}
#${PANEL_ID} .bf-subtitle {
display: block;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: 500;
opacity: 0.9;
}
#${PANEL_ID} .bf-toggle {
width: 30px;
height: 30px;
border: 0;
border-radius: 6px;
color: #ffffff;
background: rgba(255, 255, 255, 0.18);
cursor: pointer;
font-weight: 800;
}
#${PANEL_ID} .bf-body {
display: grid;
gap: 10px;
padding: 12px;
}
#${PANEL_ID}.is-collapsed .bf-body {
display: none;
}
#${PANEL_ID} .bf-note {
color: #5a6478;
}
#${PANEL_ID} .bf-note strong {
color: #d35400;
}
#${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: #ffffff;
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 {
border-color: #00a1d6;
color: #ffffff;
background: #00a1d6;
cursor: pointer;
font-weight: 750;
}
#${PANEL_ID} .bf-secondary {
cursor: pointer;
}
#${PANEL_ID} button:disabled,
#${PANEL_ID} select:disabled {
cursor: wait;
opacity: 0.68;
}
#${PANEL_ID} .bf-progress-wrap {
display: grid;
gap: 4px;
}
#${PANEL_ID} .bf-progress-track {
height: 8px;
overflow: hidden;
border-radius: 999px;
background: #edf1f7;
}
#${PROGRESS_ID} {
width: 0%;
height: 100%;
background: #00a1d6;
transition: width 0.18s ease;
}
#${PROGRESS_TEXT_ID} {
min-height: 18px;
color: #5a6478;
font-size: 12px;
}
#${STATUS_ID} {
min-height: 108px;
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;
}
.bilifetch-settings-backdrop {
position: fixed;
inset: 0;
z-index: 2147483647;
display: grid;
place-items: center;
padding: 18px;
background: rgba(17, 24, 39, 0.42);
}
.bilifetch-settings-dialog {
width: min(460px, 100%);
color: #172033;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 18px 52px rgba(20, 28, 44, 0.28);
overflow: hidden;
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.bilifetch-settings-dialog h2 {
margin: 0;
padding: 13px 14px;
color: #ffffff;
background: #00a1d6;
font-size: 16px;
}
.bilifetch-settings-dialog .bf-settings-body {
display: grid;
gap: 12px;
padding: 14px;
}
.bilifetch-settings-dialog label {
display: grid;
gap: 5px;
}
.bilifetch-settings-dialog input[type="number"] {
min-height: 34px;
padding: 6px 8px;
border: 1px solid rgba(23, 32, 51, 0.18);
border-radius: 6px;
}
.bilifetch-settings-dialog .bf-check {
display: flex;
align-items: center;
gap: 8px;
}
.bilifetch-settings-dialog .bf-settings-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.bilifetch-settings-dialog button {
min-height: 34px;
border-radius: 6px;
border: 1px solid rgba(23, 32, 51, 0.18);
background: #ffffff;
cursor: pointer;
}
.bilifetch-settings-dialog .bf-save {
color: #ffffff;
border-color: #00a1d6;
background: #00a1d6;
font-weight: 700;
}
@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 下载助手下载、预览、诊断、分P批量
合并是实验功能:会在浏览器内加载 ffmpeg.wasm,文件大、耗内存;失败时会自动导出合并命令。
打开 B 站视频页后选择下载内容,再点“预览清单”或“开始”。
`;
document.documentElement.appendChild(panel);
fillSelect(panel.querySelector(`#${MODE_ID}`), modes, localStorage.getItem(STORAGE_MODE_KEY) || "full_merge");
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-scriptcat-preview").addEventListener("click", () => {
previewSelectedMode().catch(showError);
});
panel.querySelector("#bilifetch-scriptcat-start").addEventListener("click", () => {
runSelectedMode().catch(showError);
});
panel.querySelector("#bilifetch-scriptcat-diagnose").addEventListener("click", () => {
copyDiagnostics().catch(showError);
});
panel.querySelector("#bilifetch-scriptcat-settings").addEventListener("click", () => {
openSettingsPanel();
});
showChangelogIfNeeded();
}
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 : "full_merge";
}
function selectedScope() {
const select = document.getElementById(SCOPE_ID);
return select ? select.value : "current";
}
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_SETTINGS_KEY);
if (!raw) {
return Object.assign({}, DEFAULT_SETTINGS);
}
return Object.assign({}, DEFAULT_SETTINGS, JSON.parse(raw));
} catch (error) {
console.warn("[BiliFetch] settings load failed", error);
return Object.assign({}, DEFAULT_SETTINGS);
}
}
function saveSettings(settings) {
localStorage.setItem(STORAGE_SETTINGS_KEY, JSON.stringify(Object.assign({}, DEFAULT_SETTINGS, settings)));
}
function openSettingsPanel() {
const old = document.querySelector(".bilifetch-settings-backdrop");
if (old) {
old.remove();
}
const settings = loadSettings();
const backdrop = document.createElement("div");
backdrop.className = "bilifetch-settings-backdrop";
backdrop.innerHTML = `
`;
document.documentElement.appendChild(backdrop);
backdrop.querySelector("#bilifetch-settings-close").addEventListener("click", () => backdrop.remove());
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) {
backdrop.remove();
}
});
backdrop.querySelector("#bilifetch-settings-save").addEventListener("click", () => {
const mergeWarnMB = Number(backdrop.querySelector("#bilifetch-setting-merge-warn").value || DEFAULT_SETTINGS.mergeWarnMB);
saveSettings({
mergeWarnMB: Math.max(50, Math.min(20000, mergeWarnMB)),
showChangelog: backdrop.querySelector("#bilifetch-setting-changelog").checked,
saveCommandsOnMergeFail: backdrop.querySelector("#bilifetch-setting-commands").checked,
detailedErrorTips: backdrop.querySelector("#bilifetch-setting-tips").checked,
});
backdrop.remove();
appendStatus("设置已保存。");
});
}
function showChangelogIfNeeded() {
const settings = loadSettings();
const seenVersion = localStorage.getItem(STORAGE_SEEN_VERSION_KEY);
if (!settings.showChangelog || seenVersion === SCRIPT_VERSION) {
return;
}
showChangelog(false);
localStorage.setItem(STORAGE_SEEN_VERSION_KEY, SCRIPT_VERSION);
}
function showChangelog(force) {
const lines = [
`BiliFetch 已更新到 ${SCRIPT_VERSION}`,
"- 新增下载大小预估和大文件合并提醒。",
"- 新增只复制 ffmpeg/aria2 命令模式。",
"- 新增设置页,可调整合并提醒阈值和错误提示。",
"- 新增常见错误中文解决建议。",
"- 新增审核稳版脚本,减少外部依赖和宽泛权限。",
];
if (force) {
setStatus(lines.join("\n"));
} else {
appendStatus(`\n${lines.join("\n")}`);
}
}
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) {
lastDiagnostics = [message];
const status = document.getElementById(STATUS_ID);
if (status) {
status.textContent = message;
}
}
function appendStatus(message) {
lastDiagnostics.push(message);
if (lastDiagnostics.length > 80) {
lastDiagnostics = lastDiagnostics.slice(-80);
}
const status = document.getElementById(STATUS_ID);
if (!status) {
return;
}
const prefix = status.textContent.trim() ? "\n" : "";
status.textContent += `${prefix}${message}`;
status.scrollTop = status.scrollHeight;
}
function setProgress(percent, label) {
const bar = document.getElementById(PROGRESS_ID);
const text = document.getElementById(PROGRESS_TEXT_ID);
const safePercent = Math.max(0, Math.min(100, Number(percent || 0)));
if (bar) {
bar.style.width = `${safePercent}%`;
}
if (text) {
text.textContent = label || `${Math.round(safePercent)}%`;
}
}
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}`);
if (loadSettings().detailedErrorTips) {
const tips = diagnoseError(error);
if (tips.length) {
appendStatus(`解决建议:\n- ${tips.join("\n- ")}`);
}
}
setProgress(0, "已失败,可点“复制诊断”发给作者");
notify("BiliFetch", `操作失败:${lastError.message}`);
}
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|登录|会员|权限|地区|copyright|forbidden/i.test(message)) {
tips.push("先在浏览器里登录 B 站,并确认当前账号能正常播放这个清晰度。");
tips.push("脚本不会绕过会员、版权、地区或登录限制。");
}
if (/ffmpeg|wasm|@require|core|SharedArrayBuffer|跨源|CORS/i.test(message)) {
tips.push("自动合并依赖 ffmpeg.wasm;如果浏览器或脚本猫拦截外部库,请改用“复制ffmpeg/aria2命令”模式。");
tips.push("上传审核时可使用审核稳版脚本,它没有外部 ffmpeg 依赖。");
}
if (/memory|内存|quota|ArrayBuffer|allocation|out of/i.test(message)) {
tips.push("文件可能太大,浏览器内存不够。建议只复制命令,或使用电脑便携版合并。");
}
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 notify(title, text) {
if (typeof GM_notification === "function") {
try {
GM_notification({ title, text, timeout: 4500 });
} catch (error) {
console.warn("[BiliFetch] notification failed", error);
}
}
}
function getInitialState() {
return pageWindow.__INITIAL_STATE__ || {};
}
function getPagePlayInfo() {
const playInfo = pageWindow.__playinfo__;
if (!playInfo) {
return null;
}
return playInfo.data || playInfo.result || playInfo;
}
function getBvid() {
const pathMatch = location.pathname.match(/\/video\/(BV[a-zA-Z0-9]+)/);
if (pathMatch) {
return pathMatch[1];
}
const search = new URLSearchParams(location.search);
const queryBvid = search.get("bvid");
if (queryBvid && /^BV[a-zA-Z0-9]+$/.test(queryBvid)) {
return queryBvid;
}
const state = getInitialState();
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 站视频页,例如 https://www.bilibili.com/video/BV...。");
}
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, {
method: options.method || "GET",
credentials: "include",
headers,
});
if (!response.ok) {
throw new Error(`接口返回 HTTP ${response.status}`);
}
return response.text();
}
async function requestArrayBuffer(url, options = {}) {
const headers = Object.assign(
{
Referer: "https://www.bilibili.com/",
Origin: "https://www.bilibili.com",
},
options.headers || {},
);
if (typeof GM_xmlhttpRequest === "function") {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
headers,
responseType: "arraybuffer",
timeout: options.timeout || 180000,
onload(response) {
if (response.status >= 200 && response.status < 300) {
resolve(response.response);
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.arrayBuffer();
}
async function requestJson(url, options = {}) {
const text = await requestText(url, options);
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`接口没有返回 JSON:${error.message}`);
}
}
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) {
try {
const json = await requestJson(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`);
return unwrapBiliData(json, "视频信息接口");
} catch (error) {
const state = getInitialState();
if (state.videoData && state.videoData.bvid) {
appendStatus("视频信息接口读取失败,改用页面里已有的信息。");
return state.videoData;
}
throw error;
}
}
function pickVideoPage(view) {
const pages = Array.isArray(view.pages) ? view.pages : [];
if (!pages.length && view.cid) {
return {
cid: view.cid,
page: 1,
part: view.title || view.bvid || "video",
};
}
const current = getCurrentPageNumber();
return pages.find((item) => Number(item.page) === current) || pages[0];
}
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;
}
return [pickVideoPage(view)];
}
async function getPlayInfo(bvid, cid, allowPageCache) {
const pagePlayInfo = allowPageCache ? getPagePlayInfo() : null;
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");
const json = await requestJson(url.toString());
return unwrapBiliData(json, "播放地址接口");
}
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);
const json = await requestJson(url.toString());
return unwrapBiliData(json, "播放器信息接口");
}
async function buildBase() {
const bvid = getBvid();
appendStatus(`识别到视频:${bvid}`);
appendStatus("读取视频基础信息...");
const view = await getViewInfo(bvid);
return { bvid, view };
}
async function buildContext(base, page, options = {}) {
if (!page || !page.cid) {
throw new Error("没有找到当前分P的 cid。请刷新视频页后再试。");
}
const context = {
bvid: base.bvid,
cid: page.cid,
view: base.view,
page,
baseName: makeBaseName(base.view, page),
playInfo: null,
playerInfo: null,
};
if (options.needPlay) {
appendStatus("读取最高可用音视频流...");
context.playInfo = await getPlayInfo(base.bvid, page.cid, page === pickVideoPage(base.view));
}
if (options.needPlayer) {
appendStatus("读取字幕信息...");
context.playerInfo = await getPlayerInfo(base.bvid, page.cid);
}
return context;
}
function modeNeedsPlay(mode) {
return ["full_merge", "merge", "full", "video", "audio", "commands", "copy"].includes(mode);
}
function modeNeedsPlayer(mode) {
return ["full_merge", "full", "subtitles", "copy"].includes(mode);
}
async function previewSelectedMode() {
if (running) {
return;
}
setBusy(true);
setStatus("正在生成下载清单...");
setProgress(8, "读取页面信息");
try {
const mode = selectedMode();
const scope = selectedScope();
const base = await buildBase();
const pages = getTargetPages(base.view, scope);
const lines = [
"下载前预览",
`模式:${labelOf(modes, mode)}`,
`范围:${labelOf(scopes, scope)},共 ${pages.length} 个分P`,
"",
];
for (let index = 0; index < pages.length; index += 1) {
const page = pages[index];
const baseName = makeBaseName(base.view, page);
setProgress(10 + Math.round((index / Math.max(1, pages.length)) * 80), `预览 ${index + 1}/${pages.length}`);
lines.push(`P${page.page || index + 1}:${page.part || "未命名"}`);
let context = null;
if (modeNeedsPlay(mode)) {
try {
context = await buildContext(base, page, { needPlay: true, needPlayer: false });
} catch (error) {
lines.push(` - 大小预估失败:${error.message}`);
}
}
lines.push(...previewFilesForMode(mode, baseName, context));
lines.push("");
}
if (["full_merge", "merge"].includes(mode)) {
lines.push("合并提示:浏览器会尝试加载 ffmpeg.wasm。失败时会保存合并命令文件,电脑端可用 ffmpeg 合并。");
}
setStatus(lines.join("\n"));
setProgress(100, "预览完成");
} finally {
setBusy(false);
}
}
function previewFilesForMode(mode, baseName, context) {
const lines = [];
if (mode === "full_merge") {
lines.push(` - ${baseName}.info.json`);
lines.push(` - ${baseName}.cover.jpg/png/webp`);
lines.push(` - ${baseName}.字幕.srt/json(如果视频有字幕)`);
lines.push(` - ${baseName}.danmaku.xml/csv/txt/ass`);
lines.push(` - ${baseName}.merged.mp4(实验合并,失败则导出命令)`);
} else if (mode === "merge") {
lines.push(` - ${baseName}.merged.mp4(实验合并,失败则导出命令)`);
} else if (mode === "full") {
lines.push(` - ${baseName}.info.json`);
lines.push(` - ${baseName}.cover.jpg/png/webp`);
lines.push(` - ${baseName}.字幕.srt/json(如果视频有字幕)`);
lines.push(` - ${baseName}.danmaku.xml/csv/txt/ass`);
lines.push(` - ${baseName}.video-最高画质.m4s 和 ${baseName}.audio-最高音质.m4s`);
} else if (mode === "video") {
lines.push(` - ${baseName}.video-最高画质.m4s 或完整 mp4`);
} else if (mode === "audio") {
lines.push(` - ${baseName}.audio-最高音质.m4s`);
} else if (mode === "subtitles") {
lines.push(` - ${baseName}.字幕.srt/json(如果视频有字幕)`);
} else if (mode === "danmaku") {
lines.push(` - ${baseName}.danmaku.xml/csv/txt/ass`);
} else if (mode === "cover") {
lines.push(` - ${baseName}.cover.jpg/png/webp`);
} else if (mode === "info") {
lines.push(` - ${baseName}.info.json`);
} else if (mode === "commands") {
lines.push(" - 复制 aria2 下载命令和 ffmpeg 合并命令");
lines.push(` - ${baseName}.commands.txt`);
} else if (mode === "copy") {
lines.push(" - 复制标题、BV号、CID、封面、弹幕、音视频直链和字幕链接");
}
if (context && context.playInfo) {
lines.push(` - 估算大小:${formatEstimate(estimateDownloadSize(context))}`);
}
return lines;
}
async function runSelectedMode() {
if (running) {
return;
}
setBusy(true);
setStatus("开始处理...");
setProgress(5, "准备中");
try {
const mode = selectedMode();
const scope = selectedScope();
const base = await buildBase();
const pages = getTargetPages(base.view, scope);
appendStatus(`任务范围:${labelOf(scopes, scope)},共 ${pages.length} 个分P。`);
for (let index = 0; index < pages.length; index += 1) {
const page = pages[index];
const pageLabel = `P${page.page || index + 1} ${page.part || ""}`.trim();
appendStatus(`\n[${index + 1}/${pages.length}] 开始:${pageLabel}`);
setProgress(Math.round((index / Math.max(1, pages.length)) * 90), `处理 ${index + 1}/${pages.length}`);
const context = await buildContext(base, page, {
needPlay: modeNeedsPlay(mode),
needPlayer: modeNeedsPlayer(mode),
});
await runModeForContext(mode, context);
}
setProgress(100, "全部任务已发起或保存");
appendStatus("完成检查:脚本已把能处理的内容交给浏览器下载、保存或复制。");
} finally {
setBusy(false);
}
}
async function runModeForContext(mode, context) {
if (context.playInfo && modeNeedsPlay(mode)) {
appendEstimateForContext(context, mode);
}
if (mode === "full_merge") {
await exportInfo(context);
await exportCover(context);
await exportSubtitles(context);
await exportDanmaku(context);
await mergeOrGuide(context);
} else if (mode === "merge") {
await mergeOrGuide(context);
} else if (mode === "full") {
await exportInfo(context);
await exportCover(context);
await exportSubtitles(context);
await exportDanmaku(context);
startBestVideoDownload(context);
startBestAudioDownload(context);
appendStatus("完整导出已发起。此模式不合并,视频流和音频流通常是两个文件。");
} 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") {
await exportInfo(context);
} else if (mode === "commands") {
await copyCommandReport(context);
} else if (mode === "copy") {
await copyLinkReport(context);
} else {
throw new Error(`未知模式:${mode}`);
}
}
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 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,
backupUrls: item.backupUrl || item.backup_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,
backupUrls: item.backupUrl || item.backup_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 estimateDownloadSize(context) {
const streams = collectStreams(context.playInfo);
const duration = getDurationSeconds(context);
const videoBytes = streams.bestVideo ? estimateStreamBytes(streams.bestVideo, duration) : 0;
const audioBytes = streams.bestAudio ? estimateStreamBytes(streams.bestAudio, duration) : 0;
const durlBytes = streams.durls.reduce((sum, item) => sum + Number(item.size || 0), 0);
const totalBytes = durlBytes || videoBytes + audioBytes;
return {
duration,
videoBytes,
audioBytes,
durlBytes,
totalBytes,
precise: Boolean(durlBytes),
};
}
function estimateStreamBytes(stream, durationSeconds) {
const directSize = Number(stream.raw && (stream.raw.size || stream.raw.filesize || stream.raw.file_size));
if (Number.isFinite(directSize) && directSize > 0) {
return directSize;
}
const bandwidth = Number(stream.bandwidth || (stream.raw && stream.raw.bandwidth) || 0);
if (!bandwidth || !durationSeconds) {
return 0;
}
return Math.round((bandwidth * durationSeconds) / 8);
}
function getDurationSeconds(context) {
const playMs = Number(context.playInfo && context.playInfo.timelength);
if (Number.isFinite(playMs) && playMs > 0) {
return playMs / 1000;
}
const pageDuration = Number(context.page && context.page.duration);
if (Number.isFinite(pageDuration) && pageDuration > 0) {
return pageDuration;
}
const viewDuration = Number(context.view && context.view.duration);
if (Number.isFinite(viewDuration) && viewDuration > 0) {
return viewDuration;
}
return 0;
}
function formatEstimate(estimate) {
const parts = [];
if (estimate.videoBytes) {
parts.push(`视频约 ${formatBytes(estimate.videoBytes)}`);
}
if (estimate.audioBytes) {
parts.push(`音频约 ${formatBytes(estimate.audioBytes)}`);
}
if (estimate.durlBytes) {
parts.push(`完整视频 ${formatBytes(estimate.durlBytes)}`);
}
if (!parts.length) {
return "暂时无法估算";
}
parts.push(`总计${estimate.precise ? "" : "约"} ${formatBytes(estimate.totalBytes)}`);
return parts.join(",");
}
function appendEstimateForContext(context, mode) {
const estimate = estimateDownloadSize(context);
if (!estimate.totalBytes) {
appendStatus("大小预估:暂时无法估算。");
return;
}
appendStatus(`大小预估:${formatEstimate(estimate)}`);
const settings = loadSettings();
const warnBytes = Number(settings.mergeWarnMB || DEFAULT_SETTINGS.mergeWarnMB) * 1024 * 1024;
if (["full_merge", "merge"].includes(mode) && estimate.totalBytes >= warnBytes) {
appendStatus(`提醒:预计 ${formatBytes(estimate.totalBytes)},超过设置阈值 ${settings.mergeWarnMB} MB。浏览器内合并可能很慢或失败,建议改用“复制ffmpeg/aria2命令”。`);
}
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!value) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let index = 0;
let size = value;
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 streamScore(item) {
return Number(item.height || 0) * 100000000 + Number(item.width || 0) * 100000 + Number(item.bandwidth || 0);
}
function startBestVideoDownload(context) {
const streams = collectStreams(context.playInfo);
if (streams.durls.length) {
streams.durls.forEach((item) => {
const suffix = streams.durls.length > 1 ? `.part${String(item.order).padStart(2, "0")}` : "";
startRemoteDownload(item.url, `${context.baseName}${suffix}.mp4`, `完整视频${suffix || ""}`);
});
return;
}
if (!streams.bestVideo) {
appendStatus("没有拿到视频流。可能需要登录、会员权限,或 B 站接口规则变化。");
return;
}
const label = makeVideoLabel(streams.bestVideo);
startRemoteDownload(streams.bestVideo.url, `${context.baseName}.video-${label}.m4s`, `视频流 ${label}`);
appendStatus("提示:DASH 视频流通常没有声音,需要声音请再下载音频或使用合并模式。");
}
function startBestAudioDownload(context) {
const streams = collectStreams(context.playInfo);
if (!streams.bestAudio) {
if (streams.durls.length) {
appendStatus("当前页面只给了完整视频地址,浏览器脚本不能从完整视频里单独拆出音频。");
} else {
appendStatus("没有拿到音频流。可能需要登录、会员权限,或 B 站接口规则变化。");
}
return;
}
const label = makeAudioLabel(streams.bestAudio);
startRemoteDownload(streams.bestAudio.url, `${context.baseName}.audio-${label}.m4s`, `音频 ${label}`);
}
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 startRemoteDownload(url, filename, label) {
if (!url) {
appendStatus(`${label}:没有可用链接。`);
return;
}
const safeName = sanitizeDownloadFilename(filename);
if (typeof GM_download === "function") {
try {
GM_download({
url,
name: safeName,
headers: {
Referer: location.href,
Origin: "https://www.bilibili.com",
},
saveAs: false,
onload() {
appendStatus(`${label}:浏览器下载完成。`);
},
onerror(error) {
appendStatus(`${label}:下载失败,已复制直链。请确认脚本猫和浏览器允许下载。`);
copyText(url);
console.warn("[BiliFetch] GM_download failed", error);
},
ontimeout() {
appendStatus(`${label}:下载超时,已复制直链。`);
copyText(url);
},
});
appendStatus(`${label}:已交给浏览器下载 -> ${safeName}`);
return;
} catch (error) {
appendStatus(`${label}:脚本下载接口不可用,改为复制直链。`);
console.warn("[BiliFetch] GM_download exception", error);
}
}
copyText(url);
window.open(url, "_blank", "noopener,noreferrer");
appendStatus(`${label}:已复制直链并尝试打开新标签页。`);
}
async function mergeOrGuide(context) {
const streams = collectStreams(context.playInfo);
if (streams.durls.length) {
appendStatus("当前接口给出了完整 MP4,不需要合并,直接下载完整视频。");
startBestVideoDownload(context);
return;
}
if (!streams.bestVideo || !streams.bestAudio) {
appendStatus("缺少视频流或音频流,无法合并。已导出合并说明。");
if (loadSettings().saveCommandsOnMergeFail) {
exportMergeGuide(context, streams);
}
return;
}
try {
await mergeWithFfmpegWasm(context, streams.bestVideo, streams.bestAudio);
} catch (error) {
appendStatus(`浏览器内合并失败:${error.message}`);
appendStatus("已改为保存合并命令。用户也可以分别下载视频流和音频流后在电脑端合并。");
if (loadSettings().saveCommandsOnMergeFail) {
exportMergeGuide(context, streams);
}
startRemoteDownload(streams.bestVideo.url, `${context.baseName}.video-${makeVideoLabel(streams.bestVideo)}.m4s`, "待合并视频流");
startRemoteDownload(streams.bestAudio.url, `${context.baseName}.audio-${makeAudioLabel(streams.bestAudio)}.m4s`, "待合并音频");
}
}
async function mergeWithFfmpegWasm(context, video, audio) {
appendStatus("开始浏览器内合并:加载 ffmpeg.wasm,首次使用会比较慢。");
setProgress(15, "加载合并引擎");
const ffmpeg = await getFfmpeg();
appendStatus("下载视频流到浏览器内存...");
setProgress(30, "下载视频流");
const videoBytes = new Uint8Array(await requestArrayBuffer(video.url));
appendStatus("下载音频流到浏览器内存...");
setProgress(48, "下载音频流");
const audioBytes = new Uint8Array(await requestArrayBuffer(audio.url));
const videoName = "input-video.m4s";
const audioName = "input-audio.m4s";
const outputName = "output.mp4";
appendStatus("写入临时文件并开始合并...");
setProgress(62, "写入临时文件");
await ffmpeg.writeFile(videoName, videoBytes);
await ffmpeg.writeFile(audioName, audioBytes);
setProgress(72, "正在合并 MP4");
await ffmpeg.exec([
"-i",
videoName,
"-i",
audioName,
"-c",
"copy",
"-movflags",
"faststart",
outputName,
]);
const output = await ffmpeg.readFile(outputName);
const blob = new Blob([output], { type: "video/mp4" });
downloadBlob(`${context.baseName}.merged.mp4`, blob);
setProgress(92, "清理临时文件");
await cleanupFfmpegFiles(ffmpeg, [videoName, audioName, outputName]);
appendStatus(`自动合并完成:${context.baseName}.merged.mp4`);
}
async function getFfmpeg() {
if (ffmpegInstance && ffmpegLoaded) {
return ffmpegInstance;
}
const ffmpegLib = pageWindow.FFmpegWASM;
const utilLib = pageWindow.FFmpegUtil;
if (!ffmpegLib || !ffmpegLib.FFmpeg || !utilLib || !utilLib.toBlobURL) {
throw new Error("ffmpeg.wasm 没有加载成功。可能是脚本猫禁止 @require,或浏览器拦截了外部库。");
}
let lastLoadError = null;
for (const base of FFMPEG_CORE_BASES) {
const candidate = new ffmpegLib.FFmpeg();
candidate.on("log", ({ message }) => {
if (message && /error|invalid|failed/i.test(message)) {
console.warn("[BiliFetch ffmpeg]", message);
}
});
candidate.on("progress", ({ progress }) => {
if (Number.isFinite(progress)) {
setProgress(72 + Math.round(progress * 18), "正在合并 MP4");
}
});
try {
await candidate.load({
coreURL: await utilLib.toBlobURL(`${base}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await utilLib.toBlobURL(`${base}/ffmpeg-core.wasm`, "application/wasm"),
});
ffmpegInstance = candidate;
ffmpegLoaded = true;
return ffmpegInstance;
} catch (error) {
lastLoadError = error;
console.warn("[BiliFetch] ffmpeg core load failed", base, error);
}
}
throw new Error(`ffmpeg.wasm 核心加载失败:${lastLoadError ? lastLoadError.message : "未知错误"}`);
}
async function cleanupFfmpegFiles(ffmpeg, names) {
for (const name of names) {
try {
await ffmpeg.deleteFile(name);
} catch (error) {
console.warn("[BiliFetch] cleanup failed", name, error);
}
}
}
function exportMergeGuide(context, streams) {
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`;
const text = [
"BiliFetch 合并说明",
"",
"浏览器内自动合并失败或不可用时,可以在电脑端使用 ffmpeg 合并。",
"",
"步骤:",
"1. 先把视频流和音频流下载到同一个文件夹。",
"2. 文件名建议改成下面这两个:",
` ${videoName}`,
` ${audioName}`,
"3. 在这个文件夹里打开命令行,执行:",
"",
`ffmpeg -i "${videoName}" -i "${audioName}" -c copy "${outputName}"`,
"",
"直链信息:",
`视频流:${video ? video.url : "未获取到"}`,
`音频流:${audio ? audio.url : "未获取到"}`,
"",
"说明:直链会过期,请尽快使用。请只下载自己有权保存和使用的内容。",
].join("\n");
downloadText(`${context.baseName}.merge-guide.txt`, text, "text/plain;charset=utf-8");
}
async function exportInfo(context) {
const streams = collectStreams(context.playInfo);
const payload = {
exported_at: new Date().toISOString(),
source_url: location.href,
bvid: context.bvid,
cid: context.cid,
title: context.view.title || "",
page: {
page: context.page.page,
part: context.page.part || "",
cid: context.page.cid,
},
owner: context.view.owner || null,
stat: context.view.stat || null,
selected_streams: {
video: streams.bestVideo ? summarizeStream(streams.bestVideo) : null,
audio: streams.bestAudio ? summarizeStream(streams.bestAudio) : null,
complete_video_count: streams.durls.length,
},
raw_view: context.view,
};
downloadText(`${context.baseName}.info.json`, JSON.stringify(payload, null, 2), "application/json;charset=utf-8");
}
function summarizeStream(stream) {
return {
id: stream.id || null,
codecs: stream.codecs || "",
width: stream.width || null,
height: stream.height || null,
bandwidth: stream.bandwidth || null,
url: stream.url || "",
};
}
async function exportCover(context) {
const url = normalizeUrl(context.view.pic || "");
if (!url) {
appendStatus("没有找到封面地址。");
return;
}
const ext = getExtensionFromUrl(url, "jpg");
startRemoteDownload(url, `${context.baseName}.cover.${ext}`, "封面");
}
async function exportDanmaku(context) {
appendStatus("读取弹幕...");
const url = `https://comment.bilibili.com/${encodeURIComponent(context.cid)}.xml`;
const xml = await requestText(url, { timeout: 25000 });
if (!xml.trim()) {
appendStatus("弹幕为空。");
return;
}
downloadText(`${context.baseName}.danmaku.xml`, xml, "application/xml;charset=utf-8");
const rows = parseDanmakuXml(xml);
if (!rows.length) {
appendStatus("弹幕 XML 已保存,但没有解析到普通弹幕行。");
return;
}
downloadText(`${context.baseName}.danmaku.csv`, danmakuToCsv(rows), "text/csv;charset=utf-8");
downloadText(`${context.baseName}.danmaku.txt`, danmakuToText(rows), "text/plain;charset=utf-8");
downloadText(`${context.baseName}.danmaku.ass`, danmakuToAss(rows), "text/plain;charset=utf-8");
appendStatus(`弹幕已保存:${rows.length} 条。`);
}
function parseDanmakuXml(xml) {
const doc = new DOMParser().parseFromString(xml, "application/xml");
return Array.from(doc.querySelectorAll("d")).map((node) => {
const parts = String(node.getAttribute("p") || "").split(",");
return {
time: Number(parts[0] || 0),
mode: parts[1] || "",
size: parts[2] || "",
color: parts[3] || "",
timestamp: parts[4] || "",
pool: parts[5] || "",
userHash: parts[6] || "",
rowId: parts[7] || "",
text: node.textContent || "",
};
});
}
function danmakuToCsv(rows) {
const header = ["time", "mode", "size", "color", "timestamp", "pool", "userHash", "rowId", "text"];
const lines = [header.join(",")];
for (const row of rows) {
lines.push(
header
.map((key) => {
const value = row[key] == null ? "" : String(row[key]);
return `"${value.replace(/"/g, '""')}"`;
})
.join(","),
);
}
return `\uFEFF${lines.join("\n")}`;
}
function danmakuToText(rows) {
return rows.map((row) => `[${formatShortTime(row.time)}] ${row.text}`).join("\n");
}
function danmakuToAss(rows) {
const header = [
"[Script Info]",
"ScriptType: v4.00+",
"PlayResX: 1920",
"PlayResY: 1080",
"",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style: Default,Arial,42,&H00FFFFFF,&H000000FF,&H00222222,&H66000000,0,0,0,0,100,100,0,0,1,2,0,8,20,20,30,1",
"",
"[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
];
const events = rows.map((row) => {
const start = formatAssTime(row.time);
const end = formatAssTime(row.time + 4);
return `Dialogue: 0,${start},${end},Default,,0,0,0,,${escapeAssText(row.text)}`;
});
return header.concat(events).join("\n");
}
async function exportSubtitles(context) {
const playerInfo = context.playerInfo || (await getPlayerInfo(context.bvid, context.cid).catch((error) => {
appendStatus(`字幕信息读取失败:${error.message}`);
return null;
}));
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 subtitleUrl = normalizeUrl(subtitle.subtitle_url || "");
if (!subtitleUrl) {
continue;
}
const label = sanitizeFilename(subtitle.lan_doc || subtitle.lan || `subtitle-${index + 1}`);
appendStatus(`读取字幕:${label}`);
const data = await requestJson(subtitleUrl, { timeout: 25000 });
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");
}
}
appendStatus(`字幕导出完成:${subtitles.length} 组。`);
}
function subtitleToSrt(items) {
return items
.map((item, index) => {
const start = formatSrtTime(Number(item.from || 0));
const end = formatSrtTime(Number(item.to || item.from || 0));
const content = String(item.content || "").replace(/\r/g, "").trim();
return `${index + 1}\n${start} --> ${end}\n${content}\n`;
})
.join("\n");
}
async function copyCommandReport(context) {
const report = buildCommandReport(context);
await copyText(report);
downloadText(`${context.baseName}.commands.txt`, report, "text/plain;charset=utf-8");
appendStatus("已复制并保存 ffmpeg/aria2 命令。");
}
function buildCommandReport(context) {
const streams = collectStreams(context.playInfo);
const lines = [
"BiliFetch 命令模式",
"",
`标题:${context.view.title || ""}`,
`页面:${location.href}`,
`BV号:${context.bvid}`,
`CID:${context.cid}`,
`分P:${context.page.page || 1} ${context.page.part || ""}`.trim(),
`大小预估:${formatEstimate(estimateDownloadSize(context))}`,
"",
"说明:",
"1. 直链会过期,请尽快使用。",
"2. 命令需要电脑上已经安装 aria2c 和 ffmpeg。",
"3. 如果没有 aria2c,也可以把直链复制到其他下载工具。",
"",
];
if (streams.durls.length) {
lines.push("完整视频下载命令:");
streams.durls.forEach((item) => {
const suffix = streams.durls.length > 1 ? `.part${String(item.order).padStart(2, "0")}` : "";
const filename = `${context.baseName}${suffix}.mp4`;
lines.push(buildAria2Command(item.url, filename));
});
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(buildFfmpegLocalCommand(videoName, audioName, outputName));
lines.push("");
lines.push("备用:ffmpeg 直接读取直链合并,失败时请改用上面的 aria2 下载后本地合并。");
if (video && audio) {
lines.push(buildFfmpegUrlCommand(video.url, audio.url, 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)}"`;
}
function buildFfmpegLocalCommand(videoName, audioName, outputName) {
return `ffmpeg -i "${escapeCommandArg(videoName)}" -i "${escapeCommandArg(audioName)}" -c copy "${escapeCommandArg(outputName)}"`;
}
function buildFfmpegUrlCommand(videoUrl, audioUrl, outputName) {
const headers = "Referer: https://www.bilibili.com/\\r\\nOrigin: https://www.bilibili.com\\r\\n";
return `ffmpeg -headers "${headers}" -i "${escapeCommandArg(videoUrl)}" -headers "${headers}" -i "${escapeCommandArg(audioUrl)}" -c copy "${escapeCommandArg(outputName)}"`;
}
function escapeCommandArg(value) {
return String(value || "").replace(/"/g, '\\"');
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
async function copyLinkReport(context) {
const streams = collectStreams(context.playInfo);
const subtitleLines = [];
const subtitles = context.playerInfo && context.playerInfo.subtitle && Array.isArray(context.playerInfo.subtitle.subtitles)
? context.playerInfo.subtitle.subtitles
: [];
subtitles.forEach((subtitle, index) => {
subtitleLines.push(`字幕${index + 1}(${subtitle.lan_doc || subtitle.lan || "unknown"}): ${normalizeUrl(subtitle.subtitle_url || "")}`);
});
const lines = [
"BiliFetch 链接信息",
`标题: ${context.view.title || ""}`,
`页面: ${location.href}`,
`BV号: ${context.bvid}`,
`CID: ${context.cid}`,
`分P: ${context.page.page || 1} ${context.page.part || ""}`.trim(),
`封面: ${normalizeUrl(context.view.pic || "")}`,
`弹幕XML: https://comment.bilibili.com/${context.cid}.xml`,
];
if (streams.durls.length) {
streams.durls.forEach((item) => lines.push(`完整视频${item.order}: ${item.url}`));
}
if (streams.bestVideo) {
lines.push(`最高视频流(${makeVideoLabel(streams.bestVideo)}): ${streams.bestVideo.url}`);
}
if (streams.bestAudio) {
lines.push(`最高音频(${makeAudioLabel(streams.bestAudio)}): ${streams.bestAudio.url}`);
}
lines.push(...subtitleLines);
lines.push("");
lines.push("提示: 浏览器内自动合并依赖 ffmpeg.wasm,失败时可用合并说明里的 ffmpeg 命令。");
await copyText(lines.join("\n"));
appendStatus("已复制视频信息和可用直链。");
}
async function copyDiagnostics() {
const suggestions = lastError ? diagnoseError(lastError) : [];
const payload = {
name: "BiliFetch ScriptCat diagnostics",
version: SCRIPT_VERSION,
time: new Date().toISOString(),
url: location.href,
userAgent: navigator.userAgent,
bvid: safeCall(getBvid),
mode: selectedMode(),
scope: selectedScope(),
settings: loadSettings(),
hasGMDownload: typeof GM_download === "function",
hasGMXmlhttpRequest: typeof GM_xmlhttpRequest === "function",
hasGMClipboard: typeof GM_setClipboard === "function",
hasFFmpegWasm: Boolean(pageWindow.FFmpegWASM && pageWindow.FFmpegWASM.FFmpeg),
hasFFmpegUtil: Boolean(pageWindow.FFmpegUtil && pageWindow.FFmpegUtil.toBlobURL),
lastError,
suggestions,
recentStatus: lastDiagnostics,
};
await copyText(JSON.stringify(payload, null, 2));
setStatus("诊断信息已复制。你可以把剪贴板内容发给作者排查。");
setProgress(100, "诊断已复制");
}
function safeCall(fn) {
try {
return fn();
} catch (error) {
return `读取失败:${error.message}`;
}
}
function downloadText(filename, content, mimeType) {
const blob = new Blob([content], { type: mimeType || "text/plain;charset=utf-8" });
downloadBlob(filename, blob);
}
function downloadBlob(filename, blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = sanitizeDownloadFilename(filename);
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 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 normalizeUrl(url) {
if (!url) {
return "";
}
if (url.startsWith("//")) {
return `https:${url}`;
}
try {
return new URL(url, location.href).toString();
} catch (error) {
return url;
}
}
function getExtensionFromUrl(url, fallback) {
try {
const pathname = new URL(url).pathname.toLowerCase();
const match = pathname.match(/\.([a-z0-9]{2,5})$/);
if (match) {
return match[1];
}
} catch (error) {
console.warn("[BiliFetch] cannot read extension", 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 formatAssTime(seconds) {
const totalCs = Math.max(0, Math.round(seconds * 100));
const cs = totalCs % 100;
const totalSeconds = Math.floor(totalCs / 100);
const s = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const m = totalMinutes % 60;
const h = Math.floor(totalMinutes / 60);
return `${h}:${pad(m)}:${pad(s)}.${String(cs).padStart(2, "0")}`;
}
function formatShortTime(seconds) {
const totalSeconds = Math.max(0, Math.floor(Number(seconds || 0)));
const s = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const m = totalMinutes % 60;
const h = Math.floor(totalMinutes / 60);
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
}
function pad(value) {
return String(value).padStart(2, "0");
}
function escapeAssText(value) {
return String(value || "").replace(/[{}]/g, "").replace(/\n/g, "\\N");
}
function labelOf(items, value) {
const item = items.find(([itemValue]) => itemValue === value);
return item ? item[1] : value;
}
if (typeof GM_registerMenuCommand === "function") {
GM_registerMenuCommand("打开 BiliFetch 面板", installPanel);
GM_registerMenuCommand("打开 BiliFetch 设置", openSettingsPanel);
GM_registerMenuCommand("查看 BiliFetch 更新日志", () => showChangelog(true));
GM_registerMenuCommand("复制 BiliFetch 诊断", () => copyDiagnostics().catch(showError));
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", installPanel, { once: true });
} else {
installPanel();
}
})();