337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import type { CollectedProductPayload } from '@atlas/contracts';
|
|
|
|
type SupportedMediaPlatform = 'ozon' | '1688' | 'taobao' | 'tmall';
|
|
type DomMediaPlatform = Exclude<SupportedMediaPlatform, 'ozon'>;
|
|
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<DomMediaPlatform, ImageGroup[]> = {
|
|
'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<string>();
|
|
for (const group of DOM_IMAGE_PROFILES[platform]) {
|
|
for (const selector of group.selectors) {
|
|
for (const element of Array.from(
|
|
root.querySelectorAll<HTMLElement>(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<HTMLElement>('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 <img> 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<HTMLImageElement>('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;
|
|
}
|