吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 114|回复: 2
上一主题 下一主题
收起左侧

[其他原创] B站视频下载油猴脚本

[复制链接]
跳转到指定楼层
楼主
魏文涛 发表于 2026-4-20 11:50 回帖奖励

最近有下载B站视频的需求,做了一个油猴脚本,用起来还是很不错的,分享给大家。

// ==UserScript==
// @name         B站视频下载助手
// @namespace    https://example.com/
// @version      0.4.0
// @description  bilibili 下载助手:支持清晰度选择、批量分P/剧集下载、复制 ffmpeg 命令
// @AuThor       Taoao.wei
// @match        https://www.bilibili.com/video/*
// @match        https://www.bilibili.com/bangumi/play/*
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        unsafeWindow
// @connect      bilibili.com
// @connect      *.bilibili.com
// @connect      *.bilivideo.com
// @connect      bilivideo.com
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const PANEL_ID = "tm-bili-download-panel-plus-fixed";
  const state = {
    href: location.href,
    meta: null,
    formats: [],
    currentPlayInfo: null,
    logs: [],
    isQueueRunning: false,
    queueMode: "idle",
    queueItems: [],
    queueIndex: 0,
    queueTotal: 0,
    queueDone: 0,
    queueFailed: 0,
    currentTaskLabel: "",
    queueRunId: 0,
  };

  function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  function $(selector, root = document) {
    return root.querySelector(selector);
  }

  function sanitizeFileName(name) {
    return String(name || "bilibili_video")
      .replace(/[\\/:*?"<>|]+/g, "_")
      .replace(/\s+/g, " ")
      .replace(/\.+$/g, "")
      .trim()
      .slice(0, 150);
  }

  function pad2(n) {
    return String(n).padStart(2, "0");
  }

  function uniqBy(arr, keyFn) {
    const map = new Map();
    for (const item of arr) map.set(keyFn(item), item);
    return [...map.values()];
  }

  function addLog(text) {
    const line = `[${new Date().toLocaleTimeString()}] ${text}`;
    state.logs.push(line);
    state.logs = state.logs.slice(-12);
    const logEl = $(`#${PANEL_ID} .tm-log`);
    if (logEl) logEl.textContent = state.logs.join("\n");
  }

  function setStatus(text) {
    const statusEl = $(`#${PANEL_ID} .tm-status`);
    if (statusEl) statusEl.textContent = text;
  }

  function renderQueueState() {
    const progressTextEl = $(`#${PANEL_ID} .tm-progress-text`);
    const progressFillEl = $(`#${PANEL_ID} .tm-progress-fill`);
    const currentTaskEl = $(`#${PANEL_ID} .tm-current-task`);
    const addBtn = $(`#${PANEL_ID} button[data-action="queue-current"]`);
    const addAllBtn = $(`#${PANEL_ID} button[data-action="queue-all"]`);
    const stopBtn = $(`#${PANEL_ID} button[data-action="stop-all"]`);
    const total = state.queueTotal || 0;
    const done = Math.min(state.queueDone || 0, total);
    const percent = total
      ? Math.max(0, Math.min(100, (done / total) * 100))
      : 0;

    let suffix = "(空闲)";
    if (state.queueMode === "running") {
      suffix = state.queueFailed
        ? `(运行中,失败 ${state.queueFailed})`
        : "(运行中)";
    } else if (state.queueMode === "stopping") {
      suffix = "(停止中)";
    } else if (total) {
      suffix = state.queueFailed
        ? `(完成,失败 ${state.queueFailed})`
        : "(完成)";
    }

    if (progressTextEl)
      progressTextEl.textContent = `进度:${done}/${total}${suffix}`;
    if (progressFillEl) progressFillEl.style.width = `${percent}%`;

    let currentText = "当前:空闲";
    if (state.currentTaskLabel) {
      currentText = `当前:${state.currentTaskLabel}`;
    } else if (state.queueMode === "stopping") {
      currentText = "当前:等待当前条目收尾";
    } else if (!state.isQueueRunning && total && done >= total) {
      currentText = "当前:已完成";
    }

    if (currentTaskEl) currentTaskEl.textContent = currentText;
    if (addBtn) addBtn.disabled = state.queueMode === "stopping";
    if (addAllBtn) addAllBtn.disabled = state.queueMode === "stopping";
    if (stopBtn) stopBtn.disabled = !state.isQueueRunning;
  }

  function setProgress(current, total) {
    state.queueDone = current;
    state.queueTotal = total;
    renderQueueState();
  }

  function setCurrentTask(label) {
    state.currentTaskLabel = label;
    renderQueueState();
  }

  function clearQueueTracking(resetProgress = false) {
    state.queueItems = [];
    state.queueIndex = 0;
    state.currentTaskLabel = "";
    if (resetProgress) {
      state.queueTotal = 0;
      state.queueDone = 0;
      state.queueFailed = 0;
    }
    renderQueueState();
  }

  function formatQueueTaskLabel(item) {
    if (!item) return "";
    const page = item.page || item;
    const pageLabel =
      item.scope === "current"
        ? `当前P${pad2(page.page || 1)}`
        : state.queueTotal > 1
          ? `P${pad2(page.page || 1)}`
          : `P${pad2(page.page || 1)}`;
    const qualityLabel =
      item.requestedLabel || item.qualityLabel || "当前清晰度";
    return `${pageLabel} ${page.part} - ${qualityLabel}`;
  }

  function readVarFromScripts(varName) {
    const scripts = Array.from(document.scripts);
    for (const script of scripts) {
      const text = script.textContent || "";
      if (!text.includes(varName)) continue;

      const patterns = [
        new RegExp(`window\\.${varName}\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*;`),
        new RegExp(`${varName}\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*;`),
      ];

      for (const pattern of patterns) {
        const match = text.match(pattern);
        if (!match) continue;
        try {
          return JSON.parse(match[1]);
        } catch {}
      }
    }
    return null;
  }

  function getInitialState() {
    return (
      unsafeWindow.__INITIAL_STATE__ || readVarFromScripts("__INITIAL_STATE__")
    );
  }

  function getPlayInfo() {
    return unsafeWindow.__playinfo__ || readVarFromScripts("__playinfo__");
  }

  async function waitForContext(timeout = 12000) {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      const meta = buildMeta();
      const playInfo = getPlayInfo();
      if (meta && playInfo?.data) return { meta, playInfo };
      await sleep(400);
    }
    return { meta: buildMeta(), playInfo: getPlayInfo() };
  }

  function currentVideoPageIndex(pages) {
    const url = new URL(location.href);
    const p = Number(url.searchParams.get("p") || "1");
    if (!Number.isFinite(p) || p < 1) return 1;
    return Math.min(p, Math.max(1, pages.length || 1));
  }

  function buildVideoMetaFromState(s) {
    const videoData = s?.videoData;
    if (!videoData?.bvid || !Array.isArray(videoData.pages)) return null;

    const title = sanitizeFileName(
      videoData.title ||
        document.title.replace(/_哔哩哔哩_bilibili$/, "").trim(),
    );
    const pages = videoData.pages.map((item, idx) => ({
      page: idx + 1,
      cid: item.cid,
      part: sanitizeFileName(item.part || `P${idx + 1}`),
      bvid: videoData.bvid,
    }));
    const currentPage = currentVideoPageIndex(pages);

    return {
      type: "video",
      title,
      bvid: videoData.bvid,
      pages,
      currentPage,
      current: pages[currentPage - 1],
    };
  }

  function buildBangumiMetaFromState(s) {
    const epInfo = s?.epInfo;
    if (!epInfo?.cid) return null;

    const seriesTitle = sanitizeFileName(
      s?.h1Title ||
        s?.mediaInfo?.title ||
        document.title.replace(/_哔哩哔哩_bilibili$/, "").trim(),
    );

    const epList =
      Array.isArray(s?.epList) && s.epList.length ? s.epList : [epInfo];
    const pages = epList.map((ep, idx) => ({
      page: idx + 1,
      cid: ep.cid,
      bvid: ep.bvid || epInfo.bvid,
      part: sanitizeFileName(
        [ep.titleFormat, ep.long_title].filter(Boolean).join(" ") ||
          `EP${idx + 1}`,
      ),
    }));

    const currentCid = epInfo.cid;
    const currentIndex = Math.max(
      0,
      pages.findIndex((item) => item.cid === currentCid),
    );

    return {
      type: "bangumi",
      title: seriesTitle,
      bvid: epInfo.bvid,
      pages,
      currentPage: currentIndex + 1,
      current: pages[currentIndex],
    };
  }

  function buildMeta() {
    const s = getInitialState();
    return buildVideoMetaFromState(s) || buildBangumiMetaFromState(s);
  }

  function getFormatsFromPlayInfo(playInfo) {
    return uniqBy(
      (playInfo?.data?.support_formats || []).map((item) => ({
        qn: item.quality,
        label:
          item.new_description ||
          item.display_desc ||
          item.format ||
          String(item.quality),
      })),
      (item) => item.qn,
    ).sort((a, b) => b.qn - a.qn);
  }

  function pickBestAudio(data) {
    const candidates = [];
    if (Array.isArray(data?.dash?.audio)) candidates.push(...data.dash.audio);
    if (data?.dash?.flac?.audio) candidates.push(data.dash.flac.audio);
    if (Array.isArray(data?.dash?.dolby?.audio))
      candidates.push(...data.dash.dolby.audio);
    if (!candidates.length) return null;

    return [...candidates].sort((a, b) => {
      const aFlac = /flac/i.test(a?.codecs || "") ? 1 : 0;
      const bFlac = /flac/i.test(b?.codecs || "") ? 1 : 0;
      if (bFlac !== aFlac) return bFlac - aFlac;
      return (b.bandwidth || 0) - (a.bandwidth || 0);
    })[0];
  }

  function pickBestVideo(data, quality) {
    const videos = Array.isArray(data?.dash?.video) ? data.dash.video : [];
    if (!videos.length) return null;
    const exact = videos.filter((item) => item.id === quality);
    const list = exact.length ? exact : videos;

    return [...list].sort((a, b) => {
      const h = (b.height || 0) - (a.height || 0);
      if (h !== 0) return h;
      return (b.bandwidth || 0) - (a.bandwidth || 0);
    })[0];
  }

  function detectSingleFileExt(url) {
    if (/\.flv(\?|$)/i.test(url)) return "flv";
    if (/\.mp4(\?|$)/i.test(url)) return "mp4";
    return "mp4";
  }

  function audioSaveExt(stream) {
    return /flac/i.test(stream?.codecs || "") ? "flac" : "m4a";
  }

  function videoSaveExt() {
    return "mp4";
  }

  function buildBaseName(meta, page, qualityLabel) {
    const prefix =
      meta.type === "bangumi"
        ? `[${page.bvid || meta.bvid || "BILI"}] ${meta.title}`
        : `[${meta.bvid || "BILI"}] ${meta.title}`;

    const pageSuffix =
      meta.pages.length > 1
        ? ` - P${pad2(page.page)} ${page.part}`
        : page.part && page.part !== meta.title
          ? ` - ${page.part}`
          : "";

    return sanitizeFileName(`${prefix}${pageSuffix} - ${qualityLabel}`);
  }

  function buildDownloadInfo(meta, page, playData, requestedQn, qnLabelMap) {
    if (playData?.dash) {
      const actualQn = playData.quality || requestedQn;
      const qualityLabel =
        qnLabelMap.get(actualQn) ||
        qnLabelMap.get(requestedQn) ||
        `${actualQn}P`;
      const video = pickBestVideo(playData, actualQn);
      const audio = pickBestAudio(playData);
      if (!video) throw new Error("没有找到对应的视频流");

      const baseName = buildBaseName(meta, page, qualityLabel);
      return {
        type: "dash",
        actualQn,
        qualityLabel,
        videoUrl: video.baseUrl || video.base_url,
        audioUrl: audio ? audio.baseUrl || audio.base_url : "",
        videoName: `${baseName}.video.${videoSaveExt()}`,
        audioName: audio ? `${baseName}.audio.${audioSaveExt(audio)}` : "",
        outputName: `${baseName}.merged.mp4`,
      };
    }

    if (Array.isArray(playData?.durl) && playData.durl.length > 0) {
      const actualQn = playData.quality || requestedQn;
      const qualityLabel =
        qnLabelMap.get(actualQn) ||
        qnLabelMap.get(requestedQn) ||
        `${actualQn}P`;
      const baseName = buildBaseName(meta, page, qualityLabel);
      const ext = detectSingleFileExt(playData.durl[0].url);
      return {
        type: "single",
        actualQn,
        qualityLabel,
        singleUrl: playData.durl[0].url,
        fileName: `${baseName}.${ext}`,
      };
    }

    throw new Error("当前页面没有可识别的下载流");
  }

  async function fetchPlayData(page, qn) {
    const url = new URL("https://api.bilibili.com/x/player/playurl");
    url.searchParams.set("bvid", page.bvid);
    url.searchParams.set("cid", String(page.cid));
    url.searchParams.set("qn", String(qn));
    url.searchParams.set("fnval", "16");
    url.searchParams.set("fnver", "0");
    url.searchParams.set("fourk", "1");

    const res = await fetch(url.toString(), { credentials: "include" });
    if (!res.ok) throw new Error(`请求失败:HTTP ${res.status}`);

    const json = await res.json();
    if (json.code !== 0 || !json.data) {
      throw new Error(json.message || "接口返回异常");
    }
    return json.data;
  }

  function ffmpegCommand(info) {
    if (info.type === "single") {
      return `# 单文件无需合并:${info.fileName}`;
    }
    if (!info.audioName) {
      return `ffmpeg -i "${info.videoName}" -c copy "${info.outputName}"`;
    }
    return `ffmpeg -i "${info.videoName}" -i "${info.audioName}" -c copy "${info.outputName}"`;
  }

  function getSelectedQn() {
    return Number($(`#${PANEL_ID} .tm-quality`)?.value || 0);
  }

  function getSelectedLabel() {
    return (
      $(
        `#${PANEL_ID} .tm-quality`,
      )?.selectedOptions?.[0]?.textContent?.trim() || ""
    );
  }

  function getQnLabelMap() {
    return new Map(state.formats.map((item) => [item.qn, item.label]));
  }

  function populateQualityOptions() {
    const select = $(`#${PANEL_ID} .tm-quality`);
    if (!select) return;

    const currentQn = state.currentPlayInfo?.data?.quality;
    const formats = state.formats.length
      ? state.formats
      : [{ qn: currentQn || 0, label: `当前清晰度 ${currentQn || ""}` }];

    const oldValue = Number(select.value || 0);
    select.innerHTML = formats
      .map((item) => {
        const selected = oldValue
          ? oldValue === item.qn
          : currentQn === item.qn;
        return `<option value="${item.qn}" ${selected ? "selected" : ""}>${item.label}</option>`;
      })
      .join("");
  }

  async function gmDownloadSafe(url, name) {
    return new Promise((resolve) => {
      GM_download({
        url,
        name,
        saveAs: false,
        onload: () => resolve({ ok: true }),
        onerror: (err) => resolve({ ok: false, err }),
      });
    });
  }

  async function gmXhrBlobDownload(url, name) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        responseType: "blob",
        headers: {
          Referer: location.href,
        },
        onload: (response) => {
          if (
            response.status >= 200 &&
            response.status < 300 &&
            response.response
          ) {
            triggerBlobDownload(response.response, name);
            resolve();
            return;
          }
          reject(new Error(`HTTP ${response.status}`));
        },
        onerror: (error) => {
          reject(new Error(error?.error || "gm_xhr_failed"));
        },
        ontimeout: () => {
          reject(new Error("gm_xhr_timeout"));
        },
      });
    });
  }

  async function fetchBlobWithPageContext(url) {
    const res = await fetch(url, {
      credentials: "include",
      referrer: location.href,
      referrerPolicy: "strict-origin-when-cross-origin",
    });

    if (!res.ok) {
      throw new Error(`HTTP ${res.status}`);
    }

    return await res.blob();
  }

  function triggerBlobDownload(blob, name) {
    const objectUrl = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = objectUrl;
    a.download = name;
    a.rel = "noopener noreferrer";
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(objectUrl), 30000);
  }

  async function downloadViaBlob(url, name) {
    const blob = await fetchBlobWithPageContext(url);
    triggerBlobDownload(blob, name);
  }

  function formatBlockedMessage(name, detail) {
    return `下载受阻:${name}\n${detail}\n\n当前脚本无法直接替你绕过站点的访问控制。你可以改用页面内观看、官方缓存/离线能力,或在你自己的环境里手动处理已授权内容。`;
  }

  async function fallbackDownload(url, name, reason) {
    try {
      addLog(`${reason},改用 GM_xmlhttpRequest 下载:${name}`);
      await gmXhrBlobDownload(url, name);
      return;
    } catch (gmXhrError) {
      const gmXhrDetail = gmXhrError?.message || String(gmXhrError);
      addLog(`GM_xmlhttpRequest 下载失败:${gmXhrDetail}`);
    }

    try {
      addLog(`继续改用页面上下文下载:${name}`);
      await downloadViaBlob(url, name);
    } catch (blobError) {
      const detail = blobError?.message || String(blobError);
      addLog(`页面上下文下载失败:${detail}`);
      throw new Error(
        formatBlockedMessage(
          name,
          `${reason};GM_xmlhttpRequest 与页面上下文请求均失败:${detail}`,
        ),
      );
    }
  }

  async function startDownload(url, name) {
    const result = await gmDownloadSafe(url, name);
    if (result.ok) return;

    const error =
      result.err?.error ||
      result.err?.message ||
      String(result.err || "unknown");
    if (error === "not_whitelisted") {
      await fallbackDownload(url, name, "Tampermonkey 拒绝扩展名");
      return;
    }

    if (/forbidden/i.test(error)) {
      await fallbackDownload(url, name, "直链下载被拒绝");
      return;
    }

    if (/xhr_failed/i.test(error)) {
      await fallbackDownload(url, name, "Tampermonkey XHR 下载失败");
      return;
    }

    await fallbackDownload(url, name, `GM_download 失败:${error}`);
  }

  function createQueueItem(meta, page, qn, requestedLabel, scope = "batch") {
    return {
      meta: {
        ...meta,
        pages: Array.isArray(meta?.pages)
          ? meta.pages.map((item) => ({ ...item }))
          : [],
        current: meta?.current ? { ...meta.current } : null,
      },
      page: { ...page },
      qn,
      requestedLabel,
      scope,
    };
  }

  function enqueueItems(items) {
    if (!items.length) return;
    if (
      !state.isQueueRunning &&
      state.queueMode === "idle" &&
      !state.queueItems.length
    ) {
      state.queueIndex = 0;
      state.queueTotal = 0;
      state.queueDone = 0;
      state.queueFailed = 0;
      state.currentTaskLabel = "";
    }
    state.queueItems.push(...items);
    state.queueTotal += items.length;
    renderQueueState();
  }

  function buildTaskQueue(meta, qn, requestedLabel) {
    return (meta?.pages || []).map((page) =>
      createQueueItem(meta, page, qn, requestedLabel, "batch"),
    );
  }

  function getSelectedQueueConfig() {
    const qn = getSelectedQn();
    if (!qn) throw new Error("请选择清晰度");
    return {
      qn,
      requestedLabel: getSelectedLabel() || String(qn),
    };
  }

  async function resolveTask(item) {
    const playData = await fetchPlayData(item.page, item.qn);
    return buildDownloadInfo(
      item.meta,
      item.page,
      playData,
      item.qn,
      getQnLabelMap(),
    );
  }

  async function triggerDownloadInfo(info) {
    if (info.type === "single") {
      addLog(`开始下载:${info.fileName}`);
      await startDownload(info.singleUrl, info.fileName);
      return;
    }

    addLog(`开始下载:${info.videoName}`);
    await startDownload(info.videoUrl, info.videoName);
    if (info.audioUrl) {
      await sleep(250);
      addLog(`开始下载:${info.audioName}`);
      await startDownload(info.audioUrl, info.audioName);
    }
  }

  function ensureQueueRunning() {
    if (state.queueMode === "stopping") {
      addLog("队列停止中,请稍后再加入新任务");
      return;
    }
    if (state.isQueueRunning || !state.queueItems.length) {
      renderQueueState();
      return;
    }

    state.queueRunId += 1;
    state.isQueueRunning = true;
    state.queueMode = "running";
    setStatus(`队列下载中 ${state.queueDone}/${state.queueTotal}`);
    renderQueueState();
    runQueue(state.queueRunId);
  }

  function queueCurrent() {
    if (!state.meta?.current) throw new Error("未识别当前视频信息");
    if (state.queueMode === "stopping")
      throw new Error("队列停止中,请稍后再试");

    const { qn, requestedLabel } = getSelectedQueueConfig();
    const item = createQueueItem(
      state.meta,
      state.meta.current,
      qn,
      requestedLabel,
      "current",
    );
    enqueueItems([item]);
    addLog(`已加入队列:${formatQueueTaskLabel(item)}`);
    setStatus(`已加入队列 ${state.queueTotal}/${state.queueTotal}`);
    ensureQueueRunning();
  }

  function queueAll() {
    if (!state.meta?.pages?.length) throw new Error("没有可下载的分P/剧集");
    if (state.queueMode === "stopping")
      throw new Error("队列停止中,请稍后再试");

    const { qn, requestedLabel } = getSelectedQueueConfig();
    const items = buildTaskQueue(state.meta, qn, requestedLabel);
    if (!items.length) throw new Error("没有可下载的分P/剧集");

    enqueueItems(items);
    addLog(`已加入 ${items.length} 个任务:${requestedLabel}`);
    setStatus(`已加入队列,共 ${state.queueTotal} 个任务`);
    ensureQueueRunning();
  }

  function stopBackgroundQueue(reason = "用户手动停止", options = {}) {
    const { hard = false, clearProgress = false } = options;
    if (!state.isQueueRunning && state.queueMode !== "stopping") {
      if (hard && clearProgress) clearQueueTracking(true);
      return;
    }

    if (hard) {
      state.queueRunId += 1;
      state.isQueueRunning = false;
      state.queueMode = "idle";
      clearQueueTracking(clearProgress);
      setStatus("队列已停止");
      addLog(`队列已停止:${reason}`);
      return;
    }

    if (state.queueMode === "stopping") return;

    state.isQueueRunning = false;
    state.queueMode = "stopping";
    setStatus("队列停止中...");
    addLog(`队列将在当前任务后停止:${reason}`);
    renderQueueState();
  }

  async function runQueue(runId) {
    try {
      while (
        runId === state.queueRunId &&
        state.queueMode === "running" &&
        state.queueIndex < state.queueItems.length
      ) {
        const item = state.queueItems[state.queueIndex];
        const currentNumber = state.queueIndex + 1;
        const taskLabel = formatQueueTaskLabel(item);
        setCurrentTask(taskLabel);
        setStatus(`队列下载中 ${currentNumber}/${state.queueTotal}`);
        addLog(`开始任务 ${currentNumber}/${state.queueTotal}:${taskLabel}`);

        try {
          const info = await resolveTask(item);
          if (runId !== state.queueRunId) return;
          if (info.actualQn !== item.qn) {
            addLog(
              `请求 ${item.requestedLabel},实际返回 ${info.qualityLabel}`,
            );
          }
          await triggerDownloadInfo(info);
          if (runId !== state.queueRunId) return;
          state.queueDone += 1;
        } catch (err) {
          if (runId !== state.queueRunId) return;
          state.queueFailed += 1;
          state.queueDone += 1;
          addLog(`任务失败:${taskLabel} - ${err.message || String(err)}`);
        }

        if (runId !== state.queueRunId) return;
        state.queueIndex += 1;
        setProgress(state.queueDone, state.queueTotal);

        if (state.queueMode === "stopping" || !state.isQueueRunning) break;
        await sleep(600);
      }

      if (runId !== state.queueRunId) return;

      const wasStopped = state.queueMode === "stopping";
      state.isQueueRunning = false;
      state.queueMode = "idle";
      clearQueueTracking(false);

      if (wasStopped) {
        setStatus("队列已停止");
        addLog(`队列已停止,已完成 ${state.queueDone}/${state.queueTotal}`);
        return;
      }

      setStatus("队列已完成");
      addLog(
        `队列完成:成功 ${state.queueTotal - state.queueFailed},失败 ${state.queueFailed}`,
      );
    } catch (err) {
      if (runId !== state.queueRunId) return;
      state.isQueueRunning = false;
      state.queueMode = "idle";
      clearQueueTracking(false);
      setStatus("队列异常结束");
      addLog(err.message || String(err));
    }
  }

  async function refresh() {
    setStatus("正在读取页面信息...");
    const result = await waitForContext();
    state.meta = result.meta;
    state.currentPlayInfo = result.playInfo;

    if (!state.meta) {
      setStatus("未识别到视频信息");
      addLog("当前页面暂不支持");
      renderQueueState();
      return;
    }

    state.formats = getFormatsFromPlayInfo(state.currentPlayInfo);
    populateQualityOptions();

    const partText =
      state.meta.pages.length > 1
        ? `P${pad2(state.meta.currentPage)} / 共 ${state.meta.pages.length} 个`
        : "单P";

    setStatus(`${state.meta.title} | ${partText}`);
    addLog(`已识别:${state.meta.title}`);
    if (state.formats.length) {
      addLog(
        `可选清晰度:${state.formats.map((item) => item.label).join(" / ")}`,
      );
    }
    renderQueueState();
  }

  async function downloadCurrent() {
    try {
      queueCurrent();
    } catch (err) {
      setStatus("加入队列失败");
      addLog(err.message || String(err));
      alert(err.message || String(err));
    }
  }

  async function downloadAll() {
    try {
      queueAll();
    } catch (err) {
      setStatus("加入队列失败");
      addLog(err.message || String(err));
      alert(err.message || String(err));
    }
  }

  async function copyCurrentFfmpeg() {
    try {
      const info = await getCurrentPageInfo();
      GM_setClipboard(ffmpegCommand(info));
      setStatus("已复制当前 ffmpeg 命令");
      addLog("当前 ffmpeg 命令已复制");
    } catch (err) {
      setStatus("复制失败");
      addLog(err.message || String(err));
      alert(err.message || String(err));
    }
  }

  async function copyAllFfmpeg() {
    try {
      if (!state.meta?.pages?.length) throw new Error("没有可处理的分P/剧集");
      const qn = getSelectedQn();
      if (!qn) throw new Error("请选择清晰度");

      const lines = [];
      setStatus("正在生成批量 ffmpeg 命令...");
      for (let i = 0; i < state.meta.pages.length; i++) {
        const page = state.meta.pages[i];
        const playData = await fetchPlayData(page, qn);
        const info = buildDownloadInfo(
          state.meta,
          page,
          playData,
          qn,
          getQnLabelMap(),
        );
        lines.push(ffmpegCommand(info));
        await sleep(120);
      }

      GM_setClipboard(lines.join("\n"));
      setStatus("已复制批量 ffmpeg 命令");
      addLog(`已复制 ${lines.length} 条 ffmpeg 命令`);
    } catch (err) {
      setStatus("复制失败");
      addLog(err.message || String(err));
      alert(err.message || String(err));
    }
  }

  function ensurePanel() {
    if (document.getElementById(PANEL_ID)) return;

    GM_addStyle(`
      #${PANEL_ID} {
        position: fixed;
        top: 110px;
        right: 20px;
        z-index: 999999;
        width: 340px;
        background: rgba(24,24,28,.96);
        color: #fff;
        border: 1px solid rgba(255,255,255,.12);
        border-radius: 14px;
        box-shadow: 0 12px 28px rgba(0,0,0,.28);
        backdrop-filter: blur(8px);
        padding: 12px;
        font-size: 13px;
      }
      #${PANEL_ID} * { box-sizing: border-box; }
      #${PANEL_ID} .tm-title { font-weight: 700; font-size: 14px; margin-bottom: 8px; }
      #${PANEL_ID} .tm-status { font-size: 12px; color: rgba(255,255,255,.86); margin-bottom: 10px; word-break: break-all; }
      #${PANEL_ID} .tm-progress-wrap { margin-bottom: 10px; }
      #${PANEL_ID} .tm-progress-text,
      #${PANEL_ID} .tm-current-task {
        font-size: 12px;
        color: rgba(255,255,255,.82);
        word-break: break-word;
      }
      #${PANEL_ID} .tm-progress-bar {
        margin: 6px 0;
        height: 8px;
        border-radius: 999px;
        overflow: hidden;
        background: rgba(255,255,255,.12);
      }
      #${PANEL_ID} .tm-progress-fill {
        width: 0;
        height: 100%;
        border-radius: 999px;
        background: linear-gradient(90deg, #00aeec 0%, #50d7ff 100%);
        transition: width .2s ease;
      }
      #${PANEL_ID} .tm-row { display: flex; gap: 8px; margin-bottom: 8px; }
      #${PANEL_ID} .tm-quality {
        width: 100%; border: none; border-radius: 8px; padding: 8px 10px;
        background: #2f3136; color: #fff; outline: none;
      }
      #${PANEL_ID} button {
        width: 100%; border: none; border-radius: 8px; padding: 8px 10px;
        background: #00aeec; color: #fff; cursor: pointer; font-size: 13px;
      }
      #${PANEL_ID} button:hover { opacity: .92; }
      #${PANEL_ID} button:disabled,
      #${PANEL_ID} .tm-quality:disabled {
        opacity: .55;
        cursor: not-allowed;
      }
      #${PANEL_ID} .tm-log {
        margin-top: 8px; min-height: 108px; max-height: 220px; overflow: auto;
        white-space: pre-wrap; word-break: break-word; background: rgba(255,255,255,.06);
        border-radius: 8px; padding: 8px; font-size: 12px; line-height: 1.5;
      }
    `);

    const panel = document.createElement("div");
    panel.id = PANEL_ID;
    panel.innerHTML = `
      <div class="tm-title">B站下载助手 增强版(修正版)</div>
      <div class="tm-status">初始化中...</div>
      <div class="tm-progress-wrap">
        <div class="tm-progress-text">进度:0/0(空闲)</div>
        <div class="tm-progress-bar"><div class="tm-progress-fill"></div></div>
        <div class="tm-current-task">当前:空闲</div>
      </div>
      <div class="tm-row">
        <select class="tm-quality"></select>
      </div>
      <div class="tm-row">
        <button data-action="refresh">刷新信息</button>
        <button data-action="current">加入当前</button>
      </div>
      <div class="tm-row">
        <button data-action="all">加入全部</button>
        <button data-action="stop-all">停止队列</button>
      </div>
      <div class="tm-row">
        <button data-action="ffmpeg-current">复制当前ffmpeg</button>
        <button data-action="ffmpeg-all">复制批量ffmpeg</button>
      </div>
      <div class="tm-log"></div>
    `;

    panel.addEventListener("click", async (event) => {
      const btn = event.target.closest("button");
      if (!btn) return;
      const action = btn.dataset.action;
      if (action === "refresh") return refresh();
      if (action === "current") return downloadCurrent();
      if (action === "all") return downloadAll();
      if (action === "stop-all") return stopBackgroundQueue();
      if (action === "ffmpeg-current") return copyCurrentFfmpeg();
      if (action === "ffmpeg-all") return copyAllFfmpeg();
    });

    document.body.appendChild(panel);
    renderQueueState();
  }

  async function init() {
    ensurePanel();
    await refresh();

    setInterval(async () => {
      if (location.href !== state.href) {
        state.href = location.href;
        if (state.isQueueRunning || state.queueMode === "stopping") {
          stopBackgroundQueue("页面地址变化", {
            hard: true,
            clearProgress: true,
          });
        }
        state.meta = null;
        state.formats = [];
        state.currentPlayInfo = null;
        addLog("页面地址变化,重新读取...");
        await refresh();
      }
    }, 1200);
  }

  init();
})();

源码地址:https://github.com/weiwentao996/bilibili-down

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
Zla 发表于 2026-4-22 16:37
感谢分享
3#
tushan518 发表于 2026-4-23 13:37
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-4-23 13:44

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表