// ==UserScript== // @name 电商网页图片抓取下载器 Pro // @namespace ecommerce-image-downloader-pro // @version 4.0.0 // @description 电商网页图片抓取、自动分类主图/详情图/SKU图、勾选、拖动、文件夹下载、分类ZIP // @author ChatGPT // @match *://*/* // @grant GM_download // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // @connect * // @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ========================================================= // 配置 // ========================================================= const CONFIG = { // 扫描时过滤特别小的图 filterSmallImages: true, minWidth: 180, minHeight: 180, // “只选大图” largeWidth: 800, largeHeight: 800, // 普通下载间隔 downloadDelay: 250, // ZIP同时抓取数量 zipConcurrency: 5, // ZIP是否按照分类建立文件夹 zipUseCategoryFolders: true, // 普通下载文件夹名 folderPrefix: '电商图片', // 文件名 filePrefix: 'image', // 网页标题最大长度 maxTitleLength: 55 }; const CATEGORY = { main: '主图', detail: '详情图', sku: 'SKU图', other: '其他' }; let scannedImages = []; let modal = null; let currentFilter = 'all'; // ========================================================= // 工具函数 // ========================================================= function sleep(ms) { return new Promise( resolve => setTimeout(resolve, ms) ); } function pad(num) { return String(num).padStart(2, '0'); } function sanitizeFileName(name, maxLength = 100) { let result = String(name || '') .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_') .replace(/\s+/g, ' ') .replace(/_+/g, '_') .trim() .replace(/[. ]+$/g, ''); if (!result) { result = 'untitled'; } if (result.length > maxLength) { result = result .substring(0, maxLength) .trim(); } return result; } function getDateTimeString() { const d = new Date(); return ( d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '_' + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) ); } function createPackageName() { const title = sanitizeFileName( document.title || location.hostname, CONFIG.maxTitleLength ); return sanitizeFileName( `${CONFIG.folderPrefix}_${title}_${getDateTimeString()}`, 120 ); } function normalizeUrl(url) { if (!url) return null; url = String(url).trim(); if ( url.startsWith('data:') || url.startsWith('javascript:') || url.startsWith('blob:') ) { return null; } try { return new URL( url, location.href ).href; } catch { return null; } } function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); } function formatNumber(num) { return Number( num || 0 ).toLocaleString(); } // ========================================================= // 获取元素环境信息 // 用于判断主图 / SKU / 详情 // ========================================================= function getElementContext(el) { if (!el) return ''; const parts = []; let current = el; for ( let level = 0; level < 4 && current; level++ ) { try { parts.push( current.tagName || '' ); parts.push( current.id || '' ); if ( typeof current.className === 'string' ) { parts.push( current.className ); } [ 'role', 'title', 'aria-label', 'alt', 'data-type', 'data-role', 'data-testid', 'data-sku', 'data-spm' ].forEach(attr => { const value = current.getAttribute?.(attr); if (value) { parts.push(value); } }); } catch {} current = current.parentElement; } return parts .join(' ') .toLowerCase(); } // ========================================================= // srcset 最大图片 // ========================================================= function getBestSrcset(srcset) { if (!srcset) { return null; } const list = srcset .split(',') .map(item => { const parts = item.trim().split(/\s+/); let score = 0; if (parts[1]) { if ( parts[1].endsWith('w') ) { score = parseInt(parts[1]) || 0; } else if ( parts[1].endsWith('x') ) { score = (parseFloat(parts[1]) || 0) * 10000; } } return { url: parts[0], score }; }) .filter(item => item.url); if (!list.length) { return null; } list.sort( (a, b) => b.score - a.score ); return list[0].url; } // ========================================================= // 扩展名 // ========================================================= function getExtension(url) { try { const path = new URL(url).pathname; const match = path.match( /\.(jpg|jpeg|png|webp|gif|bmp|avif|svg)$/i ); if (match) { let ext = match[1].toLowerCase(); if (ext === 'jpeg') { ext = 'jpg'; } return ext; } } catch {} return 'jpg'; } function extensionFromContentType( contentType, fallback = 'jpg' ) { const type = String(contentType || '') .toLowerCase(); if (type.includes('image/jpeg')) return 'jpg'; if (type.includes('image/png')) return 'png'; if (type.includes('image/webp')) return 'webp'; if (type.includes('image/gif')) return 'gif'; if (type.includes('image/avif')) return 'avif'; if (type.includes('image/bmp')) return 'bmp'; if (type.includes('image/svg')) return 'svg'; return fallback; } function parseContentType(headers) { const match = String(headers || '') .match( /content-type:\s*([^\r\n;]+)/i ); return match ? match[1].trim() : ''; } // ========================================================= // 添加图片 // ========================================================= function addImage( result, url, info = {} ) { const normalized = normalizeUrl(url); if (!normalized) { return; } const old = result.get(normalized); if (old) { if ( info.context && !old.context.includes( info.context ) ) { old.context += ' ' + info.context; } if ( info.domIndex !== undefined && ( old.domIndex === undefined || info.domIndex < old.domIndex ) ) { old.domIndex = info.domIndex; } const newArea = (info.width || 0) * (info.height || 0); const oldArea = (old.width || 0) * (old.height || 0); if (newArea > oldArea) { old.width = info.width || old.width; old.height = info.height || old.height; } return; } result.set( normalized, { url: normalized, width: info.width || 0, height: info.height || 0, source: info.source || 'unknown', context: info.context || '', domIndex: info.domIndex ?? 999999, selected: true, category: 'other', classification: 'auto', confidence: 0 } ); } // ========================================================= // IMG // ========================================================= function collectImgTags(result) { const elements = document.querySelectorAll('img'); elements.forEach( (img, domIndex) => { const width = img.naturalWidth || img.width || 0; const height = img.naturalHeight || img.height || 0; const context = getElementContext(img); // srcset const srcset = img.getAttribute('srcset') || img.getAttribute( 'data-srcset' ); const best = getBestSrcset(srcset); if (best) { addImage( result, best, { width, height, context, domIndex, source: 'srcset' } ); } [ 'src', 'data-src', 'data-original', 'data-lazy-src', 'data-ks-lazyload', 'data-url', 'data-image', 'data-img', 'data-lazy', 'data-original-src', 'data-zoom-image', 'data-large', 'data-large-image' ].forEach(attr => { const value = img.getAttribute(attr); if (!value) { return; } addImage( result, value, { width, height, context, domIndex, source: attr } ); }); } ); } // ========================================================= // picture // ========================================================= function collectPictureTags(result) { document .querySelectorAll( 'picture source, source[srcset]' ) .forEach( (source, index) => { const best = getBestSrcset( source.getAttribute( 'srcset' ) ); if (!best) { return; } addImage( result, best, { context: getElementContext( source ), domIndex: index, source: 'picture' } ); } ); } // ========================================================= // background-image // ========================================================= function collectBackgroundImages(result) { const elements = document.querySelectorAll('*'); elements.forEach( (el, index) => { let background; try { background = getComputedStyle(el) .backgroundImage; } catch { return; } if ( !background || background === 'none' ) { return; } const matches = [ ...background.matchAll( /url\(["']?(.*?)["']?\)/g ) ]; matches.forEach( match => { const rect = el.getBoundingClientRect(); addImage( result, match[1], { width: Math.round( rect.width || 0 ), height: Math.round( rect.height || 0 ), context: getElementContext(el), domIndex: index + 10000, source: 'background' } ); } ); } ); } // ========================================================= // 图片尺寸 // ========================================================= function checkImageSize(url) { return new Promise( resolve => { const image = new Image(); image.onload = () => { resolve({ width: image.naturalWidth || 0, height: image.naturalHeight || 0 }); }; image.onerror = () => { resolve({ width: 0, height: 0 }); }; image.src = url; } ); } async function mapWithConcurrency( items, limit, worker ) { let index = 0; async function run() { while ( index < items.length ) { const current = index++; await worker( items[current], current ); } } const workers = []; for ( let i = 0; i < Math.min( limit, items.length ); i++ ) { workers.push( run() ); } await Promise.all( workers ); } // ========================================================= // 自动分类算法 // ========================================================= function classifyImage(image) { const scores = { main: 0, detail: 0, sku: 0, other: 0 }; const context = String( image.context || '' ).toLowerCase(); const url = String( image.url || '' ).toLowerCase(); const width = image.width || 0; const height = image.height || 0; const ratio = width && height ? height / width : 1; // ===================================================== // SKU / 规格图特征 // ===================================================== if ( /sku|variant|variation|swatch|spec|property|prop-item|sku-item|sku-list|sku-image|规格|属性|款式|颜色选择|色卡/.test( context ) ) { scores.sku += 12; } if ( /(?:sku|variant|variation|swatch)[-_/.]/.test( url ) ) { scores.sku += 8; } // 小型方图 + SKU环境 if ( width >= 40 && width <= 700 && height >= 40 && height <= 700 && ratio >= 0.7 && ratio <= 1.3 && /color|colour|option|选择|颜色|规格/.test( context ) ) { scores.sku += 6; } // ===================================================== // 详情页特征 // ===================================================== if ( /detail|description|desc-|desc_|richtext|rich-text|rich-content|product-detail|detail-content|detail-image|详情|图文详情|商品详情|产品详情/.test( context ) ) { scores.detail += 12; } if ( /detail|description|desc|richcontent/.test( url ) ) { scores.detail += 5; } // 长图非常像详情页 if ( width >= 500 && height >= 900 && ratio >= 1.45 ) { scores.detail += 7; } if ( width >= 700 && height >= 1600 ) { scores.detail += 6; } // ===================================================== // 主图 / Gallery 特征 // ===================================================== if ( /gallery|carousel|swiper|slider|preview|magnifier|zoom|main-image|main_image|product-image|product_image|image-gallery|thumb-list|thumbnail-list|主图|轮播|大图预览|商品图/.test( context ) ) { scores.main += 11; } if ( /main[-_]?image|gallery|product[-_]?image/.test( url ) ) { scores.main += 4; } // 大型近方图 if ( width >= 700 && height >= 700 && ratio >= 0.75 && ratio <= 1.35 ) { scores.main += 4; } // 页面靠前的大图通常更可能是主图 if ( image.domIndex <= 15 && width >= 500 && height >= 500 && ratio >= 0.7 && ratio <= 1.4 ) { scores.main += 4; } if ( image.domIndex <= 6 && width >= 700 && height >= 700 ) { scores.main += 3; } // ===================================================== // 其他 // ===================================================== if ( image.source === 'background' ) { scores.other += 1; } if ( width < 400 || height < 400 ) { scores.other += 1; } // ===================================================== // 选择最高分 // ===================================================== const ranked = Object.entries(scores) .sort( (a, b) => b[1] - a[1] ); let category = ranked[0][0]; const best = ranked[0][1]; const second = ranked[1][1]; // 没有足够证据 → 其他 if (best < 4) { category = 'other'; } return { category, confidence: Math.max( 0, best - second ), scores }; } function classifyAllImages(images) { images.forEach( image => { const result = classifyImage(image); image.category = result.category; image.confidence = result.confidence; image.classification = 'auto'; } ); } // ========================================================= // 扫描 // ========================================================= async function collectImages( statusCallback ) { const result = new Map(); collectImgTags(result); collectPictureTags(result); collectBackgroundImages(result); let images = Array.from( result.values() ); statusCallback?.( `发现 ${images.length} 张,正在分析分辨率...` ); await mapWithConcurrency( images, 8, async ( image, index ) => { if ( image.width >= CONFIG.minWidth && image.height >= CONFIG.minHeight ) { return; } const size = await checkImageSize( image.url ); if (size.width) { image.width = size.width; } if (size.height) { image.height = size.height; } if ( index % 10 === 0 ) { statusCallback?.( `分析图片 ${index + 1}/${images.length}` ); } } ); if ( CONFIG.filterSmallImages ) { images = images.filter( image => image.width >= CONFIG.minWidth && image.height >= CONFIG.minHeight ); } statusCallback?.( '正在识别主图 / 详情图 / SKU图...' ); classifyAllImages( images ); // 默认按页面顺序 images.sort( (a, b) => a.domIndex - b.domIndex ); images.forEach( (image, index) => { image.id = index; image.originalIndex = index; image.selected = true; } ); return images; } // ========================================================= // 拖动按钮位置 // ========================================================= function loadButtonPosition() { try { const value = GM_getValue( 'ecommerceImageButtonPosition', '' ); return value ? JSON.parse(value) : null; } catch { return null; } } function saveButtonPosition( left, top ) { GM_setValue( 'ecommerceImageButtonPosition', JSON.stringify({ left, top }) ); } // ========================================================= // 悬浮按钮 // ========================================================= function createMainButton() { if ( document.getElementById( 'ecommerce-image-downloader-button' ) ) { return; } const button = document.createElement( 'button' ); button.id = 'ecommerce-image-downloader-button'; button.textContent = '抓取本页图片'; Object.assign( button.style, { position: 'fixed', zIndex: '2147483646', padding: '12px 18px', background: '#1677ff', color: '#fff', border: '0', borderRadius: '9px', fontSize: '14px', fontWeight: '600', cursor: 'grab', boxShadow: '0 4px 18px rgba(0,0,0,.25)', userSelect: 'none', touchAction: 'none' } ); document.body.appendChild( button ); const saved = loadButtonPosition(); let left = window.innerWidth - button.offsetWidth - 20; let top = window.innerHeight - button.offsetHeight - 20; if (saved) { left = Math.min( Math.max( 0, saved.left ), window.innerWidth - button.offsetWidth ); top = Math.min( Math.max( 0, saved.top ), window.innerHeight - button.offsetHeight ); } button.style.left = `${left}px`; button.style.top = `${top}px`; let dragging = false; let moved = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; button.addEventListener( 'pointerdown', event => { if ( event.pointerType === 'mouse' && event.button !== 0 ) { return; } dragging = true; moved = false; startX = event.clientX; startY = event.clientY; startLeft = parseFloat( button.style.left ) || 0; startTop = parseFloat( button.style.top ) || 0; button.style.cursor = 'grabbing'; try { button.setPointerCapture( event.pointerId ); } catch {} event.preventDefault(); } ); button.addEventListener( 'pointermove', event => { if (!dragging) { return; } const dx = event.clientX - startX; const dy = event.clientY - startY; if ( Math.abs(dx) > 4 || Math.abs(dy) > 4 ) { moved = true; } let newLeft = startLeft + dx; let newTop = startTop + dy; newLeft = Math.max( 0, Math.min( newLeft, window.innerWidth - button.offsetWidth ) ); newTop = Math.max( 0, Math.min( newTop, window.innerHeight - button.offsetHeight ) ); button.style.left = `${newLeft}px`; button.style.top = `${newTop}px`; } ); button.addEventListener( 'pointerup', async event => { if (!dragging) { return; } dragging = false; button.style.cursor = 'grab'; try { button.releasePointerCapture( event.pointerId ); } catch {} if (moved) { saveButtonPosition( parseFloat( button.style.left ), parseFloat( button.style.top ) ); return; } await scanAndOpen( button ); } ); window.addEventListener( 'resize', () => { let x = parseFloat( button.style.left ) || 0; let y = parseFloat( button.style.top ) || 0; x = Math.max( 0, Math.min( x, window.innerWidth - button.offsetWidth ) ); y = Math.max( 0, Math.min( y, window.innerHeight - button.offsetHeight ) ); button.style.left = `${x}px`; button.style.top = `${y}px`; } ); } // ========================================================= // 扫描 // ========================================================= async function scanAndOpen( button ) { const original = button.textContent; button.disabled = true; try { scannedImages = await collectImages( text => { button.textContent = text; } ); if ( !scannedImages.length ) { alert( '没有找到符合条件的图片。' ); return; } currentFilter = 'all'; showImageManager(); } catch (error) { console.error( error ); alert( '扫描失败:\n' + ( error.message || String(error) ) ); } finally { button.disabled = false; button.textContent = original; } } // ========================================================= // 分类统计 // ========================================================= function getCategoryCounts() { const counts = { all: scannedImages.length, main: 0, detail: 0, sku: 0, other: 0 }; scannedImages.forEach( image => { if ( counts[ image.category ] !== undefined ) { counts[ image.category ]++; } } ); return counts; } function getFilteredImages() { if ( currentFilter === 'all' ) { return scannedImages; } return scannedImages.filter( image => image.category === currentFilter ); } // ========================================================= // 管理窗口 // ========================================================= function showImageManager() { closeModal(); modal = document.createElement( 'div' ); modal.id = 'ecommerce-image-modal'; modal.innerHTML = `
电商图片下载器 Pro
自动识别主图 / 详情图 / SKU图
已选择 0 张
`; injectStyles( modal ); document.body.appendChild( modal ); modal .querySelector( '.eid-close' ) .onclick = closeModal; modal .querySelector( '.eid-overlay' ) .onclick = closeModal; modal .querySelector( '.eid-preview-bg' ) .onclick = closePreview; modal .querySelectorAll( '[data-action]' ) .forEach( button => { button.addEventListener( 'click', () => { handleAction( button.dataset.action ); } ); } ); modal .querySelector( '#eid-sort' ) .addEventListener( 'change', event => { sortImages( event.target.value ); renderGrid(); } ); renderAll(); } // ========================================================= // CSS // ========================================================= function injectStyles(root) { const style = document.createElement( 'style' ); style.textContent = ` #ecommerce-image-modal, #ecommerce-image-modal * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; } #ecommerce-image-modal { position: fixed; inset: 0; z-index: 2147483647; } .eid-overlay { position: absolute; inset: 0; background: rgba(0,0,0,.58); backdrop-filter: blur(3px); } .eid-window { position: absolute; top: 3vh; left: 2.5vw; width: 95vw; height: 94vh; display: flex; flex-direction: column; overflow: hidden; background: #f5f6f8; border-radius: 14px; box-shadow: 0 20px 70px rgba(0,0,0,.45); } .eid-header { min-height: 68px; padding: 0 22px; display: flex; align-items: center; justify-content: space-between; background: #fff; border-bottom: 1px solid #e8e8e8; } .eid-title { font-size: 20px; font-weight: 700; color: #111; } .eid-subtitle { margin-top: 4px; font-size: 12px; color: #888; } .eid-close { width: 38px; height: 38px; border: 0; border-radius: 50%; background: #f0f0f0; font-size: 25px; cursor: pointer; } /* 分类标签 */ .eid-categories { padding: 12px 18px; display: flex; gap: 10px; background: #fff; border-bottom: 1px solid #eee; } .eid-category { min-width: 92px; padding: 9px 14px; border: 1px solid #ddd; border-radius: 9px; background: #fff; color: #333; cursor: pointer; font-size: 13px; } .eid-category strong { margin-left: 5px; } .eid-category.active { color: #1677ff; border-color: #1677ff; background: #eef6ff; font-weight: 600; } /* toolbar */ .eid-toolbar { min-height: 66px; padding: 10px 18px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; background: #fff; border-bottom: 1px solid #eee; } .eid-toolbar-left, .eid-toolbar-right { display: flex; align-items: center; gap: 7px; } .eid-toolbar button, .eid-toolbar select { height: 37px; padding: 0 12px; border: 1px solid #d9d9d9; border-radius: 7px; background: #fff; color: #333; cursor: pointer; } .eid-folder-button { background: #1677ff !important; border-color: #1677ff !important; color: #fff !important; font-weight: 600; } .eid-zip-button { background: #16a34a !important; border-color: #16a34a !important; color: #fff !important; font-weight: 600; } .eid-selection { min-width: 115px; text-align: right; } #eid-selected-count { font-size: 14px; color: #222; font-weight: 600; } #eid-current-info { margin-top: 3px; font-size: 11px; color: #999; } /* progress */ .eid-progress { display: none; padding: 8px 18px; background: #fff; border-bottom: 1px solid #eee; } .eid-progress.active { display: block; } .eid-progress-track { width: 100%; height: 7px; overflow: hidden; background: #e6e6e6; border-radius: 20px; } .eid-progress-bar { width: 0%; height: 100%; background: #1677ff; transition: width .15s; } .eid-progress-text { margin-top: 5px; text-align: center; font-size: 12px; color: #666; } /* grid */ .eid-grid { flex: 1; overflow-y: auto; padding: 18px; display: grid; grid-template-columns: repeat( auto-fill, minmax(185px,1fr) ); gap: 14px; align-content: start; } .eid-card { position: relative; overflow: hidden; background: #fff; border: 2px solid transparent; border-radius: 10px; box-shadow: 0 2px 7px rgba(0,0,0,.08); transition: .12s; } .eid-card:hover { transform: translateY(-2px); box-shadow: 0 5px 16px rgba(0,0,0,.13); } .eid-card.selected { border-color: #1677ff; } .eid-image-box { position: relative; width: 100%; aspect-ratio: 1/1; cursor: pointer; background: repeating-conic-gradient( #f5f5f5 0% 25%, #ececec 0% 50% ) 50% / 16px 16px; } .eid-image-box img { width: 100%; height: 100%; object-fit: contain; display: block; } .eid-checkbox-box { position: absolute; top: 8px; left: 8px; z-index: 3; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 6px; background: rgba(255,255,255,.95); box-shadow: 0 2px 6px rgba(0,0,0,.2); } .eid-checkbox { width: 18px; height: 18px; } .eid-number { position: absolute; right: 8px; top: 8px; padding: 3px 7px; border-radius: 5px; background: rgba(0,0,0,.55); color: #fff; font-size: 11px; } .eid-badge { position: absolute; left: 8px; bottom: 8px; padding: 4px 8px; border-radius: 6px; color: #fff; font-size: 11px; font-weight: 600; background: rgba(0,0,0,.7); } .eid-card-info { padding: 9px 10px 10px; } .eid-resolution { font-size: 13px; font-weight: 600; color: #222; } .eid-source { margin-top: 3px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #999; font-size: 11px; } .eid-category-select { width: 100%; height: 31px; margin-top: 8px; padding: 0 6px; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #333; font-size: 12px; cursor: pointer; } .eid-manual { margin-top: 4px; font-size: 10px; color: #16a34a; } /* preview */ .eid-preview { display: none; position: fixed; inset: 0; z-index: 2147483647; } .eid-preview.active { display: block; } .eid-preview-bg { position: absolute; inset: 0; background: rgba(0,0,0,.9); } .eid-preview-body { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); max-width: 92vw; max-height: 92vh; } .eid-preview-body img { display: block; max-width: 92vw; max-height: 84vh; object-fit: contain; background: #fff; } .eid-preview-info { margin-top: 8px; color: #fff; text-align: center; font-size: 12px; word-break: break-all; } `; root.appendChild( style ); } // ========================================================= // 分类标签 // ========================================================= function renderCategories() { if (!modal) { return; } const counts = getCategoryCounts(); const container = modal.querySelector( '.eid-categories' ); const categories = [ ['all', '全部'], ['main', '主图'], ['detail', '详情图'], ['sku', 'SKU图'], ['other', '其他'] ]; container.innerHTML = categories .map( ([key, label]) => ` ` ) .join(''); container .querySelectorAll( '[data-filter]' ) .forEach( button => { button.addEventListener( 'click', () => { currentFilter = button.dataset.filter; renderAll(); } ); } ); } // ========================================================= // grid // ========================================================= function renderGrid() { if (!modal) { return; } const grid = modal.querySelector( '.eid-grid' ); grid.innerHTML = ''; const images = getFilteredImages(); images.forEach( (image, index) => { const card = document.createElement( 'div' ); card.className = 'eid-card' + ( image.selected ? ' selected' : '' ); card.innerHTML = `
${index + 1}
${CATEGORY[image.category]}
${ image.width && image.height ? `${formatNumber(image.width)} × ${formatNumber(image.height)}` : '未知尺寸' }
${escapeHtml( image.source )}
${ image.classification === 'manual' ? `
已手动调整分类
` : '' }
`; const checkbox = card.querySelector( '.eid-checkbox' ); checkbox.addEventListener( 'click', event => { event.stopPropagation(); image.selected = checkbox.checked; card.classList.toggle( 'selected', image.selected ); updateSelectedInfo(); } ); card .querySelector( '.eid-image-box' ) .addEventListener( 'click', event => { if ( event.target.closest( '.eid-checkbox-box' ) ) { return; } showPreview( image ); } ); const categorySelect = card.querySelector( '.eid-category-select' ); categorySelect.addEventListener( 'change', event => { image.category = event.target.value; image.classification = 'manual'; renderAll(); } ); grid.appendChild( card ); } ); updateSelectedInfo(); } function renderAll() { renderCategories(); renderGrid(); } // ========================================================= // 选择信息 // ========================================================= function updateSelectedInfo() { if (!modal) { return; } const selectedCount = scannedImages.filter( image => image.selected ).length; const currentCount = getFilteredImages().length; modal .querySelector( '#eid-selected-count' ) .textContent = `已选择 ${selectedCount} 张`; modal .querySelector( '#eid-current-info' ) .textContent = currentFilter === 'all' ? `全部 ${currentCount} 张` : `${CATEGORY[currentFilter]} ${currentCount} 张`; } // ========================================================= // 操作 // ========================================================= function handleAction(action) { switch (action) { case 'select-visible': getFilteredImages() .forEach( image => image.selected = true ); renderGrid(); break; case 'clear-visible': getFilteredImages() .forEach( image => image.selected = false ); renderGrid(); break; case 'select-all': scannedImages.forEach( image => image.selected = true ); renderGrid(); break; case 'select-none': scannedImages.forEach( image => image.selected = false ); renderGrid(); break; case 'invert': scannedImages.forEach( image => image.selected = !image.selected ); renderGrid(); break; case 'large-only': scannedImages.forEach( image => { image.selected = image.width >= CONFIG.largeWidth && image.height >= CONFIG.largeHeight; } ); renderGrid(); break; case 'download-folder': downloadSelectedToFolder(); break; case 'download-zip': downloadSelectedAsZip(); break; } } // ========================================================= // 排序 // ========================================================= function sortImages(type) { if ( type === 'original' ) { scannedImages.sort( (a, b) => a.originalIndex - b.originalIndex ); } if ( type === 'area-desc' ) { scannedImages.sort( (a, b) => b.width * b.height - a.width * a.height ); } if ( type === 'area-asc' ) { scannedImages.sort( (a, b) => a.width * a.height - b.width * b.height ); } if ( type === 'width-desc' ) { scannedImages.sort( (a, b) => b.width - a.width ); } } // ========================================================= // Preview // ========================================================= function showPreview(image) { if (!modal) { return; } const preview = modal.querySelector( '.eid-preview' ); preview .querySelector('img') .src = image.url; preview .querySelector( '.eid-preview-info' ) .textContent = `${CATEGORY[image.category]} | ` + `${image.width || '?'} × ${image.height || '?'} | ` + image.url; preview.classList.add( 'active' ); } function closePreview() { if (!modal) { return; } modal .querySelector( '.eid-preview' ) .classList.remove( 'active' ); } // ========================================================= // 下载进度 // ========================================================= function setProgress( current, total, text ) { if (!modal) { return; } const wrapper = modal.querySelector( '.eid-progress' ); const bar = modal.querySelector( '.eid-progress-bar' ); const label = modal.querySelector( '.eid-progress-text' ); wrapper.classList.add( 'active' ); const percent = total ? Math.round( current / total * 100 ) : 0; bar.style.width = `${percent}%`; label.textContent = text || `${current}/${total}`; } function hideProgress() { if (!modal) { return; } modal .querySelector( '.eid-progress' ) .classList.remove( 'active' ); } function disableDownloadButtons( disabled ) { if (!modal) { return; } modal .querySelectorAll( '.eid-folder-button, .eid-zip-button' ) .forEach( button => { button.disabled = disabled; } ); } // ========================================================= // 生成下载清单 // ========================================================= function buildDownloadEntries( selected ) { const counters = { main: 0, detail: 0, sku: 0, other: 0 }; return selected.map( image => { counters[ image.category ]++; const index = counters[ image.category ]; const ext = getExtension( image.url ); const resolution = image.width && image.height ? `${image.width}x${image.height}` : 'unknown'; const label = CATEGORY[ image.category ]; const filename = sanitizeFileName( `${label}_${String(index).padStart(3, '0')}_${resolution}.${ext}` ); return { image, label, filename }; } ); } // ========================================================= // GM_download // ========================================================= function downloadOne( url, filename ) { return new Promise( resolve => { GM_download({ url, name: filename, saveAs: false, onload() { resolve( true ); }, onerror(error) { console.warn( '下载失败', error ); resolve( false ); }, ontimeout() { resolve( false ); } }); } ); } // ========================================================= // 文件夹下载 // ========================================================= async function downloadSelectedToFolder() { const selected = scannedImages.filter( image => image.selected ); if (!selected.length) { alert( '请至少选择一张图片。' ); return; } const entries = buildDownloadEntries( selected ); const folder = selected.length > 1 ? createPackageName() : ''; disableDownloadButtons( true ); let success = 0; try { for ( let i = 0; i < entries.length; i++ ) { const entry = entries[i]; const path = folder ? `${folder}/${entry.filename}` : entry.filename; setProgress( i, entries.length, `正在下载 ${i + 1}/${entries.length}` ); const ok = await downloadOne( entry.image.url, path ); if (ok) { success++; } await sleep( CONFIG.downloadDelay ); } setProgress( entries.length, entries.length, '下载完成' ); alert( `下载完成\n\n` + `成功:${success} 张\n` + `失败:${entries.length - success} 张` ); } finally { disableDownloadButtons( false ); setTimeout( hideProgress, 1000 ); } } // ========================================================= // 获取图片二进制 // ========================================================= function fetchImageBinary(url) { return new Promise( (resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: 30000, onload(response) { if ( response.status >= 200 && response.status < 300 && response.response ) { resolve({ data: response.response, contentType: parseContentType( response.responseHeaders ) }); } else { reject( new Error( `HTTP ${response.status}` ) ); } }, onerror(error) { reject( new Error( error?.error || '网络错误' ) ); }, ontimeout() { reject( new Error( '请求超时' ) ); } }); } ); } // ========================================================= // 分类 ZIP // ========================================================= async function downloadSelectedAsZip() { const selected = scannedImages.filter( image => image.selected ); if (!selected.length) { alert( '请至少选择一张图片。' ); return; } if ( typeof JSZip === 'undefined' ) { alert( 'JSZip 加载失败,请刷新网页后重试。' ); return; } disableDownloadButtons( true ); const zip = new JSZip(); const packageName = createPackageName(); const entries = buildDownloadEntries( selected ); let completed = 0; let success = 0; let failed = 0; const failures = []; try { setProgress( 0, entries.length, `正在抓取 ${entries.length} 张图片...` ); await mapWithConcurrency( entries, CONFIG.zipConcurrency, async entry => { try { const response = await fetchImageBinary( entry.image.url ); const ext = extensionFromContentType( response.contentType, getExtension( entry.image.url ) ); // 修正真实扩展名 let filename = entry.filename .replace( /\.[^.]+$/, `.${ext}` ); if ( CONFIG.zipUseCategoryFolders ) { const folder = zip.folder( entry.label ); folder.file( filename, response.data ); } else { zip.file( filename, response.data ); } success++; } catch (error) { failed++; failures.push({ url: entry.image.url, category: entry.label, error: error.message || String(error) }); } finally { completed++; setProgress( completed, entries.length, `正在抓取 ${completed}/${entries.length}` ); } } ); if (!success) { throw new Error( '所有图片都抓取失败。' ); } // ================================================= // 加入抓图说明 // ================================================= const summary = [ `网页标题:${document.title}`, `网页地址:${location.href}`, '', `共选择:${entries.length} 张`, `成功:${success} 张`, `失败:${failed} 张`, '', '分类统计:', `主图:${ entries.filter( x => x.image.category === 'main' ).length } 张`, `详情图:${ entries.filter( x => x.image.category === 'detail' ).length } 张`, `SKU图:${ entries.filter( x => x.image.category === 'sku' ).length } 张`, `其他:${ entries.filter( x => x.image.category === 'other' ).length } 张` ].join('\n'); zip.file( '图片清单.txt', summary ); if ( failures.length ) { zip.file( '下载失败记录.txt', failures .map( (item, index) => `${index + 1}. [${item.category}]\n` + `${item.url}\n` + `${item.error}` ) .join( '\n\n' ) ); } setProgress( entries.length, entries.length, '正在生成分类 ZIP...' ); const blob = await zip.generateAsync( { type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } }, metadata => { if (!modal) { return; } modal .querySelector( '.eid-progress-text' ) .textContent = `正在压缩 ZIP ${Math.round(metadata.percent)}%`; } ); const blobUrl = URL.createObjectURL( blob ); const anchor = document.createElement( 'a' ); anchor.href = blobUrl; anchor.download = `${packageName}.zip`; anchor.style.display = 'none'; document.body.appendChild( anchor ); anchor.click(); anchor.remove(); setTimeout( () => URL.revokeObjectURL( blobUrl ), 60000 ); setProgress( entries.length, entries.length, '分类 ZIP 已生成' ); alert( `分类 ZIP 完成\n\n` + `成功:${success} 张\n` + `失败:${failed} 张\n\n` + `${packageName}.zip` ); } catch (error) { console.error( error ); alert( 'ZIP生成失败:\n' + ( error.message || String(error) ) ); } finally { disableDownloadButtons( false ); setTimeout( hideProgress, 1200 ); } } // ========================================================= // 关闭 // ========================================================= function closeModal() { if (modal) { modal.remove(); modal = null; } } document.addEventListener( 'keydown', event => { if ( event.key !== 'Escape' ) { return; } if ( modal && modal .querySelector( '.eid-preview' ) ?.classList .contains( 'active' ) ) { closePreview(); } else { closeModal(); } } ); // ========================================================= // 油猴菜单 // ========================================================= GM_registerMenuCommand( '打开电商图片抓取器', async () => { scannedImages = await collectImages(); if ( !scannedImages.length ) { alert( '没有发现符合条件的图片。' ); return; } currentFilter = 'all'; showImageManager(); } ); // ========================================================= // 初始化 // ========================================================= function init() { createMainButton(); } if (document.body) { init(); } else { window.addEventListener( 'DOMContentLoaded', init ); } })();