feat: 开发采集插件
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
// ==========================================
|
||||
// 本地导出实现方案 (基于1688插件方式)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 导出配置
|
||||
*/
|
||||
interface ExportConfig {
|
||||
downloadType: '1' | '2'; // 1=平铺, 2=分组到子文件夹
|
||||
includeJson: boolean; // 是否导出product.json
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出素材到本地
|
||||
* 在 background.ts 中实现
|
||||
*/
|
||||
async function exportToLocal(
|
||||
folderName: string,
|
||||
materials: {
|
||||
texts: TextMaterial[];
|
||||
images: ImageMaterial[];
|
||||
},
|
||||
config: ExportConfig
|
||||
) {
|
||||
const downloadTasks: Promise<void>[] = [];
|
||||
|
||||
// 1. 导出图片
|
||||
for (const img of materials.images) {
|
||||
const groupFolder = config.downloadType === '2' ? img.groupName : '';
|
||||
|
||||
// 文件名: 分组key-索引-规格名(可选).扩展名
|
||||
const ext = img.url.split('.').pop()?.split('?')[0] || 'jpg';
|
||||
let filename = `${img.groupKey}-${String(img.index).padStart(3, '0')}`;
|
||||
if (img.variantName) {
|
||||
filename += `-${img.variantName}`;
|
||||
}
|
||||
filename += `.${ext}`;
|
||||
|
||||
// 构建完整路径: 商品名/分组/文件名
|
||||
const path = [folderName, groupFolder, filename]
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
|
||||
downloadTasks.push(
|
||||
chrome.downloads.download({
|
||||
url: img.url,
|
||||
filename: path,
|
||||
conflictAction: 'uniquify',
|
||||
saveAs: false
|
||||
}).then(() => {
|
||||
console.log(`Downloaded: ${path}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 导出product.json (Ozon API格式)
|
||||
if (config.includeJson) {
|
||||
const productData = buildOzonProductJson(materials);
|
||||
const jsonBlob = new Blob(
|
||||
[JSON.stringify(productData, null, 2)],
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
const jsonUrl = URL.createObjectURL(jsonBlob);
|
||||
|
||||
downloadTasks.push(
|
||||
chrome.downloads.download({
|
||||
url: jsonUrl,
|
||||
filename: `${folderName}/product.json`,
|
||||
conflictAction: 'overwrite',
|
||||
saveAs: false
|
||||
}).then(() => {
|
||||
URL.revokeObjectURL(jsonUrl);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 等待所有下载完成
|
||||
await Promise.allSettled(downloadTasks);
|
||||
|
||||
return {
|
||||
total: downloadTasks.length,
|
||||
folder: folderName
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Ozon API格式的JSON
|
||||
* 参考: https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3
|
||||
*/
|
||||
function buildOzonProductJson(materials: {
|
||||
texts: TextMaterial[];
|
||||
images: ImageMaterial[];
|
||||
}): OzonProductImport {
|
||||
const title = materials.texts.find(t => t.kind === 'title')?.content || '';
|
||||
const desc = materials.texts.find(t => t.kind === 'desc')?.content || '';
|
||||
const params = materials.texts.find(t => t.kind === 'params');
|
||||
|
||||
// 图片URL按分组整理
|
||||
const mainImages = materials.images
|
||||
.filter(img => img.groupKey === 'main')
|
||||
.map(img => img.url);
|
||||
|
||||
const skuImages = materials.images
|
||||
.filter(img => img.groupKey === 'sku')
|
||||
.reduce((acc, img) => {
|
||||
if (img.variantName) {
|
||||
acc[img.variantName] = img.url;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return {
|
||||
items: [{
|
||||
// 基础信息
|
||||
name: title,
|
||||
description: desc,
|
||||
offer_id: '', // 需要用户填写
|
||||
|
||||
// 图片
|
||||
images: mainImages,
|
||||
color_image: skuImages[Object.keys(skuImages)[0]] || '',
|
||||
|
||||
// 参数 (简化版,实际需要映射到Ozon类目属性)
|
||||
attributes: params?.pairs?.map(p => ({
|
||||
complex_id: 0,
|
||||
id: 0, // 需要查询Ozon类目属性字典
|
||||
values: [{
|
||||
value: p.value
|
||||
}]
|
||||
})) || [],
|
||||
|
||||
// 尺寸重量 (需要从参数中提取或用户填写)
|
||||
height: 0,
|
||||
width: 0,
|
||||
depth: 0,
|
||||
dimension_unit: 'cm',
|
||||
weight: 0,
|
||||
weight_unit: 'g'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息处理: 导出命令
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.name === 'export-to-local') {
|
||||
exportToLocal(
|
||||
msg.payload.folderName,
|
||||
msg.payload.materials,
|
||||
msg.payload.config
|
||||
)
|
||||
.then(result => sendResponse({ ok: true, data: result }))
|
||||
.catch(error => sendResponse({ ok: false, error: error.message }));
|
||||
|
||||
return true; // 保持异步通道
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Manifest配置
|
||||
// ==========================================
|
||||
/*
|
||||
{
|
||||
"optional_permissions": [
|
||||
"downloads" // 放在optional中,首次导出时才申请
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"https://www.ozon.ru/*",
|
||||
"https://cdn*.ozon.ru/*" // 图片CDN
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
// ==========================================
|
||||
// Side Panel UI - 导出操作
|
||||
// ==========================================
|
||||
/*
|
||||
<div class="export-section">
|
||||
<h3>导出选项</h3>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="exportType" value="2" checked>
|
||||
分组到子文件夹 (推荐)
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="exportType" value="1">
|
||||
全部平铺
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="includeJson" checked>
|
||||
同时导出product.json (Ozon格式)
|
||||
</label>
|
||||
|
||||
<button onclick="handleExport()">
|
||||
导出到本地 (Downloads文件夹)
|
||||
</button>
|
||||
|
||||
<p class="hint">
|
||||
文件将保存到: ~/Downloads/[商品名]/
|
||||
</p>
|
||||
</div>
|
||||
*/
|
||||
|
||||
async function handleExport() {
|
||||
// 1. 首次使用时请求downloads权限
|
||||
const hasPermission = await chrome.permissions.contains({
|
||||
permissions: ['downloads']
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
const granted = await chrome.permissions.request({
|
||||
permissions: ['downloads']
|
||||
});
|
||||
if (!granted) {
|
||||
alert('需要下载权限才能导出文件');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取当前文件夹数据
|
||||
const materials = await getCurrentFolderMaterials();
|
||||
const folderName = cleanFilename(materials.title || '未命名商品');
|
||||
|
||||
// 3. 发送导出消息到background
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
name: 'export-to-local',
|
||||
payload: {
|
||||
folderName,
|
||||
materials,
|
||||
config: {
|
||||
downloadType: document.querySelector('input[name="exportType"]:checked').value,
|
||||
includeJson: document.querySelector('input[name="includeJson"]').checked
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
alert(`成功导出 ${result.data.total} 个文件到:\n~/Downloads/${result.data.folder}/`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件名中的非法字符
|
||||
*/
|
||||
function cleanFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[<>:"/\\|?*]/g, '_') // Windows非法字符
|
||||
.replace(/\s+/g, '_') // 空格替换为下划线
|
||||
.substring(0, 100); // 限制长度
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 类型定义
|
||||
// ==========================================
|
||||
|
||||
interface TextMaterial {
|
||||
kind: 'title' | 'params' | 'desc' | 'price';
|
||||
content: string;
|
||||
pairs?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
interface ImageMaterial {
|
||||
groupKey: 'main' | 'sku' | 'detail' | 'video';
|
||||
groupName: string;
|
||||
variantName?: string; // SKU规格名
|
||||
url: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface OzonProductImport {
|
||||
items: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
offer_id: string;
|
||||
images: string[];
|
||||
color_image: string;
|
||||
attributes: Array<{
|
||||
complex_id: number;
|
||||
id: number;
|
||||
values: Array<{
|
||||
dictionary_value_id?: number;
|
||||
value?: string;
|
||||
}>;
|
||||
}>;
|
||||
height: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
dimension_unit: string;
|
||||
weight: number;
|
||||
weight_unit: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Ozon商品页采集配置
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 人工控制触发,不做复杂等待
|
||||
* 2. 多套选择器并存,应对Ozon的A/B测试
|
||||
* 3. 优先采集跟卖必需的字段
|
||||
*/
|
||||
|
||||
import type { SiteProfile } from './types';
|
||||
|
||||
export const profileOzon: SiteProfile = {
|
||||
id: 'ozon',
|
||||
name: 'Ozon',
|
||||
|
||||
// URL匹配
|
||||
urlPatterns: [
|
||||
/^https:\/\/www\.ozon\.ru\/product\//,
|
||||
/^https:\/\/www\.ozon\.ru\/context\/detail\/id\//
|
||||
],
|
||||
|
||||
// 提取商品ID
|
||||
extractItemId: (url) => {
|
||||
// Ozon URL格式: https://www.ozon.ru/product/name-123456789/
|
||||
const match = url.match(/\/product\/[^\/]+-(\d+)/);
|
||||
return match?.[1] ?? null;
|
||||
},
|
||||
|
||||
// 简单的就绪检测 - 只要关键元素存在即可
|
||||
readySelectors: [
|
||||
'[data-widget="webProductHeading"]', // 标题区
|
||||
'[data-widget="webGallery"]' // 图片画廊
|
||||
],
|
||||
readyTimeoutMs: 5_000, // 快速失败,不等太久
|
||||
|
||||
// 图片来源属性优先级
|
||||
defaultSrcProps: ['data-src', 'currentSrc', 'src'],
|
||||
|
||||
refererOrigin: 'https://www.ozon.ru',
|
||||
|
||||
// ==========================================
|
||||
// 文本素材规则
|
||||
// ==========================================
|
||||
textRules: [
|
||||
// 1. 标题 (必需)
|
||||
{
|
||||
kind: 'title',
|
||||
selectors: [
|
||||
'[data-widget="webProductHeading"] h1',
|
||||
'.tsHeadline500Medium',
|
||||
'h1[itemprop="name"]'
|
||||
],
|
||||
extract: 'first',
|
||||
required: true
|
||||
},
|
||||
|
||||
// 2. 价格
|
||||
{
|
||||
kind: 'price',
|
||||
selectors: [
|
||||
'[data-widget="webPrice"] span[class*="tsBodyControl500"]',
|
||||
'[data-widget="webPrice"] span',
|
||||
'.c2h9_27 span', // 可能的备用类名
|
||||
'span[itemprop="price"]'
|
||||
],
|
||||
extract: 'first'
|
||||
},
|
||||
|
||||
// 3. 参数表 (特性)
|
||||
{
|
||||
kind: 'params',
|
||||
selectors: [
|
||||
'[data-widget="webCharacteristics"] dl',
|
||||
'[data-widget="webDetailedCharacteristics"] dl',
|
||||
'.k1p_27 dl'
|
||||
],
|
||||
extract: 'table',
|
||||
tableKeySelector: 'dt',
|
||||
tableValueSelector: 'dd'
|
||||
},
|
||||
|
||||
// 4. 简介/卖点
|
||||
{
|
||||
kind: 'selling_point',
|
||||
selectors: [
|
||||
'[data-widget="webFeatures"]',
|
||||
'[data-widget="webAO"]',
|
||||
'.h9o_27' // About this item
|
||||
],
|
||||
extract: 'join'
|
||||
},
|
||||
|
||||
// 5. 详细描述
|
||||
{
|
||||
kind: 'desc',
|
||||
selectors: [
|
||||
'[data-widget="webDescription"]',
|
||||
'[data-widget="webRichContent"]',
|
||||
'.RA-a1'
|
||||
],
|
||||
extract: 'join'
|
||||
}
|
||||
],
|
||||
|
||||
// ==========================================
|
||||
// 图片素材规则
|
||||
// ==========================================
|
||||
imageGroups: [
|
||||
// 主图画廊
|
||||
{
|
||||
key: 'main',
|
||||
name: '主图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-widget="webGallery"] img[class*="Image"]',
|
||||
'[data-widget="webGallery"] source', // picture元素
|
||||
'[data-widget="webPhotoGallery"] img',
|
||||
'.b013-a img' // 旧版选择器
|
||||
],
|
||||
minWidth: 200,
|
||||
minHeight: 200
|
||||
},
|
||||
|
||||
// SKU变体图 (颜色/尺寸)
|
||||
{
|
||||
key: 'sku',
|
||||
name: 'SKU图片',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-widget="webDetailSKU"] button img',
|
||||
'[data-widget="webVariants"] img',
|
||||
'[data-widget="webSku"] img',
|
||||
'.k3r_27 img' // SKU容器
|
||||
],
|
||||
// SKU规格名提取
|
||||
nameSelectors: [
|
||||
'span[class*="Value"]',
|
||||
'span[class*="Text"]',
|
||||
'.tsBodyControl400Small'
|
||||
],
|
||||
minWidth: 20,
|
||||
minHeight: 20
|
||||
},
|
||||
|
||||
// 详情图 (描述中的图片)
|
||||
{
|
||||
key: 'detail',
|
||||
name: '详情图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-widget="webDescription"] img',
|
||||
'[data-widget="webRichContent"] img',
|
||||
'[data-widget="webFeatures"] img',
|
||||
'.RA-a1 img'
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100
|
||||
},
|
||||
|
||||
// 视频 (如果有)
|
||||
{
|
||||
key: 'video',
|
||||
name: '视频',
|
||||
type: 'video',
|
||||
selectors: [
|
||||
'[data-widget="webGallery"] video',
|
||||
'[data-widget="webVideo"] video',
|
||||
'video[class*="Video"]'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// ==========================================
|
||||
// 图片URL处理规则
|
||||
// ==========================================
|
||||
originalUrlRules: [
|
||||
{
|
||||
// Ozon CDN缩略图处理
|
||||
// 例: /wc200/xxx.jpg → /wc1200/xxx.jpg (获取更高分辨率)
|
||||
match: /\/wc\d+\//,
|
||||
replace: '/wc1200/'
|
||||
},
|
||||
{
|
||||
// 或者移除尺寸参数
|
||||
// 例: image.jpg?width=200 → image.jpg
|
||||
match: /\?(width|height|size|quality)=[^&]+&?/g,
|
||||
replace: ''
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Ozon特殊处理函数
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Ozon页面额外的数据提取
|
||||
* (可选) 从页面的JSON-LD结构化数据中提取
|
||||
*/
|
||||
export function extractOzonStructuredData(): {
|
||||
brand?: string;
|
||||
sku?: string;
|
||||
availability?: string;
|
||||
} | null {
|
||||
try {
|
||||
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||||
for (const script of scripts) {
|
||||
const data = JSON.parse(script.textContent || '{}');
|
||||
if (data['@type'] === 'Product') {
|
||||
return {
|
||||
brand: data.brand?.name,
|
||||
sku: data.sku,
|
||||
availability: data.offers?.availability
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract structured data:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测Ozon页面是否已就绪
|
||||
* (简化版 - 只检查关键元素存在)
|
||||
*/
|
||||
export function isOzonPageReady(): {
|
||||
ready: boolean;
|
||||
missing: string[];
|
||||
} {
|
||||
const requiredElements = [
|
||||
{ selector: '[data-widget="webProductHeading"]', name: '标题' },
|
||||
{ selector: '[data-widget="webGallery"]', name: '图片画廊' }
|
||||
];
|
||||
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const elem of requiredElements) {
|
||||
if (!document.querySelector(elem.selector)) {
|
||||
missing.push(elem.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: missing.length === 0,
|
||||
missing
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 使用示例 (在content script中)
|
||||
// ==========================================
|
||||
/*
|
||||
import { profileOzon, isOzonPageReady } from './profiles/ozon';
|
||||
|
||||
// 用户点击"采集"按钮时
|
||||
async function handleCollect() {
|
||||
// 1. 快速检查
|
||||
const { ready, missing } = isOzonPageReady();
|
||||
if (!ready) {
|
||||
alert(`页面未完全加载,缺少: ${missing.join(', ')}\n请稍候再试`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 执行采集
|
||||
const result = await scanCurrentPage(); // 使用通用采集引擎
|
||||
|
||||
// 3. 显示结果
|
||||
console.log('采集完成:', result);
|
||||
}
|
||||
*/
|
||||
Reference in New Issue
Block a user