Universal Image Downloader

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);
    }
})();