// ==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/hotkeys-js@3.9.4/dist/hotkeys.min.js // @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js // @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js // @run-at document-end // @homepageURL https://github.com/taoyuancun123/modifyText/blob/master/modifyText.js // @supportURL https://greasyfork.org/zh-CN/scripts/419894/feedback // @license GPLv3 // @downloadURL https://update.greasyfork.icu/scripts/532788/%E5%9B%BE%E7%89%87%E4%B8%8B%E8%BD%BD%E5%99%A8.user.js // @updateURL https://update.greasyfork.icu/scripts/532788/%E5%9B%BE%E7%89%87%E4%B8%8B%E8%BD%BD%E5%99%A8.meta.js // ==/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 = `
${v.tip || ''}
`; 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 = `
${v.tip || ''}
`; 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } 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 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 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", ``); } 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 = `
${langSet.inputFilenameTip}: ${langSet.shortcutInfo}:
- px
- px
${langSet.autoBigImgModule}
`; 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 = ` `; 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(); } }); })();