feat: 开发采集、采集箱和商品编辑功能

This commit is contained in:
Joey
2026-08-15 22:17:26 +08:00
parent c61d1a3154
commit 36357843d0
130 changed files with 18005 additions and 12 deletions
+278
View File
@@ -0,0 +1,278 @@
/**
* 采集引擎入口 - 扫描当前页
*
* 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,
};
}