Greasy Fork

Greasy Fork is available in English.

哔哩哔哩动态图片下载

为方便下载bilibili图片而开发。

当前为 2025-06-09 提交的版本,查看 最新版本

// ==UserScript==
// @name         哔哩哔哩动态图片下载
// @namespace    https://space.bilibili.com/11768481
// @version      1.2
// @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
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @license      MIT
// @icon         https://www.bilibili.com/favicon.ico
// @supportURL   http://greasyfork.icu/zh-CN/scripts/531888/feedback
// @homepageURL  http://greasyfork.icu/zh-CN/scripts/531888
// ==/UserScript==

/*
 * CHANGELOG:
 * v1.2:
 * - 新增:设置面板及自定义文件名功能。
 * - 新增:文件名模板增加 {date} 占位符,用于插入下载日期 (格式 YYYYMMDD)。
 * - 新增:为“文件名格式”配置项增加一个独立的“恢复默认”按钮,方便用户单独重置。
 * - 新增:增加“强制使用备用下载模式”选项。
 * - 修复:b站新的修改。
 */

(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; }

        /* --- 设置面板样式 --- */
        .bili-dl-settings-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background-color: rgba(0, 0, 0, 0.5); z-index: 19998;
        }
        .bili-dl-settings-panel {
            position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
            background-color: #fff; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            width: 500px; max-width: 90vw; z-index: 19999; padding: 20px;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
        }
        .bili-dl-settings-panel h3 { margin: 0 0 15px; font-size: 18px; color: #333; text-align: center; }
        .bili-dl-settings-panel .setting-item { margin-bottom: 15px; }
        .bili-dl-settings-panel .setting-item label.checkbox-label { display: flex; align-items: center; cursor: pointer; font-weight: normal; }
        .bili-dl-settings-panel label { display: block; font-weight: bold; margin-bottom: 5px; color: #555; }
        .bili-dl-settings-panel input[type="text"] {
            width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
            box-sizing: border-box; font-size: 14px;
        }
        .bili-dl-settings-panel .instructions {
            font-size: 12px; color: #666; background-color: #f7f7f7;
            padding: 10px; border-radius: 4px; margin-top: 5px; line-height: 1.6;
        }
        .bili-dl-settings-panel .instructions code { background-color: #eee; padding: 2px 4px; border-radius: 3px; }
        .bili-dl-settings-panel .actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
        .bili-dl-settings-panel button {
            padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer;
            font-size: 14px; transition: background-color 0.2s;
        }
        .bili-dl-settings-panel .btn-save { background-color: #00a1d6; color: white; }
        .bili-dl-settings-panel .btn-save:hover { background-color: #007ead; }
        .bili-dl-settings-panel .btn-reset, .bili-dl-settings-panel .btn-close {
            background-color: #f1f2f3; color: #333;
        }
        .bili-dl-settings-panel .btn-reset:hover, .bili-dl-settings-panel .btn-close:hover {
            background-color: #e0e1e2;
        }
        .bili-dl-settings-panel .setting-item-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 5px;
        }
        .bili-dl-settings-panel .setting-item-header label {
            margin-bottom: 0;
        }
        .bili-dl-settings-panel .btn-link-style {
            background: none;
            border: none;
            color: #00a1d6;
            font-size: 12px;
            cursor: pointer;
            padding: 0;
            font-weight: normal;
        }
        .bili-dl-settings-panel .btn-link-style:hover {
            text-decoration: underline;
        }
    `);

    // --- 常量与配置 ---
    const MAX_CONCURRENT_DOWNLOADS = 3;   // 最大并发下载数
    const CONFIG = {
        FILENAME: {
            STORAGE_KEY: 'bili_dl_filename_template',
            DEFAULT_TEMPLATE: '{username}_{itemId}_{index}.{format}'
        },
        FALLBACK_MODE: {
            STORAGE_KEY: 'bili_dl_force_fallback',
            DEFAULT_VALUE: false
        },
        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',
            '.bili-rich-text__content'  // 新增:富文本内容区,用于提取 data-pics
        ],
        DETAIL_PAGE_SELECTORS: {
            USERNAME: ['.bili-dyn-title__text', '.opus-module-author__name'],
            SIDE_TOOLBAR: ['.side-toolbar__box', '.sidebar-wrap'],
            SIDE_TOOLBAR_TARGET: '.side-toolbar__box'
        },
        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]',
                '.bili-dyn-card-live__cover img[src]'
            ],
            RICH_TEXT_PICS_SELECTOR: 'span.bili-rich-text-viewpic[data-pics]',
            // 新增:动态信息流配置
            USERNAME_SELECTORS: ['.dyn-orig-author__name', '.bili-dyn-title__text'],
            DYN_ID_SELECTOR:
                '.bili-dyn-content__orig .dyn-card-opus[dyn-id], .bili-dyn-content__orig [data-origin-did], .bili-dyn-content__orig .bili-dyn-card-video[dyn-id], .bili-dyn-content__orig .bili-dyn-card-live[dyn-id]',
            FALLBACK_LINK_SELECTOR: 'a[href*="/dynamic/"], a[href*="/opus/"]'
        }

    };

    // --- 工具函数 ---
    const utils = {
        waitForElement: (selector, callback, timeout = 1000) => {
            let element = document.querySelector(selector);
            if (element) { callback(element); return; }
            let observer = null, 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(); }
                }, timeout);
            }
        },
        /** 文件名消毒:移除或替换非法字符,限制长度 */
        sanitizeFilename: (name) => {
            if (!name) return 'unknown_file';
            return name.replace(/[\\][:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 250);
        },
        /** 显示自动消失的提示消息 */
        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;
            return cleanUrl.split('@')[0];
        },
        /**文件名配置处理**/
        formatFilename: (template, data) => {
            let filename = template;
            for (const key in data) {
                // 只做替换,不做任何清理
                filename = filename.replaceAll(`{${key}}`, data[key]);
            }
            return filename;
        }
    };

    // --- 设置面板管理器 ---
    const settingsManager = {
        panel: null,
        overlay: null,
        init: function () {
            GM_registerMenuCommand('⚙️ 下载设置', this.openPanel.bind(this));
        },
        createPanel: function () {
            if (this.panel) return;
            this.overlay = document.createElement('div');
            this.overlay.className = 'bili-dl-settings-overlay';
            this.overlay.style.display = 'none';
            this.overlay.addEventListener('click', this.closePanel.bind(this));
            this.panel = document.createElement('div');
            this.panel.className = 'bili-dl-settings-panel';
            this.panel.style.display = 'none';

            this.panel.innerHTML = `
                <h3>哔哩哔哩动态图片下载 - 设置</h3>
                <div class="setting-item">
                    <div class="setting-item-header">
                        <label for="filename-template">文件名格式:</label>
                        <button type="button" id="reset-filename-btn" class="btn-link-style">恢复默认</button>
                    </div>
                    <input type="text" id="filename-template-input" placeholder="例如: {username}_{itemId}_{index}.{format}">
                    <div class="instructions">
                        <b>可用占位符:</b> <code>{username}</code>, <code>{itemId}</code>, <code>{date}</code>, <code>{index}</code>, <code>{format}</code><br>
                        默认模式支持文件夹。备用不支持。</br>
                        可以用/来创建文件夹,如</br>
                        {username}/{username}_{itemId}_{date}_{index}.{format}</br>
                        {date}指的是当前下载的时间,不是图片/动态发布时间
                    </div>
                </div>
                <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
                <div class="setting-item">
                    <label class="checkbox-label">
                        <input type="checkbox" id="force-fallback-checkbox" style="margin-right: 8px;">
                        切换备用下载模式 (Fetch API) 不用管,方便我调试用的
                    </label>
                    <div class="instructions">
                        当默认下载模式(GM_download)出现问题或无法下载时,脚本自动使用备用模式。一般是浏览器的问题。
                        直接当扩展用时使用该方法
                    </div>
                </div>
                <div class="actions">
                    <button id="settings-btn-close" class="btn-close">关闭</button>
                    <button id="settings-btn-save" class="btn-save">保存设置</button>
                </div>
            `;

            document.body.appendChild(this.overlay);
            document.body.appendChild(this.panel);

            this.panel.querySelector('#reset-filename-btn').addEventListener('click', () => {
                const defaultTemplate = CONFIG.FILENAME.DEFAULT_TEMPLATE;
                this.panel.querySelector('#filename-template-input').value = defaultTemplate;
                utils.showToast('文件名格式已重置,点击“保存设置”生效', 2500);
            });

            this.panel.querySelector('#settings-btn-save').addEventListener('click', this.saveSettings.bind(this));
            this.panel.querySelector('#settings-btn-close').addEventListener('click', this.closePanel.bind(this));
        },
        openPanel: function () {
            this.createPanel();
            const currentTemplate = GM_getValue(CONFIG.FILENAME.STORAGE_KEY, CONFIG.FILENAME.DEFAULT_TEMPLATE);
            this.panel.querySelector('#filename-template-input').value = currentTemplate;
            const forceFallback = GM_getValue(CONFIG.FALLBACK_MODE.STORAGE_KEY, CONFIG.FALLBACK_MODE.DEFAULT_VALUE);
            this.panel.querySelector('#force-fallback-checkbox').checked = forceFallback;
            this.overlay.style.display = 'block';
            this.panel.style.display = 'block';
        },
        closePanel: function () {
            if (this.panel) {
                this.overlay.style.display = 'none';
                this.panel.style.display = 'none';
            }
        },
        saveSettings: function () {
            const newTemplate = this.panel.querySelector('#filename-template-input').value.trim();
            if (newTemplate) {
                GM_setValue(CONFIG.FILENAME.STORAGE_KEY, newTemplate);
            } else {
                utils.showToast('文件名格式不能为空!', 3000);
                return;
            }
            const forceFallback = this.panel.querySelector('#force-fallback-checkbox').checked;
            GM_setValue(CONFIG.FALLBACK_MODE.STORAGE_KEY, forceFallback);
            utils.showToast('设置已保存!', 2000);
            this.closePanel();
        },
        resetAllSettings: function () {
            const defaultTemplate = CONFIG.FILENAME.DEFAULT_TEMPLATE;
            this.panel.querySelector('#filename-template-input').value = defaultTemplate;
            GM_setValue(CONFIG.FILENAME.STORAGE_KEY, defaultTemplate);
            const defaultFallback = CONFIG.FALLBACK_MODE.DEFAULT_VALUE;
            this.panel.querySelector('#force-fallback-checkbox').checked = defaultFallback;
            GM_setValue(CONFIG.FALLBACK_MODE.STORAGE_KEY, defaultFallback);
            utils.showToast('全部设置已恢复为默认值', 2000);
        }
    };

    // --- 核心下载功能模块 ---
    const core = {
        //用户名
        getUsernameFromPage: () => {
            for (const selector of CONFIG.DETAIL_PAGE_SELECTORS.USERNAME) {
                const elem = document.querySelector(selector);
                if (elem) return elem.innerText.trim();
            }
            return '未知用户';
        },
        //动态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]') || container.matches(CONFIG.CARD_CONFIG.RICH_TEXT_PICS_SELECTOR) || container.querySelector(CONFIG.CARD_CONFIG.RICH_TEXT_PICS_SELECTOR))) {
                    return container;
                }
            }
            return null;
        },
        //从指定的容器中提取所有有效图片信息
        extractImagesFromContainer: (container) => {
            if (!container) return [];
            const images = [];
            const seenUrls = new Set();
            Array.from(container.querySelectorAll('img[src]')).forEach(img => {
                const cleanUrl = utils.processImageUrl(img.src);
                if (!cleanUrl || seenUrls.has(cleanUrl)) return;
                seenUrls.add(cleanUrl);
                const formatMatch = cleanUrl.match(/\.(jpg|jpeg|png|gif|webp|avif)(?:[?#]|$)/i);
                images.push({ url: cleanUrl, format: formatMatch ? formatMatch[1].toLowerCase() : 'jpg' });
            });
            const richTextSpans = [];
            if (container.matches && container.matches(CONFIG.CARD_CONFIG.RICH_TEXT_PICS_SELECTOR)) richTextSpans.push(container);
            container.querySelectorAll(CONFIG.CARD_CONFIG.RICH_TEXT_PICS_SELECTOR).forEach(span => richTextSpans.push(span));
            richTextSpans.forEach(span => {
                try {
                    const picsData = JSON.parse(span.dataset.pics);
                    if (Array.isArray(picsData)) {
                        picsData.forEach(picInfo => {
                            if (picInfo && picInfo.src) {
                                const cleanUrl = utils.processImageUrl(picInfo.src);
                                if (!cleanUrl || seenUrls.has(cleanUrl)) return;
                                seenUrls.add(cleanUrl);
                                const formatMatch = cleanUrl.match(/\.(jpg|jpeg|png|gif|webp|avif)(?:[?#]|$)/i);
                                images.push({ url: cleanUrl, format: formatMatch ? formatMatch[1].toLowerCase() : 'jpg' });
                            }
                        });
                    }
                } catch (e) { console.warn('[B站图片下载] 解析 data-pics JSON 失败:', e, span.dataset.pics); }
            });
            return images;
        },
        //监听 B站相册展开后的图片加载,因为阿b新样式废弃
        monitorAlbumExpansion: (username, itemId) => {
            utils.waitForElement('.bili-album__watch__track__list', (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);
        },
        //下载功能
        downloadImages: (images, username = '未知用户', itemId = '未知ID') => {
            if (!images || images.length === 0) {
                utils.showToast('未找到可下载的图片!');
                return;
            }
            const totalImages = images.length;
            let submittedCount = 0;
            utils.showToast(`开始处理 ${totalImages} 张图片...`);
            const filenameTemplate = GM_getValue(CONFIG.FILENAME.STORAGE_KEY, CONFIG.FILENAME.DEFAULT_TEMPLATE);
            const forceFallback = GM_getValue(CONFIG.FALLBACK_MODE.STORAGE_KEY, CONFIG.FALLBACK_MODE.DEFAULT_VALUE);

            const now = new Date();
            const yyyymmdd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
            // --- 数据处理中心 ---
            // 在这里对所有从外部获取的数据进行唯一一次的、彻底的清理
            const cleanUsername = username.replace(/[/\\:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim();
            const cleanItemId = itemId;
            // --- 清理结束 ---
            const downloadBatch = (startIndex) => {
                const endIndex = Math.min(startIndex + MAX_CONCURRENT_DOWNLOADS, totalImages);
                for (let i = startIndex; i < endIndex; i++) {
                    const img = images[i];
                    // 准备用于模板的数据包,所有数据都是已经清理干净的
                    const templateData = {
                        username: cleanUsername,
                        itemId: cleanItemId,
                        date: yyyymmdd,
                        index: String(i + 1).padStart(2, '0'),
                        format: img.format
                    };
                    const filename = utils.formatFilename(filenameTemplate, templateData);

                    if (forceFallback) {
                        if (startIndex === 0 && i === 0) {
                            utils.showToast('已启用备用下载模式', 2000);
                        }
                        core.fetchAndDownload(img.url, filename);
                        submittedCount++;
                        continue;
                    }

                    try {
                        GM_download({
                            url: img.url,
                            name: filename,
                            headers: { "Referer": location.href },
                            onerror: (error, details) => {
                                console.error(`[GM_download Error] ${filename}:`, error, details);
                                utils.showToast(`下载失败,自动尝试备用方法: ${filename.split('/').pop()}`, 4000);
                                core.fetchAndDownload(img.url, filename);
                            },
                            ontimeout: () => {
                                console.warn(`[GM_download Timeout] ${filename}`);
                                utils.showToast(`下载超时,自动尝试备用方法: ${filename.split('/').pop()}`, 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);
        },
        //备用下载方式
        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}`);
                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.split('/').pop();
                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);
                utils.showToast(`备用下载失败: ${filename.split('/').pop()} (${err.message})`, 5000);
            }
        }
    };

    // --- 卡片处理模块 ---
    const cardHandler = {
        initCardDownloads: function (pageConfig) {
            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);
        },
        setupMutationObserver: function (container, pageConfig) {
            const observer = new MutationObserver((mutations) => { });
            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';
                    if (optionsContainer.classList.contains('bili-cascader-options')) {
                        downloadItem.classList.add('bili-cascader-options__item');
                        downloadItem.innerHTML = `<div class="bili-cascader-options__item-custom"><div><div class="bili-cascader-options__item-label">下载图片</div></div></div>`;
                    } 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';
                    }
                    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 {
                            utils.showToast('无法定位动态卡片');
                        }
                    });
                } else if (optionsContainer && optionsContainer.querySelector('.download-images-option')) {
                    moreButton.dataset.downloadOptionAdded = 'true';
                }
            }, 150);
        },
        getInfoFromCard: function (card, pageConfig) {
            let username = '未知UP主', itemId = `卡片_${Date.now()}`, images = [], seenImageKeys = new Set();
            // 从配置中读取选择器获取用户名
            for (const selector of pageConfig.USERNAME_SELECTORS) {
                const elem = card.querySelector(selector);
                if (elem && elem.textContent.trim()) {
                    username = elem.textContent.trim();
                    break;
                }
            }

            // 从配置中读取选择器获取动态ID
            const origCardElement = card.querySelector(pageConfig.DYN_ID_SELECTOR);
            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(pageConfig.FALLBACK_LINK_SELECTOR);
                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;
                    const bfsIndex = processedUrl.indexOf('/bfs/');
                    if (bfsIndex !== -1) imageKey = processedUrl.substring(bfsIndex);
                    if (!seenImageKeys.has(imageKey)) {
                        seenImageKeys.add(imageKey);
                        const formatMatch = processedUrl.match(/\.(jpg|jpeg|png|gif|webp|avif)(?:[?#]|$)/i);
                        images.push({ url: processedUrl, format: formatMatch ? formatMatch[1].toLowerCase() : 'jpg' });
                    }
                }
            });
            if (pageConfig.RICH_TEXT_PICS_SELECTOR) {
                card.querySelectorAll(pageConfig.RICH_TEXT_PICS_SELECTOR).forEach(span => {
                    try {
                        const picsData = JSON.parse(span.dataset.pics);
                        if (Array.isArray(picsData)) {
                            picsData.forEach(picInfo => {
                                if (picInfo && picInfo.src) {
                                    const processedUrl = utils.processImageUrl(picInfo.src);
                                    if (processedUrl) {
                                        let imageKey = processedUrl;
                                        const bfsIndex = processedUrl.indexOf('/bfs/');
                                        if (bfsIndex !== -1) imageKey = processedUrl.substring(bfsIndex);
                                        if (!seenImageKeys.has(imageKey)) {
                                            seenImageKeys.add(imageKey);
                                            const formatMatch = processedUrl.match(/\.(jpg|jpeg|png|gif|webp|avif)(?:[?#]|$)/i);
                                            images.push({ url: processedUrl, format: formatMatch ? formatMatch[1].toLowerCase() : 'jpg' });
                                        }
                                    }
                                }
                            });
                        }
                    } catch (e) { console.warn('[B站图片下载] 解析卡片内 data-pics JSON 失败:', e, span.dataset.pics); }
                });
            }
            return { username: username, itemId, images };
        }
    };

    // --- 初始化 ---
    (function init() {
        settingsManager.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 if (isDynamicDetailPage || isOpusPage) {
            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);
                }
            }
            const toolbarExists = CONFIG.DETAIL_PAGE_SELECTORS.SIDE_TOOLBAR.some(selector => document.querySelector(selector));
            if (toolbarExists) {
                utils.waitForElement(CONFIG.DETAIL_PAGE_SELECTORS.SIDE_TOOLBAR_TARGET, (actualToolbarBox) => {
                    if (actualToolbarBox.querySelector('#bili-custom-sidebar-download-button')) return;
                    const downloadAction = document.createElement('div'); downloadAction.id = 'bili-custom-sidebar-download-button'; downloadAction.className = 'side-toolbar__action download';
                    downloadAction.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11 5C11 4.44772 11.4477 4 12 4C12.5523 4 13 4.44772 13 5V12.5858L14.2929 11.2929C14.6834 10.9024 15.3166 10.9024 15.7071 11.2929C16.0976 11.6834 16.0976 12.3166 15.7071 12.7071L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L8.29289 12.7071C7.90237 12.3166 7.90237 11.6834 8.29289 11.2929C8.68342 10.9024 9.31658 10.9024 9.70711 11.2929L11 12.5858V5Z" fill="currentColor"/><path d="M4 14C4 13.4477 4.44772 13 5 13H7C7.55228 13 8 13.4477 8 14V18C8 18.5523 7.55228 19 7 19H5C4.44772 19 4 18.5523 4 18V14Z" fill="currentColor"/><path d="M16 13C16.5523 13 17 13.4477 17 14V18C17 18.5523 16.5523 19 16 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 18.5523 13 18 13H16Z" fill="currentColor" opacity="0.5"/><path d="M4 19C3.44772 19 3 19.4477 3 20C3 20.5523 3.44772 21 4 21H20C20.5523 21 21 20.5523 21 20C21 19.4477 20.5523 19 20 19H4Z" fill="currentColor"/></svg><div class="side-toolbar__action__text">下载</div>`;
                    downloadAction.addEventListener('click', triggerDownloadForDetailPage);
                    if (actualToolbarBox.firstChild) actualToolbarBox.insertBefore(downloadAction, actualToolbarBox.firstChild); else actualToolbarBox.appendChild(downloadAction);
                }, 1000);
            } else {
                if (document.getElementById('bili-download-images-button')) return;
                const downloadButton = document.createElement('button'); downloadButton.id = 'bili-download-images-button'; downloadButton.textContent = '下载图片';
                downloadButton.addEventListener('click', triggerDownloadForDetailPage);
                document.body.appendChild(downloadButton);
            }
        }
    })();

})();