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