279 lines
9.0 KiB
TypeScript
279 lines
9.0 KiB
TypeScript
/**
|
||
* 采集引擎入口 - 扫描当前页
|
||
*
|
||
* Ozon 四路径(优先级从高到低):
|
||
* ① SSR widget state(DOM data-state 属性,同步、白名单、无需网络)★ 主路径
|
||
* ② JSON-LD(schema.org/Product)
|
||
* ③ Ozon 内部页 JSON API(entrypoint-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;
|
||
// 组内重排 key(main-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,
|
||
};
|
||
}
|