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: ['.od-picture-gallery-list > .v-image-cover'], sourceProps: ['backgroundImage'], excludeWithin: ['.video-image-cover', '.v-image-cover:has(.cpv-icon, .cpv-tag-name)'], }, { 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', ], excludeWithin: ['.video-image-cover', '.v-image-cover:has(.cpv-icon, .cpv-tag-name)'], 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, mediaType: 'image', role: group.role, relation: group.relation, sortOrder: assets.length, evidenceRef: `${platform}:dom:${group.role}`, }); } } } if (platform === '1688') add1688GalleryVideos(root, pageUrl, assets); return assets; } /** 只读取商品画廊内播放器的公开直链,不扫描全页视频或根据封面猜测地址。 */ function add1688GalleryVideos(root: Document, pageUrl: string, assets: Asset[]) { const selectors = [ '[data-module="od_picture_gallery"] video', '.module-od-picture-gallery video', '.od-picture-gallery video', '.od-picture-gallery-list video', '.detail-gallery-turn video', '.od-gallery-turn-item-wrapper video', ]; const seen = new Set(); for (const selector of selectors) { for (const video of Array.from(root.querySelectorAll(selector))) { const candidates = [video.currentSrc, video.getAttribute('src'), ...Array.from(video.querySelectorAll('source')).map((source) => source.getAttribute('src'))]; const url = candidates.map((candidate) => normaliseVideoUrl(candidate ?? '', pageUrl)).find(Boolean); if (!url || seen.has(url) || assets.length >= 500) continue; seen.add(url); const cover = root.querySelector('.od-picture-gallery-list > .video-image-cover'); const poster = video.poster || video.getAttribute('poster'); const posterUrl = (poster ? normaliseImageUrl(poster, pageUrl, '1688') : undefined) ?? (cover ? imageUrlFromElement(cover, ['backgroundImage'], pageUrl, '1688') : undefined); assets.push({ url, mediaType: 'video', role: 'source_main', relation: 'product_candidate', ...(posterUrl?.startsWith('https://') ? { posterUrl } : {}), sortOrder: assets.length, evidenceRef: '1688:dom:source_video', }); } } } export function normaliseVideoUrl(raw: string, pageUrl: string): string | undefined { if (!raw.trim()) return undefined; try { const url = new URL(raw, pageUrl); return url.protocol === 'https:' && /\.(mp4|webm)$/i.test(url.pathname) ? url.href : undefined; } catch { return undefined; } } /** Restores the original CDN URL before payload storage and de-duplication. */ export function normaliseImageUrl( raw: string, pageUrl: string, platform: SupportedMediaPlatform, ): string | undefined { if (!raw.trim() || /^(?:undefined|null|none)$/i.test(raw.trim())) return 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') { // 优先使用内联地址,避免占位值 undefined 被计算样式解析成一个页面相对 URL。 const style = target.style.backgroundImage || target.ownerDocument.defaultView?.getComputedStyle(target).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; }