/** * 采集引擎入口 - 扫描当前页 * * 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; // 分组统计 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, 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(); const seen = new Set(); 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 { 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 = {}; 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, }; }