// ==UserScript== // @name 哔哩哔哩动态图片下载 // @namespace https://space.bilibili.com/11768481 // @version 1.0 // @description 为方便下载bilibili图片而开发 // @author 伊墨墨 // @match https://www.bilibili.com/opus/* // @match https://t.bilibili.com/* // @match https://space.bilibili.com/*/dynamic // @match https://www.bilibili.com/v/topic/* // @grant GM_download // @grant GM_addStyle // @license MIT // @icon https://www.bilibili.com/favicon.ico // @supportURL https://greasyfork.org/zh-CN/scripts/531888/feedback // @homepageURL https://greasyfork.org/zh-CN/scripts/531888 // @downloadURL none // ==/UserScript== (function () { 'use strict'; // --- 样式定义 --- GM_addStyle(` #bili-download-images-button { /* 旧版悬浮按钮样式 */ position: fixed; top: 50%; right: 20px; z-index: 1000; padding: 10px 20px; background-color: #00a1d6; color: white; border: none; border-radius: 5px; font-size: 16px; cursor: pointer; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); } #bili-download-images-button:hover { background-color: #007ead; } .bili-toast-message { /* 提示框样式 */ position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background-color: rgba(51, 51, 51, 0.9); color: white; padding: 10px 20px; border-radius: 5px; z-index: 9999; font-size: 14px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); pointer-events: none; } /* 动态菜单下载选项样式 */ .download-images-option { cursor: pointer; } .download-images-option:hover .bili-cascader-options__item-label, .download-images-option.bili-dyn-more__menu__item:hover { color: #00a1d6; } /* 侧边栏下载按钮样式 */ .side-toolbar__action.download { cursor: pointer; text-align: center; color: #61666D; transition: color 0.3s; margin-bottom: 16px; } .side-toolbar__action.download svg { width: 24px; height: 24px; margin-bottom: 2px; } .side-toolbar__action.download .side-toolbar__action__text { font-size: 12px; line-height: 14px; color: #9499A0; transform: scale(0.875); transform-origin: center top; } .side-toolbar__action.download:hover { color: #00A1D6; } `); // --- 常量与配置 --- const MAX_CONCURRENT_DOWNLOADS = 3; // 最大并发下载数 const CONFIG = { DETAIL_PAGE_ID_REGEX: /(?:\/opus\/|\/dynamic\/|\/)(\d{10,})/, // 用于从URL提取ID IMAGE_CONTAINER_SELECTORS: [ // 详情页图片容器选择器 (优先级顺序) '.horizontal-scroll-album__indicator', '.bili-album__preview', '.bili-dyn-item__images', '.horizontal-scroll-album', '.opus-module-content', '.bili-dyn-gallery__track', '.bili-dyn-card-video__cover' ], CARD_CONFIG: { // 卡片模式下的基础配置 CARD_SELECTOR: '.bili-dyn-list__item', // 动态卡片 (动态首页/空间) MORE_BUTTON_SELECTOR: '.bili-dyn-item__more .bili-dyn-more__btn', // "更多"按钮 CASCADER_SELECTOR: '.bili-dyn-more__cascader, .bili-popover', // 弹出菜单 OPTIONS_SELECTOR: '.bili-cascader-options, .bili-dyn-more__menu', // 菜单选项列表 LIST_CONTAINER_SELECTOR: '.bili-dyn-list__items', // 卡片列表容器 (动态首页) IMAGE_SELECTORS: [ // 卡片内图片选择器 '.bili-album__watch__track__list img[src]', '.bili-album__preview img[src]', '.bili-dyn-gallery__track img[src]', '.bili-dyn-card-video__cover img[src]' ], } }; // --- 工具函数 --- const utils = { /** 等待指定选择器的元素出现在 DOM 中 */ waitForElement: (selector, callback, timeout = 1000) => { let element = document.querySelector(selector); if (element) { callback(element); return; } let observer = null; let timeoutId = null; const cleanup = () => { if (observer) observer.disconnect(); if (timeoutId) clearTimeout(timeoutId); observer = null; timeoutId = null; }; observer = new MutationObserver((mutations, obs) => { element = document.querySelector(selector); if (element) { cleanup(); callback(element); } }); observer.observe(document.body, { childList: true, subtree: true }); if (timeout > 0) { timeoutId = setTimeout(() => { if (observer) { console.warn(`[B站图片下载] 等待元素 "${selector}" 超时 (${timeout}ms).`); cleanup(); // 超时后可以考虑调用 callback(null) 让调用者知道失败了 } }, timeout); } }, /** 文件名消毒:移除或替换非法字符,限制长度 */ sanitizeFilename: (name) => { if (!name) return 'unknown_file'; return name .replace(/[/\\:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim() .slice(0, 200); }, /** 显示自动消失的提示消息 */ showToast: (message, duration = 3000) => { const toast = document.createElement('div'); toast.className = 'bili-toast-message'; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => toast.remove(), duration); }, /** 处理图片 URL:确保是 https,移除 @ 参数 */ processImageUrl: (rawUrl) => { if (!rawUrl || typeof rawUrl !== 'string') return null; let cleanUrl = rawUrl.startsWith('//') ? 'https:' + rawUrl : rawUrl; if (!cleanUrl.startsWith('http')) return null; cleanUrl = cleanUrl.split('@')[0]; return cleanUrl; } }; // --- 核心下载功能模块 --- const core = { /** 获取当前页面的用户名 (动态详情页/Opus页) */ getUsernameFromPage: () => { const dynTitleElem = document.querySelector('.bili-dyn-title__text'); if (dynTitleElem) return dynTitleElem.innerText.trim(); const opusAuthorElem = document.querySelector('.opus-module-author__name'); if (opusAuthorElem) return opusAuthorElem.innerText.trim(); return '未知用户'; }, /** 获取当前页面的动态/Opus ID */ getItemIdFromPage: () => { const urlMatch = window.location.href.match(CONFIG.DETAIL_PAGE_ID_REGEX); if (urlMatch?.[1]) return urlMatch[1]; console.warn("[B站图片下载] 无法从URL获取ID,使用时间戳作为后备。"); return Date.now().toString().slice(-10); }, /** 查找页面中的主要图片容器 */ findImageContainerOnPage: () => { for (const selector of CONFIG.IMAGE_CONTAINER_SELECTORS) { const container = document.querySelector(selector); if (container && container.querySelector('img[src]')) { return container; } } return null; }, /** 从指定的容器中提取所有有效图片信息 */ extractImagesFromContainer: (container) => { if (!container) return []; return Array.from(container.querySelectorAll('img[src]')) .map(img => { const cleanUrl = utils.processImageUrl(img.src); if (!cleanUrl) return null; const formatMatch = cleanUrl.match(/\.(jpg|jpeg|png|gif|webp|avif)(?:[?#]|$)/i); const format = formatMatch ? formatMatch[1].toLowerCase() : 'jpg'; return { url: cleanUrl, format: format }; }) .filter(Boolean); }, /** 监听 B站相册展开后的图片加载 */ monitorAlbumExpansion: (username, itemId) => { const trackListSelector = '.bili-album__watch__track__list'; utils.waitForElement(trackListSelector, (trackContainer) => { if (trackContainer.children.length > 0) { const images = core.extractImagesFromContainer(trackContainer); if (images.length) { core.downloadImages(images, username, itemId); } else { utils.showToast('相册展开后未找到图片。'); } } else { utils.showToast('相册轨道为空。'); // 可能展开了但内容加载失败或为空 } }, 10000); // 10秒超时 }, /** 批量下载图片 */ downloadImages: (images, username = '未知用户', itemId = '未知ID') => { if (!images || images.length === 0) { utils.showToast('未找到可下载的图片!'); return; } const totalImages = images.length; let submittedCount = 0; utils.showToast(`开始处理 ${totalImages} 张图片...`); const downloadBatch = (startIndex) => { const endIndex = Math.min(startIndex + MAX_CONCURRENT_DOWNLOADS, totalImages); for (let i = startIndex; i < endIndex; i++) { const img = images[i]; const filename = utils.sanitizeFilename( `${username}_${itemId}_${String(i + 1).padStart(2, '0')}.${img.format}` ); try { GM_download({ url: img.url, name: filename, headers: { "Referer": location.href }, onerror: (error, details) => { console.error(`[GM_download Error] ${filename}:`, error, details); if (error.error === 'network' || details?.current === 'NETWORK_FAILED') { utils.showToast(`网络错误,尝试备用方法: ${filename}`, 4000); core.fetchAndDownload(img.url, filename); } else if (error.error === 'not_enabled' || error.error === 'not_granted') { utils.showToast('GM_download权限不足或未启用', 5000); core.fetchAndDownload(img.url, filename); } else { utils.showToast(`下载失败(${error.error || '未知'}): ${filename}`, 5000); core.fetchAndDownload(img.url, filename); // 尝试备用 } }, ontimeout: () => { console.warn(`[GM_download Timeout] ${filename}`); utils.showToast(`下载超时,尝试备用方法: ${filename}`, 4000); core.fetchAndDownload(img.url, filename); } }); submittedCount++; } catch (e) { console.error("[GM_download Exception] GM_download不可用:", e); utils.showToast('GM_download不可用,使用备用下载...', 3000); core.fetchAndDownload(img.url, filename); submittedCount++; } } if (endIndex < totalImages) { setTimeout(() => downloadBatch(endIndex), 1000); // 下一批延迟 } else { utils.showToast(`已提交 ${submittedCount} / ${totalImages} 个下载任务。`); } }; downloadBatch(0); }, /** 使用 Fetch API 下载图片 (备用方案) */ fetchAndDownload: async (url, filename) => { try { const response = await fetch(url, { headers: { "Referer": window.location.href } }); if (!response.ok) throw new Error(`HTTP error ${response.status} (${response.statusText})`); const blob = await response.blob(); if (!blob.size) throw new Error("下载的文件为空"); const downloadUrl = window.URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = downloadUrl; anchor.download = filename; anchor.style.display = 'none'; document.body.appendChild(anchor); anchor.click(); setTimeout(() => { document.body.removeChild(anchor); window.URL.revokeObjectURL(downloadUrl); }, 100); } catch (err) { console.error(`[Fetch Download Error] ${filename}:`, err); let errorMsg = `备用下载失败: ${filename}`; if (err.message.includes('HTTP error')) errorMsg += ` (${err.message})`; else if (err instanceof TypeError && err.message.includes('Failed to fetch')) errorMsg += ' (网络或跨域问题)'; else errorMsg += ` (${err.message || '未知错误'})`; utils.showToast(errorMsg, 5000); } } }; // --- 卡片处理模块 (动态流、话题页、用户空间) --- const cardHandler = { /** 初始化卡片下载功能 (事件委托) */ initCardDownloads: function (pageConfig) { // 使用 waitForElement 等待列表容器加载 utils.waitForElement(pageConfig.LIST_CONTAINER_SELECTOR, (listContainer) => { if (!listContainer) { console.error(`[B站图片下载] 错误:未能找到列表容器: ${pageConfig.LIST_CONTAINER_SELECTOR}`); return; } this.setupEventListeners(listContainer, pageConfig); this.setupMutationObserver(listContainer, pageConfig); // 监听容器本身的变化 }, 20000); // 增加等待时间 }, /** 在指定容器上设置事件监听器 */ setupEventListeners: function (container, pageConfig) { // 监听 "更多" 按钮的悬停 container.addEventListener('mouseenter', (event) => { const moreButton = event.target.closest(pageConfig.MORE_BUTTON_SELECTOR); if (moreButton && !moreButton.dataset.downloadHandlerAttached) { moreButton.dataset.downloadHandlerAttached = 'true'; this.addDownloadOptionWhenMenuReady(moreButton, pageConfig); } }, true); // 监听点击,应对点击才生成菜单的情况 container.addEventListener('click', (event) => { const moreButton = event.target.closest(pageConfig.MORE_BUTTON_SELECTOR); if (moreButton && !moreButton.dataset.downloadOptionAdded) { // 检查是否已成功添加过 this.addDownloadOptionWhenMenuReady(moreButton, pageConfig); } }, true); }, /** 设置 MutationObserver 监听卡片列表容器的变化 */ setupMutationObserver: function (container, pageConfig) { const observer = new MutationObserver((mutations) => { // 主要应对动态加载新卡片,事件委托理论上能处理,此观察器可用于调试或未来扩展 let hasAddedNodes = mutations.some(m => m.type === 'childList' && m.addedNodes.length > 0); // if (hasAddedNodes) { console.log('New cards potentially added.'); } }); observer.observe(container, { childList: true, subtree: false }); }, /** 尝试在菜单准备好后添加下载选项 */ addDownloadOptionWhenMenuReady: function (moreButton, pageConfig) { setTimeout(() => { const parentMoreWrapper = moreButton.closest('.bili-dyn-item__more') || moreButton.closest('div[class*="more"]'); if (!parentMoreWrapper) return; const cascader = parentMoreWrapper.querySelector(pageConfig.CASCADER_SELECTOR); if (!cascader) return; // 菜单可能尚未生成 const optionsContainer = cascader.querySelector(pageConfig.OPTIONS_SELECTOR); if (optionsContainer && !optionsContainer.querySelector('.download-images-option')) { const downloadItem = document.createElement('div'); downloadItem.className = 'download-images-option'; // 应用 B 站菜单项样式 if (optionsContainer.classList.contains('bili-cascader-options')) { downloadItem.classList.add('bili-cascader-options__item'); downloadItem.innerHTML = `
下载图片
`; } else if (optionsContainer.classList.contains('bili-dyn-more__menu')) { downloadItem.classList.add('bili-dyn-more__menu__item'); downloadItem.style.cssText = 'height: 25px; line-height: 25px; padding: 0px 12px; text-align: left;'; downloadItem.textContent = '下载图片'; } else { downloadItem.textContent = '下载图片'; // 通用后备 downloadItem.style.padding = '5px 10px'; console.warn("[B站图片下载] 未知的菜单选项容器结构,使用基础样式"); } optionsContainer.prepend(downloadItem); // 添加到顶部 moreButton.dataset.downloadOptionAdded = 'true'; // 标记已添加 // 添加点击事件 downloadItem.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); const card = moreButton.closest(pageConfig.CARD_SELECTOR); if (card) { const { username, itemId, images } = pageConfig.getInfoFunction(card, pageConfig); if (images && images.length > 0) { core.downloadImages(images, username, itemId); } else { utils.showToast('在该动态中未找到可下载的图片'); } } else { console.error('[B站图片下载] 错误:无法从按钮追溯到卡片元素'); utils.showToast('无法定位动态卡片'); } }); } else if (optionsContainer && optionsContainer.querySelector('.download-images-option')) { moreButton.dataset.downloadOptionAdded = 'true'; // 确保已存在的也被标记 } }, 150); // 延迟等待菜单渲染 }, /** 从卡片元素中提取信息 */ getInfoFromCard: function (card, pageConfig) { let username = '未知UP主'; let itemId = `卡片_${Date.now()}`; let images = []; const seenImageKeys = new Set(); // 使用 Set 来存储已见过的图片标识符,提高去重效率 // 提取用户名 (优先原作者) const origAuthorElement = card.querySelector('.dyn-orig-author__name'); const currentAuthorElement = card.querySelector('.bili-dyn-title__text'); username = origAuthorElement?.textContent.trim() || currentAuthorElement?.textContent.trim() || username; // 提取动态 ID (优先原动态) const origCardElement = card.querySelector('.bili-dyn-content__orig .dyn-card-opus[dyn-id], .bili-dyn-content__orig [data-origin-did]'); itemId = origCardElement?.getAttribute('data-origin-did') || origCardElement?.getAttribute('dyn-id') || card.getAttribute('data-did') || card.getAttribute('dyn-id') || itemId; if (itemId.startsWith('卡片_')) { // 后备:尝试从链接获取 const linkElement = card.querySelector('a[href*="/dynamic/"], a[href*="/opus/"]'); if (linkElement) { const idMatch = linkElement.href.match(CONFIG.DETAIL_PAGE_ID_REGEX); if (idMatch?.[1]) itemId = idMatch[1]; } } // 提取图片 const imgElements = card.querySelectorAll(pageConfig.IMAGE_SELECTORS.join(', ')); imgElements.forEach(img => { const rawUrl = img.src || img.dataset.src; const processedUrl = utils.processImageUrl(rawUrl); if (processedUrl) { // --- 基于路径去重 --- let imageKey = processedUrl; // 默认使用处理后的 URL 作为 key const bfsIndex = processedUrl.indexOf('/bfs/'); if (bfsIndex !== -1) { // 如果包含 /bfs/,则使用 /bfs/ 之后的部分作为 key imageKey = processedUrl.substring(bfsIndex); } // else: 如果不包含 /bfs/ (可能是其他来源的图片),则继续使用完整 URL 作为 key if (!seenImageKeys.has(imageKey)) { // 检查 key 是否已存在 seenImageKeys.add(imageKey); // 添加新的 key 到 Set const formatMatch = processedUrl.match(/\.(jpg|jpeg|png|gif|webp|avif)(?:[?#]|$)/i); const format = formatMatch ? formatMatch[1].toLowerCase() : 'jpg'; images.push({ url: processedUrl, format: format }); } } }); return { username: utils.sanitizeFilename(username), itemId, images }; } }; // --- 初始化 --- (function init() { const hostname = location.hostname; const pathname = location.pathname; const isDynamicHome = hostname === 't.bilibili.com' && pathname === '/'; const isUserDynamicPage = hostname === 'space.bilibili.com' && pathname.includes('/dynamic'); const isTopicPage = hostname === 'www.bilibili.com' && pathname.includes('/v/topic'); const isDynamicDetailPage = hostname === 't.bilibili.com' && pathname.match(CONFIG.DETAIL_PAGE_ID_REGEX); const isOpusPage = hostname === 'www.bilibili.com' && pathname.startsWith('/opus/'); let pageConfig = null; // --- 卡片模式页面处理 --- if (isDynamicHome || isUserDynamicPage || isTopicPage) { pageConfig = { ...CONFIG.CARD_CONFIG }; // 基础配置 pageConfig.getInfoFunction = cardHandler.getInfoFromCard; // 指定信息提取函数 if (isDynamicHome) { pageConfig.pageName = '动态首页'; } else if (isUserDynamicPage) { pageConfig.pageName = '用户空间动态'; } else if (isTopicPage) { pageConfig.pageName = '话题页'; pageConfig.CARD_SELECTOR = '.list__topic-card'; pageConfig.LIST_CONTAINER_SELECTOR = '.list-view.topic-list__flow-list'; } // 确保列表容器选择器有效后初始化 if (pageConfig.LIST_CONTAINER_SELECTOR) { cardHandler.initCardDownloads(pageConfig); } else { console.error(`[B站图片下载] 未能确定 "${pageConfig.pageName}" 的列表容器选择器。`); } } // --- 详情页模式处理 (侧边栏按钮+旧版按钮) --- else if (isDynamicDetailPage || isOpusPage) { const toolbarSelector = '.side-toolbar__box'; const toolbarCheck = '.sidebar-wrap'; const floatingButtonId = 'bili-download-images-button'; // 旧版悬浮按钮 ID const sidebarButtonId = 'bili-custom-sidebar-download-button'; // 新版侧边栏按钮 ID // --- 提取详情页下载核心逻辑 --- function triggerDownloadForDetailPage() { const username = core.getUsernameFromPage(); const itemId = core.getItemIdFromPage(); const container = core.findImageContainerOnPage(); if (!container) { utils.showToast('未找到图片容器!'); return; } // 检查是否需要展开相册 const requiresExpansionTrigger = container.querySelector('.bili-album__preview.more, .bili-album__preview--more, .total-mask, .bili-album-trigger--more'); const expandButton = container.querySelector('.bili-album__preview--more button, .bili-album__preview__picture:last-child, .bili-album-trigger--more'); if (requiresExpansionTrigger && expandButton) { expandButton.click(); utils.showToast('正在展开相册...', 3000); core.monitorAlbumExpansion(username, itemId); } else { const images = core.extractImagesFromContainer(container); core.downloadImages(images, username, itemId); } } // --- END: 提取详情页下载核心逻辑 --- // --- 判断使用哪种按钮 --- const toolbarBox1 = document.querySelector(toolbarCheck); // 检查侧边栏容器是否存在 const toolbarBox2 = document.querySelector(toolbarSelector); // 检查侧边栏容器是否存在 if (toolbarBox1 || toolbarBox2) { // **情况1: 存在侧边栏 -> 使用新版侧边栏按钮** utils.waitForElement(toolbarSelector, (actualToolbarBox) => { if (actualToolbarBox.querySelector(`#${sidebarButtonId}`)) return; // 避免重复创建 const downloadAction = document.createElement('div'); downloadAction.id = sidebarButtonId; downloadAction.className = 'side-toolbar__action download'; downloadAction.innerHTML = `
下载
`; // 点击事件调用提取出的函数 downloadAction.addEventListener('click', triggerDownloadForDetailPage); // 添加到侧边栏 if (actualToolbarBox.firstChild) actualToolbarBox.insertBefore(downloadAction, actualToolbarBox.firstChild); else actualToolbarBox.appendChild(downloadAction); }, 1000); // 等待侧边栏容器 } else { // **情况2: 不存在侧边栏 -> 使用旧版悬浮按钮** if (document.getElementById(floatingButtonId)) return; // 避免重复创建 const downloadButton = document.createElement('button'); downloadButton.id = floatingButtonId; // 应用 CSS 样式 ID downloadButton.textContent = '下载图片'; // 按钮文字 // 点击事件调用提取出的函数 downloadButton.addEventListener('click', triggerDownloadForDetailPage); document.body.appendChild(downloadButton); // 添加到页面 // 确保 #bili-download-images-button 的 CSS 是启用的 //console.log("[B站图片下载] 未检测到侧边栏,启用悬浮下载按钮。"); } // --- END: 判断使用哪种按钮 --- } // else { console.log('[B站图片下载] 当前页面不适用脚本。'); } })(); // 立即执行初始化 })();