feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 采集引擎入口 - 扫描当前页
|
||||
*
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user