Greasy Fork

Greasy Fork is available in English.

图片下载器

强大的图片提取和批量下载工具,适用于绝大多数网站。轻松抓取右键限制、无法直接保存的图片,如背景图、Canvas绘制图、漫画(腾讯/B站)、图库素材(千库/包图)、文库文档图片(道客/豆丁)等。功能:ZIP打包下载、自动查找大图、图片筛选、自定义规则。(推荐 Chrome/Firefox + Tampermonkey)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         图片下载器

// @namespace    http://tampermonkey.net/
// @version      1.1.0
// @description  强大的图片提取和批量下载工具,适用于绝大多数网站。轻松抓取右键限制、无法直接保存的图片,如背景图、Canvas绘制图、漫画(腾讯/B站)、图库素材(千库/包图)、文库文档图片(道客/豆丁)等。功能:ZIP打包下载、自动查找大图、图片筛选、自定义规则。(推荐 Chrome/Firefox + Tampermonkey)

// @author       shenfangda
// @match        *://*/*
// @include      *
// @connect      *
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @require      https://unpkg.com/[email protected]/dist/hotkeys.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js
// @run-at       document-end
// @homepageURL  https://github.com/taoyuancun123/modifyText/blob/master/modifyText.js
// @supportURL   http://greasyfork.icu/zh-CN/scripts/419894/feedback
// @license      GPLv3
// ==/UserScript==

(function () {
    'use strict';

    // --- Localization ---
    const lang = navigator.language || navigator.userLanguage;
    let langSet;
    const localization = {
        zh: {
            selectAll: "全选",
            downloadBtn: "下载选中",
            downloadMenuText: "打开图片下载器 (Alt+W)",
            zipDownloadBtn: "ZIP下载选中",
            selectAlert: "请至少选中一张图片。",
            fetchTip: "准备抓取 Canvas 图片...",
            fetchCount1: `抓取 Canvas 图片第 `,
            fetchCount2: ' 张',
            fetchingCanvas: "正在抓取 Canvas 图片...",
            fetchDoneTip1: "已选(0/",
            fetchDoneTip1Type2: "已选(",
            fetchDoneTip2: ")张图片",
            totalFound: "共找到 ",
            images: " 张图片",
            regRulePlace: "输入待替换正则",
            regReplacePlace: "输入替换它的字符串或函数",
            zipOptionDesc: "勾选使用zip下载后,会请求跨域权限,否则zip下载基本下载不到图片。", // This description seems obsolete as GM_xmlhttpRequest is always used now. Keep or remove? Let's keep for now.
            zipCheckText: "使用 Zip 下载", // This checkbox seems removed in the original code, relying on button choice.
            downloadUrlFile: "下载图片地址列表",
            moreSetting: "更多设置",
            autoBigImgModule: "自动大图规则",
            defaultSettingRule: "默认规则",
            exportCustomRule: "导出自定义规则",
            importCustomRule: "导入自定义规则",
            fold: "收起",
            inputFilenameTip: "输入下载文件名前缀",
            extraGrab: "强力抓取(实验性)",
            extraGrabTooltip: "尝试拦截所有动态加载的图片,可能影响页面性能,需刷新页面生效。",
            shortcutInfo: "快捷键",
            filterWidth: "宽度:",
            filterHeight: "高度:",
            preparingZip: "正在准备 ZIP 文件...",
            zipReady: "ZIP 文件准备就绪!",
            downloadingImages: "正在下载图片...",
            downloadComplete: "下载完成!",
        },
        en: {
            selectAll: "Select All",
            downloadBtn: "Download Selected",
            downloadMenuText: "Open Image Downloader (Alt+W)",
            zipDownloadBtn: "ZIP Download Selected",
            selectAlert: "Please select at least one image.",
            fetchTip: "Preparing to fetch Canvas images...",
            fetchCount1: `Fetching canvas image #`,
            fetchCount2: '',
            fetchingCanvas: "Fetching Canvas images...",
            fetchDoneTip1: "Selected (0/",
            fetchDoneTip1Type2: "Selected (",
            fetchDoneTip2: ") images",
            totalFound: "Found ",
            images: " images",
            regRulePlace: "Enter regex to replace",
            regReplacePlace: "Enter replacement string or function",
            zipOptionDesc: "When zip option checked, will request CORS right, otherwise zipDownload may not get all pics.",
            zipCheckText: "Use Zip Download",
            downloadUrlFile: "Download Image URL List",
            moreSetting: "More Settings",
            autoBigImgModule: "Auto Big Image Rules",
            defaultSettingRule: "Default Rules",
            exportCustomRule: "Export Custom Rules",
            importCustomRule: "Import Custom Rules",
            fold: "Fold",
            inputFilenameTip: "Enter download filename prefix",
            extraGrab: "Extra Grab (Experimental)",
            extraGrabTooltip: "Try to intercept all dynamically loaded images. May impact page performance. Requires page refresh to take effect.",
            shortcutInfo: "Shortcut",
            filterWidth: "Width:",
            filterHeight: "Height:",
            preparingZip: "Preparing ZIP file...",
            zipReady: "ZIP file ready!",
            downloadingImages: "Downloading images...",
            downloadComplete: "Download complete!",
        }
    };

    if (lang.toLowerCase().startsWith("zh-")) {
        langSet = localization.zh;
    } else {
        // Default to English for other languages for now
        langSet = localization.en;
    }

    // --- Global Variables & State ---
    let currentImgUrls = [];          // URLs discovered in the current run
    let imgSelectedIndices = [];      // Indices of selected images (relative to filteredImgUrls)
    let filteredImgUrls = [];         // URLs after filtering and auto-big-image processing
    let zipBase64Sources = {};        // Store Base64 data for ZIP, keyed by original URL
    let isFetchingBase64 = false;     // Flag to prevent concurrent Base64 fetching
    let downloadFileNameBase = '';    // Base name for downloads
    let shortCutString = "alt+w";     // Default shortcut
    const originalSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
    let interceptedSrcs = [];         // Store srcs captured by 'Extra Grab'

    // --- Auto Big Image Module ---
    const autoBigImage = {
        // ... (Keep the autoBigImage object exactly as it was in the original provided script) ...
        // Including: bigImageArray, defaultRules, defaultRulesChecked, userRules, userRulesChecked,
        // replace(), getBigImageArray(), showDefaultRules(), showRules(), onclickShowDefaultBtn(),
        // oncheckChange(), oncheckChangeCustom(), setRulesChecked(), getCustomRules(), setCustomRules(),
        // exportCustomRules()

        // --- Start of autoBigImage Object definition ---
        bigImageArray: [],
        defaultRules:[
            {originReg:/(?<=(.+sinaimg\.(?:cn|com)\/))([\w\.]+)(?=(\/.+))/i,replacement:"large",tip:"for weib.com"},
            {originReg:/(?<=(.+alicdn\.(?:cn|com)\/.+\.(jpg|jpeg|gif|png|bmp|webp)))_.+/i,replacement:"",tip:"for alibaba web"},
            {originReg:/(.+alicdn\.(?:cn|com)\/.+)(\.\d+x\d+)(\.(jpg|jpeg|gif|png|bmp|webp)).*/i,replacement:(match,p1,p2,p3)=>p1+p3,tip:"for 1688"},
            {originReg:/(?<=(.+360buyimg\.(?:cn|com)\/))(\w+\/)(?=(.+\.(jpg|jpeg|gif|png|bmp|webp)))/i,replacement:"n0/",tip:"for jd"},
            {originReg:/(?<=(.+hdslb\.(?:cn|com)\/.+\.(jpg|jpeg|gif|png|bmp|webp)))@.+/i,replacement:"",tip:"for bilibili"},
            {originReg:/th(\.wallhaven\.cc\/)(?!full).+\/(\w{2}\/)([\w\.]+)(\.jpg)/i,replacement:(match,p1,p2,p3)=>"w"+p1+"full/"+p2+"wallhaven-"+p3+".jpg",tip:"for wallhaven"},
            {originReg:/th(\.wallhaven\.cc\/)(?!full).+\/(\w{2}\/)([\w\.]+)(\.png)/i,replacement:(match,p1,p2,p3)=>"w"+p1+"full/"+p2+"wallhaven-"+p3+".png",tip:"for wallhaven png"}, // Added PNG variant
            {originReg:/(.*\.twimg\.\w+\/.+\?format=)(\w+)(\&name=*)(.*)/i,replacement:(match,p1,p2,p3,p4)=>p1+p2+p3+"orig",tip:"for twitter new format"}, // Updated twitter rule
             {originReg:/(.*\.twimg\.\w+\/.+\&name=*)(.*)/i,replacement:(match,p1,p2,p3)=>p1+"orig",tip:"for twitter old format"},
            {originReg:/(shonenjump\.com\/.*\/)poster_thumb(\/.*)/,replacement:'$1poster$2',tip:"for www.shonenjump.com"},
            {originReg:/(qzone\.qq\.com.*!!\/.*)$/,replacement:'$1/0',tip:"for Qzone"},
            {originReg:/(.*wordpress\.com.*)(\?w=\d+)$/,replacement:'$1',tip:"for wordpress"},
            {originReg:/(img\.ithome\.com\/newsuploadfiles.*)_.*\.(jpg|png|gif|webp)/i,replacement:'$1.$2',tip:"for ithome.com"}, // Example new rule
        ],
        defaultRulesChecked: [],
        userRules: [],
        userRulesChecked: [],
        replace(originImgUrls) {
            let that = this;
            that.bigImageArray = [];
            // Ensure unique, non-empty URLs
            let tempArray = Array.from(new Set(originImgUrls)).filter(item => typeof item === 'string' && item.trim() !== '');
            that.setRulesChecked(); // Load checked status

            tempArray.forEach(urlStr => {
                if (!urlStr) return;
                let replaced = false; // Flag to track if any rule matched

                // Handle data URLs directly
                if (urlStr.startsWith("data:image/")) {
                    that.bigImageArray.push(urlStr);
                    return;
                }

                // Apply default rules
                that.defaultRules.forEach((rule, ruleIndex) => {
                    if (that.defaultRulesChecked[ruleIndex] !== "checked") return;
                    try {
                        let bigImage = urlStr.replace(rule.originReg, rule.replacement);
                        if (bigImage !== urlStr) {
                            that.bigImageArray.push(bigImage); // Add the potentially larger image
                            replaced = true;
                            // console.log(`Rule ${rule.tip || ruleIndex} applied: ${urlStr} -> ${bigImage}`);
                        }
                    } catch (e) {
                        console.error("Error applying default rule:", rule, e);
                    }
                });

                // Apply user rules
                that.userRules.forEach((rule, ruleIndex) => {
                     if (that.userRulesChecked[ruleIndex] !== "checked") return;
                     try {
                         // Ensure RegExp is valid if loaded from string
                         let regExp = rule.originReg;
                         if (typeof regExp === 'string') {
                              try {
                                 const match = regExp.match(/^\/(.+)\/([gimyus]*)$/);
                                 if (match) {
                                     regExp = new RegExp(match[1], match[2]);
                                 } else {
                                     // Assume it's just the pattern, add 'i' flag by default if none provided
                                     regExp = new RegExp(regExp, 'i');
                                 }
                              } catch (reError) {
                                 console.error("Invalid RegExp string in user rule:", rule.originReg, reError);
                                 return; // Skip this rule
                             }
                         }

                         let replacementFunc = rule.replacement;
                         if (typeof replacementFunc === 'string' && replacementFunc.startsWith('(') && replacementFunc.includes('=>')) {
                              try {
                                 replacementFunc = eval(replacementFunc); // Evaluate string to function if it looks like an arrow function
                              } catch (evalError) {
                                  console.error("Invalid replacement function string in user rule:", rule.replacement, evalError);
                                  replacementFunc = rule.replacement; // Keep as string if eval fails
                              }
                         }


                         let bigImage = urlStr.replace(regExp, replacementFunc);
                         if (bigImage !== urlStr) {
                             that.bigImageArray.push(bigImage);
                             replaced = true;
                              // console.log(`User rule ${ruleIndex} applied: ${urlStr} -> ${bigImage}`);
                         }
                     } catch (e) {
                         console.error("Error applying user rule:", rule, e);
                     }
                 });

                // If no rule resulted in a replacement, add the original URL
                if (!replaced) {
                    that.bigImageArray.push(urlStr);
                }
            });

            // Ensure original URLs are also included if they weren't replaced
            // This logic might be complex depending on whether we want *only* big or *both*
            // Current logic adds *only* the big one if replaced, otherwise the original.
            // To include both original and big: push urlStr *before* the loops, then push bigImage inside if different.
            // Let's refine: Add original first, then add big if different.

            that.bigImageArray = []; // Reset
            tempArray.forEach(urlStr => {
                 if (!urlStr || urlStr.startsWith("data:image/")) {
                     if (urlStr) that.bigImageArray.push(urlStr);
                     return;
                 }

                 that.bigImageArray.push(urlStr); // Add original first

                 let foundBig = false;

                 // Apply default rules
                 that.defaultRules.forEach((rule, ruleIndex) => {
                    if (that.defaultRulesChecked[ruleIndex] !== "checked" || foundBig) return;
                     try {
                         let bigImage = urlStr.replace(rule.originReg, rule.replacement);
                         if (bigImage !== urlStr) {
                             that.bigImageArray.push(bigImage);
                             foundBig = true;
                         }
                     } catch (e) { console.error("Error applying default rule:", rule, e); }
                 });

                // Apply user rules
                 that.userRules.forEach((rule, ruleIndex) => {
                      if (that.userRulesChecked[ruleIndex] !== "checked" || foundBig) return;
                     try {
                          let regExp = rule.originReg;
                           if (typeof regExp === 'string') {
                                try {
                                    const match = regExp.match(/^\/(.+)\/([gimyus]*)$/);
                                    regExp = match ? new RegExp(match[1], match[2]) : new RegExp(regExp, 'i');
                                } catch (reError) { console.error("Invalid RegExp string in user rule:", rule.originReg, reError); return; }
                           }

                           let replacementFunc = rule.replacement;
                           if (typeof replacementFunc === 'string' && replacementFunc.startsWith('(') && replacementFunc.includes('=>')) {
                               try { replacementFunc = eval(replacementFunc); } catch (evalError) { console.error("Invalid replacement function string:", rule.replacement, evalError); }
                           }

                         let bigImage = urlStr.replace(regExp, replacementFunc);
                         if (bigImage !== urlStr) {
                             that.bigImageArray.push(bigImage);
                             foundBig = true;
                         }
                     } catch (e) { console.error("Error applying user rule:", rule, e); }
                 });

            });

        },
        getBigImageArray(originImgUrls) {
            this.replace(originImgUrls);
            // Return unique URLs only
            return Array.from(new Set(this.bigImageArray)).filter(Boolean);
        },
        showDefaultRules() {
            let that = this;
            let defaultContainer = document.body.querySelector(".tyc-set-domain-default");
            if (!defaultContainer) return;
            defaultContainer.innerHTML = ''; // Clear previous content
            that.setRulesChecked();

            this.defaultRules.forEach((v, i) => {
                const regValue = v.originReg instanceof RegExp ? v.originReg.toString() : v.originReg;
                const repValue = typeof v.replacement === 'function' ? v.replacement.toString() : v.replacement;
                let rulesHtml = `<div class="tyc-set-replacerule">
                            <input type="checkbox" name="active" class="tyc-default-active" ${that.defaultRulesChecked[i] || ''}>
                            <input type="text" name="regrule" placeholder="${langSet.regRulePlace}" class="tyc-search-title" value="${escapeHtml(regValue)}">
                            <input type="text" name="replace" placeholder="${langSet.regReplacePlace}" class="tyc-search-url" value="${escapeHtml(repValue)}">
                            <span class="tyc-default-tip">${v.tip || ''}</span>
                    </div>
                `;
                defaultContainer.insertAdjacentHTML("beforeend", rulesHtml);
            });
        },
        showRules(containerName, rulesType, checkType, checkClassName) {
            let that = this;
            let Container = document.body.querySelector("." + containerName);
             if (!Container) return;
             Container.innerHTML = ''; // Clear previous content
            that.setRulesChecked();
            that.setCustomRules(); // Ensure user rules are loaded

            that[rulesType].forEach((v, i) => {
                const regValue = v.originReg instanceof RegExp ? v.originReg.toString() : v.originReg;
                const repValue = typeof v.replacement === 'function' ? v.replacement.toString() : v.replacement;
                let rulesHtml = `<div class="tyc-set-replacerule">
                            <input type="checkbox" name="active" class="${checkClassName}" ${that[checkType][i] || ''}>
                            <input type="text" name="regrule" placeholder="${langSet.regRulePlace}" class="tyc-search-title" value="${escapeHtml(regValue)}">
                            <input type="text" name="replace" placeholder="${langSet.regReplacePlace}" class="tyc-search-url" value="${escapeHtml(repValue)}">
                            <span class="tyc-default-tip">${v.tip || ''}</span>
                    </div>
                `;
                Container.insertAdjacentHTML("beforeend", rulesHtml);
            });
        },
         onclickShowDefaultBtn() {
            let defaultContainer = document.body.querySelector(".tyc-set-domain-default");
            if (!defaultContainer) return;
            if (defaultContainer.style.display === "none" || defaultContainer.style.display === '') {
                defaultContainer.style.display = "flex"; // Or block, depending on desired layout
            } else {
                defaultContainer.style.display = "none";
            }
        },
        oncheckChange() {
            let checks = document.body.querySelectorAll(".tyc-default-active");
            this.defaultRulesChecked = [];
            checks.forEach(v => {
                this.defaultRulesChecked.push(v.checked ? "checked" : "");
            });
            GM_setValue("defaultRulesChecked", this.defaultRulesChecked);
            // console.log("Default rules checked status saved:", this.defaultRulesChecked);
             // Optionally re-filter images immediately after changing rules
            if (document.querySelector(".tyc-image-container")) {
                initUI(); // Re-initialize UI which includes filtering
            }
        },
        oncheckChangeCustom() {
            let checks = document.body.querySelectorAll(".tyc-custom-active");
             this.userRulesChecked = [];
             checks.forEach(v => {
                this.userRulesChecked.push(v.checked ? "checked" : "");
            });
            GM_setValue("userRulesChecked", this.userRulesChecked);
            // console.log("User rules checked status saved:", this.userRulesChecked);
             // Optionally re-filter images immediately after changing rules
             if (document.querySelector(".tyc-image-container")) {
                initUI(); // Re-initialize UI which includes filtering
            }
        },
        setRulesChecked() {
            // Default rules
            const storedDefaultChecks = GM_getValue("defaultRulesChecked");
            if (storedDefaultChecks && Array.isArray(storedDefaultChecks)) {
                 this.defaultRulesChecked = storedDefaultChecks;
                 // Ensure length matches current default rules, adding "checked" for new rules
                 if (this.defaultRulesChecked.length < this.defaultRules.length) {
                     const delta = this.defaultRules.length - this.defaultRulesChecked.length;
                     for (let i = 0; i < delta; i++) {
                         this.defaultRulesChecked.push("checked");
                     }
                     GM_setValue("defaultRulesChecked", this.defaultRulesChecked); // Save updated checks
                 } else if (this.defaultRulesChecked.length > this.defaultRules.length) {
                     // If rules were removed, shorten the checks array
                     this.defaultRulesChecked = this.defaultRulesChecked.slice(0, this.defaultRules.length);
                      GM_setValue("defaultRulesChecked", this.defaultRulesChecked);
                 }
             } else {
                // Initialize if not set
                 this.defaultRulesChecked = this.defaultRules.map(() => "checked");
                 GM_setValue("defaultRulesChecked", this.defaultRulesChecked);
            }

            // User rules
            this.setCustomRules(); // Ensure user rules are loaded first
             const storedUserChecks = GM_getValue("userRulesChecked");
             if (storedUserChecks && Array.isArray(storedUserChecks)) {
                 this.userRulesChecked = storedUserChecks;
                 // Adjust length similar to default rules
                 if (this.userRulesChecked.length < this.userRules.length) {
                      const delta = this.userRules.length - this.userRulesChecked.length;
                      for (let i = 0; i < delta; i++) {
                          this.userRulesChecked.push("checked");
                      }
                      GM_setValue("userRulesChecked", this.userRulesChecked);
                  } else if (this.userRulesChecked.length > this.userRules.length) {
                      this.userRulesChecked = this.userRulesChecked.slice(0, this.userRules.length);
                      GM_setValue("userRulesChecked", this.userRulesChecked);
                  }
              } else {
                 // Initialize if not set
                  this.userRulesChecked = this.userRules.map(() => "checked");
                  GM_setValue("userRulesChecked", this.userRulesChecked);
             }
         },
         getCustomRules(event) {
            const fileInput = event.target; // Should be the hidden file input
            if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
                console.log("No file selected for import.");
                return;
            }
            const file = fileInput.files[0];
            const fileReader = new FileReader();

            fileReader.onload = (e) => {
                 const result = e.target.result;
                 try {
                     // Use a safer approach than eval if possible, but eval is common for this.
                     // Consider JSON if rules can be structured that way. Assuming eval for now based on original code.
                     let importedRules = eval(result); // Be cautious with eval!
                     if (!Array.isArray(importedRules)) {
                         throw new Error("Imported data is not an array.");
                     }
                     // Basic validation of rule structure
                     importedRules = importedRules.filter(rule => rule && typeof rule.originReg !== 'undefined' && typeof rule.replacement !== 'undefined');

                     this.userRules = importedRules;

                     // Reset checks and save
                     GM_deleteValue("userRulesChecked");
                     this.setRulesChecked(); // This will re-initialize userRulesChecked based on the new userRules length
                     GM_setValue("userRules", JSON.stringify(this.userRules)); // Store as JSON string

                     console.log("Custom rules imported successfully:", this.userRules);
                      alert("Custom rules imported successfully!");

                     // Refresh the display
                     const customContainer = document.body.querySelector(".tyc-set-domain-custom");
                      if (customContainer) {
                         customContainer.innerHTML = ""; // Clear existing display
                         this.showRules("tyc-set-domain-custom", "userRules", "userRulesChecked", "tyc-custom-active");
                     }
                      // Re-filter images with new rules
                      if (document.querySelector(".tyc-image-container")) {
                         initUI();
                     }

                 } catch (error) {
                     console.error("Error importing custom rules:", error);
                     alert(`Error importing custom rules: ${error.message}\nPlease ensure the file contains a valid JavaScript array of rule objects.`);
                 } finally {
                     // Reset file input value to allow importing the same file again
                     fileInput.value = '';
                 }
             };

            fileReader.onerror = (e) => {
                 console.error("Error reading file:", e);
                 alert("Error reading the selected file.");
                 fileInput.value = ''; // Reset input
             };

             fileReader.readAsText(file); // Default encoding (UTF-8 usually)
         },
         setCustomRules() {
            const storedRules = GM_getValue("userRules");
            if (storedRules) {
                 try {
                     this.userRules = JSON.parse(storedRules); // Parse JSON string
                     if (!Array.isArray(this.userRules)) {
                         console.warn("Stored user rules are not an array, resetting.");
                         this.userRules = [];
                         GM_setValue("userRules", "[]"); // Store empty array as JSON
                     }
                 } catch (error) {
                     console.error("Error parsing stored user rules:", error);
                     this.userRules = [];
                     GM_setValue("userRules", "[]"); // Reset on error
                 }
             } else {
                 this.userRules = []; // Initialize if not found
             }
         },
        exportCustomRules() {
             this.setCustomRules(); // Ensure current rules are loaded
             if (!this.userRules || this.userRules.length === 0) {
                 alert("No custom rules to export.");
                 return;
             }
             try {
                 // Convert RegExp and Functions to strings for reliable serialization
                 const exportableRules = this.userRules.map(rule => ({
                      ...rule,
                      originReg: rule.originReg instanceof RegExp ? rule.originReg.toString() : rule.originReg,
                      replacement: typeof rule.replacement === 'function' ? rule.replacement.toString() : rule.replacement,
                 }));

                 // Use JSON.stringify for a structured format, but eval will be needed on import.
                 // Or create a more JS-like string representation. Let's stick to JSON string for easier parsing.
                 const rulesString = JSON.stringify(exportableRules, null, 2); // Pretty print JSON

                 // Alternative: Create a JS array string (might be closer to original intent if using eval)
                 // const rulesString = `[${exportableRules.map(rule =>
                 //     `\n  { originReg: ${rule.originReg.includes('/') ? rule.originReg : `/${rule.originReg}/i`}, replacement: ${JSON.stringify(rule.replacement)}, tip: ${JSON.stringify(rule.tip || '')} }`
                 // ).join(',')} \n]`;


                 const blob = new Blob([rulesString], { type: "text/plain;charset=utf-8" });
                 saveAs(blob, "image_downloader_custom_rules.txt"); // Or .json if using JSON stringify
                 console.log("Custom rules exported.");
             } catch (error) {
                 console.error("Error exporting custom rules:", error);
                 alert(`Error exporting rules: ${error.message}`);
             }
         }

        // --- End of autoBigImage Object definition ---
    };


    // --- Utility Functions ---
    function escapeHtml(unsafe) {
        if (typeof unsafe !== 'string') return unsafe;
        return unsafe
             .replace(/&/g, "&amp;")
             .replace(/</g, "&lt;")
             .replace(/>/g, "&gt;")
             .replace(/"/g, "&quot;")
             .replace(/'/g, "&#039;");
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // --- Core Logic ---

    /**
     * Intercepts image src assignments if 'Extra Grab' is enabled.
     */
    function enableExtraGrab() {
        if (!originalSrcDescriptor) return; // Safety check
         try {
            Object.defineProperty(HTMLImageElement.prototype, 'src', {
                configurable: true, // Allow redefining later
                get: function() {
                    return originalSrcDescriptor.get.call(this);
                },
                set: function(value) {
                    if (value && typeof value === 'string' && !interceptedSrcs.includes(value)) {
                        interceptedSrcs.push(value);
                        // console.log('Extra Grab intercepted:', value);
                    }
                    originalSrcDescriptor.set.call(this, value);
                }
            });
            console.log("Image Downloader: Extra Grab enabled.");
         } catch (e) {
             console.error("Image Downloader: Failed to enable Extra Grab.", e);
         }
    }

    /**
      * Restores the original image src descriptor.
      */
    function disableExtraGrab() {
        if (!originalSrcDescriptor) return;
        try {
            Object.defineProperty(HTMLImageElement.prototype, 'src', originalSrcDescriptor);
            console.log("Image Downloader: Extra Grab disabled.");
        } catch (e) {
            console.error("Image Downloader: Failed to disable Extra Grab.", e);
        }
    }

    /**
     * Initializes variables at the start of the wrapper function.
     */
    function setupVariables() {
        currentImgUrls = [];
        imgSelectedIndices = [];
        filteredImgUrls = [];
        zipBase64Sources = {};
        isFetchingBase64 = false;

        // Generate default filename
        try {
            const domainParts = document.domain.split('.');
            const mainDomain = domainParts.length > 1 ? domainParts[domainParts.length - 2] : document.domain;
            const timeStamp = new Date().getTime().toString();
            downloadFileNameBase = `${mainDomain}_${timeStamp.slice(-6)}`; // Shorter timestamp
        } catch (e) {
            console.error("Error generating filename:", e);
            downloadFileNameBase = `images_${new Date().getTime().toString().slice(-6)}`;
        }

        // Load shortcut from storage
        shortCutString = GM_getValue("shortCutString") || "alt+w";
    }

    /**
     * Finds images from various sources (img tags, srcset, background-image, canvas).
     */
    function discoverImages() {
        console.log("Image Downloader: Discovering images...");
        const discoveredUrls = new Set(); // Use a Set for automatic deduplication initially

        // 1. From <img> tags (src and srcset)
        try {
            const imgElements = document.getElementsByTagName("img");
            for (const img of imgElements) {
                if (img.src && !img.src.startsWith('javascript:')) {
                    discoveredUrls.add(img.src);
                }
                if (img.srcset) {
                    const sources = img.srcset.split(',').map(s => s.trim().split(/\s+/)[0]);
                    sources.forEach(src => {
                        if (src && !src.startsWith('javascript:')) discoveredUrls.add(src)
                    });
                    // Basic high-res check (could be improved by parsing 'w' descriptors)
                    if (sources.length > 0) discoveredUrls.add(sources[sources.length - 1]);
                }
            }
        } catch (e) {
            console.error("Error discovering images from <img> tags:", e);
        }

         // 2. From intercepted sources ('Extra Grab')
         interceptedSrcs.forEach(src => discoveredUrls.add(src));

        // 3. From CSS background-image
        try {
            const styleSheets = Array.from(document.styleSheets);
             styleSheets.forEach(sheet => {
                try {
                    const rules = Array.from(sheet.cssRules || []);
                    rules.forEach(rule => {
                        if (rule.style && rule.style.backgroundImage) {
                            const bgUrlMatch = rule.style.backgroundImage.match(/url\(['"]?(.+?)['"]?\)/);
                             if (bgUrlMatch && bgUrlMatch[1] && !bgUrlMatch[1].startsWith('data:') && !bgUrlMatch[1].startsWith('javascript:')) {
                                 // Resolve relative URLs
                                 discoveredUrls.add(new URL(bgUrlMatch[1], document.baseURI).href);
                             }
                         }
                     });
                 } catch (sheetError) {
                     // Ignore CORS errors for external stylesheets
                     if (!sheetError.message.includes('Cannot access')) {
                         // console.warn("Error processing stylesheet:", sheetError);
                     }
                 }
             });

             // Also check inline styles (less efficient but necessary)
             const allElements = document.querySelectorAll('*');
             allElements.forEach(el => {
                 if (el.style && el.style.backgroundImage) {
                      const bgUrlMatch = el.style.backgroundImage.match(/url\(['"]?(.+?)['"]?\)/);
                      if (bgUrlMatch && bgUrlMatch[1] && !bgUrlMatch[1].startsWith('data:') && !bgUrlMatch[1].startsWith('javascript:')) {
                         discoveredUrls.add(new URL(bgUrlMatch[1], document.baseURI).href);
                      }
                  }
              });

         } catch (e) {
             console.error("Error discovering images from background-image:", e);
         }


        // 4. Special handling for specific sites (like the original hathitrust)
        if (window.location.href.includes("hathitrust.org")) {
            try {
                const imgs = document.querySelectorAll(".image img");
                if (imgs.length > 0) {
                    const canvas = document.createElement("canvas");
                    for (const img of imgs) {
                         try {
                            canvas.width = img.naturalWidth || img.width;
                            canvas.height = img.naturalHeight || img.height;
                             if (canvas.width > 0 && canvas.height > 0) {
                                canvas.getContext("2d").drawImage(img, 0, 0);
                                discoveredUrls.add(canvas.toDataURL("image/png"));
                             }
                         } catch(imgCanvasError) {
                            console.warn("HathiTrust: Error processing image to canvas", imgCanvasError);
                         }
                    }
                }
            } catch(hathiError) {
                console.error("HathiTrust specific handling error:", hathiError);
            }
        }

        // 5. BiliBili Manga anti-scraping bypass (if still needed/working)
        if (window.location.href.includes("manga.bilibili.com/")) {
             try {
                 let iframe = document.getElementById("tyc-insert-iframe");
                 if (!iframe) {
                     iframe = document.createElement("iframe");
                     iframe.style.display = "none";
                     iframe.id = "tyc-insert-iframe";
                     document.body.insertAdjacentElement("afterbegin", iframe);
                     iframe.contentDocument.body.insertAdjacentHTML("afterbegin", `<canvas id="tyc-insert-canvas"></canvas>`);
                 }
                 const originalCanvasProto = document.body.getElementsByTagName('canvas')[0]?.__proto__;
                 const targetCanvasProto = iframe.contentDocument.getElementById("tyc-insert-canvas")?.__proto__;
                 if (originalCanvasProto && targetCanvasProto && originalCanvasProto.toBlob !== targetCanvasProto.toBlob) {
                     console.log("Attempting BiliBili canvas bypass...");
                     originalCanvasProto.toBlob = targetCanvasProto.toBlob;
                 }
             } catch (biliError) {
                 console.error("BiliBili Manga specific handling error:", biliError);
             }
         }

        // Convert Set to Array and store
        currentImgUrls = Array.from(discoveredUrls).filter(Boolean); // Filter out any potential null/undefined values
        console.log(`Image Downloader: Discovered ${currentImgUrls.length} potential image URLs.`);

        // Asynchronously handle canvas elements
        return handleCanvasElements();
    }

    /**
     * Finds canvas elements and converts them to data URLs asynchronously.
     * Returns a Promise that resolves when all canvases are processed.
     */
     function handleCanvasElements() {
         return new Promise((resolve) => {
             const canvasElements = document.getElementsByTagName("canvas");
             if (canvasElements.length === 0) {
                 console.log("No canvas elements found.");
                 resolve(); // Resolve immediately if no canvases
                 return;
             }

             console.log(`Found ${canvasElements.length} canvas elements. Attempting to extract...`);
             updateStatusTip(langSet.fetchingCanvas);

             let processedCount = 0;
             let extractedCount = 0;
             const canvasPromises = [];

             for (let i = 0; i < canvasElements.length; i++) {
                 const canvas = canvasElements[i];
                 // Skip tiny or potentially non-image canvases
                 if (canvas.width < 16 || canvas.height < 16) {
                      processedCount++;
                      continue;
                 }

                 const promise = new Promise((canvasResolve, canvasReject) => {
                     try {
                         canvas.toBlob(
                             (blob) => {
                                 if (blob) {
                                     const reader = new FileReader();
                                     reader.onloadend = function () {
                                         const base64 = reader.result;
                                         if (base64 && typeof base64 === 'string' && base64.startsWith("data:image")) {
                                             if (!currentImgUrls.includes(base64)) { // Check before adding
                                                currentImgUrls.push(base64);
                                                extractedCount++;
                                             }
                                         }
                                         processedCount++;
                                         updateStatusTip(`${langSet.fetchCount1}${processedCount}/${canvasElements.length}${langSet.fetchCount2}`);
                                         canvasResolve(); // Resolve this canvas promise
                                     };
                                     reader.onerror = function (e) {
                                         console.warn("FileReader error for canvas blob:", e);
                                         processedCount++;
                                         updateStatusTip(`${langSet.fetchCount1}${processedCount}/${canvasElements.length}${langSet.fetchCount2}`);
                                         canvasResolve(); // Still resolve, just couldn't read
                                     };
                                     reader.readAsDataURL(blob);
                                 } else {
                                      // console.log(`Canvas ${i} toBlob returned null.`);
                                      processedCount++;
                                      updateStatusTip(`${langSet.fetchCount1}${processedCount}/${canvasElements.length}${langSet.fetchCount2}`);
                                      canvasResolve(); // Resolve even if blob is null
                                 }
                             },
                             'image/png' // Specify type (png is usually lossless)
                         );
                     } catch (e) {
                          // console.warn(`Error calling toBlob on canvas ${i}:`, e);
                          processedCount++;
                          updateStatusTip(`${langSet.fetchCount1}${processedCount}/${canvasElements.length}${langSet.fetchCount2}`);
                          canvasResolve(); // Resolve on error to not block others
                     }
                 });
                  canvasPromises.push(promise);
             }

             // Wait for all canvas processing attempts to complete
             Promise.all(canvasPromises).then(() => {
                 console.log(`Canvas processing complete. Extracted ${extractedCount} new images.`);
                 // Final deduplication after adding canvas images
                 currentImgUrls = Array.from(new Set(currentImgUrls));
                  resolve(); // Resolve the main promise
             });
         });
     }


    /**
      * Creates the downloader UI panel.
      */
    function createUI() {
        // Remove existing panel first
        const existingContainer = document.querySelector(".tyc-image-container");
        if (existingContainer) {
            existingContainer.remove();
        }

        const css = `
            .tyc-image-container {
                color: #333;
                position: fixed;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                z-index: 2147483645; /* High z-index */
                background-color: rgba(240, 240, 240, 0.98); /* Slightly transparent background */
                border: 1px solid #ccc;
                overflow-y: scroll; /* Allow vertical scroll */
                display: flex;
                flex-direction: column;
                font-family: sans-serif;
                font-size: 13px;
                 box-sizing: border-box;
            }
             .tyc-image-container *, .tyc-image-container *::before, .tyc-image-container *::after {
                 box-sizing: inherit; /* Inherit box-sizing */
            }

            .tyc-control-section {
                width: 100%;
                padding: 8px 15px;
                background-color: #e8e8e8;
                border-bottom: 1px solid #ccc;
                z-index: 2147483646; /* Above image wrapper */
                position: sticky; /* Keep controls visible */
                top: 0;
                display: flex;
                flex-direction: column;
                gap: 8px; /* Spacing between control rows */
            }

            .tyc-control-row {
                display: flex;
                flex-wrap: wrap; /* Allow wrapping on smaller screens */
                align-items: center;
                gap: 10px; /* Spacing between controls */
            }

            .tyc-image-container button, .tyc-image-container input[type="text"], .tyc-image-container input[type="number"] {
                border: 1px solid #aaa;
                border-radius: 4px;
                padding: 5px 8px;
                height: 30px;
                font-size: 12px;
                 background-color: #fff;
            }
            .tyc-image-container input[type="text"], .tyc-image-container input[type="number"] {
                max-width: 120px;
             }

            .tyc-image-container button {
                cursor: pointer;
                background-color: #f0f0f0;
                transition: background-color 0.2s ease, color 0.2s ease;
                 white-space: nowrap;
             }

            .tyc-image-container button:hover {
                background-color: #007bff;
                color: #fff;
                border-color: #0056b3;
             }
             .tyc-image-container button:active {
                 background-color: #0056b3;
             }

             .tyc-btn-close {
                position: absolute;
                top: 8px;
                right: 15px;
                font-size: 20px;
                font-weight: bold;
                padding: 0 10px;
                height: 30px;
                line-height: 28px;
                border-radius: 50%;
                background-color: #ddd;
                color: #555;
                border: 1px solid #aaa;
            }
            .tyc-btn-close:hover {
                 background-color: #f56c6c;
                 color: white;
                 border-color: #f56c6c;
            }

             .tyc-image-wrapper {
                padding: 15px;
                display: flex;
                flex-wrap: wrap;
                justify-content: center; /* Center images horizontally */
                align-items: flex-start; /* Align tops of images */
                gap: 10px; /* Spacing between image items */
                flex-grow: 1; /* Take remaining vertical space */
            }

            .tyc-img-item-container {
                border: 2px solid #ccc; /* Default border */
                border-radius: 4px;
                overflow: hidden; /* Contain image and info */
                position: relative; /* For positioning info overlay */
                background-color: #fff;
                display: flex; /* Use flex for centering image if needed */
                flex-direction: column;
                transition: border-color 0.2s ease;
                width: 200px; /* Default width */
                height: 220px; /* Default height including potential info */
             }
             .tyc-img-item-container.selected {
                 border-color: #007bff; /* Highlight selected */
             }

             .tyc-image-preview {
                 display: block; /* Remove extra space below image */
                 width: 100%;
                 height: 180px; /* Fixed height for preview area */
                 object-fit: contain; /* Scale image while preserving aspect ratio */
                 cursor: pointer;
                 background-color: #f0f0f0; /* Placeholder background */
             }
             .tyc-image-preview:hover {
                 opacity: 0.85;
            }
              /* Hide broken image icons */
             .tyc-image-preview[src=""], .tyc-image-preview:not([src]) {
                visibility: hidden; /* Or use background image */
             }
             .tyc-image-preview::before { /* Placeholder text */
                 content: 'Loading...';
                 display: flex;
                 align-items: center;
                 justify-content: center;
                 height: 100%;
                 color: #aaa;
                 font-size: 12px;
                 visibility: visible; /* Ensure placeholder is visible */
              }
              .tyc-image-preview[data-loaded="true"]::before {
                 display: none; /* Hide placeholder when loaded */
             }


             .tyc-image-info-container {
                height: 40px; /* Space for info and buttons */
                background-color: rgba(230, 230, 230, 0.9);
                padding: 5px;
                display: flex;
                justify-content: space-around; /* Distribute items */
                align-items: center;
                font-size: 11px;
                color: #555;
                border-top: 1px solid #ddd;
            }

             .tyc-image-dimensions {
                white-space: nowrap;
             }

            .tyc-img-actions button {
                 background: none;
                 border: none;
                 padding: 2px;
                 cursor: pointer;
                 color: #555;
                 height: auto;
             }
             .tyc-img-actions button:hover {
                 color: #007bff;
                 background: none;
            }
             .tyc-img-actions svg {
                 width: 18px;
                 height: 18px;
                 vertical-align: middle;
             }

            .tyc-input-checkbox { margin-right: 3px; vertical-align: middle; }
            .tyc-label { vertical-align: middle; margin-right: 5px; }

             .tyc-filter-group { display: inline-flex; align-items: center; gap: 5px; }
            .tyc-filter-group input[type="number"] { width: 60px; }

            .tyc-extend-set {
                 border-top: 1px solid #ccc;
                 margin-top: 10px;
                 padding-top: 10px;
                 display: none; /* Hidden by default */
                 flex-direction: column;
                 gap: 10px;
             }
              .tyc-extend-set.visible { display: flex; }

             .tyc-extend-set-container {
                 border: 1px solid #ddd;
                 padding: 10px;
                 border-radius: 4px;
                 background-color: #f8f8f8;
            }

             .tyc-autobigimg-set .tyc-abi-title {
                 display: flex;
                 justify-content: space-between;
                 align-items: center;
                 margin-bottom: 10px;
                 padding-bottom: 5px;
                 border-bottom: 1px solid #ddd;
                 font-weight: bold;
             }
             .tyc-autobigimg-set .tyc-abi-title button { font-size: 11px; padding: 3px 6px; height: auto; }

            .tyc-set-domain {
                 border: 1px solid #ccc;
                 padding: 8px;
                 margin-bottom: 10px;
                 max-height: 200px; /* Limit height */
                 overflow-y: auto;
                 background-color: #fff;
                 display: flex;
                 flex-direction: column;
                 gap: 5px;
             }
              .tyc-set-domain-default { display: none; } /* Default rules hidden initially */
               .tyc-set-domain-default.visible { display: flex; }

            .tyc-set-replacerule {
                 display: flex;
                 align-items: center;
                 gap: 8px;
                 flex-wrap: wrap; /* Allow wrap within rule */
                 padding: 3px 0;
            }
             .tyc-set-replacerule input[type="text"] {
                 flex-grow: 1; /* Allow text inputs to grow */
                 min-width: 150px;
                 font-size: 11px;
             }
             .tyc-set-replacerule input[type="checkbox"] { flex-shrink: 0; }
              .tyc-set-replacerule .tyc-default-tip {
                 font-size: 10px;
                 color: #777;
                 margin-left: auto; /* Push tip to the right */
                 padding-left: 10px;
             }

             .tyc-status-tip {
                 font-weight: bold;
                 color: #333;
            }

             /* Big Image Preview */
             .tyc-show-big-image {
                 position: fixed;
                 inset: 0; /* Cover viewport */
                 background-color: rgba(0, 0, 0, 0.8); /* Dark overlay */
                 display: flex;
                 justify-content: center;
                 align-items: center;
                 z-index: 2147483647; /* Highest z-index */
                 padding: 20px;
                 cursor: pointer; /* Click anywhere to close */
             }
             .tyc-show-big-image img {
                 max-width: 95vw;
                 max-height: 95vh;
                 object-fit: contain;
                 display: block;
                 border: 3px solid white;
                 border-radius: 4px;
                 background-color: white; /* In case of transparent images */
             }
             .tyc-extend-btn svg { transition: transform 0.3s ease; }
             .tyc-extend-btn.extend-open svg { transform: rotate(180deg); }
             #tycfileElem { display: none; } /* Hide file input */
         `;

        const html = `
            <style>${css}</style>
            <div class="tyc-image-container">
                <div class="tyc-control-section">
                    <!-- Row 1: Main Actions & Info -->
                    <div class="tyc-control-row">
                        <input class="tyc-select-all tyc-input-checkbox" type="checkbox" id="tyc-select-all-cb">
                        <label for="tyc-select-all-cb" class="tyc-label">${langSet.selectAll}</label>
                        <button class="tyc-btn-download">${langSet.downloadBtn}</button>
                        <button class="tyc-btn-zipDownload">${langSet.zipDownloadBtn}</button>
                        <button class="tyc-download-url-btn">${langSet.downloadUrlFile}</button>
                         <span class="tyc-status-tip"></span>
                        <span style="margin-left: auto;">${langSet.inputFilenameTip}:</span>
                        <input type="text" class="tyc-file-name" value="${downloadFileNameBase}" style="width: 150px;">
                         <span title="${langSet.shortcutInfo}">${langSet.shortcutInfo}:</span>
                        <input type="text" class="tyc-shortCutString" value="${shortCutString}" style="width: 80px;">
                        <button class="tyc-btn-close" title="Close (Esc)">X</button>
                     </div>

                     <!-- Row 2: Filters & Toggles -->
                     <div class="tyc-control-row">
                        <div class="tyc-filter-group">
                             <input type="checkbox" class="tyc-width-check tyc-input-checkbox" id="tyc-width-check-cb">
                            <label for="tyc-width-check-cb" class="tyc-label">${langSet.filterWidth}</label>
                            <input type="number" class="tyc-width-value-min" min="0" max="99999" value="0">
                            <span>-</span>
                             <input type="number" class="tyc-width-value-max" min="0" max="99999" value="99999">px
                        </div>
                         <div class="tyc-filter-group">
                             <input type="checkbox" class="tyc-height-check tyc-input-checkbox" id="tyc-height-check-cb">
                            <label for="tyc-height-check-cb" class="tyc-label">${langSet.filterHeight}</label>
                            <input type="number" class="tyc-height-value-min" min="0" max="99999" value="0">
                            <span>-</span>
                            <input type="number" class="tyc-height-value-max" min="0" max="99999" value="99999">px
                         </div>
                        <div class="tyc-extra-grab" title="${langSet.extraGrabTooltip}">
                            <input type="checkbox" class="tyc-extra-grab-check tyc-input-checkbox" id="tyc-extra-grab-cb">
                            <label for="tyc-extra-grab-cb" class="tyc-label">${langSet.extraGrab}</label>
                        </div>
                         <button class="tyc-extend-btn" style="margin-left: auto;">
                            ${langSet.moreSetting}
                             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16">
                                <path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
                             </svg>
                         </button>
                     </div>

                     <!-- Row 3: Extended Settings (Hidden by default) -->
                    <div class="tyc-extend-set">
                        <div class="tyc-autobigimg-set tyc-extend-set-container">
                             <div class="tyc-abi-title">
                                 <span>${langSet.autoBigImgModule}</span>
                                <div> <!-- Group buttons -->
                                    <button class="tyc-default-rule-show">${langSet.defaultSettingRule}</button>
                                    <button class="tyc-export-custom-rule">${langSet.exportCustomRule}</button>
                                    <input type="file" id="tycfileElem" accept="text/plain,.json,.js" style="display:none">
                                    <button class="tyc-import-custom-rule">${langSet.importCustomRule}</button>
                                 </div>
                             </div>
                            <div class="tyc-set-domain tyc-set-domain-default">
                                <!-- Default rules populated by JS -->
                            </div>
                             <div class="tyc-set-domain tyc-set-domain-custom">
                                 <!-- Custom rules populated by JS -->
                             </div>
                         </div>
                        <!-- Add other extend settings here if needed -->
                     </div>
                 </div>
                <div class="tyc-image-wrapper">
                    <!-- Images populated by JS -->
                 </div>
            </div>
        `;

        document.body.insertAdjacentHTML("beforeend", html); // Use beforeend to ensure it's appended last

        // Populate rules after creating the container elements
         autoBigImage.showDefaultRules();
         autoBigImage.showRules("tyc-set-domain-custom", "userRules", "userRulesChecked", "tyc-custom-active");
    }

    /**
     * Attaches event listeners to the UI elements.
     */
    function attachEventListeners() {
        const container = document.querySelector(".tyc-image-container");
        if (!container) return;

        // Close button
        container.querySelector(".tyc-btn-close").addEventListener('click', closePanel);

        // Global keydown listener for Esc
        document.addEventListener('keydown', handleEscKey);

        // Select All checkbox
        container.querySelector(".tyc-select-all").addEventListener('change', handleSelectAllChange);

        // Download buttons
        container.querySelector(".tyc-btn-download").addEventListener('click', handleDownloadSelected);
        container.querySelector(".tyc-btn-zipDownload").addEventListener('click', handleZipDownloadSelected);
        container.querySelector(".tyc-download-url-btn").addEventListener('click', handleDownloadUrlList);

        // Filter and toggle checkboxes/inputs
        container.querySelector(".tyc-width-check").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-height-check").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-extra-grab-check").addEventListener('change', handleExtraGrabChange);
        container.querySelector(".tyc-width-value-min").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-width-value-max").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-height-value-min").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-height-value-max").addEventListener('change', handleFilterChange);
        container.querySelector(".tyc-file-name").addEventListener('change', handleFilenameChange);
        container.querySelector(".tyc-shortCutString").addEventListener('change', handleShortcutChange);


        // More Settings toggle
        container.querySelector(".tyc-extend-btn").addEventListener('click', toggleExtendSettings);

        // Auto Big Image Rule Controls
        container.querySelector(".tyc-default-rule-show").addEventListener('click', autoBigImage.onclickShowDefaultBtn);
        container.querySelector(".tyc-export-custom-rule").addEventListener('click', autoBigImage.exportCustomRules);
        container.querySelector(".tyc-import-custom-rule").addEventListener('click', () => document.getElementById('tycfileElem')?.click());
        document.getElementById('tycfileElem')?.addEventListener('change', autoBigImage.getCustomRules.bind(autoBigImage)); // Bind 'this'

         // Listener delegation for rule checkboxes (attach to the containers)
         const defaultRuleContainer = container.querySelector('.tyc-set-domain-default');
         const customRuleContainer = container.querySelector('.tyc-set-domain-custom');
         if (defaultRuleContainer) {
             defaultRuleContainer.addEventListener('change', (e) => {
                 if (e.target.matches('.tyc-default-active')) {
                     autoBigImage.oncheckChange();
                 }
             });
         }
         if (customRuleContainer) {
              customRuleContainer.addEventListener('change', (e) => {
                  if (e.target.matches('.tyc-custom-active')) {
                      autoBigImage.oncheckChangeCustom();
                  }
              });
         }


        // Listener delegation for image clicks and actions within the wrapper
        const imageWrapper = container.querySelector(".tyc-image-wrapper");
        if (imageWrapper) {
            imageWrapper.addEventListener('click', handleImageWrapperClick);
        }
    }

    /**
     * Handles clicks within the image wrapper for selection, fullscreen, and single download.
     */
    function handleImageWrapperClick(event) {
        const target = event.target;
        const imgItemContainer = target.closest('.tyc-img-item-container'); // Find parent container

        if (!imgItemContainer) return; // Clicked outside an image item

        const imageIndex = parseInt(imgItemContainer.dataset.index, 10);
        if (isNaN(imageIndex)) return; // Should not happen

        // Click on action buttons
        if (target.closest('.tyc-action-fullscreen')) {
            showBigImagePreview(filteredImgUrls[imageIndex]);
        } else if (target.closest('.tyc-action-download')) {
            downloadSingleImage(filteredImgUrls[imageIndex], imageIndex);
        }
        // Click on the image preview itself for selection
        else if (target.matches('.tyc-image-preview')) {
            toggleImageSelection(imageIndex, imgItemContainer);
        }
    }

    /**
      * Updates the UI based on the current state (filters, selection).
      */
    function initUI() {
        console.log("Initializing UI...");
        if (!document.querySelector(".tyc-image-container")) {
            console.error("UI container not found during init.");
            return;
        }

        // 1. Apply Filters & Auto Big Image
        applyFiltersAndRules();

        // 2. Reset Selection State (important when filters change)
        imgSelectedIndices = [];
        // zipBase64Sources = {}; // Keep fetched base64 unless explicitly cleared

        // 3. Render Images
        renderImages();

        // 4. Update Status/Counts
        updateSelectionCount();
        updateTotalCount(); // Update total count based on filtered images

        // 5. Load saved settings into UI elements
        loadSettingsToUI();

         // 6. Fetch Base64 for visible images (optional, could be deferred to Zip button click)
        // fetchBase64ForZip(filteredImgUrls); // Consider performance implications
    }

    /**
     * Resets UI elements and internal state, preparing for a fresh image discovery.
     */
    function cleanUI() {
        const container = document.querySelector(".tyc-image-container");
        if (container) {
             const imageWrapper = container.querySelector(".tyc-image-wrapper");
             if (imageWrapper) imageWrapper.innerHTML = ""; // Clear images
             updateStatusTip(""); // Clear status
             updateSelectionCount(); // Reset count display
             updateTotalCount();
        }
        imgSelectedIndices = [];
        filteredImgUrls = [];
        zipBase64Sources = {};
        isFetchingBase64 = false;
         // Don't clear currentImgUrls here, it's done before discovery
    }

    /**
     * Closes the downloader panel and removes listeners.
     */
    function closePanel() {
        const container = document.querySelector(".tyc-image-container");
        if (container) {
            container.remove();
        }
        // Remove global listener
         document.removeEventListener('keydown', handleEscKey);
         console.log("Image Downloader panel closed.");
    }

    /**
     * Handles the Escape key press to close the panel.
     */
    function handleEscKey(event) {
        if (event.key === "Escape") {
            // Close big image preview first if open
             const bigPreview = document.querySelector(".tyc-show-big-image");
             if (bigPreview) {
                 bigPreview.remove();
            } else {
                 closePanel();
            }
        }
    }

    // --- Event Handler Functions ---

    function handleSelectAllChange(event) {
        const isChecked = event.target.checked;
        imgSelectedIndices = []; // Clear previous selection

        if (isChecked) {
             // Select all indices from 0 to length-1
             for (let i = 0; i < filteredImgUrls.length; i++) {
                 imgSelectedIndices.push(i);
             }
         }
        // Else: imgSelectedIndices remains empty

         // Update UI for all items
         const allItems = document.querySelectorAll('.tyc-img-item-container');
         allItems.forEach((item, index) => {
            if (isChecked) {
                 item.classList.add('selected');
             } else {
                 item.classList.remove('selected');
             }
         });

        updateSelectionCount();
    }

     async function handleDownloadSelected() {
        const urlsToDownload = imgSelectedIndices.map(index => filteredImgUrls[index]).filter(Boolean);
        if (urlsToDownload.length === 0) {
             alert(langSet.selectAlert);
             return;
         }

        const baseFilename = document.querySelector('.tyc-file-name')?.value || downloadFileNameBase;
        updateStatusTip(langSet.downloadingImages);
        console.log(`Starting download of ${urlsToDownload.length} images with base name: ${baseFilename}`);

         let downloadedCount = 0;
         for (let i = 0; i < urlsToDownload.length; i++) {
             const url = urlsToDownload[i];
             // Determine file extension
             let extension = 'jpg'; // Default
             try {
                 if (url.startsWith('data:image/')) {
                     const match = url.match(/^data:image\/(\w+);/);
                     extension = match ? match[1].replace('jpeg', 'jpg') : 'png'; // Common types
                      if (extension === 'svg+xml') extension = 'svg';
                  } else {
                     const pathname = new URL(url).pathname;
                     const lastDot = pathname.lastIndexOf('.');
                     if (lastDot !== -1) {
                         extension = pathname.substring(lastDot + 1).toLowerCase().split('?')[0]; // Handle query params
                          // Basic sanitation
                          if (!/^[a-z0-9]+$/.test(extension) || extension.length > 5) {
                             extension = 'jpg';
                         }
                     }
                  }
             } catch (e) { console.warn("Could not determine extension for:", url); }


             const filename = `${baseFilename}_${String(i + 1).padStart(3, '0')}.${extension}`;
             try {
                 // console.log(`Downloading: ${url} as ${filename}`);
                 saveAs(url, filename); // Use FileSaver.js
                 downloadedCount++;
                 updateStatusTip(`${langSet.downloadingImages} (${downloadedCount}/${urlsToDownload.length})`);
                 await sleep(200); // Small delay between downloads to avoid browser blocking
             } catch (error) {
                 console.error(`Failed to initiate download for: ${url}`, error);
                  updateStatusTip(`Error downloading image ${i + 1}. Check console.`);
                 await sleep(500); // Longer pause on error
             }
         }
        updateStatusTip(langSet.downloadComplete + ` (${downloadedCount}/${urlsToDownload.length})`);
        console.log("Download process finished.");
    }

    async function handleZipDownloadSelected() {
        const selectedIndicesForZip = [...imgSelectedIndices]; // Copy selection at time of click
        const urlsToZip = selectedIndicesForZip.map(index => filteredImgUrls[index]).filter(Boolean);

         if (urlsToZip.length === 0) {
             alert(langSet.selectAlert);
             return;
         }

        if (isFetchingBase64) {
             alert("Still fetching image data for zipping, please wait.");
             return;
         }

        updateStatusTip(langSet.preparingZip);
        isFetchingBase64 = true;
        console.log(`Starting ZIP process for ${urlsToZip.length} images.`);

        const baseFilename = document.querySelector('.tyc-file-name')?.value || downloadFileNameBase;
        const zip = new JSZip();
        const zipFolder = zip.folder(baseFilename); // Create folder inside zip named after base filename

        let processedCount = 0;
        const totalToProcess = urlsToZip.length;

        const fetchPromises = urlsToZip.map((url, index) => {
            return new Promise(async (resolve, reject) => {
                 const originalIndex = selectedIndicesForZip[index]; // Keep track of original index for filename
                try {
                    const base64Data = await getBase64ForZip(url); // Fetch or retrieve cached
                    if (base64Data) {
                        let extension = 'jpg';
                        const match = base64Data.match(/^data:image\/(\w+);/);
                         extension = match ? match[1].replace('jpeg', 'jpg') : 'png';
                         if (extension === 'svg+xml') extension = 'svg';

                        const filename = `${String(originalIndex + 1).padStart(3, '0')}.${extension}`; // Use original order index
                        zipFolder.file(filename, base64Data.split(',')[1], { base64: true });
                    } else {
                         console.warn(`Skipping invalid/unfetchable URL for ZIP: ${url}`);
                    }
                 } catch (error) {
                    console.error(`Error processing URL for ZIP: ${url}`, error);
                 } finally {
                     processedCount++;
                     updateStatusTip(`${langSet.preparingZip} (${processedCount}/${totalToProcess})`);
                     resolve(); // Resolve promise even on failure to not block Promise.all
                 }
             });
         });

         try {
             await Promise.all(fetchPromises); // Wait for all fetches/conversions

             if (Object.keys(zipFolder.files).length === 0) {
                alert("No images could be added to the ZIP file. Check console for errors.");
                updateStatusTip("ZIP creation failed.");
                 return;
            }

             updateStatusTip(langSet.zipReady + " Generating file...");
             console.log("Generating ZIP blob...");

             zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } })
                 .then(function (content) {
                     console.log("ZIP blob generated, saving...");
                     saveAs(content, `${baseFilename}.zip`);
                     updateStatusTip("ZIP download initiated!");
                     // Optionally clear cache if needed: zipBase64Sources = {};
                 })
                 .catch(err => {
                     console.error("Error generating ZIP:", err);
                     alert(`Error generating ZIP file: ${err.message}`);
                     updateStatusTip("ZIP generation failed.");
                 });
         } catch (error) {
             console.error("Error during ZIP processing:", error);
             updateStatusTip("ZIP creation failed.");
         } finally {
             isFetchingBase64 = false;
         }
    }

    /**
      * Fetches an image URL and returns its Base64 representation. Uses cache.
      */
     function getBase64ForZip(url) {
         return new Promise((resolve, reject) => {
             if (!url || typeof url !== 'string') {
                 resolve(null); // Invalid URL
                 return;
             }

             // Return immediately if it's already a Base64 string
             if (url.startsWith('data:image/')) {
                 resolve(url);
                 return;
             }

             // Check cache
             if (zipBase64Sources[url]) {
                 resolve(zipBase64Sources[url]);
                 return;
             }

             // Fetch using GM_xmlhttpRequest for CORS
             try {
                 GM_xmlhttpRequest({
                     method: "GET",
                     url: url,
                     responseType: "blob",
                      headers: {
                        // Add Referer if needed, often helps
                        Referer: window.location.origin
                    },
                     onload: function (response) {
                         if (response.status >= 200 && response.status < 300) {
                             const blob = response.response;
                             const reader = new FileReader();
                             reader.onloadend = function () {
                                 const base64 = reader.result;
                                 if (typeof base64 === 'string' && base64.startsWith('data:image')) {
                                      zipBase64Sources[url] = base64; // Cache result
                                      resolve(base64);
                                 } else {
                                     console.warn("Failed to read blob as base64 for:", url);
                                     resolve(null); // Indicate failure to convert
                                 }
                             };
                             reader.onerror = function(e) {
                                 console.error("FileReader error for blob:", url, e);
                                 resolve(null); // Indicate read failure
                             };
                             reader.readAsDataURL(blob);
                         } else {
                             console.warn(`GM_xmlhttpRequest failed for ${url}, status: ${response.status}`);
                             resolve(null); // Indicate fetch failure
                         }
                     },
                     onerror: function (error) {
                         console.error(`GM_xmlhttpRequest error for ${url}:`, error);
                         resolve(null); // Indicate network error
                     },
                     ontimeout: function() {
                        console.warn(`GM_xmlhttpRequest timed out for ${url}`);
                         resolve(null);
                    }
                 });
             } catch (e) {
                 console.error(`Error initiating GM_xmlhttpRequest for ${url}:`, e);
                 resolve(null); // Indicate initiation error
             }
         });
     }

    function handleDownloadUrlList() {
        const urlsToDownload = imgSelectedIndices.map(index => filteredImgUrls[index]).filter(Boolean);
         if (urlsToDownload.length === 0) {
             alert(langSet.selectAlert);
             return;
         }

        const urlText = urlsToDownload.join("\n");
        const blob = new Blob([urlText], { type: "text/plain;charset=utf-8" });
         const baseFilename = document.querySelector('.tyc-file-name')?.value || downloadFileNameBase;
        saveAs(blob, `${baseFilename}_urls.txt`);
    }

    function handleFilterChange() {
        // Save the setting
        const target = event.target;
        if (target.type === 'checkbox') {
            GM_setValue(target.classList[0], target.checked); // e.g., tyc-width-check
        } else if (target.type === 'number') {
            GM_setValue(target.classList[0], target.value); // e.g., tyc-width-value-min
        }
        // Re-initialize the UI to apply filters
        initUI();
    }

     function handleExtraGrabChange(event) {
         const isChecked = event.target.checked;
         GM_setValue('tyc-extra-grab-check', isChecked);
         alert(langSet.extraGrabTooltip); // Inform user about refresh requirement
         // Actual enabling/disabling happens on page load based on saved value
     }


    function handleFilenameChange(event) {
         downloadFileNameBase = event.target.value;
         // No need to save this to GM_setValue unless persistence is desired
    }

    function handleShortcutChange(event) {
        const newShortcut = event.target.value.toLowerCase();
        if (newShortcut && newShortcut !== shortCutString) {
             try {
                 // Unbind old shortcut
                 hotkeys.unbind(shortCutString, shortcutFunction);
                 // Bind new shortcut
                 hotkeys(newShortcut, shortcutFunction);
                 shortCutString = newShortcut;
                 GM_setValue("shortCutString", shortCutString);
                 console.log("Shortcut updated to:", shortCutString);
             } catch (e) {
                 console.error("Failed to update shortcut:", e);
                 alert(`Failed to set shortcut "${newShortcut}". It might be invalid or already in use.`);
                 // Revert UI and variable
                 event.target.value = shortCutString;
             }
         } else {
            event.target.value = shortCutString; // Revert if invalid or same
        }
    }

    function toggleExtendSettings() {
        const extendSet = document.querySelector(".tyc-extend-set");
        const button = document.querySelector(".tyc-extend-btn");
         const svg = button.querySelector('svg');

        if (extendSet && button && svg) {
             const isOpen = extendSet.classList.toggle('visible');
             button.classList.toggle('extend-open', isOpen);
             svg.style.transform = isOpen ? 'rotate(180deg)' : 'rotate(0deg)';
              button.querySelector('span').textContent = isOpen ? langSet.fold : langSet.moreSetting;

             // Adjust image wrapper margin-top dynamically
             adjustWrapperMargin();
         }
    }

    /**
     * Toggles the selection state of an image.
     */
    function toggleImageSelection(index, containerElement) {
         const selectedIndexPosition = imgSelectedIndices.indexOf(index);

         if (selectedIndexPosition > -1) {
             // Deselect
             imgSelectedIndices.splice(selectedIndexPosition, 1);
             containerElement.classList.remove('selected');
         } else {
             // Select
             imgSelectedIndices.push(index);
             containerElement.classList.add('selected');
         }

        updateSelectionCount();
        // Update "Select All" checkbox state
        const selectAllCheckbox = document.querySelector('.tyc-select-all');
         if (selectAllCheckbox) {
             selectAllCheckbox.checked = imgSelectedIndices.length === filteredImgUrls.length && filteredImgUrls.length > 0;
         }
    }

     // --- UI Update Functions ---

     function adjustWrapperMargin() {
          const controlSection = document.querySelector('.tyc-control-section');
          const imageWrapper = document.querySelector('.tyc-image-wrapper');
          if (controlSection && imageWrapper) {
              // Setting margin directly might conflict with fixed positioning. Padding might be better.
              // Let's recalculate necessary top padding/margin for the wrapper
              // Since control section is sticky, wrapper just needs enough space below it.
              // Using padding on the container might be better.
              const container = document.querySelector('.tyc-image-container');
              if (container) {
                  const controlHeight = controlSection.offsetHeight;
                   // container.style.paddingTop = `${controlHeight}px`;
              }
          }
      }

      function updateStatusTip(message) {
          const tipElement = document.querySelector(".tyc-status-tip");
          if (tipElement) {
              tipElement.textContent = message;
          }
      }

    function updateSelectionCount() {
        const count = imgSelectedIndices.length;
        const total = filteredImgUrls.length;
         const message = `${langSet.fetchDoneTip1Type2}${count}/${total}${langSet.fetchDoneTip2}`;
         updateStatusTip(message); // Use main status tip area
    }

    function updateTotalCount() {
        // Could add a separate element or prepend to status tip
        // Example: Prepending to status tip (might get overwritten)
        // const totalMessage = `${langSet.totalFound}${filteredImgUrls.length}${langSet.images}. `;
        // const currentTip = document.querySelector(".tyc-status-tip")?.textContent || '';
        // updateStatusTip(totalMessage + currentTip);
        // Or just rely on the selection count's total part.
    }


    /**
     * Loads saved settings from GM_setValue into the UI elements.
     */
    function loadSettingsToUI() {
        const container = document.querySelector(".tyc-image-container");
        if (!container) return;

        // Filters
        container.querySelector(".tyc-width-check").checked = GM_getValue("tyc-width-check", false);
        container.querySelector(".tyc-height-check").checked = GM_getValue("tyc-height-check", false);
        container.querySelector(".tyc-width-value-min").value = GM_getValue("tyc-width-value-min", "0");
        container.querySelector(".tyc-width-value-max").value = GM_getValue("tyc-width-value-max", "99999");
        container.querySelector(".tyc-height-value-min").value = GM_getValue("tyc-height-value-min", "0");
        container.querySelector(".tyc-height-value-max").value = GM_getValue("tyc-height-value-max", "99999");

         // Extra Grab
         container.querySelector(".tyc-extra-grab-check").checked = GM_getValue("tyc-extra-grab-check", false);

        // Shortcut
        container.querySelector(".tyc-shortCutString").value = shortCutString; // Already loaded into variable
    }

     /**
      * Applies filters (width/height) and auto-big-image rules to currentImgUrls.
      */
      function applyFiltersAndRules() {
          // 1. Start with all discovered URLs
          let tempFiltered = [...currentImgUrls];

          // 2. Apply Auto Big Image rules (potentially adds more URLs)
          tempFiltered = autoBigImage.getBigImageArray(tempFiltered);
          console.log(`After AutoBigImage: ${tempFiltered.length} URLs`);


          // 3. Apply Dimension Filters (Width/Height)
          // Need to load images temporarily to check dimensions - this can be slow!
          // Consider adding a loading state or doing this async?
          // For now, implement synchronously as in the original.
          const checkWidth = GM_getValue("tyc-width-check", false);
          const minWidth = parseInt(GM_getValue("tyc-width-value-min", "0"), 10);
          const maxWidth = parseInt(GM_getValue("tyc-width-value-max", "99999"), 10);
          const checkHeight = GM_getValue("tyc-height-check", false);
          const minHeight = parseInt(GM_getValue("tyc-height-value-min", "0"), 10);
          const maxHeight = parseInt(GM_getValue("tyc-height-value-max", "99999"), 10);

           if (checkWidth || checkHeight) {
               console.log("Applying dimension filters...");
               // This synchronous filtering can lock the UI for many images.
               // An async approach would be better for UX but more complex.
                tempFiltered = tempFiltered.filter(url => {
                   try {
                        // Skip filtering for invalid URLs
                       if (!url || typeof url !== 'string' || (!url.startsWith('http') && !url.startsWith('data:'))) {
                            return true; // Keep potentially invalid URLs for now? Or filter? Let's keep.
                        }

                       // Create temporary image - may not work reliably for all URLs in a script context
                       // without actually adding to DOM or waiting for onload.
                       // This part is inherently unreliable without async loading checks.
                       // We'll use naturalWidth/Height which might be 0 if not loaded.
                       const img = new Image();
                       img.src = url; // Assign src, but dimension might not be available yet

                       // Warning: The following check relies on browser caching or immediate dimension availability,
                       // which is NOT guaranteed. This filtering step is inherently flawed without async handling.
                        let width = img.naturalWidth || img.width; // Use naturalWidth if available
                        let height = img.naturalHeight || img.height;

                        // Very basic check: if dimensions are 0, maybe skip filtering it?
                        // Or assume it passes? Let's assume it passes if dimensions are unknown (0).
                        if (width === 0 && height === 0 && !url.startsWith('data:')) {
                             // console.log(`Dimensions unknown for ${url}, keeping.`);
                             return true;
                         }

                        const widthOk = !checkWidth || (width >= minWidth && width <= maxWidth);
                        const heightOk = !checkHeight || (height >= minHeight && height <= maxHeight);

                       // If dimensions were resolved and filters applied:
                       // console.log(`Filtering ${url}: ${width}x${height} -> WidthOK:${widthOk}, HeightOK:${heightOk}`);

                       return widthOk && heightOk;
                   } catch (e) {
                       console.warn(`Error filtering image by dimension: ${url}`, e);
                       return true; // Keep image if filtering fails
                   }
               });
               console.log(`After dimension filters: ${tempFiltered.length} URLs`);
           }

           // 4. Final unique URLs
           filteredImgUrls = Array.from(new Set(tempFiltered)).filter(Boolean);
           console.log(`Final filtered count: ${filteredImgUrls.length}`);
       }


    /**
     * Renders the filtered images in the UI.
     */
    function renderImages() {
        const imageWrapper = document.querySelector(".tyc-image-wrapper");
        if (!imageWrapper) return;

        imageWrapper.innerHTML = ''; // Clear previous images
        const fragment = document.createDocumentFragment();

        if (filteredImgUrls.length === 0) {
            imageWrapper.textContent = "No images found matching criteria.";
            return;
        }

        filteredImgUrls.forEach((imgUrl, index) => {
             if (!imgUrl || typeof imgUrl !== 'string') return; // Skip invalid entries

            const itemContainer = document.createElement('div');
            itemContainer.className = 'tyc-img-item-container';
            itemContainer.dataset.index = index; // Store index

            const imgPreview = document.createElement('img');
            imgPreview.className = 'tyc-image-preview';
            imgPreview.loading = 'lazy'; // Lazy load images
             imgPreview.dataset.loaded = 'false';

             // Handle image loading and error states
             imgPreview.onload = () => {
                 imgPreview.dataset.loaded = 'true'; // Mark as loaded
                 const dimensions = `${imgPreview.naturalWidth}x${imgPreview.naturalHeight}`;
                  const dimElement = itemContainer.querySelector('.tyc-image-dimensions');
                  if (dimElement) dimElement.textContent = dimensions;
                  // Maybe re-apply filters here if needed based on loaded dimensions? Complex.
              };
              imgPreview.onerror = () => {
                  imgPreview.alt = 'Image failed to load';
                  imgPreview.dataset.loaded = 'error'; // Mark as error
                  // itemContainer.style.display = 'none'; // Option: hide broken images
                  const dimElement = itemContainer.querySelector('.tyc-image-dimensions');
                   if (dimElement) dimElement.textContent = 'Error';
                   // Add a visual indicator for error
                   imgPreview.style.filter = 'grayscale(100%) opacity(50%)';
                   imgPreview.style.border = '2px dashed red';

              };

             // Set src *after* attaching listeners
              imgPreview.src = imgUrl;

            itemContainer.appendChild(imgPreview);

            // Info container
            const infoContainer = document.createElement('div');
            infoContainer.className = 'tyc-image-info-container';

            // Dimensions placeholder
            const dimensionsSpan = document.createElement('span');
            dimensionsSpan.className = 'tyc-image-dimensions';
             dimensionsSpan.textContent = 'Loading...'; // Placeholder until onload

            // Action buttons
            const actionsDiv = document.createElement('div');
            actionsDiv.className = 'tyc-img-actions';
            actionsDiv.innerHTML = `
                <button class="tyc-action-fullscreen" title="View Fullscreen">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707zm4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707zm0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707zm-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707z"/></svg>
                </button>
                <button class="tyc-action-download" title="Download This Image">
                     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
                </button>
            `;

            infoContainer.appendChild(dimensionsSpan);
            infoContainer.appendChild(actionsDiv);
            itemContainer.appendChild(infoContainer);

            fragment.appendChild(itemContainer);
        });

        imageWrapper.appendChild(fragment);
        adjustWrapperMargin(); // Adjust margin after rendering
    }


    /**
     * Shows the large image preview overlay.
     */
    function showBigImagePreview(imageUrl) {
        // Remove existing preview first
        const existingPreview = document.querySelector(".tyc-show-big-image");
        if (existingPreview) {
            existingPreview.remove();
        }

        const previewContainer = document.createElement('div');
        previewContainer.className = 'tyc-show-big-image';
        previewContainer.title = 'Click to close'; // Tooltip

        const img = document.createElement('img');
        img.src = imageUrl;
        img.alt = 'Large Preview';

        previewContainer.appendChild(img);

        // Click anywhere on the overlay (or the image) to close
        previewContainer.addEventListener('click', () => {
            previewContainer.remove();
        });

        document.body.appendChild(previewContainer);
    }

    /**
     * Downloads a single image using FileSaver.
     */
    function downloadSingleImage(url, index) {
        if (!url) return;
        const baseFilename = document.querySelector('.tyc-file-name')?.value || downloadFileNameBase;
         // Determine file extension (same logic as batch download)
         let extension = 'jpg';
         try {
             if (url.startsWith('data:image/')) {
                 const match = url.match(/^data:image\/(\w+);/);
                 extension = match ? match[1].replace('jpeg', 'jpg') : 'png';
                 if (extension === 'svg+xml') extension = 'svg';
             } else {
                 const pathname = new URL(url).pathname;
                 const lastDot = pathname.lastIndexOf('.');
                 if (lastDot !== -1) {
                     extension = pathname.substring(lastDot + 1).toLowerCase().split('?')[0];
                      if (!/^[a-z0-9]+$/.test(extension) || extension.length > 5) extension = 'jpg';
                  }
             }
         } catch (e) { console.warn("Could not determine extension for single download:", url); }

        const filename = `${baseFilename}_${String(index + 1).padStart(3, '0')}.${extension}`;
        try {
            console.log(`Downloading single: ${url} as ${filename}`);
            saveAs(url, filename);
         } catch (error) {
             console.error(`Failed to initiate download for: ${url}`, error);
             alert(`Failed to download image: ${error.message}`);
         }
    }


    // --- Main Execution Flow ---

    /**
     * The main function called by the menu command or shortcut.
     */
    async function wrapper() {
        console.log("Image Downloader activated.");

        // 1. Setup initial state and variables
        setupVariables();

        // 2. Check if panel already exists, toggle if it does
        const existingPanel = document.querySelector(".tyc-image-container");
        if (existingPanel) {
            closePanel();
            return;
        }

        // 3. Create the basic UI structure
        createUI();

        // 4. Discover images (including async canvas handling)
        updateStatusTip(langSet.fetchTip); // Initial status
        await discoverImages(); // Wait for discovery (including canvas) to complete

        // 5. Initialize UI (apply filters, render images, load settings)
        initUI(); // This now applies filters, renders, and updates counts

        // 6. Attach all event listeners
        attachEventListeners();

        console.log("Image Downloader panel ready.");
    }

    /**
     * Function called by the shortcut key.
     */
    function shortcutFunction(event, handler) {
        event.preventDefault(); // Prevent default browser action for the shortcut
        wrapper();
    }

    // --- Script Initialization ---

    // Register menu command
    GM_registerMenuCommand(langSet.downloadMenuText, wrapper);

    // Register shortcut
    try {
        shortCutString = GM_getValue("shortCutString") || "alt+w";
        hotkeys(shortCutString, shortcutFunction);
     } catch(e) {
         console.error("Failed to register shortcut:", e);
         // Fallback or default if hotkeys lib failed or shortcut invalid
         shortCutString = "alt+w";
         try { hotkeys(shortCutString, shortcutFunction); } catch(e2) { console.error("Fallback shortcut failed too:", e2); }
    }


    // Enable 'Extra Grab' on page load if the setting is checked
    if (GM_getValue("tyc-extra-grab-check", false)) {
        enableExtraGrab();
    } else {
        // Ensure it's disabled if setting is off (in case it was left on)
        // This requires the script to run early enough, @run-at document-start might be better
        // but can cause issues finding elements. Sticking with document-end for now.
        // disableExtraGrab(); // This might be too late if script runs at document-end
    }

     // Clean up listener on unload? Tampermonkey usually handles this, but good practice:
     window.addEventListener('unload', () => {
         try { hotkeys.unbind(shortCutString, shortcutFunction); } catch(e) {}
         document.removeEventListener('keydown', handleEscKey);
         if (GM_getValue("tyc-extra-grab-check", false)) {
             // Attempt to restore original descriptor on unload, might not always work
             // disableExtraGrab();
         }
     });

})();