// ==UserScript== // @name Bilibili图文动态预览图片下载 // @namespace BilibiliDynamicPreviewDownload // @license MIT // @version 1.6.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 // @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 // ==/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 }); }; startObserver(); 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)); } })();