/**
* 图片提取 - 主图、SKU、详情图、视频
* 从 docs/extension/plan.md §6.4 移植(核心逻辑)
*/
import { toAbsoluteUrl, toOriginalUrl, urlInBrackets, looksLikeImageUrl, dedupeKey } from './url';
import { queryAllDeep } from './dom';
import type { ImageGroupRule, SiteProfile, SrcProp } from '../profiles/types';
export interface ImageMaterial {
key: string; // 'main-001'
groupKey: string; // 'main'
groupName: string; // '主图'
variantName?: string; // SKU 规格名(仅 sku 组)
url: string; // 已还原为原图
thumbUrl: string; // 页面上的原始小图地址
index: number;
type: 'img' | 'video';
width?: number;
height?: number;
}
/**
* 从元素上读出图片地址与名称,按 srcProps 顺序降级
*/
function readImageSource(
el: Element,
srcProps: SrcProp[],
nameSelectors?: string[]
): { url: string; name: string; imgEl: HTMLImageElement | null } {
let url = '';
let name = '';
// 真正承载图片的元素。选择器命中容器时它是子
,
// 尺寸过滤必须量它而不是容器,否则容器的 offsetWidth 会让小图蒙混过关
let imgEl: HTMLImageElement | null = el instanceof HTMLImageElement ? el : null;
for (const prop of srcProps) {
if (url) break;
if (prop === 'backgroundImage') {
// SKU 组常用 CSS 背景图
if (el.tagName === 'IMG') {
const img = el as HTMLImageElement;
url = img.currentSrc || img.src || '';
name = img.alt || '';
} else {
// 尝试多种 SKU DOM 结构
const bgCandidates = ['.prop-img', '.sku-item-image', '.single-sku-img-pop', '.item-image-icon'];
for (const sel of bgCandidates) {
const node = el.querySelector(sel);
if (!node) continue;
if (node instanceof HTMLImageElement && node.src) {
url = node.src;
} else {
const bg = getComputedStyle(node).backgroundImage || '';
url = (urlInBrackets(bg) || bg).replace(/['"]/g, '');
}
if (url) break;
}
// 兜底:元素自身背景图
if (!url) {
const bg = getComputedStyle(el).backgroundImage || '';
const cand = (urlInBrackets(bg) || bg).replace(/['"]/g, '');
if (looksLikeImageUrl(cand)) url = cand;
}
}
} else {
const raw = (el as any)[prop] || el.getAttribute(prop);
if (raw) {
// srcset 场景下 currentSrc 才是实际加载的那张
url = prop === 'src' ? ((el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || '') : raw;
}
}
}
// 选择器命中的是容器、图在子节点上(淘宝 valueItem-- 就是这种)
// 1688 的 SKU 是 CSS 背景图,走不到这里;淘宝的是真实
,靠这段兜住
if (!url && el.tagName !== 'IMG') {
const inner = el.querySelector('img');
if (inner) {
url = inner.getAttribute('data-src') || inner.currentSrc || inner.src || '';
imgEl = inner;
if (!name) name = inner.alt || '';
}
}
// 名称统一取(SKU 规格名)
if (!name && nameSelectors?.length) {
for (const sel of nameSelectors) {
const t = el.querySelector(sel)?.textContent?.trim();
if (t) { name = t; break; }
}
}
return { url: url ? toAbsoluteUrl(url) : '', name, imgEl };
}
/**
* 占位图识别。阿里系用 `-tps-1-1.png` / `-tps-2-2.png` 这类极小透明图
* 占位,真实地址要等懒加载。采到它们等于污染数据。
*/
function isPlaceholder(url: string, imgEl: HTMLImageElement | null): boolean {
if (/-tps-\d-\d\.(png|gif)/i.test(url)) return true;
if (/^data:image\/gif/i.test(url)) return true;
// 已加载完成但尺寸只有几像素 → 占位图
if (imgEl?.complete && imgEl.naturalWidth > 0 && imgEl.naturalWidth <= 4) return true;
return false;
}
export function collectImages(profile: SiteProfile): ImageMaterial[] {
const result: ImageMaterial[] = [];
for (const group of profile.imageGroups) {
const srcProps = group.srcProps ?? profile.defaultSrcProps;
// 去重按组独立:一张图同时是主图和 SKU 图是正常的,
// 全局去重会让后处理的组丢图(连带丢掉 SKU 规格名)
const seen = new Set();
// 排除"当前高亮"元素(画廊)
const activeSet = new Set(group.activeSelectors ? queryAllDeep(group.activeSelectors) : []);
for (const el of queryAllDeep(group.selectors)) {
if (activeSet.has(el)) continue;
// 跳过位于排除容器内的元素(如详情区里的用户评价图)
if (group.excludeWithin?.some(sel => el.closest(sel))) continue;
const { url: rawUrl, name, imgEl } = readImageSource(el, srcProps, group.nameSelectors);
if (!rawUrl) continue;
// 占位图不能进结果——它不是商品图
if (group.type === 'img' && isPlaceholder(rawUrl, imgEl)) continue;
// 视频校验
if (group.type === 'video' && !/\.(mp4|avi|mov|wmv|m3u8)(\?|$)/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
continue;
}
const url = group.type === 'img' ? toOriginalUrl(rawUrl) : rawUrl;
// 尺寸过滤。量真正承载图片的
,不是外层容器——
// 否则容器的 offsetWidth 会让 2x2 占位图通过 minWidth 检查
if (group.type === 'img' && (group.minWidth || group.minHeight)) {
const measured = imgEl ?? (el as HTMLElement);
const w = (measured as HTMLImageElement).naturalWidth || (measured as HTMLElement).offsetWidth || 0;
const h = (measured as HTMLImageElement).naturalHeight || (measured as HTMLElement).offsetHeight || 0;
// 尺寸为 0 说明还没加载完,放过它(别误杀懒加载图)
if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue;
}
// 去重。SKU 组把规格名并入 key——不同规格共用同一张图时
// 两条都要留下,否则规格与图的对应关系就断了
const k = group.key === 'sku' ? `${dedupeKey(url)}::${name}` : dedupeKey(url);
if (seen.has(k)) continue;
seen.add(k);
result.push({
key: `${group.key}-${String(result.filter(r => r.groupKey === group.key).length + 1).padStart(3, '0')}`,
groupKey: group.key,
groupName: group.name,
variantName: group.key === 'sku' ? name || undefined : undefined,
url,
thumbUrl: rawUrl,
index: result.length,
type: group.type
});
}
}
return result;
}