feat: 插件开发 ozon 端主体完成

This commit is contained in:
Joey
2026-08-16 17:32:43 +08:00
parent 1591d5e35a
commit b57933e983
32 changed files with 1960 additions and 512 deletions
+20 -299
View File
@@ -1,307 +1,21 @@
/**
* 统一采集引擎入口 - 扫描当前页
* 统一采集引擎入口 - 只做路由:按平台分发到 platforms/ 下的平台文件。
*
* 平台选择采集策略
* - ozon四路径(SSR data-state ★主路径 → JSON-LD → 页 JSON API DOM 兜底),多源合并
* - taobao/tmallSSRwindow.__ICE_APP_CONTEXT__)★主路径 + DOM 补充(详情图在 DOM
* - 1688:纯 DOM(多套选择器变体
*
* 各路径产出的素材最终走同一个合并器:文本按 kind 合并(params 按键并集),
* 图片按组去重后重排 key。
* 平台编排逻辑(各路径与合并策略)见
* platforms/ozon.ts Ozon 四路径(SSR data-state / JSON-LD / 站内 API / DOM
* platforms/taobao.ts 淘宝/天猫(桥读全局 / mtop 签名 API / DOM
* platforms/1688.ts 1688(桥读 window.context / detailUrl 详情 / DOM
* 共用合并器见 merge.ts。
*/
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, type BreadcrumbItem } from './ozon-state';
import { extractSSRData, type SSRData } from './ssr';
import { buildFromSSR } from './ssr-builder';
import { dedupeKey, toOriginalUrl, toThumbUrl } from './url';
import type { SiteProfile } from '../profiles/types';
import { readWindowKeys } from '../bridge/read-window';
import { scanOzon } from './platforms/ozon';
import { scanTaobao } from './platforms/taobao';
import { scan1688 } from './platforms/1688';
import type { ScanResult } from './merge';
export type { ImageMaterial, TextMaterial };
export interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
breadcrumbs: BreadcrumbItem[];
scannedAt: number;
stats: Record<string, number>; // 分组统计
warnings: string[]; // 警告(如详情图为 0
source: 'state' | 'ssr' | '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: '视频' },
];
// ── Ozon:结构化合并(state + jsonld + api)───────────────────────────────
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 }>;
}
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],
};
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 };
}
// ── 统一合并器 ────────────────────────────────────────────────────────────
/** 按组分组合并:靠前来源优先,靠后来源填缺,按 dedupeKey 去重后重排 index */
function mergeImages(
primary: ImageMaterial[],
fallback: 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 primary) push(m);
for (const m of fallback) push(m);
const out: ImageMaterial[] = [];
for (const g of GROUP_ORDER) {
const arr = byGroup.get(g.key);
if (!arr) continue;
arr.forEach((m, i) => {
m.key = `${g.key}-${String(i + 1).padStart(3, '0')}`;
m.groupName = g.name;
});
out.push(...arr);
}
return out;
}
function finalize(
profile: SiteProfile,
itemId: string | null,
texts: TextMaterial[],
images: ImageMaterial[],
breadcrumbs: BreadcrumbItem[],
source: ScanResult['source']
): ScanResult {
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 张,请滚动到页面底部后重新采集');
return {
platform: profile.id,
itemId,
url: location.href,
texts,
images,
breadcrumbs,
scannedAt: Date.now(),
stats,
warnings,
source,
};
}
// ── 各平台策略 ────────────────────────────────────────────────────────────
/** Ozon:四路径合并(来自 extension-v2 生产逻辑) */
async function scanOzon(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
const state = extractOzonState();
let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom';
const jsonld = extractJsonLd();
let api: OzonPageData | null = null;
if (itemId) {
try {
api = await fetchOzonPageData(itemId);
} catch (err) {
console.warn('[SuiteCollector] API 提取异常:', err);
}
}
const bundle = mergeStructured(state, jsonld, api);
const structured = buildFromBundle(profile, bundle);
if ((structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0) && source === 'dom') {
source = 'mixed';
}
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const domTexts = collectTexts(profile).materials;
const domImages = collectImages(profile);
const texts = mergeTexts(structured.texts, domTexts);
const images = mergeImages(structured.images, domImages, profile);
return finalize(profile, itemId, texts, images, state.breadcrumbs, source);
}
/** 淘宝/天猫:SSR 主路径 + DOM 补充(详情图、SKU 兜底都在 DOM 里) */
async function scanTaobao(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
const ssrData: SSRData | null = extractSSRData();
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
let breadcrumbs: BreadcrumbItem[] = [];
if (ssrData) {
const built = buildFromSSR(ssrData, profile);
// ssr-builder 的本地类型 groupKey 是 string,这里对齐到 ImageGroupKey
primaryTexts = built.texts;
primaryImages = built.images as ImageMaterial[];
source = 'ssr';
}
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
const domImages = collectImages(profile);
if (primaryImages.length > 0 && domImages.length > 0) source = 'mixed';
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, itemId ?? ssrData?.item.itemId ?? null, texts, images, breadcrumbs, source);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
/** 1688:纯 DOM(多套画廊选择器变体覆盖线上版本) */
async function scan1688(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: texts, missingRequired } = collectTexts(profile);
const images = collectImages(profile);
const result = finalize(profile, itemId, texts, images, [], 'dom');
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
// ── 入口 ──────────────────────────────────────────────────────────────────
export type { ScanResult };
export type { ImageMaterial, TextMaterial } from './merge';
export async function scanCurrentPage(): Promise<ScanResult | null> {
const profile = matchProfile(location.href);
@@ -334,9 +48,16 @@ export async function scanCurrentPage(): Promise<ScanResult | null> {
return result;
}
// 诊断工具:列出页面全局数据键(在商品页 console 跑 __SuiteCollector.probe()
export async function probeWindowKeys(): Promise<string[]> {
const res = await readWindowKeys(['*'], 1500);
return (res['__sc_window_keys__'] as string[]) ?? [];
}
// 暴露到全局供 side panel / console 调用
if (typeof window !== 'undefined') {
(window as any).__SuiteCollector = {
scan: scanCurrentPage,
probe: probeWindowKeys,
};
}