Skip to content

Instantly share code, notes, and snippets.

@kaixinol
Last active September 4, 2026 10:23
Show Gist options
  • Select an option

  • Save kaixinol/9f612c93b8632aa9fdbcfe7fe45f34db to your computer and use it in GitHub Desktop.

Select an option

Save kaixinol/9f612c93b8632aa9fdbcfe7fe45f34db to your computer and use it in GitHub Desktop.
油猴脚本,在B站个人空间的投稿 - 图文界面,提供右键直接下载动态中的图片
// ==UserScript==
// @name Bilibili图文动态预览图片下载
// @namespace BilibiliDynamicPreviewDownload
// @license MIT
// @version 1.6.1
// @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
// @grant GM_xmlhttpRequest
// @connect api.bilibili.com
// @require https://cdn.jsdelivr.net/npm/fflate@0.8.2/umd/index.js
// @require https://update.greasyfork.org/scripts/580254/1837601/Bilibili%20WBI%20Signer%20Library.js
// @icon https://www.bilibili.com/favicon.ico
// @downloadURL https://update.greasyfork.org/scripts/524897/Bilibili%E5%8A%A8%E6%80%81%E9%A2%84%E8%A7%88%E5%9B%BE%E7%89%87%E4%B8%8B%E8%BD%BD.user.js
// @updateURL https://update.greasyfork.org/scripts/524897/Bilibili%E5%8A%A8%E6%80%81%E9%A2%84%E8%A7%88%E5%9B%BE%E7%89%87%E4%B8%8B%E8%BD%BD.meta.js
// ==/UserScript==
(function () {
"use strict";
const cacheHelper = {
set: (key, value, minutes = 60) => {
const data = { value, expire: Date.now() + minutes * 60 * 1000 };
localStorage.setItem(key, JSON.stringify(data));
},
get: (key) => {
const raw = localStorage.getItem(key);
if (!raw) return null;
const data = JSON.parse(raw);
if (Date.now() > data.expire) {
localStorage.removeItem(key);
return null;
}
return data.value;
},
};
const getWbiKeysWithCache = async () => {
const CACHE_KEY = "bili_wbi_cache";
const cachedKeys = cacheHelper.get(CACHE_KEY);
if (cachedKeys) return cachedKeys;
const keys = await BiliWbi.getWbiKeys();
cacheHelper.set(CACHE_KEY, keys, 60);
return keys;
};
const loadDownloadedDynamicIds = () => {
const stored = GM_getValue("downloadedDynamicIds", null);
if (!stored) return new Set();
if (Array.isArray(stored)) return new Set(stored);
if (typeof stored === "object") return new Set(Object.keys(stored));
return new Set();
};
let downloadedDynamicIds = loadDownloadedDynamicIds();
const saveDownloadedDynamicIds = () => {
GM_setValue("downloadedDynamicIds", Array.from(downloadedDynamicIds));
};
const getFileExtensionFromUrl = (url) =>
(url.match(/\.([a-zA-Z0-9]+)$/) || [])[1] || "jpg";
const downloadBlob = (blob, name) => {
const objectUrl = URL.createObjectURL(blob);
GM_download({
url: objectUrl,
name,
saveAs: false,
onload: () => URL.revokeObjectURL(objectUrl),
onerror: () => URL.revokeObjectURL(objectUrl),
});
};
const createZipAndDownload = async (urls, fileName) => {
const files = {};
await Promise.all(
urls.map(async (url, index) => {
const res = await fetch(url);
if (!res.ok) throw new Error("fetch failed");
const data = new Uint8Array(await res.arrayBuffer());
const ext = getFileExtensionFromUrl(url);
files[`${fileName} - ${index + 1}.${ext}`] = data;
}),
);
const zipped = fflate.zipSync(files, { level: 0 });
const blob = new Blob([zipped], { type: "application/zip" });
downloadBlob(blob, `${fileName}.zip`);
};
const downloadFile = async (url, index, fileName) => {
const res = await fetch(url);
if (!res.ok) throw new Error("fetch failed");
const blob = await res.blob();
const ext = getFileExtensionFromUrl(url);
downloadBlob(blob, `${fileName} - ${index + 1}.${ext}`);
};
const stripThumbnailSuffix = (url) =>
url
.replace(/^http:/, "https:")
.replace(/@\d+w.*$/, "")
.replace(/@\d+h.*$/, "")
.replace(/@\..*$/, "");
const extractImagesFromArticle = async (articleId) => {
try {
const apiUrl = `https://api.bilibili.com/x/article/view?id=${articleId}`;
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
headers: {
Referer: `https://www.bilibili.com/read/cv${articleId}`,
},
onload: resolve,
onerror: reject,
});
});
const jsonData = JSON.parse(response.responseText);
if (jsonData.code !== 0) return [];
const data = jsonData.data;
const urls = new Set();
const addUrl = (src) => {
if (src && src.includes("/article/") && !src.includes("/face/")) {
urls.add(stripThumbnailSuffix(src));
}
};
if (data.type === 3) {
const content = JSON.parse(data.content);
content.ops?.forEach((op) => {
const image = op.insert?.nativeImage ?? op.insert?.["native-image"];
if (image?.url) addUrl(image.url);
});
} else {
const doc = new DOMParser().parseFromString(data.content, "text/html");
doc.querySelectorAll("img").forEach((img) => addUrl(img.src));
}
data.image_urls?.forEach(addUrl);
data.origin_image_urls?.forEach(addUrl);
return [...urls];
} catch (e) {
console.error(e);
return [];
}
};
const extractImagesFromDom = () => {
const contentArea = document.querySelector(".opus-module-content");
if (!contentArea) return [];
const images = contentArea.querySelectorAll("img");
const urls = new Set();
images.forEach((img) => {
const src = img.src || img.dataset?.src || "";
if (src.includes("/article/") && !src.includes("/face/")) {
urls.add(stripThumbnailSuffix(src));
}
});
return [...urls];
};
const fetchJsonData = async (dynamicId) => {
try {
const { img_key, sub_key } = await getWbiKeysWithCache();
const queryString = BiliWbi.getWbiQuery(
{ id: dynamicId },
img_key,
sub_key,
);
const apiUrl =
"https://api.bilibili.com/x/polymer/web-dynamic/v1/detail?" +
queryString;
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
onload: resolve,
onerror: reject,
});
});
const jsonData = JSON.parse(response.responseText);
if (jsonData.code !== 0) return;
const info = jsonData.data.item.modules.module_author;
const fileName = `${info.name} - ${info.mid} - ${dynamicId}`;
const major = jsonData.data.item.modules.module_dynamic.major;
let pictures = [];
if (major?.draw?.items) {
pictures = major.draw.items.map((p) =>
p.src.replace(/^http:/, "https:"),
);
} else if (major?.opus?.pics) {
pictures = major.opus.pics.map((p) =>
p.url.replace(/^http:/, "https:"),
);
} else if (major?.type === "MAJOR_TYPE_ARTICLE") {
const articleId = major?.article?.id ?? major?.id ?? major?.biz_id;
pictures = articleId ? await extractImagesFromArticle(articleId) : [];
if (pictures.length === 0) pictures = extractImagesFromDom();
}
if (pictures.length > 1) {
await createZipAndDownload(pictures, fileName);
} else if (pictures.length === 1) {
await downloadFile(pictures[0], 0, fileName);
}
if (pictures.length > 0) {
downloadedDynamicIds.add(String(dynamicId));
saveDownloadedDynamicIds();
updateLinkColor(dynamicId);
}
} catch (e) {
console.error(e);
}
};
const handleEvent = (event, targetElement) => {
event.preventDefault();
event.stopPropagation();
const link = targetElement.querySelector("a");
const match = link?.href.match(/\/(\d+)\??/);
if (match) fetchJsonData(match[1]);
};
const updateLinkColor = (dynamicId) => {
const link = document.querySelector(`a[href*="${dynamicId}"]`);
if (link) link.parentElement.style.backgroundColor = "green";
};
const observer = new MutationObserver(() => {
document.querySelectorAll("div.opus-body div.item").forEach((el) => {
if (!el.hasAttribute("data-listener")) {
el.addEventListener("contextmenu", (e) => handleEvent(e, el), true);
el.setAttribute("data-listener", "true");
}
const link = el.querySelector("a");
const m = link?.href.match(/\/(\d+)\??/);
if (m && downloadedDynamicIds.has(m[1])) {
link.parentElement.style.backgroundColor = "green";
}
});
});
const startObserver = () => {
const target = document.querySelector("div.space-upload");
if (target) {
observer.observe(target, { childList: true, subtree: true });
return;
}
const wait = new MutationObserver(() => {
const t = document.querySelector("div.space-upload");
if (!t) return;
wait.disconnect();
observer.observe(t, { childList: true, subtree: true });
});
wait.observe(document.body, { childList: true, subtree: true });
};
let isOpusPage = false;
const checkRoute = () => {
const url = new URL(location.href);
const currentIsOpusPage =
url.hostname === "space.bilibili.com" &&
/^\/\d+\/upload\/opus\/?$/.test(url.pathname);
if (currentIsOpusPage === isOpusPage) return;
isOpusPage = currentIsOpusPage;
if (isOpusPage) {
startObserver();
}
};
setInterval(checkRoute, 100);
checkRoute();
const getID = () => {
const opus = location.pathname.match(/^\/opus\/(\d+)/);
if (opus) return opus[1];
const t = location.href.match(/^https?:\/\/t\.bilibili\.com\/(\d+)/);
return t?.[1] || null;
};
const exportDownloadedDynamicIds = () => {
const blob = new Blob([JSON.stringify([...downloadedDynamicIds])], {
type: "application/json",
});
downloadBlob(blob, "downloadedDynamicIds.json");
};
const importDownloadedDynamicIds = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = () => {
const reader = new FileReader();
reader.onload = () => {
JSON.parse(reader.result).forEach((id) =>
downloadedDynamicIds.add(String(id)),
);
saveDownloadedDynamicIds();
alert("导入成功");
};
reader.readAsText(input.files[0]);
};
input.click();
};
GM_registerMenuCommand("导出已下载ID", exportDownloadedDynamicIds);
GM_registerMenuCommand("导入已下载ID", importDownloadedDynamicIds);
const dynamicId = getID();
if (dynamicId) {
GM_registerMenuCommand("下载本条动态图片", () => fetchJsonData(dynamicId));
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment