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