// ==UserScript==
// @name 淘宝/天猫评论图集高清原图下载器【商品名随机码版】
// @namespace tb-comment-hd-gallery-downloader
// @version 4.8
// @description 淘宝/天猫评论图下载:强力去重,右侧单图可删,按 商品名a7/01.jpg 保存
// @match *://*.taobao.com/*
// @match *://*.tmall.com/*
// @match *://*.tmall.hk/*
// @match *://*.world.taobao.com/*
// @grant GM_download
// @connect gw.alicdn.com
// @connect img.alicdn.com
// @connect alicdn.com
// ==/UserScript==
(function () {
'use strict';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const BLOCKED_URLS = [
'https://img.alicdn.com/imgextra/i0/O1CN01GbZNxl26Vzotrjqli_!!6000000007668-2-tps-160-160.png'
];
function fullUrl(url) {
if (!url) return '';
if (url.startsWith('//')) return 'https:' + url;
return url;
}
function cleanName(name, maxLen = 255) {
return (name || '未命名')
.replace(/[\\/:*?"<>|]/g, '')
.replace(/[\r\n\t]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLen);
}
function randomTwoCode() {
const chars = '0123456789abcdefghijklmnopqrstuvwxyz';
return Array.from({ length: 2 }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
}
function getRootFolderName() {
const input = document.querySelector('#tb-folder-name-input');
const value = input?.value?.trim() || '';
return cleanName(value || '淘宝评论原图', 253);
}
function getSkuBaseName() {
return cleanName(getRootFolderName(), 80).replace(/\s+/g, '');
}
function makeSkuFolderName() {
const skuName = getSkuBaseName() || '商品';
return cleanName(`${skuName}${randomTwoCode()}`, 255);
}
function normalizeUrl(url) {
return fullUrl(url || '')
.split('?')[0]
.replace(/^http:/i, 'https:')
.replace(/\/i\d+\//i, '/i0/');
}
function getHDImage(url) {
if (!url) return '';
url = normalizeUrl(url);
const match = url.match(/.*?-(0|2)-(rate|tbbala)(?:_livephoto)?\.(jpg|jpeg|png|webp)/i);
if (match) return match[0];
const match2 = url.match(/.*?!!\d+-(rate|tbbala)(?:_livephoto)?\.(jpg|jpeg|png|webp)/i);
if (match2) return match2[0];
const imgMatch = url.match(/.*?\.(jpg|jpeg|png|webp)/i);
if (imgMatch) return imgMatch[0];
return url;
}
function getImageIdentity(url) {
return normalizeUrl(getHDImage(url))
.replace(/_(\d+)x(\d+)(q\d+)?\.(jpg|jpeg|png|webp)$/i, '.$4')
.replace(/_\d+x\d+\.(jpg|jpeg|png|webp)$/i, '.$1')
.replace(/\.sum\.(jpg|jpeg|png|webp)$/i, '.$1')
.replace(/\.webp$/i, '.jpg')
.replace(/\.jpeg$/i, '.jpg');
}
function getGroupIdentities(urls) {
return [...new Set((urls || []).map(getImageIdentity).filter(Boolean))];
}
function getGroupKey(urls) {
return getGroupIdentities(urls).sort().join('|');
}
function isSimilarGroup(urlsA, urlsB, threshold = 0.8) {
const a = getGroupIdentities(urlsA);
const b = getGroupIdentities(urlsB);
if (!a.length || !b.length) return false;
const setA = new Set(a);
const setB = new Set(b);
let same = 0;
setA.forEach(item => {
if (setB.has(item)) same++;
});
return same / Math.min(setA.size, setB.size) >= threshold;
}
function getImageSrc(img) {
return img.getAttribute('data-src') ||
img.getAttribute('src') ||
img.currentSrc ||
img.src ||
'';
}
function isBlockedImageUrl(url) {
const clean = normalizeUrl(url);
return BLOCKED_URLS.some(blocked => clean === normalizeUrl(blocked));
}
function isValidImageUrl(url) {
if (!url) return false;
if (isBlockedImageUrl(url)) return false;
if (url.includes('tps-145-145')) return false;
if (url.includes('tps-56-56')) return false;
if (url.includes('tps-160-160')) return false;
if (url.includes('playerIcon')) return false;
if (url.includes('6000000002189-2-tps')) return false;
if (url.includes('6000000006294-2-tps')) return false;
if (url.includes('6000000007668-2-tps-160-160')) return false;
return /\.(jpg|jpeg|png|webp)$/i.test(url);
}
function isImageSizeOk(img) {
const w = img.naturalWidth || img.width || 0;
const h = img.naturalHeight || img.height || 0;
return w >= 300 && h >= 300;
}
function getFileExt(url) {
const match = url.match(/\.(jpg|jpeg|png|webp)$/i);
return match ? match[1].toLowerCase() : 'jpg';
}
function getImages(container) {
const imgs = [...container.querySelectorAll('img')];
const seen = new Set();
const result = [];
imgs.forEach(img => {
if (!isImageSizeOk(img)) return;
const hdUrl = getHDImage(getImageSrc(img));
if (!isValidImageUrl(hdUrl)) return;
const identity = getImageIdentity(hdUrl);
if (seen.has(identity)) return;
seen.add(identity);
result.push(hdUrl);
});
return result;
}
function getComments() {
return [...document.querySelectorAll('[class*="Comment--"]')]
.filter(el => {
const imgs = getImages(el);
return imgs.length > 0 && imgs.length <= 80;
});
}
function getImageItems() {
return [...document.querySelectorAll(
'[class*="commentsImgItem"], [class*="photo--"], [class*="cover--"]'
)];
}
function injectStyle() {
if (document.querySelector('#tb-hd-style')) return;
const style = document.createElement('style');
style.id = 'tb-hd-style';
style.innerHTML = `
#tb-hd-download-panel {
position: fixed;
right: 18px;
top: 18px;
z-index: 999999999;
width: 600px;
max-height: calc(100vh - 36px);
overflow: hidden;
padding: 0;
border-radius: 20px;
background: rgba(255,255,255,.96);
backdrop-filter: blur(18px);
box-shadow: 0 18px 46px rgba(0,0,0,.14), 0 8px 24px rgba(255,80,0,.22);
border: 1px solid rgba(255,80,0,.22);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, "Microsoft YaHei", sans-serif;
color: #222;
}
.tb-panel-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
cursor: move;
user-select: none;
background: linear-gradient(135deg, #ff5000, #ff8a1c);
color: #fff;
}
.tb-panel-title {
display: flex;
align-items: center;
gap: 10px;
}
.tb-logo {
width: 38px;
height: 38px;
border-radius: 13px;
background: rgba(255,255,255,.22);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 19px;
}
.tb-title-main {
font-size: 17px;
font-weight: 900;
line-height: 1.2;
}
.tb-title-sub {
font-size: 12px;
opacity: .9;
margin-top: 3px;
}
.tb-panel-body {
padding: 14px 16px 16px;
max-height: calc(100vh - 105px);
overflow-y: auto;
background: linear-gradient(180deg, #fff7f1 0%, #ffffff 120px);
}
.tb-folder-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
#tb-folder-name-input {
flex: 1;
height: 38px;
border-radius: 13px;
border: 1px solid rgba(255,80,0,.24);
background: #fff;
padding: 0 13px;
font-size: 13px;
outline: none;
color: #333;
}
.tb-folder-preview {
width: 150px;
height: 38px;
line-height: 38px;
border-radius: 13px;
background: #fff3eb;
color: #ff5000;
font-size: 12px;
font-weight: 800;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border: 1px solid rgba(255,80,0,.18);
}
.tb-btn-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.tb-btn {
height: 40px;
border: none;
outline: none;
border-radius: 13px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.tb-btn.primary {
color: #fff;
background: linear-gradient(135deg, #ff8a1c, #ff5000);
}
.tb-btn.success {
color: #fff;
background: linear-gradient(135deg, #20c997, #12b886);
}
.tb-btn.blue {
color: #fff;
background: linear-gradient(135deg, #4dabf7, #228be6);
}
.tb-btn.light {
color: #ff5000;
background: #fff;
border: 1px solid rgba(255,80,0,.26);
}
.tb-download-label {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
margin-bottom: 10px;
border-radius: 999px;
background: linear-gradient(135deg, #fff7f2, #fff);
color: #ff5000;
font-size: 13px;
font-weight: 800;
border: 1px solid rgba(255,80,0,.25);
cursor: pointer;
user-select: none;
}
.tb-download-label input,
.tb-filter-check {
accent-color: #ff5000;
}
.tb-img-select-wrap {
position: absolute !important;
right: 8px;
top: 8px;
z-index: 99999999;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0,0,0,.42);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid rgba(255,255,255,.45);
}
.tb-img-select-wrap input {
width: 17px;
height: 17px;
margin: 0;
accent-color: #ff5000;
cursor: pointer;
}
.tb-img-selected {
outline: 3px solid #ff5000 !important;
outline-offset: -3px !important;
border-radius: 8px !important;
}
#tb-filter-result-panel {
margin-top: 12px;
max-height: 560px;
overflow-y: auto;
background: #fffaf7;
border: 1px solid #ffd4bd;
border-radius: 18px;
padding: 12px;
}
.tb-filter-header {
position: sticky;
top: 0;
z-index: 2;
background: linear-gradient(135deg,#ff5000,#ff8a1c);
color: #fff;
padding: 11px 13px;
border-radius: 13px;
font-weight: 900;
margin-bottom: 12px;
}
.tb-filter-close {
float: right;
border: none;
background: #fff;
color: #ff5000;
border-radius: 999px;
padding: 4px 11px;
cursor: pointer;
font-weight: 800;
}
.tb-filter-card {
margin-bottom: 13px;
padding: 11px;
border: 1px solid #ffd4bd;
border-radius: 15px;
background: #fff;
}
.tb-filter-title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
font-weight: 900;
color: #ff5000;
margin-bottom: 10px;
}
.tb-filter-title label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.tb-filter-pics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 9px;
}
.tb-filter-img-wrap {
position: relative;
overflow: hidden;
border-radius: 12px;
}
.tb-filter-img-del {
position: absolute;
right: 6px;
top: 6px;
z-index: 3;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
color: #fff;
background: rgba(0,0,0,.62);
cursor: pointer;
font-size: 16px;
line-height: 20px;
font-weight: 900;
padding: 0;
}
.tb-filter-img-del:hover {
background: #ff5000;
}
.tb-filter-pics img {
width: 100%;
aspect-ratio: 3 / 4;
height: auto;
object-fit: cover;
border-radius: 12px;
border: 1px solid #ffe0cf;
background: #fff;
}
.tb-filter-tip {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 12px;
color: #9a4b16;
background: #fff2e8;
font-size: 12px;
line-height: 1.5;
}
`;
document.head.appendChild(style);
}
function makePanelDraggable(panel, handle) {
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
handle.addEventListener('mousedown', e => {
if (e.target.closest('button, input, label')) return;
dragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
panel.style.left = `${startLeft}px`;
panel.style.top = `${startTop}px`;
panel.style.right = 'auto';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
panel.style.left = `${Math.max(0, startLeft + e.clientX - startX)}px`;
panel.style.top = `${Math.max(0, startTop + e.clientY - startY)}px`;
});
document.addEventListener('mouseup', () => {
dragging = false;
document.body.style.userSelect = '';
});
}
function addPanel() {
if (document.querySelector('#tb-hd-download-panel')) return;
injectStyle();
const panel = document.createElement('div');
panel.id = 'tb-hd-download-panel';
panel.innerHTML = `
<div class="tb-panel-top">
<div class="tb-panel-title">
<span class="tb-logo">淘</span>
<div>
<div class="tb-title-main">评论原图下载</div>
<div class="tb-title-sub">强力去重 · 单图可删 · 商品名随机码保存</div>
</div>
</div>
</div>
<div class="tb-panel-body">
<div class="tb-folder-row">
<input id="tb-folder-name-input" placeholder="输入商品名,目录按 商品名a7 生成">
<div class="tb-folder-preview" id="tb-folder-preview">淘宝评论原图a7</div>
</div>
<div class="tb-btn-row">
<button class="tb-btn primary" id="tb-scan-group-btn">评论分组</button>
<button class="tb-btn blue" id="tb-filter-group-btn">筛选</button>
<button class="tb-btn blue" id="tb-scan-img-btn">图集选择</button>
<button class="tb-btn success" id="tb-download-btn">下载选中</button>
<button class="tb-btn light" id="tb-select-all-btn">全选/反选</button>
</div>
<div class="tb-filter-tip">
下载目录:商品名a7 / 01.jpg。不会再创建“商品名/分组01”这种多层文件夹。
</div>
<div id="tb-filter-body"></div>
</div>
`;
document.body.appendChild(panel);
makePanelDraggable(panel, panel.querySelector('.tb-panel-top'));
const input = document.querySelector('#tb-folder-name-input');
const preview = document.querySelector('#tb-folder-preview');
input.addEventListener('input', () => {
preview.textContent = `${getSkuBaseName()}${randomTwoCode()}`;
});
document.querySelector('#tb-scan-group-btn').onclick = markComments;
document.querySelector('#tb-filter-group-btn').onclick = filterCommentGroups;
document.querySelector('#tb-scan-img-btn').onclick = markImageItems;
document.querySelector('#tb-download-btn').onclick = downloadSelected;
document.querySelector('#tb-select-all-btn').onclick = toggleAll;
}
function markComments() {
const comments = getComments();
let validCount = 0;
comments.forEach(comment => {
if (comment.querySelector('.tb-download-check')) return;
const imgs = getImages(comment);
if (!imgs.length) return;
validCount++;
const box = document.createElement('label');
box.className = 'tb-download-label';
box.innerHTML = `
<input type="checkbox" class="tb-download-check tb-group-check">
<span>本组 ${imgs.length} 张原图</span>
`;
comment.prepend(box);
});
alert(`评论分组扫描完成:找到 ${validCount} 个图片评论分组`);
}
function filterCommentGroups() {
const body = document.querySelector('#tb-filter-body');
if (!body) return;
body.innerHTML = '';
const comments = getComments();
const groups = [];
comments.forEach(comment => {
const urls = getImages(comment);
if (urls.length < 3) return;
const key = getGroupKey(urls);
if (!key) return;
const duplicated = groups.some(oldGroup => {
return oldGroup.key === key || isSimilarGroup(oldGroup.urls, urls, 0.8);
});
if (duplicated) return;
groups.push({
key,
urls,
count: urls.length
});
});
groups.sort((a, b) => b.count - a.count);
if (!groups.length) {
alert('当前已加载评论里没有找到 3 张以上且不重复的分组。请先滚动评论加载更多,再点筛选。');
return;
}
const resultPanel = document.createElement('div');
resultPanel.id = 'tb-filter-result-panel';
resultPanel.innerHTML = `
<div class="tb-filter-header">
筛选结果:${groups.length} 组,已强力去重
<button id="tb-close-filter-panel" class="tb-filter-close">清空</button>
</div>
`;
groups.forEach((group, index) => {
const card = document.createElement('div');
card.className = 'tb-filter-card';
card.dataset.urls = JSON.stringify(group.urls);
const pics = group.urls.map(url => `
<div class="tb-filter-img-wrap" data-url="${url}">
<button type="button" class="tb-filter-img-del" title="删除此图,删除后不会下载">×</button>
<img src="${url}" loading="lazy">
</div>
`).join('');
card.innerHTML = `
<div class="tb-filter-title">
<label>
<input type="checkbox" class="tb-filter-check">
第 ${index + 1} 组 · <b class="tb-filter-count">${group.count}</b> 张
</label>
<span><b class="tb-filter-count-right">${group.count}</b> 张原图</span>
</div>
<div class="tb-filter-pics">${pics}</div>
`;
resultPanel.appendChild(card);
});
body.appendChild(resultPanel);
document.querySelector('#tb-close-filter-panel').onclick = () => {
body.innerHTML = '';
};
resultPanel.addEventListener('click', e => {
const del = e.target.closest('.tb-filter-img-del');
if (!del) return;
e.preventDefault();
e.stopPropagation();
const wrap = del.closest('.tb-filter-img-wrap');
const card = del.closest('.tb-filter-card');
if (!wrap || !card) return;
const removedIdentity = getImageIdentity(wrap.dataset.url);
let urls = [];
try {
urls = JSON.parse(card.dataset.urls || '[]');
} catch (err) {
urls = [];
}
urls = urls.filter(url => getImageIdentity(url) !== removedIdentity);
card.dataset.urls = JSON.stringify(urls);
wrap.remove();
card.querySelectorAll('.tb-filter-count, .tb-filter-count-right').forEach(el => {
el.textContent = urls.length;
});
if (urls.length === 0) card.remove();
});
alert(`筛选完成:显示 ${groups.length} 个强力去重分组。右上角 × 可删除单张图。`);
}
function markImageItems() {
const items = getImageItems();
let validCount = 0;
const groupMap = new Map();
items.forEach(item => {
if (item.querySelector('.tb-single-check')) return;
const img = item.querySelector('img');
if (!img) return;
if (!isImageSizeOk(img)) return;
const url = getHDImage(getImageSrc(img));
if (!isValidImageUrl(url)) return;
const identity = getImageIdentity(url);
if (!groupMap.has(identity)) {
groupMap.set(identity, String(groupMap.size + 1).padStart(2, '0'));
}
const groupId = groupMap.get(identity);
item.style.position = 'relative';
item.dataset.tbImageUrl = url;
item.dataset.tbGalleryGroupId = groupId;
const wrap = document.createElement('label');
wrap.className = 'tb-img-select-wrap';
wrap.title = `选中下载高清原图,分组:${groupId}`;
wrap.innerHTML = `<input type="checkbox" class="tb-single-check">`;
const checkbox = wrap.querySelector('input');
checkbox.addEventListener('change', () => {
item.classList.toggle('tb-img-selected', checkbox.checked);
});
wrap.addEventListener('click', e => {
e.stopPropagation();
});
item.appendChild(wrap);
validCount++;
});
alert(`图集扫描完成:找到 ${validCount} 张可选择图片`);
}
function toggleAll() {
const checks = [
...document.querySelectorAll('.tb-download-check'),
...document.querySelectorAll('.tb-single-check'),
...document.querySelectorAll('.tb-filter-check')
];
if (!checks.length) {
alert('请先点击“评论分组”“筛选”或“图集选择”');
return;
}
const hasUnchecked = checks.some(c => !c.checked);
checks.forEach(c => {
c.checked = hasUnchecked;
const item = c.closest('[class*="commentsImgItem"], [class*="photo--"], [class*="cover--"]');
if (item) item.classList.toggle('tb-img-selected', c.checked);
});
}
function addTask(tasks, seen, groupCounters, rawUrl, folder) {
if (!rawUrl || !folder) return;
const hdUrl = getHDImage(rawUrl);
if (!isValidImageUrl(hdUrl)) return;
const identity = getImageIdentity(hdUrl);
if (seen.has(identity)) return;
seen.add(identity);
if (!groupCounters[folder]) groupCounters[folder] = 0;
groupCounters[folder]++;
const ext = getFileExt(hdUrl);
const filename = `${folder}/${String(groupCounters[folder]).padStart(2, '0')}.${ext}`;
tasks.push({
url: hdUrl,
filename
});
}
async function downloadSelected() {
const tasks = [];
const seen = new Set();
const groupCounters = {};
const selectedGroups = [...document.querySelectorAll('.tb-group-check:checked')]
.map(input => input.closest('[class*="Comment--"]'))
.filter(Boolean);
selectedGroups.forEach(comment => {
const folder = makeSkuFolderName();
const imgs = getImages(comment);
imgs.forEach(url => {
addTask(tasks, seen, groupCounters, url, folder);
});
});
const selectedFilterGroups = [...document.querySelectorAll('.tb-filter-check:checked')]
.map(input => input.closest('.tb-filter-card'))
.filter(Boolean);
selectedFilterGroups.forEach(card => {
let urls = [];
try {
urls = JSON.parse(card.dataset.urls || '[]');
} catch (e) {
urls = [];
}
const folder = makeSkuFolderName();
urls.forEach(url => {
addTask(tasks, seen, groupCounters, url, folder);
});
});
const selectedSingles = [...document.querySelectorAll('.tb-single-check:checked')]
.map(input => input.closest('[class*="commentsImgItem"], [class*="photo--"], [class*="cover--"]'))
.filter(Boolean);
const singleSkuFolders = {};
selectedSingles.forEach(item => {
const rawUrl = item.dataset.tbImageUrl;
if (!rawUrl) return;
const groupId = item.dataset.tbGalleryGroupId || `single${randomTwoCode()}`;
if (!singleSkuFolders[groupId]) {
singleSkuFolders[groupId] = makeSkuFolderName();
}
addTask(tasks, seen, groupCounters, rawUrl, singleSkuFolders[groupId]);
});
if (!tasks.length) {
alert('请先勾选要下载的图片或分组');
return;
}
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
console.log('下载高清原图:', task.url, task.filename);
GM_download({
url: task.url,
name: task.filename,
saveAs: false,
onload: () => console.log('下载完成:', task.filename),
onerror: err => console.warn('下载失败:', task.url, err)
});
await sleep(500);
}
alert(
`已开始下载 ${tasks.length} 张高清原图。\n\n` +
`目录结构:${getSkuBaseName()}${randomTwoCode()}/01.jpg\n` +
`不会再创建“商品名/分组01”这种多层文件夹。\n` +
`随机干扰符只使用2位字母数字,不加横杠。\n` +
`重复图片已按图片身份去重。\n` +
`右侧删除过的图片不会下载。`
);
}
addPanel();
const observer = new MutationObserver(() => {
addPanel();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。