Bilibili动态预览图片下载

在B站个人空间的投稿 - 图文界面,提供右键直接下载动态中的图片,并记录已下载的动态ID,改变背景颜色来区别。(新支持新旧动态页面以及旧版专栏内图片)

// ==UserScript==
// @name         Bilibili动态预览图片下载
// @namespace    BilibiliDynamicPreviewDownload
// @license      MIT
// @version      1.2.0
// @description  在B站个人空间的投稿 - 图文界面,提供右键直接下载动态中的图片,并记录已下载的动态ID,改变背景颜色来区别。(新支持新旧动态页面以及旧版专栏内图片)
// @author       Kaesinol
// @match        https://space.bilibili.com/*
// @match        https://www.bilibili.com/opus/*
// @match        https://t.bilibili.com/*
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @require      https://update.greasyfork.dpdns.org/scripts/473358/1237031/JSZip.js
// ==/UserScript==

(function () {
  "use strict";

  // --- 数据存取兼容性处理 ---
  const loadDownloadedDynamicIds = () => {
    const stored = GM_getValue("downloadedDynamicIds", null);
    if (!stored) {
      return new Set();
    }
    // 如果存的是数组,则直接构造 Set
    if (Array.isArray(stored)) {
      return new Set(stored);
    }
    // 如果存的是对象(旧的 dict 格式),取键数组构造 Set
    if (typeof stored === "object") {
      return new Set(Object.keys(stored));
    }
    // 默认返回新的 Set
    return new Set();
  };

  // 全局保存当前已下载的动态 ID(用字符串形式存储)
  let downloadedDynamicIds = loadDownloadedDynamicIds();

  // 保存时统一把 Set 转换为数组存储,方便下次加载时兼容
  const saveDownloadedDynamicIds = () => {
    GM_setValue("downloadedDynamicIds", Array.from(downloadedDynamicIds));
  };

  // ----- 业务逻辑函数 -----

  const fetchJsonData = async (dynamicId, ret=false) => {
    const apiUrl = `https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/get_dynamic_detail?dynamic_id=${dynamicId}`;
    try {
      const response = await fetch(apiUrl);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const jsonData = await response.json();
      if (ret)
        return jsonData;
      const cardData = JSON.parse(jsonData.data.card.card);
      const pictures =
        cardData.item?.pictures?.map((p) =>
          p.img_src.replace(/^http:/, "https:")
        ) ||
        cardData.origin_image_urls ||
        [];
      const uname = jsonData.data.card.desc.user_profile.info.uname;
      const uid = jsonData.data.card.desc.user_profile.info.uid;
      const fileName = `${uname} - ${uid} - ${dynamicId}`;

      console.log("提取的图片链接:", pictures);
      if (pictures.length > 1) await createZipAndDownload(pictures, fileName);
      else await downloadFile(pictures[0], 0, fileName);

      // 添加到 Set 中,并保存(动态 ID 转为字符串)
      downloadedDynamicIds.add(String(dynamicId));
      saveDownloadedDynamicIds();
      updateLinkColor(dynamicId);
    } catch (error) {
      console.error("请求或解析失败:", error);
    }
  };

  const createZipAndDownload = async (urls, fileName) => {
    const zip = new JSZip();
    const promises = urls.map((url, index) => {
      return fetch(url)
        .then((response) => {
          if (!response.ok) {
            throw new Error(`Failed to fetch ${url}`);
          }
          return response.blob();
        })
        .then((blob) => {
          const extensionMatch = getFileExtensionFromUrl(url);
          const extension = extensionMatch[1];
          const fileNameWithIndex = `${fileName} - ${index + 1}.${extension}`;
          zip.file(fileNameWithIndex, blob);
        })
        .catch((error) => {
          console.error("下载文件失败:", error);
        });
    });

    await Promise.all(promises);

    zip
      .generateAsync({ type: "blob" })
      .then((content) => {
        GM_download({
          url: URL.createObjectURL(content),
          name: `${fileName}.zip`,
          saveAs: false,
        });
      })
      .catch((error) => {
        console.error("ZIP生成失败:", error);
      });
  };

  const getFileExtensionFromUrl = (url) => url.match(/\.([a-zA-Z0-9]+)$/);

  const downloadFile = async (url, index, fileName) => {
    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`Failed to fetch ${url}`);
      }
      const blob = await response.blob();
      const extensionMatch = getFileExtensionFromUrl(url);
      const extension = extensionMatch[1];
      const fileDownloadName = `${fileName} - ${index + 1}.${extension}`;

      GM_download({
        url: URL.createObjectURL(blob),
        name: fileDownloadName,
        saveAs: false,
      });
    } catch (error) {
      console.error("下载文件失败:", error);
    }
  };

  const handleEvent = (event, targetElement) => {
    event.preventDefault();
    event.stopPropagation();

    if (event.type === "contextmenu") {
      const match = targetElement.querySelector("a").href.match(/\/(\d+)\??/);
      if (match && match[1]) {
        const dynamicId = match[1];
        fetchJsonData(dynamicId);
      } else {
        console.warn("未匹配到动态ID:", targetElement.href);
      }
    }
  };

  const updateLinkColor = (dynamicId) => {
    const link = document.querySelector(`a[href*="${dynamicId}"]`);
    if (link) {
      link.parentElement.style.backgroundColor = "green";
    }
  };

  const observer = new MutationObserver(() => {
    let targetElements = document.querySelectorAll("div.opus-body div.item");
    targetElements.forEach((targetElement) => {
      if (!targetElement.hasAttribute("data-listener")) {
        targetElement.addEventListener(
          "contextmenu",
          (event) => handleEvent(event, targetElement),
          true
        );
        targetElement.setAttribute("data-listener", "true");
      }

      // 检查已下载的动态ID,并更新相应链接的颜色
      const link = targetElement.querySelector("a");
      const match = link.href.match(/\/(\d+)\??/);
      if (match && downloadedDynamicIds.has(match[1])) {
        link.parentElement.style.backgroundColor = "green";
      }
    });
  });

  observer.observe(document.body, {
    childList: true,
    subtree: true,
  });

  const initialTargetElements = document.querySelectorAll(
    "div.opus-body div.item"
  );
  initialTargetElements.forEach((targetElement) => {
    targetElement.addEventListener(
      "contextmenu",
      (event) => handleEvent(event, targetElement),
      true
    );
  });

  // ----- 油猴命令菜单 -----

  // 导出已下载的动态 ID 为 JSON 文件
  const exportDownloadedDynamicIds = () => {
    // 转换 Set 为数组并转为 JSON 字符串
    const idsArray = Array.from(downloadedDynamicIds);
    const jsonContent = JSON.stringify(idsArray);
    const blob = new Blob([jsonContent], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    GM_download({
      url: url,
      name: "downloadedDynamicIds.json",
      saveAs: true, // 弹出保存对话框
    });
  };

  // 导入 JSON 文件(并集更新到当前动态ID集合)
  const importDownloadedDynamicIds = () => {
    // 创建隐藏的文件输入元素
    const input = document.createElement("input");
    input.type = "file";
    input.accept = ".json,application/json";
    input.style.display = "none";

    input.addEventListener("change", (e) => {
      const file = e.target.files[0];
      if (file) {
        const reader = new FileReader();
        reader.onload = (e) => {
          try {
            const imported = JSON.parse(e.target.result);
            if (!Array.isArray(imported)) {
              alert("导入文件格式不正确:应为 JSON 数组");
              return;
            }
            // 将导入的数据取并集
            imported.forEach((id) => {
              downloadedDynamicIds.add(String(id));
            });
            saveDownloadedDynamicIds();
            alert("导入成功!");
          } catch (error) {
            console.error("解析文件失败:", error);
            alert("解析文件失败,请确保文件格式正确");
          }
        };
        reader.readAsText(file);
      }
    });

    // 将文件输入添加到DOM后触发点击,然后移除
    document.body.appendChild(input);
    input.click();
    input.remove();
  };
  const getID = () => {
    let dynamicId = null;
    const opusMatch = location.pathname.match(/^\/opus\/(\d+)/);
    if (opusMatch) {
      dynamicId = opusMatch[1];
    } else {
      const tMatch = location.href.match(/^https?:\/\/t\.bilibili\.com\/(\d+)/);
      if (tMatch) {
        dynamicId = tMatch[1];
      }
    }
    return dynamicId;
  };

  (function registerSingleDynamicPageCommand() {
    const dynamicId = getID();
    if (!dynamicId) return;
    GM_registerMenuCommand("下载本条动态图片", () => {
      fetchJsonData(dynamicId);
    });
  })();
  const downloadOldColumnImages = async () => {
    // 找到所有旧版专栏的图片,过滤掉 alt="cut-off" 的占位图
    const imgEls = Array.from(document.querySelectorAll('.opus-module-content img:not([alt="cut-off"])'));
    const dynamicId = getID();
    const jsonData = await fetchJsonData(dynamicId,true);
    const uname = jsonData.data.card.desc.user_profile.info.uname;
    const uid = jsonData.data.card.desc.user_profile.info.uid;
    const fileName = `${uname} - ${uid} - ${dynamicId}`;
    if (imgEls.length === 0) {
      alert('未找到可下载的旧版专栏图片');
      return;
    }
    // 统一替换为 https 协议,并提取 URL 数组
    const urls = imgEls.map(el => el.src.replace(/^http:/, 'https:').replace(/@.*/, ''));
    // 使用已有的 createZipAndDownload 打包下载
    await createZipAndDownload(urls, fileName);
  };
  GM_registerMenuCommand("下载旧版专栏图片", downloadOldColumnImages);
  // 注册(不可用)油猴菜单命令
  GM_registerMenuCommand("导出已下载的动态ID", exportDownloadedDynamicIds);
  GM_registerMenuCommand("导入已下载的动态ID", importDownloadedDynamicIds);

})();
长期地址
遇到问题?请前往 GitHub 提 Issues,或加Q群1031348184

赞助商

Fishcpy

广告

Rainyun

注册一下就行

Rainyun

一年攒够 12 元