// ==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 = `
`;
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
);
}
})();