图片下载器 (修复版)

支持进度显示和选择序号的图片批量下载工具(修复 zip 损坏问题)

// ==UserScript==
// @name         图片下载器 (修复版)
// @namespace    http://tampermonkey.net/
// @version      3.3
// @description  支持进度显示和选择序号的图片批量下载工具(修复 zip 损坏问题)
// @author       陈粥子
// @license      MIT
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_download
// @connect      *
// @run-at       document-end
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
/*                         
            ■■         ■  
 ■■■■■■■■   ■■    ■■■■■■■ 
      ■■    ■■         ■■ 
      ■■ ■  ■■  ■■     ■■ 
      ■■ ■■ ■■  ■      ■■ 
      ■■  ■ ■■ ■■      ■■ 
      ■■  ■ ■■ ■       ■■ 
  ■■■■■■  ■ ■■ ■   ■■■■■■ 
  ■■  ■■    ■■    ■■   ■  
  ■■        ■■  ■ ■■      
  ■■     ■■■■■■■■■■■      
  ■         ■■    ■■      
 ■■   ■■   ■■■    ■■   ■■ 
 ■■■■■■■■  ■■■■   ■■■■■■■ 
      ■■   ■■■ ■■      ■■ 
      ■■  ■■■■ ■■      ■■ 
      ■■  ■ ■■  ■■     ■■ 
      ■■  ■ ■■  ■      ■  
      ■■ ■  ■■         ■  
      ■■■   ■■        ■■  
      ■■    ■■        ■■  
     ■■     ■■        ■■  
  ■■■■■     ■■     ■■■■   
    ■■       ■      ■■    
*/
// ==/UserScript==

(function() {
    'use strict';

    // 全局变量
    let imageUrls = new Set();
    let selectedItems = [];
    const isMobile = window.innerWidth <= 768;

    /* -------------------- UI 创建 & 样式(与之前相同) -------------------- */
    function createUI() {
        const downloadBtn = document.createElement('div');
        downloadBtn.id = 'img-dl-btn';
        downloadBtn.innerHTML = '↓';
        downloadBtn.style.cssText = `
            position: fixed;
            bottom: 20px;
            right: 20px;
            z-index: 2147483647;
            width: ${isMobile ? '40px' : '50px'};
            height: ${isMobile ? '40px' : '50px'};
            background: #2196F3;
            color: white;
            font-size: ${isMobile ? '20px' : '24px'};
            font-weight: bold;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 50%;
            box-shadow: 0 2px 10px rgba(0,0,0,0.3);
            cursor: pointer;
            user-select: none;
        `;

        const modal = document.createElement('div');
        modal.id = 'img-dl-modal';
        modal.innerHTML = `
            <div class="modal-header">
                <span>图片预览</span>
                <span class="close-btn">×</span>
            </div>
            <div class="image-grid"></div>
            <div class="modal-footer">
                <button class="select-btn">全选</button>
                <span class="count">0张</span>
                <button class="download-btn" disabled>下载</button>
            </div>
        `;
        modal.style.cssText = `
            display: none;
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 2147483646;
            width: ${isMobile ? '95vw' : '80vw'};
            height: ${isMobile ? '85vh' : '75vh'};
            background: white;
            border-radius: ${isMobile ? '8px' : '12px'};
            box-shadow: 0 4px 20px rgba(0,0,0,0.15);
            flex-direction: column;
            overflow: hidden;
        `;

        const progressModal = document.createElement('div');
        progressModal.id = 'progress-modal';
        progressModal.innerHTML = `
            <div class="progress-header">
                <span>下载进度</span>
            </div>
            <div class="progress-container">
                <div class="progress-bar">
                    <div class="progress-fill"></div>
                </div>
                <div class="progress-text">准备开始下载...</div>
                <div class="progress-stats">
                    <span class="success-count">成功: 0</span>
                    <span class="fail-count">失败: 0</span>
                </div>
            </div>
            <div class="progress-footer">
                <button class="cancel-btn">取消</button>
            </div>
        `;
        progressModal.style.cssText = `
            display: none;
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 2147483646;
            width: ${isMobile ? '80vw' : '400px'};
            background: white;
            border-radius: ${isMobile ? '8px' : '12px'};
            box-shadow: 0 4px 20px rgba(0,0,0,0.15);
            flex-direction: column;
            overflow: hidden;
        `;

        const overlay = document.createElement('div');
        overlay.id = 'img-dl-overlay';
        overlay.style.cssText = `
            display: none;
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background: rgba(0,0,0,0.6);
            z-index: 2147483645;
        `;

        document.body.appendChild(downloadBtn);
        document.body.appendChild(modal);
        document.body.appendChild(progressModal);
        document.body.appendChild(overlay);
    }

    function addStyles() {
        const css = `
            #img-dl-modal { display:flex; flex-direction: column; }
            #img-dl-modal .modal-header { padding: 12px 16px; border-bottom: 1px solid #eee; display:flex; justify-content:space-between; align-items:center; font-size:${isMobile?'16px':'18px'}; background:#f8f8f8; flex-shrink:0; }
            #img-dl-modal .close-btn { font-size:24px; cursor:pointer; width:30px; height:30px; display:flex; align-items:center; justify-content:center; border-radius:50%; }
            #img-dl-modal .image-grid { flex:1; padding:10px; overflow-y:auto; display:grid; grid-template-columns:repeat(auto-fill, minmax(${isMobile?'100px':'120px'}, 1fr)); grid-auto-rows:min-content; gap:12px; background:#fff; }
            .image-item { position:relative; border:2px solid #eee; border-radius:8px; overflow:hidden; aspect-ratio:1/1; background:#f5f5f5; display:flex; align-items:center; justify-content:center; box-sizing:border-box; }
            .image-item.selected { border-color:#2196F3; box-shadow:0 0 0 2px rgba(33,150,243,0.3); }
            .image-item img { max-width:100%; max-height:100%; object-fit:contain; display:block; background:#fff; padding:4px; box-sizing:border-box; }
            #img-dl-modal .modal-footer { padding:12px 16px; border-top:1px solid #eee; display:flex; justify-content:space-between; align-items:center; background:#f8f8f8; flex-shrink:0; }
            #img-dl-modal button { padding:8px 16px; border:none; border-radius:4px; background:#2196F3; color:white; cursor:pointer; font-size:${isMobile?'14px':'16px'}; display:flex; align-items:center; justify-content:center; }
            #img-dl-modal .refresh-btn { background:#4CAF50; margin-left:8px; }
            #img-dl-modal .download-btn:disabled { background:#ccc; cursor:not-allowed; }
            #progress-modal { display:flex; flex-direction:column; }
            #progress-modal .progress-header { padding:16px; background:#2196F3; color:white; font-size:18px; text-align:center; }
            #progress-modal .progress-container { padding:20px; }
            #progress-modal .progress-bar { height:20px; background:#eee; border-radius:10px; overflow:hidden; margin-bottom:10px; }
            #progress-modal .progress-fill { height:100%; background:#4CAF50; width:0%; transition:width 0.3s; }
            #progress-modal .progress-text { text-align:center; margin-bottom:10px; font-size:14px; color:#555; }
            #progress-modal .progress-stats { display:flex; justify-content:space-around; font-size:14px; color:#555; }
            #progress-modal .progress-footer { padding:16px; display:flex; justify-content:center; border-top:1px solid #eee; }
            #progress-modal .cancel-btn { padding:8px 24px; background:#f44336; color:white; border:none; border-radius:4px; cursor:pointer; font-size:16px; }
            .image-index { position:absolute; top:0; right:0; background:rgba(33,150,243,0.85); color:white; font-size:14px; width:22px; height:22px; border-radius:0 0 0 12px; display:flex; align-items:center; justify-content:center; font-weight:bold; z-index:1; }
            @media (max-width:480px) {
                #img-dl-modal .image-grid { grid-template-columns:repeat(auto-fill, minmax(80px, 1fr)); gap:10px; padding:8px; }
                .image-item { border-radius:6px; border-width:1px; }
                #img-dl-modal .modal-footer { flex-wrap:wrap; gap:8px; }
                #img-dl-modal .count { order:3; width:100%; text-align:center; }
                #progress-modal { width:90vw; }
                .image-index { width:18px; height:18px; font-size:10px; border-radius:0 0 0 8px; }
                #img-dl-modal button { padding:6px 12px; font-size:13px; }
            }
        `;
        GM_addStyle(css);
    }

    /* -------------------- 获取页面图片(保持原逻辑) -------------------- */
    function getPageImages() {
        const images = new Set();

        document.querySelectorAll('img').forEach(img => {
            if (!img) return;
            const srcs = new Set([
                img.src,
                img.dataset.src,
                img.dataset.original,
                img.dataset.srcset,
                img.dataset.lazySrc,
                img.dataset.lazyload,
                img.dataset.thumb,
                img.getAttribute('data-src'),
                img.getAttribute('data-original'),
                img.getAttribute('data-srcset'),
                img.getAttribute('data-lazy'),
                img.getAttribute('data-lazy-src')
            ].filter(Boolean));

            srcs.forEach(src => {
                if (src.includes(',')) {
                    src.split(',').forEach(part => {
                        const url = part.trim().split(' ')[0];
                        if (url) images.add(cleanImageUrl(url));
                    });
                } else {
                    images.add(cleanImageUrl(src));
                }
            });
        });

        document.querySelectorAll('picture source').forEach(source => {
            if (!source) return;
            const srcset = source.srcset;
            if (!srcset) return;
            srcset.split(',').forEach(part => {
                const url = part.trim().split(' ')[0];
                if (url) images.add(cleanImageUrl(url));
            });
        });

        document.querySelectorAll('*').forEach(el => {
            try {
                const style = window.getComputedStyle(el);
                const bgImages = [];
                if (style.backgroundImage && style.backgroundImage !== 'none') bgImages.push(style.backgroundImage);
                if (style.background && style.background !== 'none') bgImages.push(style.background);
                bgImages.forEach(bg => {
                    const urls = bg.match(/url\(['"]?(.*?)['"]?\)/g);
                    if (urls) {
                        urls.forEach(u => {
                            const clean = u.replace(/^url\(['"]?/, '').replace(/['"]?\)$/, '');
                            images.add(cleanImageUrl(clean));
                        });
                    }
                    const imageSetMatches = bg.match(/image-set\((.*?)\)/g);
                    if (imageSetMatches) {
                        imageSetMatches.forEach(set => {
                            const items = set.match(/url\(['"]?(.*?)['"]?\)/g);
                            if (items) {
                                items.forEach(u => {
                                    const clean = u.replace(/^url\(['"]?/, '').replace(/['"]?\)$/, '');
                                    images.add(cleanImageUrl(clean));
                                });
                            }
                        });
                    }
                });
            } catch (e) {}
        });

        document.querySelectorAll('canvas').forEach(canvas => {
            try {
                const dataURL = canvas.toDataURL('image/png');
                images.add(dataURL);
            } catch (e) {}
        });

        document.querySelectorAll('svg').forEach(svg => {
            try {
                const serializer = new XMLSerializer();
                const svgStr = serializer.serializeToString(svg);
                const blob = new Blob([svgStr], { type: 'image/svg+xml' });
                const url = URL.createObjectURL(blob);
                images.add(url);
            } catch (e) {}
        });

        document.querySelectorAll('video').forEach(video => {
            if (video.poster) images.add(cleanImageUrl(video.poster));
        });

        return Array.from(images);
    }

    function cleanImageUrl(url) {
        if (!url) return '';
        const [baseUrl] = url.split(/[?#]/);
        if (baseUrl.startsWith('//')) return location.protocol + baseUrl;
        if (baseUrl.startsWith('/')) return location.origin + baseUrl;
        if (baseUrl.startsWith('./') || baseUrl.startsWith('../')) return new URL(baseUrl, location.href).href;
        return baseUrl;
    }

    /* -------------------- —— 关键:扩展名相关函数(替换/修复版) —— -------------------- */

    // 1. 仅从 URL 提取扩展名(回退使用)
    function getImageExtension(url) {
        try {
            let cleanUrl = (url || '').split('?')[0].split('#')[0];
            const match = cleanUrl.match(/\.([a-z0-9]{2,5})$/i);
            if (match) {
                const ext = match[1].toLowerCase();
                if (['jpg','jpeg','png','gif','webp','bmp','svg','ico'].includes(ext)) {
                    return ext === 'jpeg' ? 'jpg' : ext;
                }
            }
            return 'jpg';
        } catch (e) {
            return 'jpg';
        }
    }

    // 2. 根据远程响应判断扩展名(支持 GM_xmlhttpRequest 返回的 arraybuffer)
    async function getImageExtensionFromResponse(respObj, url) {
        try {
            // 先从响应头中找 Content-Type(respObj.headers 可能是字符串)
            const headersStr = respObj.responseHeaders || respObj.headers || '';
            const ctMatch = headersStr.match(/content-type:\s*([^\r\n;]+)/i);
            if (ctMatch) {
                const ct = ctMatch[1].toLowerCase();
                if (ct.includes('jpeg') || ct.includes('jpg')) return 'jpg';
                if (ct.includes('png')) return 'png';
                if (ct.includes('gif')) return 'gif';
                if (ct.includes('webp')) return 'webp';
                if (ct.includes('bmp')) return 'bmp';
                if (ct.includes('svg')) return 'svg';
                if (ct.includes('x-icon') || ct.includes('ico')) return 'ico';
            }

            // 再用前几个字节判断(respObj.arrayBuffer 是 ArrayBuffer)
            const arrayBuffer = respObj.arrayBuffer;
            if (arrayBuffer && arrayBuffer.byteLength > 0) {
                const bytes = new Uint8Array(arrayBuffer.slice(0, 12));
                // png
                if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'png';
                // jpg
                if (bytes[0] === 0xFF && bytes[1] === 0xD8) return 'jpg';
                // gif
                if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'gif';
                // webp (RIFF....WEBP)
                if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) {
                    const sub = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
                    if (sub === 'WEBP') return 'webp';
                }
                // bmp
                if (bytes[0] === 0x42 && bytes[1] === 0x4D) return 'bmp';
                // svg 文件通常是文本 "<svg"
                const textStart = new TextDecoder().decode(bytes);
                if (textStart.trim().startsWith('<svg')) return 'svg';
            }

            // 最后回退到 URL
            return getImageExtension(url);
        } catch (e) {
            return getImageExtension(url);
        }
    }

    // 3. 正确把图片内容加入 zip(使用 ArrayBuffer)
    async function addImageToZip(zip, respObj, filename) {
        // respObj.arrayBuffer 必须存在(fetchImage 会确保)
        const arrayBuffer = respObj.arrayBuffer;
        if (!arrayBuffer) throw new Error('没有可用的 ArrayBuffer 数据');
        zip.file(filename, arrayBuffer);
    }

    /* -------------------- 网络请求:使用 GM_xmlhttpRequest,返回 arraybuffer(改良) -------------------- */
    function fetchImage(url, timeout = 20000) {
        return new Promise((resolve, reject) => {
            try {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: 'arraybuffer', // 直接拿到 ArrayBuffer,便于 zip 使用
                    headers: {
                        'Referer': location.href,
                        'Origin': location.origin
                    },
                    timeout: timeout,
                    onload: function(resp) {
                        if (resp.status >= 400) return reject(new Error(`HTTP ${resp.status}`));
                        resolve({
                            arrayBuffer: resp.response, // ArrayBuffer
                            responseHeaders: resp.responseHeaders || '',
                            finalUrl: resp.finalUrl || url,
                            url: url
                        });
                    },
                    onerror: function(err) { reject(new Error('网络错误')); },
                    ontimeout: function() { reject(new Error('请求超时')); },
                    onabort: function() { reject(new Error('请求中止')); }
                });
            } catch (e) {
                reject(e);
            }
        });
    }

    /* -------------------- 预览 / 选择 / 下载 主流程(使用上面修复的函数) -------------------- */

    function showPreview() {
        const modal = document.getElementById('img-dl-modal');
        const grid = modal.querySelector('.image-grid');
        const images = getPageImages();

        imageUrls = new Set(images);
        grid.innerHTML = '';
        selectedItems = [];

        images.forEach((url, index) => {
            const item = document.createElement('div');
            item.className = 'image-item';

            const img = document.createElement('img');
            img.src = url;
            img.alt = `Image ${index + 1}`;
            img.onerror = function() { item.style.display = 'none'; };

            const indexElement = document.createElement('div');
            indexElement.className = 'image-index';
            indexElement.style.display = 'none';
            indexElement.textContent = '0';

            item.appendChild(img);
            item.appendChild(indexElement);
            item.dataset.url = url;

            item.addEventListener('click', () => {
                toggleSelection(item);
                updateSelectionCount();
            });

            grid.appendChild(item);
        });

        modal.style.display = 'flex';
        document.getElementById('img-dl-overlay').style.display = 'block';
        updateSelectionCount();
    }

    function refreshPreview() {
        const modal = document.getElementById('img-dl-modal');
        const grid = modal.querySelector('.image-grid');
        selectedItems = [];
        document.querySelector('.select-btn').textContent = '全选';

        const images = getPageImages();
        imageUrls = new Set(images);
        grid.innerHTML = '';

        images.forEach((url, index) => {
            const item = document.createElement('div');
            item.className = 'image-item';

            const img = document.createElement('img');
            img.src = url;
            img.alt = `Image ${index + 1}`;
            img.onerror = function() { item.style.display = 'none'; };

            const indexElement = document.createElement('div');
            indexElement.className = 'image-index';
            indexElement.style.display = 'none';
            indexElement.textContent = '0';

            item.appendChild(img);
            item.appendChild(indexElement);
            item.dataset.url = url;

            item.addEventListener('click', () => {
                toggleSelection(item);
                updateSelectionCount();
            });

            grid.appendChild(item);
        });

        updateSelectionCount();
    }

    function toggleSelection(item) {
        const isSelected = item.classList.contains('selected');

        if (isSelected) {
            item.classList.remove('selected');
            item.querySelector('.image-index').style.display = 'none';
            const index = selectedItems.indexOf(item);
            if (index !== -1) selectedItems.splice(index, 1);
        } else {
            item.classList.add('selected');
            const indexTag = item.querySelector('.image-index');
            const selectionNumber = selectedItems.length + 1;
            indexTag.textContent = selectionNumber;
            indexTag.style.display = 'block';
            selectedItems.push(item);
        }

        updateSelectedIndexes();
    }

    function updateSelectedIndexes() {
        selectedItems.forEach((item, index) => {
            const indexTag = item.querySelector('.image-index');
            indexTag.textContent = index + 1;
            indexTag.style.display = 'block';
        });
    }

    function updateSelectionCount() {
        const modal = document.getElementById('img-dl-modal');
        modal.querySelector('.count').textContent = `${selectedItems.length}张`;
        modal.querySelector('.download-btn').disabled = selectedItems.length === 0;
    }

    function showProgress(total) {
        const progressModal = document.getElementById('progress-modal');
        progressModal.style.display = 'block';
        document.getElementById('img-dl-overlay').style.display = 'block';
        updateProgress(0, 0, 0, total, '准备开始下载...');
    }

    function updateProgress(current, success, fail, total, message) {
        const progressModal = document.getElementById('progress-modal');
        const progressFill = progressModal.querySelector('.progress-fill');
        const progressText = progressModal.querySelector('.progress-text');
        const successCount = progressModal.querySelector('.success-count');
        const failCount = progressModal.querySelector('.fail-count');

        const percent = total > 0 ? Math.round((current / total) * 100) : 0;
        progressFill.style.width = `${percent}%`;
        progressText.textContent = message || `正在下载 ${current}/${total}...`;
        successCount.textContent = `成功: ${success}`;
        failCount.textContent = `失败: ${fail}`;
    }

    function hideProgress() {
        const progressModal = document.getElementById('progress-modal');
        progressModal.style.display = 'none';
        document.getElementById('img-dl-overlay').style.display = 'none';
    }

    // 核心:批量下载并压缩(使用修复后的 fetch/getExt/addToZip)
    async function downloadSelected() {
        const modal = document.getElementById('img-dl-modal');

        if (selectedItems.length === 0) return;

        modal.querySelector('.download-btn').disabled = true;
        showProgress(selectedItems.length);

        const zip = new JSZip();
        let successCount = 0;
        let failCount = 0;
        let isCancelled = false;

        const cancelBtn = document.querySelector('#progress-modal .cancel-btn');
        const onCancel = () => { isCancelled = true; hideProgress(); modal.querySelector('.download-btn').disabled = false; };
        cancelBtn.addEventListener('click', onCancel, { once: true });

        for (let i = 0; i < selectedItems.length; i++) {
            if (isCancelled) {
                updateProgress(i, successCount, failCount, selectedItems.length, '下载已取消');
                setTimeout(() => hideProgress(), 1200);
                return;
            }

            const url = selectedItems[i].dataset.url;
            updateProgress(i, successCount, failCount, selectedItems.length, `正在请求: ${url.split('/').pop()}`);

            try {
                const respObj = await fetchImage(url);
                const ext = await getImageExtensionFromResponse(respObj, url);
                await addImageToZip(zip, respObj, `image_${i + 1}.${ext}`);
                successCount++;
                updateProgress(i + 1, successCount, failCount, selectedItems.length);
            } catch (err) {
                console.error('下载或压缩单张失败:', url, err);
                failCount++;
                updateProgress(i + 1, successCount, failCount, selectedItems.length, `下载失败: ${url.split('/').pop()}`);
            }

            // 小间隔避免阻塞
            await new Promise(r => setTimeout(r, 100));
        }

        if (successCount > 0 && !isCancelled) {
            updateProgress(selectedItems.length, successCount, failCount, selectedItems.length, '正在生成压缩包...');
            await new Promise(r => setTimeout(r, 300));
            try {
                const content = await zip.generateAsync({ type: 'blob' });
                const blobUrl = URL.createObjectURL(content);
                const a = document.createElement('a');
                a.href = blobUrl;
                a.download = `images_${Date.now()}.zip`;
                a.click();
                URL.revokeObjectURL(blobUrl);
                updateProgress(selectedItems.length, successCount, failCount, selectedItems.length, '下载完成!');
                setTimeout(() => hideProgress(), 1200);
            } catch (e) {
                console.error('生成 zip 失败', e);
                updateProgress(selectedItems.length, successCount, failCount, selectedItems.length, '生成压缩包失败');
                setTimeout(() => hideProgress(), 1200);
            }
        } else if (!isCancelled) {
            updateProgress(selectedItems.length, successCount, failCount, selectedItems.length, '没有成功下载任何图片');
            setTimeout(() => hideProgress(), 1200);
        }

        closeModal();
        modal.querySelector('.download-btn').disabled = false;
    }

    function closeModal() {
        document.getElementById('img-dl-modal').style.display = 'none';
        document.getElementById('img-dl-overlay').style.display = 'none';
    }

    function addRefreshButtonToFooter() {
        const footer = document.querySelector('.modal-footer');
        const refreshBtn = document.createElement('button');
        refreshBtn.className = 'refresh-btn';
        refreshBtn.innerHTML = '刷新';
        refreshBtn.style.cssText = `background:#4CAF50;margin-left:8px;`;
        footer.insertBefore(refreshBtn, footer.querySelector('.count'));
        refreshBtn.addEventListener('click', refreshPreview);
    }

    function init() {
        createUI();
        addStyles();
        addRefreshButtonToFooter();

        document.getElementById('img-dl-btn').addEventListener('click', showPreview);
        document.querySelector('#img-dl-modal .close-btn').addEventListener('click', closeModal);

        document.querySelector('#img-dl-modal .select-btn').addEventListener('click', function() {
            const items = document.querySelectorAll('.image-item');
            const allSelected = items.length > 0 && Array.from(items).every(item => item.classList.contains('selected'));

            selectedItems.forEach(item => { item.classList.remove('selected'); item.querySelector('.image-index').style.display = 'none'; });
            selectedItems = [];

            items.forEach(item => {
                item.classList.toggle('selected', !allSelected);
                if (!allSelected) selectedItems.push(item);
            });

            updateSelectedIndexes();
            updateSelectionCount();
            this.textContent = allSelected ? '全选' : '取消全选';
        });

        document.querySelector('#img-dl-modal .download-btn').addEventListener('click', downloadSelected);
        document.getElementById('img-dl-overlay').addEventListener('click', closeModal);

        imageUrls = new Set(getPageImages());
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();