Greasy Fork

Greasy Fork is available in English.

Bilibili 专栏图片/GIF 一键下载器

一键下载 Bilibili 专栏贴文图片/GIF,排除头像与留言图,显示进度与完成提示,档名含贴文 ID!使用 GM_download 进行下载打包

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

// ==UserScript==
// @name         Bilibili Article Image/GIF One-Click Downloader
// @name:zh-TW   Bilibili 專欄圖片/GIF 一鍵下載器
// @name:zh-CN   Bilibili 专栏图片/GIF 一键下载器
// @namespace    http://tampermonkey.net/
// @version      1.8
// @description  One-click download of images/GIFs from Bilibili article posts, excluding avatars and comment images. Displays progress and completion notifications, with filenames including the post ID! Uses GM_download for downloading and packaging.
// @description:zh-TW 一鍵下載 Bilibili 專欄貼文圖片/GIF,排除頭像與留言圖,顯示進度與完成提示,檔名含貼文 ID!使用 GM_download 進行下載打包
// @description:zh-CN 一键下载 Bilibili 专栏贴文图片/GIF,排除头像与留言图,显示进度与完成提示,档名含贴文 ID!使用 GM_download 进行下载打包
// @author       ChatGPT
// @match        https://www.bilibili.com/opus/*
// @grant        GM_download
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // 等待頁面加載完成後延遲 200ms 執行新增下載按鈕
    window.addEventListener('load', () => {
        setTimeout(addDownloadButton, 200);
    });

    // 新增「逐張圖片下載」按鈕
    function addDownloadButton() {
        // 若按鈕已存在則不重複新增
        if (document.querySelector('#bili-download-button')) return;

        const button = document.createElement('button');
        button.textContent = '📥 逐張圖片下載'; // 按鈕文字
        button.id = 'bili-download-button';

        // 設定按鈕樣式:固定定位、置頂置右、高層級、藍色背景等
        Object.assign(button.style, {
            position: 'fixed',
            top: '100px',
            right: '20px',
            zIndex: 9999,
            padding: '10px 15px',
            backgroundColor: '#00a1d6',
            color: 'white',
            border: 'none',
            borderRadius: '5px',
            cursor: 'pointer',
            fontSize: '14px',
        });

        // 點擊按鈕時禁用按鈕,開始蒐集圖片 URL 並下載
        button.addEventListener('click', () => {
            button.disabled = true;
            collectImageUrls(button);
        });

        // 將按鈕加入網頁中
        document.body.appendChild(button);
    }

    // 簡單延遲函式,回傳 Promise,方便 async/await 使用
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // 從網址中取得貼文 ID (例:https://www.bilibili.com/opus/1045724253437034499)
    function getPostIdFromUrl() {
        const match = window.location.pathname.match(/\/opus\/(\d+)/);
        return match ? match[1] : 'unknown';
    }

    // 蒐集貼文區內所有可下載圖片的 URL(排除頭像與留言圖)
    function collectImageUrls(button) {
        // 尋找貼文內容容器
        const contentContainer = document.querySelector('.article-content, .normal-post, .opus-detail');
        if (!contentContainer) {
            alert("⚠️ 無法找到貼文內容區塊!");
            button.disabled = false; // 恢復按鈕
            return;
        }

        // 選出所有圖片標籤
        const images = Array.from(contentContainer.querySelectorAll('img'));
        const urls = [];

        images.forEach(img => {
            // 取得圖片來源 URL,優先用 src,備用 data-src
            let url = img.src || img.getAttribute('data-src');
            if (!url || url.startsWith('data:')) return; // 過濾 base64 編碼圖片

            // 排除條件:
            const classList = img.className || '';
            // 常見頭像 class 名稱
            const isAvatarClass = classList.includes('avatar') || classList.includes('user-face') || classList.includes('bili-avatar');
            // 留言頭像區塊
            const isCommentAvatar = img.closest('.reaction-item__face');
            // 發文者頭像或裝飾
            const isAuthorAvatar = img.closest('.opus-module-author__avatar') || img.closest('.opus-module-author__decorate');
            // 全站用戶頭像快捷區
            const isGlobalAvatar = img.closest('#user-avatar');
            // 解析度太小 (通常是頭像或裝飾)
            const isTooSmall = (img.naturalWidth && img.naturalWidth <= 60) && (img.naturalHeight && img.naturalHeight <= 60);

            // 符合任一排除條件就跳過
            if (isAvatarClass || isCommentAvatar || isAuthorAvatar || isGlobalAvatar || isTooSmall) return;

            // 去除圖片 URL 中可能的參數,避免壓縮或調整尺寸的參數影響下載原圖
            url = url.replace(/@.*$/, '');

            // 收錄可下載的圖片 URL
            urls.push(url);
        });

        if (urls.length === 0) {
            alert("⚠️ 沒有找到可下載的圖片或 GIF。");
            button.disabled = false; // 恢復按鈕
            return;
        }

        // 取得貼文 ID 並開始下載
        const postId = getPostIdFromUrl();
        downloadMedia(urls, button, postId);
    }

    // 下載媒體檔案,逐張下載且等待下載完成後繼續下一張
    async function downloadMedia(urls, button, postId) {
        let success = 0;

        for (let i = 0; i < urls.length; i++) {
            const url = urls[i];
            // 判斷是否為 GIF 檔案
            const isGif = url.includes('.gif') || url.includes('image/gif');
            const ext = isGif ? 'gif' : 'png';
            // 組合檔名,格式:貼文ID_序號
            const filename = `${postId}_${String(i + 1).padStart(4, '0')}.${ext}`;

            // 透過 Promise 包裝 GM_download,等待單張下載完成再繼續
            await new Promise((resolve) => {
                try {
                    GM_download({
                        url,
                        name: filename,
                        saveAs: false,
                        // 單張下載成功,更新成功數與按鈕文字,並解決 Promise
                        onload: () => {
                            success++;
                            button.textContent = `下載中 (${success}/${urls.length})`;
                            resolve();
                        },
                        // 下載失敗,印出警告,仍解決 Promise 以繼續下一張
                        onerror: (err) => {
                            console.warn(`❌ 無法下載: ${url}`, err);
                            resolve();
                        }
                    });
                } catch (e) {
                    console.error(`❌ GM_download 錯誤: ${url}`, e);
                    resolve();
                }
            });
        }

        // 全部下載完畢後,更新按鈕文字為完成提示
        button.textContent = '✅ 下載完成';
        // 30秒後恢復按鈕文字與啟用狀態
        setTimeout(() => {
            button.disabled = false;
            button.textContent = '📥 逐張圖片下載';
        }, 30000);
    }
})();