末世辅助网

文章搜索
搜索
当前位置:首页 > 网站源码 > JavaScript/TypeScript > 文章详情

知乎视频下载油猴脚本

末世  JavaScript/TypeScript  2026-5-28  0评论

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

免责声明

本站提供的一切软件、教程和内容信息仅限用于学习和研究目的;不得将上述内容用于商业或者非法用途,否则,一切后果请用户自负。本站信息来自网络收集整理,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑或手机中彻底删除上述内容。如果您喜欢该程序和内容,请支持正版,购买注册,得到更好的正版服务。我们非常重视版权问题,如有侵权请邮件[moshi6@qq.com]与我们联系处理。敬请谅解!

挤眼 亲亲 咆哮 开心 想想 可怜 糗大了 委屈 哈哈 小声点 右哼哼 左哼哼 疑问 坏笑 赚钱啦 悲伤 耍酷 勾引 厉害 握手 耶 嘻嘻 害羞 鼓掌 馋嘴 抓狂 抱抱 围观 威武 给力
提交评论

清空信息
关闭评论