Universal Image Downloader

Professional UI, Smart Source Scan (No Scroll), and Strict Reader Isolation

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Universal Image Downloader
// @namespace    https://greasyfork.org/en/users/1553223-ozler365
// @version      7.7
// @description  Professional UI, Smart Source Scan (No Scroll), and Strict Reader Isolation
// @author       ozler365
// @license      MIT
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_registerMenuCommand
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @run-at       document-end
// @match        *://*/*
// ==/UserScript==

/* jshint esversion: 6 */
/* eslint-env es6 */

(function () {
    'use strict';

    // 1. Configuration & Localization
    const isZh = (navigator.language || 'en').toLowerCase().includes("zh");
    // Detect Mobile Environment
    const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth < 768;

    const i18n = {
        subFolder: isZh ? "文件夹名称" : "Folder Name",
        selectAll: isZh ? "全选" : "Select All",
        download: isZh ? "批量下载" : "Download All",
        zip: isZh ? "打包下载" : "ZIP Pack",
        selected: isZh ? "已选择" : "Selected",
        source: isZh ? "来源" : "Source",
        menuOpen: isZh ? "启动下载器" : "Open Downloader",
        sourceScan: isZh ? "⚡ 强制加载 (正则)" : "⚡ Force Load (Regex)",
        processing: isZh ? "分析中..." : "Analyzing...",
    };

    // 2. Helper Functions
    function getAbsUrl(url) {
        if (!url || typeof url !== 'string' || url.startsWith('blob:') || url.startsWith('data:')) return url;
        try { return new URL(url, document.baseURI).href; } catch(e) { return url; }
    }

    function getFilename(url) {
        try {
            const u = new URL(url, document.baseURI);
            let name = u.pathname.split('/').pop();
            if (!name || name.indexOf('.') === -1) return url;
            return decodeURIComponent(name);
        } catch(e) {
            return url.split('/').pop().split('?')[0];
        }
    }

    async function getImageBlob(url) {
        return new Promise((resolve, reject) => {
            if (url.startsWith('data:') || url.startsWith('blob:')) {
                fetch(url).then(res => res.blob()).then(resolve).catch(reject);
                return;
            }
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                responseType: "blob",
                anonymous: false,
                headers: {
                    "Referer": window.location.href,
                    "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"
                },
                onload: (res) => {
                    if (res.status === 200 && res.response.size > 512) resolve(res.response);
                    else fetch(url).then(r => r.blob()).then(resolve).catch(reject);
                },
                onerror: () => fetch(url).then(r => r.blob()).then(resolve).catch(reject)
            });
        });
    }

    async function convertToJpeg(blob) {
        return new Promise((resolve) => {
            const img = new Image();
            const url = URL.createObjectURL(blob);
            img.onload = () => {
                const canvas = document.createElement('canvas');
                canvas.width = img.width; canvas.height = img.height;
                const ctx = canvas.getContext('2d');
                ctx.fillStyle = '#FFF'; ctx.fillRect(0, 0, canvas.width, canvas.height);
                ctx.drawImage(img, 0, 0);
                canvas.toBlob(b => { URL.revokeObjectURL(url); resolve(b || blob); }, 'image/jpeg', 0.9);
            };
            img.onerror = () => { URL.revokeObjectURL(url); resolve(blob); };
            img.src = url;
        });
    }

    // 3. Main UI Logic
    function openUI() {
        if (document.querySelector(".tyc-overlay")) return;

        const styles = `
            .tyc-overlay { position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); backdrop-filter:blur(8px); z-index:2147483640; display:flex; justify-content:center; align-items:center; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
            .tyc-modal { width:90vw; height:85vh; background:#fcfcfc; border-radius:12px; display:flex; flex-direction:column; overflow:hidden; resize:both; min-width:600px; box-shadow: 0 10px 40px rgba(0,0,0,0.4); border: 1px solid #444; }
            .tyc-header { padding:15px 25px; background:#fff; border-bottom:1px solid #e0e0e0; display:flex; align-items:center; gap:15px; flex-wrap:wrap; box-shadow: 0 2px 5px rgba(0,0,0,0.03); z-index: 10; }
            .tyc-input { padding:8px 12px; border:2px solid #ccc !important; border-radius:6px; font-weight:600; width:140px; background-color: #ffffff !important; color: #222 !important; box-shadow: inset 0 1px 3px rgba(0,0,0,0.05); font-size: 14px; }
            .tyc-input:focus { border-color: #007bff !important; outline: none; }
            .tyc-btn { padding:8px 16px; border-radius:6px; border:none; cursor:pointer; font-weight:700; font-size:13px; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1); color: white; }
            .tyc-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.15); filter: brightness(110%); }
            .tyc-btn:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
            .tyc-btn-blue { background: linear-gradient(135deg, #007bff, #0062cc); }
            .tyc-btn-green { background: linear-gradient(135deg, #28a745, #218838); }
            .tyc-btn-orange { background: linear-gradient(135deg, #fd7e14, #e67e22); }
            .tyc-btn-gray { background: #f8f9fa; color: #333; border: 1px solid #ddd; }
            .tyc-btn-gray:hover { background: #e2e6ea; }
            .tyc-badge { background:#333; color:#fff; padding:6px 12px; border-radius:20px; font-weight:700; font-size: 12px; white-space: nowrap; }
            .tyc-label { display:flex; align-items:center; gap:8px; cursor:pointer; font-weight: 600; color: #444; user-select: none; }
            .tyc-grid { flex:1; overflow-y:auto; padding:20px; display:grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-auto-rows: 260px; gap:15px; background:#f0f2f5; }
            .tyc-card { background:#fff; border-radius:8px; border:3px solid transparent; cursor:pointer; overflow:hidden; display:flex; align-items:center; justify-content:center; box-shadow:0 2px 8px rgba(0,0,0,0.06); transition: transform 0.1s; position: relative; }
            .tyc-card:hover { transform: scale(1.02); }
            .tyc-card.selected { border-color:#007bff; background:#edf5ff; }
            .tyc-card.selected::after { content:"✓"; position:absolute; top:5px; right:5px; background:#007bff; color:white; width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:bold; font-size:14px; }
            .tyc-card img { max-width:95%; max-height:95%; object-fit:contain; pointer-events:none; }

            /* --- MOBILE OPTIMIZATIONS --- */
            @media screen and (max-width: 768px) {
                .tyc-modal { 
                    width: 95vw; 
                    height: 80vh; 
                    min-width: unset; 
                    border-radius: 8px; 
                    border: 1px solid #555; 
                }
                .tyc-header { padding: 8px; gap: 6px; justify-content: space-between; }
                .tyc-input { width: 70px; font-size: 12px; padding: 4px; }
                .tyc-btn { padding: 5px 8px; font-size: 11px; white-space: nowrap; }
                .tyc-grid { padding: 8px; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-auto-rows: 140px; gap: 6px; }
                .tyc-badge { display: none; }
                .tyc-label span { display: none; }
            }
        `;

        const html = `
            <div class="tyc-overlay">
                <style>${styles}</style>
                <div class="tyc-modal">
                    <div class="tyc-header">
                        <label class="tyc-label">
                            <input type="checkbox" id="tyc-select-all" style="width:18px; height:18px;">
                            <span>${i18n.selectAll}</span>
                        </label>
                        <div style="height:25px; border-left:1px solid #ddd; margin:0 5px;"></div>
                        <input type="text" id="tyc-folder" class="tyc-input" placeholder="${i18n.subFolder}">
                        <span class="tyc-badge" id="tyc-count">0</span>
                        <div style="flex:1;"></div>

                        <button class="tyc-btn tyc-btn-orange" id="tyc-source-scan">${i18n.sourceScan}</button>
                        <button class="tyc-btn tyc-btn-blue" id="tyc-download">${i18n.download}</button>
                        <button class="tyc-btn tyc-btn-green" id="tyc-zip">${i18n.zip}</button>
                        <button class="tyc-btn tyc-btn-gray" onclick="this.closest('.tyc-overlay').remove()" style="padding: 8px 12px; font-size: 16px;">✕</button>
                    </div>
                    <div class="tyc-grid" id="tyc-grid"></div>
                </div>
            </div>
        `;

        document.body.insertAdjacentHTML("beforeend", html);

        // --- VARIABLES ---
        const grid = document.getElementById("tyc-grid");
        const countBadge = document.getElementById("tyc-count");
        let selectedIndices = new Set();
        let imgUrls = [];
        let seenFilenames = new Set();

        // --- 1. DOM SCANNER ---
        const scanDOM = () => {
            const tempUrls = new Set();
            const imgRegex = /\.(jpg|jpeg|png|webp|avif|bmp|gif)($|\?)/i;
            const hostname = window.location.hostname;

            const mainSelectors = [
                '#_imageList', '.viewer_lst', '#readerarea', '.reading-content',
                '.chapter-images', '.entry-content', '#chapter-container',
                '#images-container', '.viewer-container', '.ts-main-image'
            ];

            let scope = document.body;
            for (let s of mainSelectors) {
                let found = document.querySelector(s);
                if (found) { scope = found; break; }
            }

            let noiseClasses = ['.listupd', '.widget', '.sidebar', 'header', 'footer', 'nav', '.related', '.comments', '.recommend', '.menu', '.dropdown', '.select-chapter', '.chapter-select', '#chapter-selector'];
            if (!hostname.includes('webtoons.com')) {
                noiseClasses.push('.hidden', '.invisible', '.d-none', '[style*="display: none"]', '[style*="display:none"]');
            }

            const ignoredTags = ['SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT', 'TEXTAREA', 'OPTION', 'SELECT', 'INPUT', 'BUTTON', 'SVG', 'PATH', 'USE'];

            scope.querySelectorAll("*").forEach(el => {
                if (!el.tagName) return;
                if (ignoredTags.includes(el.tagName.toUpperCase())) return;
                if (el.closest(noiseClasses.join(','))) return;

                const attrs = [el.src, el.getAttribute('data-src'), el.getAttribute('data-url'), el.getAttribute('data-original')];

                attrs.forEach(val => {
                    if (val && typeof val === 'string') {
                        if (val.includes('previous') || val.includes('next') || val.includes('thumb')) {
                             if (!hostname.includes('webtoons.com') && (val.includes('150x150') || val.includes('300x300'))) return;
                        }
                        if (imgRegex.test(val) || val.startsWith('blob:') || val.startsWith('data:image/')) {
                            tempUrls.add(getAbsUrl(val));
                        }
                    }
                });

                const bg = window.getComputedStyle(el).backgroundImage;
                if (bg && bg !== 'none') {
                    const match = bg.match(/url\("?(.+?)"?\)/);
                    if (match) tempUrls.add(getAbsUrl(match[1]));
                }
            });

            const result = [];
            tempUrls.forEach(u => {
                if (!u || u.startsWith('data:image/svg')) return;
                const fname = getFilename(u);
                if (!seenFilenames.has(fname)) {
                    seenFilenames.add(fname);
                    result.push(u);
                }
            });
            return result;
        };

        // --- 2. SOURCE CODE SCANNER ---
        const scanSourceCode = () => {
            const html = document.documentElement.innerHTML;
            const regex = /(https?:\\?\/\\?\/[^"'\s<>]+\.(?:jpg|jpeg|png|webp|avif))/gi;
            const matches = html.match(regex) || [];
            const cleanUrls = new Set();

            matches.forEach(match => {
                let clean = match.replace(/\\/g, '');
                if (clean.includes('avatar') || clean.includes('logo') || clean.includes('icon') || clean.includes('thumb')) return;
                if (clean.includes('.js') || clean.includes('.css')) return;
                cleanUrls.add(getAbsUrl(clean));
            });

            return Array.from(cleanUrls);
        };

        const render = () => {
            grid.innerHTML = "";
            imgUrls.forEach((url, index) => {
                const card = document.createElement("div");
                card.className = "tyc-card";
                if (selectedIndices.has(index)) card.classList.add("selected");
                card.innerHTML = `<img src="${url}" loading="lazy" onerror="this.parentElement.remove()">`;
                card.onclick = () => {
                    if (selectedIndices.has(index)) {
                        selectedIndices.delete(index);
                        card.classList.remove("selected");
                    } else {
                        selectedIndices.add(index);
                        card.classList.add("selected");
                    }
                    countBadge.innerText = `${i18n.selected}: ${selectedIndices.size}`;
                };
                grid.appendChild(card);
            });
            countBadge.innerText = `${i18n.selected}: ${selectedIndices.size}`;
        };

        imgUrls = scanDOM();
        render();

        document.getElementById("tyc-select-all").onchange = (e) => {
            const isChecked = e.target.checked;
            selectedIndices.clear();
            document.querySelectorAll(".tyc-card").forEach((card, idx) => {
                if (isChecked) { selectedIndices.add(idx); card.classList.add("selected"); }
                else { card.classList.remove("selected"); }
            });
            countBadge.innerText = `${i18n.selected}: ${selectedIndices.size}`;
        };

        const sourceScanBtn = document.getElementById("tyc-source-scan");
        sourceScanBtn.onclick = () => {
            sourceScanBtn.innerText = i18n.processing;
            sourceScanBtn.disabled = true;

            setTimeout(() => {
                const sourceUrls = scanSourceCode();
                let addedCount = 0;
                sourceUrls.forEach(u => {
                    const fname = getFilename(u);
                    if (!seenFilenames.has(fname)) {
                        seenFilenames.add(fname);
                        imgUrls.push(u);
                        addedCount++;
                    }
                });
                render();
                sourceScanBtn.innerText = `Found +${addedCount}`;
                sourceScanBtn.classList.remove('tyc-btn-orange');
                sourceScanBtn.classList.add('tyc-btn-green');
                setTimeout(() => {
                    sourceScanBtn.innerText = i18n.sourceScan;
                    sourceScanBtn.disabled = false;
                }, 2000);
            }, 500);
        };

        // --- DOWNLOAD HANDLER ---
        document.getElementById("tyc-download").onclick = async function() {
            const selected = Array.from(selectedIndices).sort((a,b)=>a-b);
            const btn = this;
            const folderInput = document.getElementById("tyc-folder").value.trim();
            const folderPrefix = folderInput ? folderInput + "/" : "";

            btn.disabled = true;

            // --- SMART DELAY ---
            // Mobile browsers drop downloads if they happen too fast (throttling).
            // We set a 1500ms delay for Mobile, and keep it fast (250ms) for Desktop.
            const downloadDelay = isMobile ? 1500 : 250; 

            for (let i = 0; i < selected.length; i++) {
                btn.innerText = `${i+1}/${selected.length}`;
                try {
                    const blob = await getImageBlob(imgUrls[selected[i]]);
                    const jpeg = await convertToJpeg(blob);
                    
                    const imgIndex = selected[i] + 1;
                    const fileName = `image${imgIndex}.jpg`;

                    if (!isMobile && typeof GM_download === 'function') {
                        const bUrl = URL.createObjectURL(jpeg);
                        GM_download({
                            url: bUrl,
                            name: folderPrefix + fileName,
                            onload: () => URL.revokeObjectURL(bUrl)
                        });
                    } else {
                        saveAs(jpeg, fileName);
                    }
                } catch(e) { console.error(e); }
                
                // Wait before next download
                await new Promise(r => setTimeout(r, downloadDelay));
            }
            btn.innerText = i18n.download;
            btn.disabled = false;
        };

        document.getElementById("tyc-zip").onclick = async function() {
            const selected = Array.from(selectedIndices).sort((a,b)=>a-b);
            const btn = this;
            const zip = new JSZip();
            const title = document.title.replace(/[\\/:*?"<>|]/g, "_");
            btn.disabled = true;
            for (let i = 0; i < selected.length; i++) {
                btn.innerText = `ZIP ${Math.round(((i+1)/selected.length)*100)}%`;
                try {
                    const blob = await getImageBlob(imgUrls[selected[i]]);
                    const jpeg = await convertToJpeg(blob);
                    
                    const imgIndex = selected[i] + 1;
                    zip.file(`image${imgIndex}.jpg`, jpeg);
                } catch(e) { console.error(e); }
            }
            zip.generateAsync({type:"blob"}).then(c => {
                saveAs(c, `${title}.zip`);
                btn.innerText = i18n.zip;
                btn.disabled = false;
            });
        };
    }

    window.addEventListener('keydown', (e) => {
        if (e.altKey && e.code === 'KeyW') {
            e.preventDefault();
            const existing = document.querySelector(".tyc-overlay");
            if (existing) existing.remove(); else openUI();
        }
    }, true);

    if (typeof GM_registerMenuCommand === "function") {
        GM_registerMenuCommand(i18n.menuOpen, openUI);
    }
})();