161 lines
5.3 KiB
TypeScript
161 lines
5.3 KiB
TypeScript
/**
|
||
* Ozon 采集编排(平台文件):四路径合并(来自 extension-v2 生产逻辑)。
|
||
* ① SSR widget state(DOM data-state 属性,白名单)★主路径
|
||
* ② JSON-LD(schema.org/Product)
|
||
* ③ 站内页 JSON API(entrypoint-api.bx)
|
||
* ④ DOM data-widget 选择器兜底 + 详情图补充
|
||
*/
|
||
import { autoScrollToBottom, waitForAny } from '../dom';
|
||
import { collectImages, type ImageMaterial } from '../image';
|
||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||
import { toOriginalUrl, toThumbUrl } from '../url';
|
||
import { extractJsonLd } from '../jsonld';
|
||
import { fetchOzonPageData, type OzonPageData } from '../ozon-api';
|
||
import { extractOzonState, type OzonStateData } from '../ozon-state';
|
||
import { finalize, mergeImages, type ScanResult } from '../merge';
|
||
import type { SiteProfile } from '../../profiles/types';
|
||
|
||
interface StructuredBundle {
|
||
title?: string;
|
||
price?: string;
|
||
brand?: string;
|
||
description?: string;
|
||
characteristics: Array<{ key: string; value: string }>;
|
||
galleryImages: string[];
|
||
videos: string[];
|
||
videoCovers: string[];
|
||
skuVariants: Array<{ name: string; image?: string }>;
|
||
}
|
||
|
||
function mergeStructured(
|
||
state: OzonStateData,
|
||
jsonld: ReturnType<typeof extractJsonLd>,
|
||
api: OzonPageData | null
|
||
): StructuredBundle {
|
||
const bundle: StructuredBundle = {
|
||
title: state.title || jsonld?.title || api?.title,
|
||
price: state.price || jsonld?.price || api?.price,
|
||
brand: jsonld?.brand,
|
||
description: api?.description || jsonld?.description,
|
||
characteristics: [...state.characteristics],
|
||
galleryImages: [...state.galleryImages],
|
||
videos: [...state.videos],
|
||
videoCovers: [...state.videoCovers],
|
||
skuVariants: [...state.skuVariants],
|
||
};
|
||
|
||
for (const u of api?.images ?? []) {
|
||
if (!bundle.galleryImages.includes(u)) bundle.galleryImages.push(u);
|
||
}
|
||
for (const u of api?.videos ?? []) {
|
||
if (!bundle.videos.includes(u)) bundle.videos.push(u);
|
||
}
|
||
const seenChars = new Set(bundle.characteristics.map((c) => `${c.key}::${c.value}`));
|
||
for (const c of api?.characteristics ?? []) {
|
||
const k = `${c.key}::${c.value}`;
|
||
if (!seenChars.has(k)) {
|
||
seenChars.add(k);
|
||
bundle.characteristics.push(c);
|
||
}
|
||
}
|
||
|
||
return bundle;
|
||
}
|
||
|
||
function buildFromBundle(profile: SiteProfile, bundle: StructuredBundle): {
|
||
texts: TextMaterial[];
|
||
images: ImageMaterial[];
|
||
} {
|
||
const texts: TextMaterial[] = [];
|
||
const images: ImageMaterial[] = [];
|
||
|
||
if (bundle.title) texts.push({ kind: 'title', content: bundle.title });
|
||
if (bundle.price) texts.push({ kind: 'price', content: bundle.price });
|
||
if (bundle.brand) texts.push({ kind: 'brand', content: bundle.brand });
|
||
if (bundle.characteristics.length) {
|
||
texts.push({
|
||
kind: 'params',
|
||
content: bundle.characteristics.map((p) => `${p.key}: ${p.value}`).join('\n'),
|
||
pairs: bundle.characteristics,
|
||
});
|
||
}
|
||
if (bundle.description) texts.push({ kind: 'desc', content: bundle.description });
|
||
|
||
let idx = 0;
|
||
bundle.galleryImages.forEach((u, i) => {
|
||
const orig = toOriginalUrl(u, profile.originalUrlRules);
|
||
images.push({
|
||
key: `main-${String(i + 1).padStart(3, '0')}`,
|
||
groupKey: 'main',
|
||
groupName: '主图',
|
||
url: orig,
|
||
thumbUrl: toThumbUrl(orig),
|
||
index: idx++,
|
||
type: 'img',
|
||
});
|
||
});
|
||
|
||
bundle.skuVariants.forEach((s, i) => {
|
||
if (!s.image) return;
|
||
const orig = toOriginalUrl(s.image, profile.originalUrlRules);
|
||
images.push({
|
||
key: `sku-${String(i + 1).padStart(3, '0')}`,
|
||
groupKey: 'sku',
|
||
groupName: 'SKU图片',
|
||
variantName: s.name || undefined,
|
||
url: orig,
|
||
thumbUrl: toThumbUrl(orig),
|
||
index: idx++,
|
||
type: 'img',
|
||
});
|
||
});
|
||
|
||
bundle.videos.forEach((u, i) => {
|
||
images.push({
|
||
key: `video-${String(i + 1).padStart(3, '0')}`,
|
||
groupKey: 'video',
|
||
groupName: '视频',
|
||
url: u,
|
||
thumbUrl: bundle.videoCovers[i] ?? '',
|
||
index: idx++,
|
||
type: 'video',
|
||
});
|
||
});
|
||
|
||
return { texts, images };
|
||
}
|
||
|
||
export async function scanOzon(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
|
||
const state = extractOzonState();
|
||
let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom';
|
||
|
||
const jsonld = extractJsonLd();
|
||
|
||
let api: OzonPageData | null = null;
|
||
if (itemId) {
|
||
try {
|
||
api = await fetchOzonPageData(itemId);
|
||
} catch (err) {
|
||
console.warn('[SuiteCollector] API 提取异常:', err);
|
||
}
|
||
}
|
||
|
||
const bundle = mergeStructured(state, jsonld, api);
|
||
const structured = buildFromBundle(profile, bundle);
|
||
if ((structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0) && source === 'dom') {
|
||
source = 'mixed';
|
||
}
|
||
|
||
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
|
||
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||
await autoScrollToBottom();
|
||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
|
||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||
const domTexts = collectTexts(profile).materials;
|
||
const domImages = collectImages(profile);
|
||
|
||
const texts = mergeTexts(structured.texts, domTexts);
|
||
const images = mergeImages(structured.images, domImages, profile);
|
||
return finalize(profile, itemId, texts, images, state.breadcrumbs, source);
|
||
}
|