您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在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.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); })();