Last active
September 4, 2026 10:23
-
-
Save kaixinol/9f612c93b8632aa9fdbcfe7fe45f34db to your computer and use it in GitHub Desktop.
油猴脚本,在B站个人空间的投稿 - 图文界面,提供右键直接下载动态中的图片
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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