Files
ozon-seller-kit/extension/src/collector/image.ts
T
2026-08-11 17:09:23 +08:00

172 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 图片提取 - 主图、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 = '';
// 真正承载图片的元素。选择器命中容器时它是子 <img>
// 尺寸过滤必须量它而不是容器,否则容器的 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 背景图,走不到这里;淘宝的是真实 <img>,靠这段兜住
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<string>();
// 排除"当前高亮"元素(画廊)
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;
// 尺寸过滤。量真正承载图片的 <img>,不是外层容器——
// 否则容器的 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;
}