feat: 插件开发 ozon 端主体完成
This commit is contained in:
+20
-299
@@ -1,307 +1,21 @@
|
||||
/**
|
||||
* 统一采集引擎入口 - 扫描当前页
|
||||
* 统一采集引擎入口 - 只做路由:按平台分发到 platforms/ 下的平台文件。
|
||||
*
|
||||
* 按平台选择采集策略:
|
||||
* - ozon:四路径(SSR data-state ★主路径 → JSON-LD → 页 JSON API → DOM 兜底),多源合并
|
||||
* - taobao/tmall:SSR(window.__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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user