原来做了个B站的视频下载脚本,大家提了很多意见。原帖
需要说明B站是音轨视频轨道分离存储的,所以下载下来音视频是独立的。油猴脚本权限比较低,故合成操作实现比较困难,所以提供ffmpeg合成命令。
优化脚本界面可以最小化
最近知乎摸鱼发现有好多有趣的视频,想分享有点麻烦,所以又做了个知乎的脚本。
界面:
点击加入当前就可以下载,两个脚本差不多就不多粘图片了,都可以点右上角最小化。
以下是源码(附件与贴的代码一致):
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,
isCollapsed: false,
};
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 renderFloatingBallState() {
const countEl = $(`#${PANEL_ID} .tm-ball-count`);
if (!countEl) return;
if (state.queueMode === "running") {
countEl.textContent = `${state.queueDone || 0}/${state.queueTotal || 0}`;
} else {
countEl.textContent = String(state.meta?.pages?.length || 0);
}
}
function setPanelCollapsed(collapsed) {
state.isCollapsed = collapsed;
const panel = document.getElementById(PANEL_ID);
if (!panel) return;
panel.classList.toggle("tm-collapsed", collapsed);
panel.title = collapsed ? "展开B站下载助手" : "";
renderFloatingBallState();
}
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;
renderFloatingBallState();
}
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 shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function ffmpegCommand(info) {
if (info.type === "single") {
return `# 单文件无需合并:${info.fileName}`;
}
if (!info.audioName) {
return `ffmpeg -i ${shellQuote(info.videoName)} -c copy ${shellQuote(info.outputName)}`;
}
return `ffmpeg -i ${shellQuote(info.videoName)} -i ${shellQuote(info.audioName)} -c copy ${shellQuote(info.outputName)}`;
}
async function getCurrentDownloadInfo() {
if (!state.meta?.current) throw new Error("未识别当前视频信息");
const { qn } = getSelectedQueueConfig();
const playData = await fetchPlayData(state.meta.current, qn);
return buildDownloadInfo(
state.meta,
state.meta.current,
playData,
qn,
getQnLabelMap(),
);
}
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 getCurrentDownloadInfo();
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;
transition: width .18s ease, height .18s ease, border-radius .18s ease, padding .18s ease;
}
#${PANEL_ID}.tm-collapsed {
width: 58px;
height: 58px;
padding: 0;
border-radius: 50%;
cursor: pointer;
overflow: hidden;
}
#${PANEL_ID} * { box-sizing: border-box; }
#${PANEL_ID} .tm-ball {
display: none;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
background: linear-gradient(135deg, #00aeec 0%, #50d7ff 100%);
user-select: none;
}
#${PANEL_ID}.tm-collapsed .tm-ball { display: flex; }
#${PANEL_ID}.tm-collapsed .tm-panel-body { display: none; }
#${PANEL_ID} .tm-ball-main { font-weight: 800; font-size: 19px; line-height: 1; }
#${PANEL_ID} .tm-ball-count { margin-top: 4px; font-size: 11px; line-height: 1; opacity: .95; }
#${PANEL_ID} .tm-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
#${PANEL_ID} .tm-title { flex: 1; font-weight: 700; font-size: 14px; }
#${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} .tm-collapse-btn {
width: 30px;
flex: 0 0 30px;
padding: 4px 0;
border-radius: 999px;
background: rgba(255,255,255,.12);
font-size: 16px;
line-height: 1;
}
#${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-ball">
<div class="tm-ball-main">B</div>
<div class="tm-ball-count">0</div>
</div>
<div class="tm-panel-body">
<div class="tm-header">
<div class="tm-title">B站下载助手 增强版(修正版)</div>
<button class="tm-collapse-btn" data-action="collapse" title="折叠成悬浮球">−</button>
</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>
</div>
`;
panel.addEventListener("click", async (event) => {
if (state.isCollapsed) {
setPanelCollapsed(false);
return;
}
const btn = event.target.closest("button");
if (!btn) return;
const action = btn.dataset.action;
if (action === "collapse") return setPanelCollapsed(true);
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);
setPanelCollapsed(state.isCollapsed);
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();
})();
知乎:
// ==UserScript==
// @name 知乎视频下载助手
// @namespace https://example.com/
// @version 0.1.0
// @description zhihu 下载助手:识别页面视频资源,支持当前视频/全部视频加入队列下载
// @author Taoao.wei
// @match https://www.zhihu.com/*
// @match https://zhuanlan.zhihu.com/*
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant GM_addStyle
// @grant unsafeWindow
// @connect zhihu.com
// @connect *.zhihu.com
// @connect zhimg.com
// @connect *.zhimg.com
// @connect vzuu.com
// @connect *.vzuu.com
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const PANEL_ID = "tm-zhihu-download-panel";
const SCRIPT_SCAN_LIMIT = 2_000_000;
const MAX_WALK_DEPTH = 30;
const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
let mutationTimer = null;
const state = {
href: location.href,
videos: [],
selectedVideoId: "",
logs: [],
isQueueRunning: false,
queueMode: "idle",
queueItems: [],
queueIndex: 0,
queueTotal: 0,
queueDone: 0,
queueFailed: 0,
currentTaskLabel: "",
queueRunId: 0,
scanVersion: 0,
isCollapsed: false,
};
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function $(selector, root = document) {
return root.querySelector(selector);
}
function sanitizeFileName(name) {
return String(name || "zhihu_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) {
const key = keyFn(item);
if (!map.has(key)) map.set(key, 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 renderFloatingBallState() {
const countEl = $(`#${PANEL_ID} .tm-ball-count`);
if (!countEl) return;
if (state.queueMode === "running") {
countEl.textContent = `${state.queueDone || 0}/${state.queueTotal || 0}`;
} else {
countEl.textContent = String(state.videos.length || 0);
}
}
function setPanelCollapsed(collapsed) {
state.isCollapsed = collapsed;
const panel = document.getElementById(PANEL_ID);
if (!panel) return;
panel.classList.toggle("tm-collapsed", collapsed);
panel.title = collapsed ? "展开知乎视频下载助手" : "";
renderFloatingBallState();
}
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" || !state.videos.length;
if (addAllBtn)
addAllBtn.disabled =
state.queueMode === "stopping" || !state.videos.length;
if (stopBtn) stopBtn.disabled = !state.isQueueRunning;
renderFloatingBallState();
}
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 getPageTitle() {
const h1 = $("h1")?.textContent?.trim();
const title = h1 || document.title || "知乎视频";
return (
title
.replace(/\s*-\s*知乎\s*$/, "")
.replace(/\s+/, " ")
.trim() || "知乎视频"
);
}
function normalizeUrl(raw) {
if (!raw) return "";
return String(raw)
.trim()
.replace(/^['"]|['"]$/g, "")
.replace(/\\u002F/gi, "/")
.replace(/\\u0026/gi, "&")
.replace(/\\\//g, "/")
.replace(/&/g, "&")
.replace(/\s+$/g, "")
.replace(/[),.;]+$/g, "");
}
function detectVideoKind(url, options = {}) {
const clean = normalizeUrl(url);
if (!clean) return "unknown";
if (clean.startsWith("blob:")) return "blob";
const withoutQuery = clean.split("?")[0].toLowerCase();
if (/\.m3u8$/i.test(withoutQuery)) return "m3u8";
if (/\.(mp4|m4v|mov)$/i.test(withoutQuery)) return "mp4";
if (options.allowGenericMediaUrl && /^https?:\/\//i.test(clean))
return "video-src";
return "unknown";
}
function extractUrlsFromText(text) {
if (!text || typeof text !== "string") return [];
const normalized = normalizeUrl(text);
const matches = normalized.match(/https?:\/\/[^\s"'<>\\]+/g) || [];
return matches.map(normalizeUrl).filter(Boolean);
}
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function createCandidate(url, from, extra = {}) {
const normalized = normalizeUrl(url);
const kind =
extra.kind ||
detectVideoKind(normalized, {
allowGenericMediaUrl: from === "dom-video",
});
return {
id: "",
url: normalized,
kind,
title: extra.title || getPageTitle(),
from,
pageUrl: location.href,
path: extra.path || "",
index: 0,
};
}
function scanDomVideos() {
const candidates = [];
const videos = Array.from(document.querySelectorAll("video"));
let blobCount = 0;
videos.forEach((video, videoIndex) => {
const container = video.closest(
"article, .RichContent, .Post-RichTextContainer, [data-za-detail-view-path-module]",
);
const title =
container?.querySelector("h1, h2, h3")?.textContent?.trim() ||
container?.getAttribute("aria-label") ||
getPageTitle();
const urls = [
video.currentSrc,
video.src,
...Array.from(video.querySelectorAll("source")).map(
(source) => source.src,
),
].filter(Boolean);
urls.forEach((url, sourceIndex) => {
const candidate = createCandidate(url, "dom-video", {
title,
path: `video[${videoIndex}].source[${sourceIndex}]`,
});
if (candidate.kind === "blob") {
blobCount += 1;
return;
}
candidates.push(candidate);
});
});
if (blobCount)
addLog(
`发现 ${blobCount} 个 blob 视频地址,继续尝试从页面数据查找真实链接`,
);
return candidates;
}
function readJsonScriptById(id) {
const el = document.getElementById(id);
if (!el?.textContent) return null;
return safeJsonParse(el.textContent);
}
function walkForVideoUrls(
value,
from,
result = [],
seen = new WeakSet(),
depth = 0,
path = [],
) {
if (value == null || depth > MAX_WALK_DEPTH) return result;
if (typeof value === "string") {
for (const url of extractUrlsFromText(value)) {
const kind = detectVideoKind(url);
if (kind === "mp4" || kind === "m3u8") {
result.push(
createCandidate(url, from, { kind, path: path.join(".") }),
);
}
}
const trimmed = value.trim();
if (trimmed.length < 300_000 && /^[{[]/.test(trimmed)) {
const parsed = safeJsonParse(trimmed);
if (parsed)
walkForVideoUrls(
parsed,
from,
result,
seen,
depth + 1,
path.concat("json"),
);
}
return result;
}
if (typeof value !== "object") return result;
if (seen.has(value)) return result;
seen.add(value);
if (Array.isArray(value)) {
value.forEach((item, index) => {
walkForVideoUrls(
item,
from,
result,
seen,
depth + 1,
path.concat(String(index)),
);
});
return result;
}
for (const [key, child] of Object.entries(value)) {
walkForVideoUrls(child, from, result, seen, depth + 1, path.concat(key));
}
return result;
}
function scanJsInitialData() {
const data = readJsonScriptById("js-initialData");
if (!data) return [];
return walkForVideoUrls(data, "initialData");
}
function scanUnsafeInitialState() {
const data = win.__INITIAL_STATE__;
if (!data) return [];
return walkForVideoUrls(data, "initialState");
}
function scanScriptText() {
const candidates = [];
for (const script of Array.from(document.scripts)) {
const text = script.textContent || "";
if (!text || text.length > SCRIPT_SCAN_LIMIT) continue;
if (!/(mp4|m3u8|vzuu|zhimg|video)/i.test(text)) continue;
for (const url of extractUrlsFromText(text)) {
const kind = detectVideoKind(url);
if (kind === "mp4" || kind === "m3u8") {
candidates.push(createCandidate(url, "script-text", { kind }));
}
}
}
return candidates;
}
function scoreCandidate(candidate) {
const fromScore =
{ "dom-video": 0, initialData: 1, initialState: 2, "script-text": 3 }[
candidate.from
] ?? 5;
const kindScore =
{ mp4: 0, "video-src": 1, m3u8: 2, unknown: 3 }[candidate.kind] ?? 4;
const qualityScore = /1080|fhd|hd/i.test(candidate.url)
? -1
: /720/i.test(candidate.url)
? 0
: 1;
return fromScore * 100 + kindScore * 10 + qualityScore;
}
function sourceLabel(from) {
return (
{
"dom-video": "页面视频",
initialData: "页面数据",
initialState: "页面状态",
"script-text": "脚本数据",
}[from] ||
from ||
"未知来源"
);
}
function candidateTitle(candidate) {
return sanitizeFileName(candidate?.title || getPageTitle() || "知乎视频");
}
function candidateLabel(candidate) {
return `${pad2(candidate.index || 1)} ${candidateTitle(candidate)}(${candidate.kind.toUpperCase()} · ${sourceLabel(candidate.from)})`;
}
function normalizeVideoCandidates(candidates) {
return uniqBy(
candidates
.map((candidate) => {
const url = normalizeUrl(candidate.url);
return {
...candidate,
url,
kind:
candidate.kind === "video-src"
? "video-src"
: detectVideoKind(url),
title: sanitizeFileName(candidate.title || getPageTitle()),
};
})
.filter(
(candidate) =>
candidate.url &&
candidate.kind !== "blob" &&
candidate.kind !== "unknown",
)
.sort((a, b) => scoreCandidate(a) - scoreCandidate(b)),
(candidate) => candidate.url,
).map((candidate, index) => ({
...candidate,
id: `${candidate.from}-${index}-${candidate.kind}`,
index: index + 1,
}));
}
async function scanZhihuVideos() {
const candidates = [
...scanDomVideos(),
...scanJsInitialData(),
...scanUnsafeInitialState(),
...scanScriptText(),
];
return normalizeVideoCandidates(candidates);
}
function currentVideo() {
return (
state.videos.find((item) => item.id === state.selectedVideoId) ||
state.videos[0] ||
null
);
}
function renderVideoSelect() {
const select = $(`#${PANEL_ID} .tm-video-select`);
if (!select) return;
if (!state.videos.length) {
select.innerHTML = `<option value="">未识别到视频</option>`;
select.disabled = true;
return;
}
if (!state.videos.some((item) => item.id === state.selectedVideoId)) {
state.selectedVideoId = state.videos[0].id;
}
select.disabled = false;
select.innerHTML = state.videos
.map((item) => {
const label = candidateLabel(item);
return `<option value="${item.id}" ${item.id === state.selectedVideoId ? "selected" : ""}>${label}</option>`;
})
.join("");
}
function renderVideoSummary() {
renderVideoSelect();
const video = currentVideo();
if (!state.videos.length) {
setStatus("未识别到视频资源;请播放视频后刷新,或等待页面加载完成");
} else {
setStatus(
`已识别 ${state.videos.length} 个视频候选,当前:${candidateTitle(video)}(${video.kind.toUpperCase()} · ${sourceLabel(video.from)})`,
);
}
renderQueueState();
}
async function refresh(options = {}) {
const scanVersion = ++state.scanVersion;
if (!options.silent) setStatus("正在扫描知乎视频资源...");
try {
const videos = await scanZhihuVideos();
if (scanVersion !== state.scanVersion) return;
const previousCount = state.videos.length;
const previousSelected = state.selectedVideoId;
state.videos = videos;
state.selectedVideoId = videos.some(
(item) => item.id === previousSelected,
)
? previousSelected
: videos[0]?.id || "";
renderVideoSummary();
if (!options.silent || previousCount !== videos.length) {
addLog(
videos.length
? `已识别 ${videos.length} 个视频候选`
: "未识别到可下载视频地址",
);
}
} catch (err) {
setStatus("扫描失败");
addLog(err?.message || String(err));
}
}
async function gmDownloadSafe(url, name) {
if (typeof GM_download !== "function")
return { ok: false, err: "GM_download_unavailable" };
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) {
if (typeof GM_xmlhttpRequest !== "function")
throw new Error("GM_xmlhttpRequest_unavailable");
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
responseType: "blob",
headers: {
Referer: location.href,
Origin: location.origin,
},
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) {
addLog(
`GM_xmlhttpRequest 下载失败:${gmXhrError?.message || String(gmXhrError)}`,
);
}
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 buildFileName(candidate, targetExt) {
const ext = targetExt || (candidate.kind === "m3u8" ? "m3u8" : "mp4");
const title = candidate.title || getPageTitle();
return `${sanitizeFileName(`知乎 - ${title} - ${pad2(candidate.index || 1)}`)}.${ext}`;
}
async function downloadVideoCandidate(candidate) {
if (!candidate) throw new Error("没有可下载的视频候选");
if (candidate.kind === "blob")
throw new Error("blob 地址无法直接下载,请播放后刷新或等待真实地址出现");
const name = buildFileName(candidate);
if (candidate.kind === "m3u8") {
addLog(`当前是 m3u8 清单,将下载清单文件:${name}`);
} else {
addLog(`开始下载:${name}`);
}
await startDownload(candidate.url, name);
}
function formatQueueTaskLabel(item) {
if (!item?.candidate) return "";
const candidate = item.candidate;
const scope =
item.scope === "current" ? "当前" : `视频${pad2(candidate.index || 1)}`;
return `${scope} ${candidateTitle(candidate)}(${candidate.kind.toUpperCase()} · ${sourceLabel(candidate.from)})`;
}
function createQueueItem(candidate, scope = "batch") {
return {
candidate: { ...candidate },
scope,
};
}
function enqueueItems(items) {
const usable = items.filter(
(item) => item?.candidate?.url && item.candidate.kind !== "blob",
);
if (!usable.length) {
addLog("没有可加入队列的视频地址");
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(...usable);
state.queueTotal += usable.length;
renderQueueState();
addLog(
`已加入 ${usable.length} 个任务,队列共 ${state.queueItems.length} 个待处理项`,
);
ensureQueueRunning();
}
function queueCurrent() {
const video = currentVideo();
if (!video) {
addLog("当前没有可加入的视频,请先刷新视频");
return;
}
enqueueItems([createQueueItem(video, "current")]);
}
function queueAll() {
if (!state.videos.length) {
addLog("当前没有可加入的视频,请先刷新视频");
return;
}
enqueueItems(state.videos.map((video) => createQueueItem(video, "batch")));
}
function ensureQueueRunning() {
if (state.isQueueRunning) return;
state.queueMode = "running";
state.isQueueRunning = true;
const runId = ++state.queueRunId;
renderQueueState();
runQueue(runId);
}
function stopBackgroundQueue(reason = "用户请求停止", options = {}) {
if (!state.isQueueRunning && state.queueMode !== "stopping") {
if (options.clearProgress) clearQueueTracking(true);
return;
}
state.queueMode = "stopping";
if (options.hard) {
state.queueRunId += 1;
state.isQueueRunning = false;
state.queueMode = "idle";
clearQueueTracking(Boolean(options.clearProgress));
}
addLog(reason);
renderQueueState();
}
async function runQueue(runId) {
try {
while (state.queueIndex < state.queueItems.length) {
if (runId !== state.queueRunId || state.queueMode === "stopping") break;
const item = state.queueItems[state.queueIndex];
setCurrentTask(formatQueueTaskLabel(item));
try {
await downloadVideoCandidate(item.candidate);
state.queueDone += 1;
addLog(`任务完成:${formatQueueTaskLabel(item)}`);
} catch (err) {
state.queueDone += 1;
state.queueFailed += 1;
addLog(
`任务失败:${formatQueueTaskLabel(item)};${err?.message || String(err)}`,
);
}
state.queueIndex += 1;
renderQueueState();
await sleep(400);
}
} finally {
if (runId === state.queueRunId) {
state.isQueueRunning = false;
state.queueMode = "idle";
state.currentTaskLabel = "";
state.queueItems = [];
state.queueIndex = 0;
renderQueueState();
}
}
}
async function copyText(text, successText) {
if (!text) throw new Error("没有可复制的内容");
if (typeof GM_setClipboard === "function") {
GM_setClipboard(text);
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
throw new Error("当前环境不支持复制到剪贴板");
}
setStatus(successText);
addLog(successText);
}
async function copyCurrentLink() {
const video = currentVideo();
if (!video) return addLog("当前没有可复制的视频链接");
await copyText(video.url, "已复制当前视频链接");
}
async function copyAllLinks() {
if (!state.videos.length) return addLog("当前没有可复制的视频链接");
await copyText(
state.videos.map((item) => item.url).join("\n"),
`已复制 ${state.videos.length} 个视频链接`,
);
}
function ensurePanel() {
if (document.getElementById(PANEL_ID)) return;
GM_addStyle(`
#${PANEL_ID} {
position: fixed;
top: 110px;
right: 20px;
z-index: 999999;
width: 360px;
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;
transition: width .18s ease, height .18s ease, border-radius .18s ease, padding .18s ease;
}
#${PANEL_ID}.tm-collapsed {
width: 58px;
height: 58px;
padding: 0;
border-radius: 50%;
cursor: pointer;
overflow: hidden;
}
#${PANEL_ID} * { box-sizing: border-box; }
#${PANEL_ID} .tm-ball {
display: none;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
background: linear-gradient(135deg, #1677ff 0%, #35a2ff 100%);
user-select: none;
}
#${PANEL_ID}.tm-collapsed .tm-ball { display: flex; }
#${PANEL_ID}.tm-collapsed .tm-panel-body { display: none; }
#${PANEL_ID} .tm-ball-main { font-weight: 800; font-size: 19px; line-height: 1; }
#${PANEL_ID} .tm-ball-count { margin-top: 4px; font-size: 11px; line-height: 1; opacity: .95; }
#${PANEL_ID} .tm-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
#${PANEL_ID} .tm-title { flex: 1; font-weight: 700; font-size: 14px; }
#${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, #0066ff 0%, #2b8cff 100%);
transition: width .2s ease;
}
#${PANEL_ID} .tm-row { display: flex; gap: 8px; margin-bottom: 8px; }
#${PANEL_ID} .tm-video-select {
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: #1677ff; color: #fff; cursor: pointer; font-size: 13px;
}
#${PANEL_ID} .tm-collapse-btn {
width: 30px;
flex: 0 0 30px;
padding: 4px 0;
border-radius: 999px;
background: rgba(255,255,255,.12);
font-size: 16px;
line-height: 1;
}
#${PANEL_ID} button:hover { opacity: .92; }
#${PANEL_ID} button:disabled,
#${PANEL_ID} .tm-video-select: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-ball">
<div class="tm-ball-main">知</div>
<div class="tm-ball-count">0</div>
</div>
<div class="tm-panel-body">
<div class="tm-header">
<div class="tm-title">知乎视频下载助手</div>
<button class="tm-collapse-btn" data-action="collapse" title="折叠成悬浮球">−</button>
</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-video-select"><option value="">扫描中...</option></select>
</div>
<div class="tm-row">
<button data-action="refresh">刷新视频</button>
<button data-action="queue-current">加入当前</button>
</div>
<div class="tm-row">
<button data-action="queue-all">加入全部</button>
<button data-action="stop-all">停止队列</button>
</div>
<div class="tm-row">
<button data-action="copy-current">复制当前链接</button>
<button data-action="copy-all">复制全部链接</button>
</div>
<div class="tm-log"></div>
</div>
`;
panel.addEventListener("click", async (event) => {
if (state.isCollapsed) {
setPanelCollapsed(false);
return;
}
const btn = event.target.closest("button");
if (!btn) return;
const action = btn.dataset.action;
try {
if (action === "collapse") return setPanelCollapsed(true);
if (action === "refresh") return refresh();
if (action === "queue-current") return queueCurrent();
if (action === "queue-all") return queueAll();
if (action === "stop-all") return stopBackgroundQueue();
if (action === "copy-current") return copyCurrentLink();
if (action === "copy-all") return copyAllLinks();
} catch (err) {
setStatus("操作失败");
addLog(err?.message || String(err));
}
});
panel.addEventListener("change", (event) => {
const select = event.target.closest(".tm-video-select");
if (!select) return;
state.selectedVideoId = select.value;
renderVideoSummary();
});
document.body.appendChild(panel);
setPanelCollapsed(state.isCollapsed);
renderQueueState();
}
function scheduleDomRefresh() {
if (state.isQueueRunning) return;
clearTimeout(mutationTimer);
mutationTimer = setTimeout(() => refresh({ silent: true }), 1000);
}
function observeDomChanges() {
const observer = new MutationObserver((mutations) => {
const hasPageChange = mutations.some((mutation) => {
const target =
mutation.target instanceof Element
? mutation.target
: mutation.target.parentElement;
if (target?.closest?.(`#${PANEL_ID}`)) return false;
return mutation.addedNodes.length || mutation.removedNodes.length;
});
if (hasPageChange) scheduleDomRefresh();
});
observer.observe(document.body, { childList: true, subtree: true });
}
async function init() {
ensurePanel();
await refresh();
observeDomChanges();
setInterval(async () => {
if (location.href !== state.href) {
state.href = location.href;
if (state.isQueueRunning || state.queueMode === "stopping") {
stopBackgroundQueue("页面地址变化,停止当前队列", {
hard: true,
clearProgress: true,
});
}
state.videos = [];
state.selectedVideoId = "";
addLog("页面地址变化,重新扫描...");
await refresh();
}
}, 1200);
}
init();
})();
内容仅供学习研究,保护版权人人有责。