import type { CollectedProductPayload } from '@atlas/contracts'; type SupportedMediaPlatform = 'ozon' | '1688' | 'taobao' | 'tmall'; type DomMediaPlatform = Exclude; type Asset = CollectedProductPayload['assets'][number]; type ImageGroup = { role: Asset['role']; relation: Asset['relation']; selectors: string[]; sourceProps?: Array< | 'data-lazyload-src' | 'data-src' | 'srcset' | 'currentSrc' | 'src' | 'backgroundImage' >; excludeWithin?: string[]; minWidth?: number; minHeight?: number; }; /** * These profiles are ported from the proven collector extension. The crucial * invariant is that images always come from a product widget, never from a * page-wide `document.images` scan (which also includes logos, ads and * recommendation cards). */ const DOM_IMAGE_PROFILES: Record = { '1688': [ { role: 'source_main', relation: 'inherited_product_default', selectors: [ '[data-module="od_picture_gallery"] img', '.module-od-picture-gallery img', '#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img', '#screen .od-gallery-turn-item-wrapper .od-gallery-img', '#content .od-scroller-item .v-image-cover', '#content .od-picture-gallery-list .v-image-cover', '#dt-tab img', '.detail-gallery-turn img.detail-gallery-img', '.img-list-wrapper img.od-gallery-img', ], minWidth: 200, minHeight: 200, }, { role: 'source_sku', relation: 'axis_value', selectors: [ '[data-module="od_sku_selection"] img', '.module-od-sku-selection img', '.pc-sku-wrapper .prop-item-inner-wrapper', '.sku-item-wrapper', '.specification-cell', '.sku-filter-button', '.expand-view-item', '.feature-item img', ], sourceProps: ['backgroundImage'], minWidth: 20, minHeight: 20, }, { role: 'source_detail', relation: 'product_candidate', selectors: [ '#detail img', '.de-description-detail img', '#detailContentContainer img', '.html-description img', ], minWidth: 300, minHeight: 100, }, ], taobao: [ { role: 'source_main', relation: 'inherited_product_default', selectors: [ '[class*="picGallery--"] img', '#picGalleryEle img', '[class*="thumbnailPic--"]', ], }, { role: 'source_sku', relation: 'axis_value', selectors: [ '[class*="valueItem--"]', '[class*="valueItemImgWrap--"]', ], minWidth: 20, minHeight: 20, }, taobaoDetailProfile(), ], tmall: [ { role: 'source_main', relation: 'inherited_product_default', selectors: [ '[class*="picGallery--"] img', '#picGalleryEle img', '[class*="thumbnailPic--"]', ], }, { role: 'source_sku', relation: 'axis_value', selectors: [ '[class*="valueItem--"]', '[class*="valueItemImgWrap--"]', ], minWidth: 20, minHeight: 20, }, taobaoDetailProfile(), ], }; function taobaoDetailProfile(): ImageGroup { return { role: 'source_detail', relation: 'product_candidate', selectors: [ '[class*="tabDetailWrap--"] img', '[class*="detailInfo--"] img', ], // The old collector verified that review photos and recommendation // cards can be large enough to pass a size-only filter. excludeWithin: [ '[class*="Comment--"]', '[class*="comments--"]', '[class*="userInfo--"]', '[class*="rate"]', '[class*="RecommendInfo--"]', '[data-spm="recommends"]', '[class*="recommend--"]', '[class*="cardPic--"]', ], minWidth: 300, minHeight: 100, }; } export function collectPlatformDomAssets( platform: DomMediaPlatform, root: Document, pageUrl: string, ): Asset[] { const assets: Asset[] = []; const seen = new Set(); for (const group of DOM_IMAGE_PROFILES[platform]) { for (const selector of group.selectors) { for (const element of Array.from( root.querySelectorAll(selector), )) { if ( group.excludeWithin?.some((excluded) => element.closest(excluded), ) ) continue; if (!passesDimensions(element, group)) continue; const url = imageUrlFromElement( element, group.sourceProps, pageUrl, platform, ); if (!url) continue; // Keep the old collector's group-level de-duplication: an // aspect cover may legitimately also be a gallery image. const key = `${group.role}:${dedupeImageUrl(url, platform)}`; if (seen.has(key)) continue; seen.add(key); assets.push({ url, role: group.role, relation: group.relation, sortOrder: assets.length, evidenceRef: `${platform}:dom:${group.role}`, }); } } } return assets; } /** Restores the original CDN URL before payload storage and de-duplication. */ export function normaliseImageUrl( raw: string, pageUrl: string, platform: SupportedMediaPlatform, ): string | undefined { let url: URL; try { url = new URL(raw, pageUrl); } catch { return undefined; } if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined; if (platform === 'ozon') { // Ozon thumbnails use /wc50/, /wc100/, /c600/, etc. The same media // without this segment is its original CDN resource. url.pathname = url.pathname .replace(/\/(?:wc|c)\d+\//i, '/') .replace(/\/{2,}/g, '/'); } else { // Alibaba image URLs commonly append a transform after the file // extension (e.g. image.jpg_400x400.jpg). Keep the original image. const original = url.pathname.match( /^(.+?\.(?:jpe?g|png|gif|bmp|heic|webp|avif))_/i, ); if (original) url.pathname = original[1]; } for (const parameter of ['width', 'height', 'size', 'quality', 'w', 'h']) { url.searchParams.delete(parameter); } return url.toString(); } export function dedupeImageUrl(url: string, platform: SupportedMediaPlatform) { const normalised = normaliseImageUrl(url, url, platform) ?? url; try { const parsed = new URL(normalised); parsed.search = ''; parsed.hash = ''; return parsed.toString(); } catch { return normalised; } } function imageUrlFromElement( element: HTMLElement, sourceProps: ImageGroup['sourceProps'] = [ 'data-lazyload-src', 'data-src', 'srcset', 'currentSrc', 'src', 'backgroundImage', ], pageUrl: string, platform: SupportedMediaPlatform, ) { const nestedTarget = element.matches('img, source') ? null : element.querySelector('img, source'); const imageTarget = element.matches('img, source') ? element : nestedTarget; for (const prop of sourceProps) { // 1688 uses background images on the selected SKU option container. // For every other property, prefer the actual nested image/source. const target = prop === 'backgroundImage' ? element : imageTarget; if (!target) continue; let raw = ''; if (prop === 'srcset') raw = bestSrcset(target.getAttribute('srcset') ?? ''); else if (prop === 'currentSrc') raw = (target as HTMLImageElement).currentSrc ?? ''; else if (prop === 'backgroundImage') { const style = target.ownerDocument.defaultView?.getComputedStyle(target) .backgroundImage ?? target.style.backgroundImage; raw = style.match(/url\(["']?(.*?)["']?\)/i)?.[1] ?? ''; // Some 1688 SKU selectors directly match an despite the // group being background-image-first. if (!raw && target.tagName === 'IMG') { raw = (target as HTMLImageElement).currentSrc || target.getAttribute('src') || ''; } if (!raw && nestedTarget && nestedTarget !== target) { const nestedStyle = nestedTarget.ownerDocument.defaultView?.getComputedStyle( nestedTarget, ).backgroundImage ?? nestedTarget.style.backgroundImage; raw = nestedStyle.match(/url\(["']?(.*?)["']?\)/i)?.[1] ?? ((nestedTarget as HTMLImageElement).currentSrc || nestedTarget.getAttribute('src') || ''); } } else raw = target.getAttribute(prop) ?? ''; const url = raw && normaliseImageUrl(raw, pageUrl, platform); if (url) return url; } return undefined; } function passesDimensions(element: HTMLElement, group: ImageGroup) { if (!group.minWidth && !group.minHeight) return true; const image = element.matches('img') ? (element as HTMLImageElement) : element.querySelector('img'); const measured = image ?? element; const width = image?.naturalWidth || measured.offsetWidth || 0; const height = image?.naturalHeight || measured.offsetHeight || 0; // Browser snapshots and unrendered lazy images often report 0. As in the // old collector, only reject when a real measured size proves it is tiny. return !( width > 0 && height > 0 && (width < (group.minWidth ?? 0) || height < (group.minHeight ?? 0)) ); } function bestSrcset(srcset: string) { let best = ''; let bestSize = -1; for (const part of srcset .split(',') .map((value) => value.trim()) .filter(Boolean)) { const [url, descriptor = ''] = part.split(/\s+/, 2); const width = descriptor.match(/^(\d+)w$/)?.[1]; const density = descriptor.match(/^(\d+(?:\.\d+)?)x$/)?.[1]; const size = width ? Number(width) : density ? Number(density) * 1_000 : 0; if (size >= bestSize) { best = url; bestSize = size; } } return best; }