Files
ozon-seller-kit/extension-v2/src/collector/scan.ts
T

279 lines
9.0 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.
/**
* 采集引擎入口 - 扫描当前页
*
* Ozon 四路径(优先级从高到低):
* ① SSR widget stateDOM data-state 属性,同步、白名单、无需网络)★ 主路径
* ② JSON-LDschema.org/Product
* ③ Ozon 内部页 JSON APIentrypoint-api.bx,只收画廊 widget 的图)
* ④ DOM 选择器(data-widget 区块)—— 兜底 + 详情图补充
*
* ① 白名单保证不会读到「为您推荐 / 一起购买」等其它商品 carousel 的图片。
*/
import { matchProfile } from '../profiles';
import { waitForAny } from './dom';
import { collectImages, type ImageMaterial } from './image';
import { collectTexts, mergeTexts, type TextMaterial } from './text';
import { extractJsonLd } from './jsonld';
import { fetchOzonPageData, type OzonPageData } from './ozon-api';
import { extractOzonState, type OzonStateData } from './ozon-state';
import { dedupeKey, toOriginalUrl, toThumbUrl } from './url';
import type { SiteProfile } from '../profiles/types';
export type { ImageMaterial, TextMaterial };
import type { BreadcrumbItem } from './ozon-state';
export interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径(用于 studio 类目推荐)
scannedAt: number;
stats: Record<string, number>; // 分组统计
warnings: string[]; // 警告(如详情图为 0
source: 'state' | 'jsonld' | 'api' | 'dom' | 'mixed'; // 主路径
}
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
{ key: 'main', name: '主图' },
{ key: 'sku', name: 'SKU图片' },
{ key: 'detail', name: '详情图' },
{ key: 'video', name: '视频' },
];
/** 合并后的结构化素材 */
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 }>;
}
/** 合并 state + JSON-LD + API,靠前来源优先,靠后来源填空缺 */
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],
};
// API 补充:画廊图片、视频、参数(state 没有才补)
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 };
}
/** 按组分组合并:结构化优先,DOM 填缺,按 dedupeKey 去重后重排 index */
function mergeImages(
structured: ImageMaterial[],
dom: ImageMaterial[],
profile: SiteProfile
): ImageMaterial[] {
const byGroup = new Map<string, ImageMaterial[]>();
const seen = new Set<string>();
let counter = 0;
const push = (m: ImageMaterial) => {
const k = m.groupKey === 'sku'
? `${dedupeKey(m.url, profile.originalUrlRules)}::${m.variantName ?? ''}`
: dedupeKey(m.url, profile.originalUrlRules);
if (seen.has(k)) return;
seen.add(k);
const arr = byGroup.get(m.groupKey) ?? [];
arr.push({ ...m, index: counter++ });
byGroup.set(m.groupKey, arr);
};
for (const m of structured) push(m);
for (const m of dom) push(m);
const out: ImageMaterial[] = [];
for (const g of GROUP_ORDER) {
const arr = byGroup.get(g.key);
if (!arr) continue;
// 组内重排 keymain-001 …)
arr.forEach((m, i) => {
m.key = `${g.key}-${String(i + 1).padStart(3, '0')}`;
m.groupName = g.name;
});
out.push(...arr);
}
return out;
}
export async function scanCurrentPage(): Promise<ScanResult | null> {
const profile = matchProfile(location.href);
if (!profile) {
console.warn('[Ozon Seller Kit] 当前页面不支持采集:', location.href);
return null;
}
const itemId = profile.extractItemId(location.href);
console.log('[Ozon Seller Kit] 开始采集:', profile.name, itemId, location.href);
// ── 路径①:SSR widget state(同步、白名单)──
const state = extractOzonState();
let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom';
// ── 路径②:JSON-LD ──
const jsonld = extractJsonLd();
// ── 路径③:Ozon 页 JSON API(异步,失败不阻塞)──
let api: OzonPageData | null = null;
if (profile.id === 'ozon' && itemId) {
try {
api = await fetchOzonPageData(itemId);
} catch (err) {
console.warn('[Ozon Seller Kit] API 提取异常:', err);
}
}
const bundle = mergeStructured(state, jsonld, api);
const structured = buildFromBundle(profile, bundle);
const usedStructured = structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0;
if (usedStructured && source === 'dom') source = 'mixed';
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
if (!anchor) {
console.warn('[Ozon Seller Kit] 等待页面就绪超时(继续尝试 DOM 采集)');
}
const domTexts = collectTexts(profile).materials;
const domImages = collectImages(profile);
// ── 合并 ──
const texts = mergeTexts(structured.texts, domTexts);
const images = mergeImages(structured.images, domImages, profile);
const stats: Record<string, number> = {};
for (const img of images) stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
const warnings: string[] = [];
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题(所有路径均失败)');
if (images.length === 0) warnings.push('未扫描到任何图片/视频');
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
console.log('[Ozon Seller Kit] 采集完成:', {
texts: texts.map((t) => t.kind),
images: images.length,
stats,
warnings,
source,
});
return {
platform: profile.id,
itemId,
url: location.href,
texts,
images,
breadcrumbs: state.breadcrumbs,
scannedAt: Date.now(),
stats,
warnings,
source,
};
}
// 暴露到全局供 side panel / console 调用
if (typeof window !== 'undefined') {
(window as any).__SellerHelperOzon = {
scan: scanCurrentPage,
};
}