最近有下载B站视频的需求,做了一个油猴脚本,用起来还是很不错的,分享给大家。
// ==UserScript==
// @name B站视频下载助手
// @namespace https://example.com/
// @version 0.4.0
// @description bilibili 下载助手:支持清晰度选择、批量分P/剧集下载、复制 ffmpeg 命令
// @AuThor Taoao.wei
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/bangumi/play/*
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant GM_addStyle
// @grant unsafeWindow
// @connect bilibili.com
// @connect *.bilibili.com
// @connect *.bilivideo.com
// @connect bilivideo.com
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const PANEL_ID = "tm-bili-download-panel-plus-fixed";
const state = {
href: location.href,
meta: null,
formats: [],
currentPlayInfo: null,
logs: [],
isQueueRunning: false,
queueMode: "idle",
queueItems: [],
queueIndex: 0,
queueTotal: 0,
queueDone: 0,
queueFailed: 0,
currentTaskLabel: "",
queueRunId: 0,
};
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function $(selector, root = document) {
return root.querySelector(selector);
}
function sanitizeFileName(name) {
return String(name || "bilibili_video")
.replace(/[\\/:*?"<>|]+/g, "_")
.replace(/\s+/g, " ")
.replace(/\.+$/g, "")
.trim()
.slice(0, 150);
}
function pad2(n) {
return String(n).padStart(2, "0");
}
function uniqBy(arr, keyFn) {
const map = new Map();
for (const item of arr) map.set(keyFn(item), item);
return [...map.values()];
}
function addLog(text) {
const line = `[${new Date().toLocaleTimeString()}] ${text}`;
state.logs.push(line);
state.logs = state.logs.slice(-12);
const logEl = $(`#${PANEL_ID} .tm-log`);
if (logEl) logEl.textContent = state.logs.join("\n");
}
function setStatus(text) {
const statusEl = $(`#${PANEL_ID} .tm-status`);
if (statusEl) statusEl.textContent = text;
}
function renderQueueState() {
const progressTextEl = $(`#${PANEL_ID} .tm-progress-text`);
const progressFillEl = $(`#${PANEL_ID} .tm-progress-fill`);
const currentTaskEl = $(`#${PANEL_ID} .tm-current-task`);
const addBtn = $(`#${PANEL_ID} button[data-action="queue-current"]`);
const addAllBtn = $(`#${PANEL_ID} button[data-action="queue-all"]`);
const stopBtn = $(`#${PANEL_ID} button[data-action="stop-all"]`);
const total = state.queueTotal || 0;
const done = Math.min(state.queueDone || 0, total);
const percent = total
? Math.max(0, Math.min(100, (done / total) * 100))
: 0;
let suffix = "(空闲)";
if (state.queueMode === "running") {
suffix = state.queueFailed
? `(运行中,失败 ${state.queueFailed})`
: "(运行中)";
} else if (state.queueMode === "stopping") {
suffix = "(停止中)";
} else if (total) {
suffix = state.queueFailed
? `(完成,失败 ${state.queueFailed})`
: "(完成)";
}
if (progressTextEl)
progressTextEl.textContent = `进度:${done}/${total}${suffix}`;
if (progressFillEl) progressFillEl.style.width = `${percent}%`;
let currentText = "当前:空闲";
if (state.currentTaskLabel) {
currentText = `当前:${state.currentTaskLabel}`;
} else if (state.queueMode === "stopping") {
currentText = "当前:等待当前条目收尾";
} else if (!state.isQueueRunning && total && done >= total) {
currentText = "当前:已完成";
}
if (currentTaskEl) currentTaskEl.textContent = currentText;
if (addBtn) addBtn.disabled = state.queueMode === "stopping";
if (addAllBtn) addAllBtn.disabled = state.queueMode === "stopping";
if (stopBtn) stopBtn.disabled = !state.isQueueRunning;
}
function setProgress(current, total) {
state.queueDone = current;
state.queueTotal = total;
renderQueueState();
}
function setCurrentTask(label) {
state.currentTaskLabel = label;
renderQueueState();
}
function clearQueueTracking(resetProgress = false) {
state.queueItems = [];
state.queueIndex = 0;
state.currentTaskLabel = "";
if (resetProgress) {
state.queueTotal = 0;
state.queueDone = 0;
state.queueFailed = 0;
}
renderQueueState();
}
function formatQueueTaskLabel(item) {
if (!item) return "";
const page = item.page || item;
const pageLabel =
item.scope === "current"
? `当前P${pad2(page.page || 1)}`
: state.queueTotal > 1
? `P${pad2(page.page || 1)}`
: `P${pad2(page.page || 1)}`;
const qualityLabel =
item.requestedLabel || item.qualityLabel || "当前清晰度";
return `${pageLabel} ${page.part} - ${qualityLabel}`;
}
function readVarFromScripts(varName) {
const scripts = Array.from(document.scripts);
for (const script of scripts) {
const text = script.textContent || "";
if (!text.includes(varName)) continue;
const patterns = [
new RegExp(`window\\.${varName}\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*;`),
new RegExp(`${varName}\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*;`),
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (!match) continue;
try {
return JSON.parse(match[1]);
} catch {}
}
}
return null;
}
function getInitialState() {
return (
unsafeWindow.__INITIAL_STATE__ || readVarFromScripts("__INITIAL_STATE__")
);
}
function getPlayInfo() {
return unsafeWindow.__playinfo__ || readVarFromScripts("__playinfo__");
}
async function waitForContext(timeout = 12000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const meta = buildMeta();
const playInfo = getPlayInfo();
if (meta && playInfo?.data) return { meta, playInfo };
await sleep(400);
}
return { meta: buildMeta(), playInfo: getPlayInfo() };
}
function currentVideoPageIndex(pages) {
const url = new URL(location.href);
const p = Number(url.searchParams.get("p") || "1");
if (!Number.isFinite(p) || p < 1) return 1;
return Math.min(p, Math.max(1, pages.length || 1));
}
function buildVideoMetaFromState(s) {
const videoData = s?.videoData;
if (!videoData?.bvid || !Array.isArray(videoData.pages)) return null;
const title = sanitizeFileName(
videoData.title ||
document.title.replace(/_哔哩哔哩_bilibili$/, "").trim(),
);
const pages = videoData.pages.map((item, idx) => ({
page: idx + 1,
cid: item.cid,
part: sanitizeFileName(item.part || `P${idx + 1}`),
bvid: videoData.bvid,
}));
const currentPage = currentVideoPageIndex(pages);
return {
type: "video",
title,
bvid: videoData.bvid,
pages,
currentPage,
current: pages[currentPage - 1],
};
}
function buildBangumiMetaFromState(s) {
const epInfo = s?.epInfo;
if (!epInfo?.cid) return null;
const seriesTitle = sanitizeFileName(
s?.h1Title ||
s?.mediaInfo?.title ||
document.title.replace(/_哔哩哔哩_bilibili$/, "").trim(),
);
const epList =
Array.isArray(s?.epList) && s.epList.length ? s.epList : [epInfo];
const pages = epList.map((ep, idx) => ({
page: idx + 1,
cid: ep.cid,
bvid: ep.bvid || epInfo.bvid,
part: sanitizeFileName(
[ep.titleFormat, ep.long_title].filter(Boolean).join(" ") ||
`EP${idx + 1}`,
),
}));
const currentCid = epInfo.cid;
const currentIndex = Math.max(
0,
pages.findIndex((item) => item.cid === currentCid),
);
return {
type: "bangumi",
title: seriesTitle,
bvid: epInfo.bvid,
pages,
currentPage: currentIndex + 1,
current: pages[currentIndex],
};
}
function buildMeta() {
const s = getInitialState();
return buildVideoMetaFromState(s) || buildBangumiMetaFromState(s);
}
function getFormatsFromPlayInfo(playInfo) {
return uniqBy(
(playInfo?.data?.support_formats || []).map((item) => ({
qn: item.quality,
label:
item.new_description ||
item.display_desc ||
item.format ||
String(item.quality),
})),
(item) => item.qn,
).sort((a, b) => b.qn - a.qn);
}
function pickBestAudio(data) {
const candidates = [];
if (Array.isArray(data?.dash?.audio)) candidates.push(...data.dash.audio);
if (data?.dash?.flac?.audio) candidates.push(data.dash.flac.audio);
if (Array.isArray(data?.dash?.dolby?.audio))
candidates.push(...data.dash.dolby.audio);
if (!candidates.length) return null;
return [...candidates].sort((a, b) => {
const aFlac = /flac/i.test(a?.codecs || "") ? 1 : 0;
const bFlac = /flac/i.test(b?.codecs || "") ? 1 : 0;
if (bFlac !== aFlac) return bFlac - aFlac;
return (b.bandwidth || 0) - (a.bandwidth || 0);
})[0];
}
function pickBestVideo(data, quality) {
const videos = Array.isArray(data?.dash?.video) ? data.dash.video : [];
if (!videos.length) return null;
const exact = videos.filter((item) => item.id === quality);
const list = exact.length ? exact : videos;
return [...list].sort((a, b) => {
const h = (b.height || 0) - (a.height || 0);
if (h !== 0) return h;
return (b.bandwidth || 0) - (a.bandwidth || 0);
})[0];
}
function detectSingleFileExt(url) {
if (/\.flv(\?|$)/i.test(url)) return "flv";
if (/\.mp4(\?|$)/i.test(url)) return "mp4";
return "mp4";
}
function audioSaveExt(stream) {
return /flac/i.test(stream?.codecs || "") ? "flac" : "m4a";
}
function videoSaveExt() {
return "mp4";
}
function buildBaseName(meta, page, qualityLabel) {
const prefix =
meta.type === "bangumi"
? `[${page.bvid || meta.bvid || "BILI"}] ${meta.title}`
: `[${meta.bvid || "BILI"}] ${meta.title}`;
const pageSuffix =
meta.pages.length > 1
? ` - P${pad2(page.page)} ${page.part}`
: page.part && page.part !== meta.title
? ` - ${page.part}`
: "";
return sanitizeFileName(`${prefix}${pageSuffix} - ${qualityLabel}`);
}
function buildDownloadInfo(meta, page, playData, requestedQn, qnLabelMap) {
if (playData?.dash) {
const actualQn = playData.quality || requestedQn;
const qualityLabel =
qnLabelMap.get(actualQn) ||
qnLabelMap.get(requestedQn) ||
`${actualQn}P`;
const video = pickBestVideo(playData, actualQn);
const audio = pickBestAudio(playData);
if (!video) throw new Error("没有找到对应的视频流");
const baseName = buildBaseName(meta, page, qualityLabel);
return {
type: "dash",
actualQn,
qualityLabel,
videoUrl: video.baseUrl || video.base_url,
audioUrl: audio ? audio.baseUrl || audio.base_url : "",
videoName: `${baseName}.video.${videoSaveExt()}`,
audioName: audio ? `${baseName}.audio.${audioSaveExt(audio)}` : "",
outputName: `${baseName}.merged.mp4`,
};
}
if (Array.isArray(playData?.durl) && playData.durl.length > 0) {
const actualQn = playData.quality || requestedQn;
const qualityLabel =
qnLabelMap.get(actualQn) ||
qnLabelMap.get(requestedQn) ||
`${actualQn}P`;
const baseName = buildBaseName(meta, page, qualityLabel);
const ext = detectSingleFileExt(playData.durl[0].url);
return {
type: "single",
actualQn,
qualityLabel,
singleUrl: playData.durl[0].url,
fileName: `${baseName}.${ext}`,
};
}
throw new Error("当前页面没有可识别的下载流");
}
async function fetchPlayData(page, qn) {
const url = new URL("https://api.bilibili.com/x/player/playurl");
url.searchParams.set("bvid", page.bvid);
url.searchParams.set("cid", String(page.cid));
url.searchParams.set("qn", String(qn));
url.searchParams.set("fnval", "16");
url.searchParams.set("fnver", "0");
url.searchParams.set("fourk", "1");
const res = await fetch(url.toString(), { credentials: "include" });
if (!res.ok) throw new Error(`请求失败:HTTP ${res.status}`);
const json = await res.json();
if (json.code !== 0 || !json.data) {
throw new Error(json.message || "接口返回异常");
}
return json.data;
}
function ffmpegCommand(info) {
if (info.type === "single") {
return `# 单文件无需合并:${info.fileName}`;
}
if (!info.audioName) {
return `ffmpeg -i "${info.videoName}" -c copy "${info.outputName}"`;
}
return `ffmpeg -i "${info.videoName}" -i "${info.audioName}" -c copy "${info.outputName}"`;
}
function getSelectedQn() {
return Number($(`#${PANEL_ID} .tm-quality`)?.value || 0);
}
function getSelectedLabel() {
return (
$(
`#${PANEL_ID} .tm-quality`,
)?.selectedOptions?.[0]?.textContent?.trim() || ""
);
}
function getQnLabelMap() {
return new Map(state.formats.map((item) => [item.qn, item.label]));
}
function populateQualityOptions() {
const select = $(`#${PANEL_ID} .tm-quality`);
if (!select) return;
const currentQn = state.currentPlayInfo?.data?.quality;
const formats = state.formats.length
? state.formats
: [{ qn: currentQn || 0, label: `当前清晰度 ${currentQn || ""}` }];
const oldValue = Number(select.value || 0);
select.innerHTML = formats
.map((item) => {
const selected = oldValue
? oldValue === item.qn
: currentQn === item.qn;
return `<option value="${item.qn}" ${selected ? "selected" : ""}>${item.label}</option>`;
})
.join("");
}
async function gmDownloadSafe(url, name) {
return new Promise((resolve) => {
GM_download({
url,
name,
saveAs: false,
onload: () => resolve({ ok: true }),
onerror: (err) => resolve({ ok: false, err }),
});
});
}
async function gmXhrBlobDownload(url, name) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
responseType: "blob",
headers: {
Referer: location.href,
},
onload: (response) => {
if (
response.status >= 200 &&
response.status < 300 &&
response.response
) {
triggerBlobDownload(response.response, name);
resolve();
return;
}
reject(new Error(`HTTP ${response.status}`));
},
onerror: (error) => {
reject(new Error(error?.error || "gm_xhr_failed"));
},
ontimeout: () => {
reject(new Error("gm_xhr_timeout"));
},
});
});
}
async function fetchBlobWithPageContext(url) {
const res = await fetch(url, {
credentials: "include",
referrer: location.href,
referrerPolicy: "strict-origin-when-cross-origin",
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return await res.blob();
}
function triggerBlobDownload(blob, name) {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = name;
a.rel = "noopener noreferrer";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 30000);
}
async function downloadViaBlob(url, name) {
const blob = await fetchBlobWithPageContext(url);
triggerBlobDownload(blob, name);
}
function formatBlockedMessage(name, detail) {
return `下载受阻:${name}\n${detail}\n\n当前脚本无法直接替你绕过站点的访问控制。你可以改用页面内观看、官方缓存/离线能力,或在你自己的环境里手动处理已授权内容。`;
}
async function fallbackDownload(url, name, reason) {
try {
addLog(`${reason},改用 GM_xmlhttpRequest 下载:${name}`);
await gmXhrBlobDownload(url, name);
return;
} catch (gmXhrError) {
const gmXhrDetail = gmXhrError?.message || String(gmXhrError);
addLog(`GM_xmlhttpRequest 下载失败:${gmXhrDetail}`);
}
try {
addLog(`继续改用页面上下文下载:${name}`);
await downloadViaBlob(url, name);
} catch (blobError) {
const detail = blobError?.message || String(blobError);
addLog(`页面上下文下载失败:${detail}`);
throw new Error(
formatBlockedMessage(
name,
`${reason};GM_xmlhttpRequest 与页面上下文请求均失败:${detail}`,
),
);
}
}
async function startDownload(url, name) {
const result = await gmDownloadSafe(url, name);
if (result.ok) return;
const error =
result.err?.error ||
result.err?.message ||
String(result.err || "unknown");
if (error === "not_whitelisted") {
await fallbackDownload(url, name, "Tampermonkey 拒绝扩展名");
return;
}
if (/forbidden/i.test(error)) {
await fallbackDownload(url, name, "直链下载被拒绝");
return;
}
if (/xhr_failed/i.test(error)) {
await fallbackDownload(url, name, "Tampermonkey XHR 下载失败");
return;
}
await fallbackDownload(url, name, `GM_download 失败:${error}`);
}
function createQueueItem(meta, page, qn, requestedLabel, scope = "batch") {
return {
meta: {
...meta,
pages: Array.isArray(meta?.pages)
? meta.pages.map((item) => ({ ...item }))
: [],
current: meta?.current ? { ...meta.current } : null,
},
page: { ...page },
qn,
requestedLabel,
scope,
};
}
function enqueueItems(items) {
if (!items.length) return;
if (
!state.isQueueRunning &&
state.queueMode === "idle" &&
!state.queueItems.length
) {
state.queueIndex = 0;
state.queueTotal = 0;
state.queueDone = 0;
state.queueFailed = 0;
state.currentTaskLabel = "";
}
state.queueItems.push(...items);
state.queueTotal += items.length;
renderQueueState();
}
function buildTaskQueue(meta, qn, requestedLabel) {
return (meta?.pages || []).map((page) =>
createQueueItem(meta, page, qn, requestedLabel, "batch"),
);
}
function getSelectedQueueConfig() {
const qn = getSelectedQn();
if (!qn) throw new Error("请选择清晰度");
return {
qn,
requestedLabel: getSelectedLabel() || String(qn),
};
}
async function resolveTask(item) {
const playData = await fetchPlayData(item.page, item.qn);
return buildDownloadInfo(
item.meta,
item.page,
playData,
item.qn,
getQnLabelMap(),
);
}
async function triggerDownloadInfo(info) {
if (info.type === "single") {
addLog(`开始下载:${info.fileName}`);
await startDownload(info.singleUrl, info.fileName);
return;
}
addLog(`开始下载:${info.videoName}`);
await startDownload(info.videoUrl, info.videoName);
if (info.audioUrl) {
await sleep(250);
addLog(`开始下载:${info.audioName}`);
await startDownload(info.audioUrl, info.audioName);
}
}
function ensureQueueRunning() {
if (state.queueMode === "stopping") {
addLog("队列停止中,请稍后再加入新任务");
return;
}
if (state.isQueueRunning || !state.queueItems.length) {
renderQueueState();
return;
}
state.queueRunId += 1;
state.isQueueRunning = true;
state.queueMode = "running";
setStatus(`队列下载中 ${state.queueDone}/${state.queueTotal}`);
renderQueueState();
runQueue(state.queueRunId);
}
function queueCurrent() {
if (!state.meta?.current) throw new Error("未识别当前视频信息");
if (state.queueMode === "stopping")
throw new Error("队列停止中,请稍后再试");
const { qn, requestedLabel } = getSelectedQueueConfig();
const item = createQueueItem(
state.meta,
state.meta.current,
qn,
requestedLabel,
"current",
);
enqueueItems([item]);
addLog(`已加入队列:${formatQueueTaskLabel(item)}`);
setStatus(`已加入队列 ${state.queueTotal}/${state.queueTotal}`);
ensureQueueRunning();
}
function queueAll() {
if (!state.meta?.pages?.length) throw new Error("没有可下载的分P/剧集");
if (state.queueMode === "stopping")
throw new Error("队列停止中,请稍后再试");
const { qn, requestedLabel } = getSelectedQueueConfig();
const items = buildTaskQueue(state.meta, qn, requestedLabel);
if (!items.length) throw new Error("没有可下载的分P/剧集");
enqueueItems(items);
addLog(`已加入 ${items.length} 个任务:${requestedLabel}`);
setStatus(`已加入队列,共 ${state.queueTotal} 个任务`);
ensureQueueRunning();
}
function stopBackgroundQueue(reason = "用户手动停止", options = {}) {
const { hard = false, clearProgress = false } = options;
if (!state.isQueueRunning && state.queueMode !== "stopping") {
if (hard && clearProgress) clearQueueTracking(true);
return;
}
if (hard) {
state.queueRunId += 1;
state.isQueueRunning = false;
state.queueMode = "idle";
clearQueueTracking(clearProgress);
setStatus("队列已停止");
addLog(`队列已停止:${reason}`);
return;
}
if (state.queueMode === "stopping") return;
state.isQueueRunning = false;
state.queueMode = "stopping";
setStatus("队列停止中...");
addLog(`队列将在当前任务后停止:${reason}`);
renderQueueState();
}
async function runQueue(runId) {
try {
while (
runId === state.queueRunId &&
state.queueMode === "running" &&
state.queueIndex < state.queueItems.length
) {
const item = state.queueItems[state.queueIndex];
const currentNumber = state.queueIndex + 1;
const taskLabel = formatQueueTaskLabel(item);
setCurrentTask(taskLabel);
setStatus(`队列下载中 ${currentNumber}/${state.queueTotal}`);
addLog(`开始任务 ${currentNumber}/${state.queueTotal}:${taskLabel}`);
try {
const info = await resolveTask(item);
if (runId !== state.queueRunId) return;
if (info.actualQn !== item.qn) {
addLog(
`请求 ${item.requestedLabel},实际返回 ${info.qualityLabel}`,
);
}
await triggerDownloadInfo(info);
if (runId !== state.queueRunId) return;
state.queueDone += 1;
} catch (err) {
if (runId !== state.queueRunId) return;
state.queueFailed += 1;
state.queueDone += 1;
addLog(`任务失败:${taskLabel} - ${err.message || String(err)}`);
}
if (runId !== state.queueRunId) return;
state.queueIndex += 1;
setProgress(state.queueDone, state.queueTotal);
if (state.queueMode === "stopping" || !state.isQueueRunning) break;
await sleep(600);
}
if (runId !== state.queueRunId) return;
const wasStopped = state.queueMode === "stopping";
state.isQueueRunning = false;
state.queueMode = "idle";
clearQueueTracking(false);
if (wasStopped) {
setStatus("队列已停止");
addLog(`队列已停止,已完成 ${state.queueDone}/${state.queueTotal}`);
return;
}
setStatus("队列已完成");
addLog(
`队列完成:成功 ${state.queueTotal - state.queueFailed},失败 ${state.queueFailed}`,
);
} catch (err) {
if (runId !== state.queueRunId) return;
state.isQueueRunning = false;
state.queueMode = "idle";
clearQueueTracking(false);
setStatus("队列异常结束");
addLog(err.message || String(err));
}
}
async function refresh() {
setStatus("正在读取页面信息...");
const result = await waitForContext();
state.meta = result.meta;
state.currentPlayInfo = result.playInfo;
if (!state.meta) {
setStatus("未识别到视频信息");
addLog("当前页面暂不支持");
renderQueueState();
return;
}
state.formats = getFormatsFromPlayInfo(state.currentPlayInfo);
populateQualityOptions();
const partText =
state.meta.pages.length > 1
? `P${pad2(state.meta.currentPage)} / 共 ${state.meta.pages.length} 个`
: "单P";
setStatus(`${state.meta.title} | ${partText}`);
addLog(`已识别:${state.meta.title}`);
if (state.formats.length) {
addLog(
`可选清晰度:${state.formats.map((item) => item.label).join(" / ")}`,
);
}
renderQueueState();
}
async function downloadCurrent() {
try {
queueCurrent();
} catch (err) {
setStatus("加入队列失败");
addLog(err.message || String(err));
alert(err.message || String(err));
}
}
async function downloadAll() {
try {
queueAll();
} catch (err) {
setStatus("加入队列失败");
addLog(err.message || String(err));
alert(err.message || String(err));
}
}
async function copyCurrentFfmpeg() {
try {
const info = await getCurrentPageInfo();
GM_setClipboard(ffmpegCommand(info));
setStatus("已复制当前 ffmpeg 命令");
addLog("当前 ffmpeg 命令已复制");
} catch (err) {
setStatus("复制失败");
addLog(err.message || String(err));
alert(err.message || String(err));
}
}
async function copyAllFfmpeg() {
try {
if (!state.meta?.pages?.length) throw new Error("没有可处理的分P/剧集");
const qn = getSelectedQn();
if (!qn) throw new Error("请选择清晰度");
const lines = [];
setStatus("正在生成批量 ffmpeg 命令...");
for (let i = 0; i < state.meta.pages.length; i++) {
const page = state.meta.pages[i];
const playData = await fetchPlayData(page, qn);
const info = buildDownloadInfo(
state.meta,
page,
playData,
qn,
getQnLabelMap(),
);
lines.push(ffmpegCommand(info));
await sleep(120);
}
GM_setClipboard(lines.join("\n"));
setStatus("已复制批量 ffmpeg 命令");
addLog(`已复制 ${lines.length} 条 ffmpeg 命令`);
} catch (err) {
setStatus("复制失败");
addLog(err.message || String(err));
alert(err.message || String(err));
}
}
function ensurePanel() {
if (document.getElementById(PANEL_ID)) return;
GM_addStyle(`
#${PANEL_ID} {
position: fixed;
top: 110px;
right: 20px;
z-index: 999999;
width: 340px;
background: rgba(24,24,28,.96);
color: #fff;
border: 1px solid rgba(255,255,255,.12);
border-radius: 14px;
box-shadow: 0 12px 28px rgba(0,0,0,.28);
backdrop-filter: blur(8px);
padding: 12px;
font-size: 13px;
}
#${PANEL_ID} * { box-sizing: border-box; }
#${PANEL_ID} .tm-title { font-weight: 700; font-size: 14px; margin-bottom: 8px; }
#${PANEL_ID} .tm-status { font-size: 12px; color: rgba(255,255,255,.86); margin-bottom: 10px; word-break: break-all; }
#${PANEL_ID} .tm-progress-wrap { margin-bottom: 10px; }
#${PANEL_ID} .tm-progress-text,
#${PANEL_ID} .tm-current-task {
font-size: 12px;
color: rgba(255,255,255,.82);
word-break: break-word;
}
#${PANEL_ID} .tm-progress-bar {
margin: 6px 0;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: rgba(255,255,255,.12);
}
#${PANEL_ID} .tm-progress-fill {
width: 0;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #00aeec 0%, #50d7ff 100%);
transition: width .2s ease;
}
#${PANEL_ID} .tm-row { display: flex; gap: 8px; margin-bottom: 8px; }
#${PANEL_ID} .tm-quality {
width: 100%; border: none; border-radius: 8px; padding: 8px 10px;
background: #2f3136; color: #fff; outline: none;
}
#${PANEL_ID} button {
width: 100%; border: none; border-radius: 8px; padding: 8px 10px;
background: #00aeec; color: #fff; cursor: pointer; font-size: 13px;
}
#${PANEL_ID} button:hover { opacity: .92; }
#${PANEL_ID} button:disabled,
#${PANEL_ID} .tm-quality:disabled {
opacity: .55;
cursor: not-allowed;
}
#${PANEL_ID} .tm-log {
margin-top: 8px; min-height: 108px; max-height: 220px; overflow: auto;
white-space: pre-wrap; word-break: break-word; background: rgba(255,255,255,.06);
border-radius: 8px; padding: 8px; font-size: 12px; line-height: 1.5;
}
`);
const panel = document.createElement("div");
panel.id = PANEL_ID;
panel.innerHTML = `
<div class="tm-title">B站下载助手 增强版(修正版)</div>
<div class="tm-status">初始化中...</div>
<div class="tm-progress-wrap">
<div class="tm-progress-text">进度:0/0(空闲)</div>
<div class="tm-progress-bar"><div class="tm-progress-fill"></div></div>
<div class="tm-current-task">当前:空闲</div>
</div>
<div class="tm-row">
<select class="tm-quality"></select>
</div>
<div class="tm-row">
<button data-action="refresh">刷新信息</button>
<button data-action="current">加入当前</button>
</div>
<div class="tm-row">
<button data-action="all">加入全部</button>
<button data-action="stop-all">停止队列</button>
</div>
<div class="tm-row">
<button data-action="ffmpeg-current">复制当前ffmpeg</button>
<button data-action="ffmpeg-all">复制批量ffmpeg</button>
</div>
<div class="tm-log"></div>
`;
panel.addEventListener("click", async (event) => {
const btn = event.target.closest("button");
if (!btn) return;
const action = btn.dataset.action;
if (action === "refresh") return refresh();
if (action === "current") return downloadCurrent();
if (action === "all") return downloadAll();
if (action === "stop-all") return stopBackgroundQueue();
if (action === "ffmpeg-current") return copyCurrentFfmpeg();
if (action === "ffmpeg-all") return copyAllFfmpeg();
});
document.body.appendChild(panel);
renderQueueState();
}
async function init() {
ensurePanel();
await refresh();
setInterval(async () => {
if (location.href !== state.href) {
state.href = location.href;
if (state.isQueueRunning || state.queueMode === "stopping") {
stopBackgroundQueue("页面地址变化", {
hard: true,
clearProgress: true,
});
}
state.meta = null;
state.formats = [];
state.currentPlayInfo = null;
addLog("页面地址变化,重新读取...");
await refresh();
}
}, 1200);
}
init();
})();
源码地址:https://github.com/weiwentao996/bilibili-down