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
+124
View File
@@ -0,0 +1,124 @@
# Ozon Seller Kit - Ozon 采集插件(extension-v2
采集 Ozon 商品页信息(标题 / 价格 / 参数 / 卖点 / 描述 / 图片 / 视频)到本地商品文件夹。
参考实现:`reference/maozi-plugin-3.2.3`(毛子ERP,Ozon 跟卖插件)。本项目按其采集思路,
落成本仓库 `docs/` 已经定下的「纯采集器 + 本地商品文件夹」架构(见 `docs/contracts/product-json.md`)。
## 与毛子ERP 的对应关系
| 毛子ERP 做法 | 本插件实现 |
|---|---|
| 请求 Ozon 内部页 JSON 接口 `entrypoint-api.bx/page/json/v2` | ✅ `src/collector/ozon-api.ts`(只收画廊 widget 的图)|
| 解析 `application/ld+json` 结构化数据 | ✅ `src/collector/jsonld.ts` |
| DOM `data-widget` 区块选择器 | ✅ `src/profiles/ozon.ts`(兜底 + 详情图补充)|
| —(Ozon SSR 页面自带 widget data-state | ✅ `src/collector/ozon-state.ts`(★ 主路径,白名单)|
| 上传到毛子云后端 | ❌ 改为写本地商品文件夹(架构决策 D3/R1)|
| 登录 / AI / 跟卖工作流 | ❌ 不实现,插件只做采集 |
## 快速开始
```bash
cd extension-v2
pnpm install
pnpm dev # 或 pnpm build 出 .output/chrome-mv3
```
然后:
1. 打开 `chrome://extensions/`
2. 开启「开发者模式」
3. 「加载已解压的扩展程序」→ 选择 `extension-v2/.output/chrome-mv3`
## 使用
1. 打开任意 Ozon 商品详情页(`ozon.ru` / `ozon.kz` / `ozon.by`
2. 滚动到页面底部,让详情图完成懒加载
3. 点扩展图标 → 侧边栏打开
4. 「选择保存目录」(仅首次,之后自动记住)
5. 「开始采集当前页」→ 核对结果、勾选图片
6. 「导出到本地」→ 生成商品文件夹
## 采集路径(四路径降级)
```
① SSR widget stateDOM data-state 属性)── 主路径,同步、白名单、无需网络
webGallery / webPrice / webProductHeading / webShortCharacteristics / webAspects …
│ 缺失
② JSON-LDschema.org/Product)── 标准化,稳定(标题/品牌/价格/评分)
│ 缺失
③ Ozon 页 JSON APIentrypoint-api.bx)── 补充完整参数表与富文本描述
│ 缺失
④ DOM 选择器(data-widget 区块)── 兜底 + 详情图补充
```
**「为您推荐 / 一起购买」等其它商品 carousel 的图片不会被采集**
- ① 只读白名单 widgetwebGallery / webAspects 等)的 data-state,绝不遍历全页;
- ③ 只从画廊类 widget 收图片,不递归整个 widgetStates(旧版 bug 所在);
- ④ DOM 选择器按 data-widget 区块作用域限定。
## 目录结构
```
extension-v2/
├── entrypoints/
│ ├── background.ts # 图片代理 fetch(绕 CORS / 防盗链)
│ ├── sidepanel/ # 采集控制 UI
│ └── content/index.ts # 注入商品页,暴露采集入口
├── src/
│ ├── profiles/ # ozon.tsDOM 选择器 + URL/CDN 规则)
│ ├── collector/ # 采集引擎
│ │ ├── ozon-state.ts # ★ SSR data-state 提取(主路径,白名单)
│ │ ├── jsonld.ts # JSON-LD 提取
│ │ ├── ozon-api.ts # Ozon 页 JSON API(只收画廊 widget 的图)
│ │ ├── scan.ts # scanCurrentPage() 入口(四路径编排)
│ │ ├── image.ts / text.ts / dom.ts / url.ts
│ ├── export/ # builder / filesystem / idb
│ └── schema/product.ts # product.json 契约(TS 侧)
├── scripts/verify-pages.ts # 用 reference/ozon*.html 验证采集逻辑
├── wxt.config.ts
└── package.json
```
## Console 调试
在 Ozon 商品详情页的 Console 里执行:
```js
const r = await window.__SellerHelperOzon.scan();
console.table(r.texts);
console.table(r.images);
console.log('stats:', r.stats, 'warnings:', r.warnings, 'source:', r.source);
```
## 验证
`scripts/verify-pages.ts` 用真实保存的页面(`reference/ozon1.html``ozon2.html`
跑采集逻辑,覆盖:标题 / 价格 / 评分 / 画廊原图 / SKU 变体(图+名)/ 参数表 /
URL 还原(/wc\d+/、/c\d+/ 尺寸标记 → 原图)/ 缩略图生成。
```bash
# 从 extension-v2 目录
node_modules/.pnpm/esbuild@0.25.12/node_modules/esbuild/bin/esbuild \
scripts/verify-pages.ts --bundle --platform=node --format=esm --outfile=/tmp/verify.mjs
node /tmp/verify.mjs
```
## 当前状态与已知限制
- ✅ 四路径采集引擎(SSR state / JSON-LD / API / DOM),选择器已在真实页面核实
- ✅ 「为您推荐 / 一起购买」等其它商品图片已排除(白名单 + 画廊 widget 限定)
- ✅ Side Panel UI(分组预览、勾选、文件夹名、写盘进度)
- ✅ File System Access 写本地商品文件夹(product.json + sources.json + images/
- ✅ 图片 CDN 域名(ir.ozone.ru / io.ozone.ru / v-1.ozone.ru / cdn1.ozonusercontent.com)已加入 host_permissions
- ⚠️ 详情图(webDescription 区)在静态快照里没有,需滚动到底部后由 DOM 补充
- ⚠️ 完整参数表(>5 项)走 API 补充,若 API 被风控则只有前 5 项(webShortCharacteristics
## 相关文档
- [总体架构](../../docs/architecture.md)
- [商品文件夹契约](../../docs/contracts/product-json.md)
- [插件方案(含毛子ERP 逆向分析)](../../docs/extension/plan.md)
- [插件方案修正(Ozon 优先)](../../docs/extension/plan-revision.md)
+44
View File
@@ -0,0 +1,44 @@
import { uploadMaterials } from '../src/api/client';
// Background Service Worker —— 唯一出网口(代理图片 fetch / 上传服务端,绕 CORS / 防盗链)
export default defineBackground(() => {
console.log('[套娃采集助手] background started');
// 点击扩展图标 → 打开 Side Panel
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.action === 'fetchImage') {
fetchImageAsDataUrl(msg.url)
.then((dataUrl) => sendResponse({ ok: true, dataUrl }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true; // 保持异步通道
}
if (msg?.action === 'uploadMaterials') {
uploadMaterials(msg.baseUrl, msg.token, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
return false;
});
});
/** 代理取图,返回 base64 data URL(结构化克隆可安全跨消息传递) */
async function fetchImageAsDataUrl(url: string): Promise<string> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const type = res.headers.get('content-type') || 'image/jpeg';
const buf = await res.arrayBuffer();
const bytes = new Uint8Array(buf);
// 分块转二进制串,避免超长参数列表
let bin = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return `data:${type};base64,${btoa(bin)}`;
}
+20
View File
@@ -0,0 +1,20 @@
// Content Script —— 注入 Ozon 商品页,暴露采集入口
import { scanCurrentPage } from '../../src/collector/scan';
export default defineContentScript({
matches: [
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*'
],
main() {
console.log('[Ozon Seller Kit] Content script loaded');
// 暴露采集入口到全局(供 side panel 调用 / console 调试)
(window as any).__SellerHelperOzon = {
scan: scanCurrentPage,
};
console.log('[Ozon Seller Kit] 就绪。Console 可测: await window.__SellerHelperOzon.scan()');
},
});
+710
View File
@@ -0,0 +1,710 @@
import { createRoot } from 'react-dom/client';
import { useEffect, useMemo, useState } from 'react';
import {
App as AntdApp,
Alert,
Button,
Card,
Collapse,
ConfigProvider,
Form,
Input,
Row,
Col,
Space,
Tag,
Typography,
theme,
} from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
CloudUploadOutlined,
DownloadOutlined,
ScanOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
import { cleanFilename } from '../../src/collector/url';
import { buildProduct, type TextEdits } from '../../src/export/builder';
import { chooseRootDir, writeProductFolder, type ExportResult } from '../../src/export/filesystem';
import { loadRootDir } from '../../src/export/idb';
import { buildMaterialsPayload } from '../../src/api/client';
import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings';
const { Title, Text } = Typography;
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
{ key: 'main', name: '主图' },
{ key: 'sku', name: 'SKU图' },
{ key: 'detail', name: '详情图' },
{ key: 'video', name: '视频' },
];
function defaultSelection(result: ScanResult): Set<string> {
const sel = new Set<string>();
let detailCount = 0;
for (const img of result.images) {
if (img.groupKey === 'detail') {
if (detailCount < 3) sel.add(img.key);
detailCount++;
} else {
sel.add(img.key);
}
}
return sel;
}
function norm(s: string): string {
return s.toLowerCase().trim().replace(/[,:()()]/g, '');
}
/** 从参数表里抽出「包装重量 + 包装尺寸(长宽高)」,其余参数保留 */
function extractWeightAndDims(pairs: Array<{ key: string; value: string }>): {
weight: string;
dims: { l: string; w: string; h: string };
dimsUnit: 'mm' | 'cm';
remaining: Array<{ key: string; value: string }>;
} {
// 包装重量:优先「包装重量」,其次「重量 / вес」
const weightP =
pairs.find((p) => {
const k = norm(p.key);
return k.includes('包装重量') || k.includes('вес упаковки');
}) ??
pairs.find((p) => {
const k = norm(p.key);
return k.includes('重量') || k.includes('вес');
});
// 分开的长/宽/高(用「长度/宽度/高度」而非「长/宽/高」,避免误匹配「长X宽x高」这种合并键)
const lenP = pairs.find((p) => {
const k = norm(p.key);
return k.includes('包装长度') || k.includes('长度') || k.includes('длина');
});
const widP = pairs.find((p) => {
const k = norm(p.key);
return k.includes('包装宽度') || k.includes('宽度') || k.includes('ширина');
});
const heiP = pairs.find((p) => {
const k = norm(p.key);
return k.includes('包装高度') || k.includes('高度') || k.includes('высота');
});
let l = lenP?.value ?? '';
let w = widP?.value ?? '';
let h = heiP?.value ?? '';
let dimsUnit: 'mm' | 'cm' = 'cm';
// 合并的「包装尺寸(长X宽x高),厘米 = 48*18*25」→ 拆分
let dimP: { key: string; value: string } | undefined;
if (!l && !w && !h) {
dimP = pairs.find((p) => norm(p.key).includes('包装尺寸'));
if (!dimP) dimP = pairs.find((p) => norm(p.key).includes('размер') || norm(p.key).includes('габарит'));
if (!dimP) dimP = pairs.find((p) => norm(p.key).includes('尺寸'));
if (dimP) {
const isMm = /(мм|mm|毫米)/.test(`${dimP.key} ${dimP.value}`.toLowerCase());
dimsUnit = isMm ? 'mm' : 'cm';
const nums = dimP.value.match(/\d+(?:[.,]\d+)?/g) ?? [];
if (nums.length >= 3) {
l = nums[0] ?? '';
w = nums[1] ?? '';
h = nums[2] ?? '';
}
}
} else {
const combined = `${lenP?.key ?? ''} ${lenP?.value ?? ''} ${widP?.value ?? ''} ${heiP?.value ?? ''}`.toLowerCase();
dimsUnit = /(мм|mm|毫米)/.test(combined) ? 'mm' : 'cm';
}
// 其余参数保留(去掉已抽走的重量/尺寸项)
const used = new Set([weightP, lenP, widP, heiP, dimP].filter(Boolean));
const remaining = pairs.filter((p) => !used.has(p));
return { weight: weightP?.value ?? '', dims: { l, w, h }, dimsUnit, remaining };
}
/** 调用页面里的采集入口,返回 ScanResult 或 null(未加载/不支持) */
async function scanTab(tabId: number): Promise<ScanResult | null> {
try {
const [r] = await chrome.scripting.executeScript({
target: { tabId },
func: () => (window as any).__SellerHelperOzon?.scan?.(),
});
return (r?.result as ScanResult) ?? null;
} catch {
return null;
}
}
/** 判断当前 tab 是否 Ozon 商品详情页 */
async function isProductPage(tabId: number): Promise<boolean> {
try {
const [r] = await chrome.scripting.executeScript({
target: { tabId },
func: () =>
/\/product\/[^/]+-\d+\/?/.test(location.pathname) ||
/\/context\/detail\/id\/\d+/.test(location.pathname),
});
return !!r?.result;
} catch {
return false;
}
}
function Panel() {
const { message } = AntdApp.useApp();
const { token } = theme.useToken();
const [form] = Form.useForm();
const [status, setStatus] = useState('');
const [error, setError] = useState('');
const [result, setResult] = useState<ScanResult | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [params, setParams] = useState<Array<{ key: string; value: string }>>([]);
const [dimsUnit, setDimsUnit] = useState<'mm' | 'cm'>('cm');
const [folderName, setFolderName] = useState('');
const [rootLabel, setRootLabel] = useState('');
const [exporting, setExporting] = useState(false);
const [uploading, setUploading] = useState(false);
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
const [uploadResult, setUploadResult] = useState<{ product_id: string; stage: string; assets_queued: number } | null>(null);
// 服务端设置(上传用)
const [settings, setSettings] = useState<BackendSettings | null>(null);
const [baseUrl, setBaseUrl] = useState('http://127.0.0.1:8800');
const [appToken, setAppToken] = useState('');
useEffect(() => {
loadRootDir().then((h) => setRootLabel(h ? h.name : ''));
loadSettings().then((s) => {
setSettings(s);
setBaseUrl(s.baseUrl);
setAppToken(s.token);
});
}, []);
const groups = useMemo(() => {
if (!result) return [];
return GROUP_ORDER.map((g) => ({
...g,
items: result.images.filter((img) => img.groupKey === g.key),
})).filter((g) => g.items.length > 0);
}, [result]);
const selectedCount = useMemo(() => {
if (!result) return 0;
return result.images.filter((img) => selected.has(img.key)).length;
}, [result, selected]);
const onSaveSettings = async () => {
const s: BackendSettings = { baseUrl: baseUrl.trim(), token: appToken.trim() };
await saveSettings(s);
setSettings(s);
message.success('服务端设置已保存');
};
const handleScan = async () => {
setError('');
setResult(null);
setExportResult(null);
setUploadResult(null);
setStatus('采集中');
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
setError('无法获取当前标签页');
setStatus('');
return;
}
let data = await scanTab(tab.id);
// 内容脚本没加载(页面在插件安装/重载前就打开了)→ 手动注入后重试
if (!data) {
try {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content-scripts/content.js'],
});
await new Promise((r) => setTimeout(r, 300));
data = await scanTab(tab.id);
} catch {
/* 注入失败忽略,走下方错误提示 */
}
}
if (!data) {
const isProduct = await isProductPage(tab.id);
setError(
isProduct
? '采集失败:内容脚本未生效,请刷新商品页后重试'
: '当前页面不是 Ozon 商品详情页,请打开一个商品页后再采集',
);
setStatus('');
return;
}
setResult(data);
setSelected(defaultSelection(data));
setFolderName(cleanFilename(data.texts.find((t) => t.kind === 'title')?.content ?? '') || '');
const rawParams = data.texts.find((t) => t.kind === 'params')?.pairs ?? [];
const { weight, dims, dimsUnit: du, remaining } = extractWeightAndDims(rawParams);
setParams(remaining);
setDimsUnit(du);
// 填表单
form.setFieldsValue({
title: data.texts.find((t) => t.kind === 'title')?.content ?? '',
price: data.texts.find((t) => t.kind === 'price')?.content ?? '',
brand: data.texts.find((t) => t.kind === 'brand')?.content || '无品牌',
sellingPoints: data.texts.find((t) => t.kind === 'selling_point')?.content ?? '',
desc: data.texts.find((t) => t.kind === 'desc')?.content ?? '',
packWeight: weight,
packLen: dims.l,
packWidth: dims.w,
packHeight: dims.h,
});
setStatus('采集完成');
} catch (err) {
setError(`采集失败: ${err instanceof Error ? err.message : String(err)}`);
setStatus('');
}
};
const handlePickDir = async () => {
setError('');
try {
// 始终弹选择器(选择或更换目录)
const handle = await chooseRootDir();
setRootLabel(handle.name);
message.success(`已选择目录「${handle.name}`);
} catch (err) {
// 用户取消选择(AbortError)不算错误,静默处理
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
setError(`选择目录失败: ${err instanceof Error ? err.message : String(err)}`);
}
};
const toggleOne = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleGroup = (items: ImageMaterial[]) => {
setSelected((prev) => {
const next = new Set(prev);
const allOn = items.every((i) => next.has(i.key));
for (const i of items) {
if (allOn) next.delete(i.key);
else next.add(i.key);
}
return next;
});
};
const getEdits = (): TextEdits => {
const v = form.getFieldsValue();
return {
title: v.title,
price: v.price,
brand: v.brand,
sellingPoints: v.sellingPoints,
desc: v.desc,
params,
weight: v.packWeight,
dims: { l: v.packLen, w: v.packWidth, h: v.packHeight },
dimsUnit,
};
};
const handleExport = async () => {
if (!result) return;
if (selectedCount === 0) {
setError('请至少勾选一张图片');
return;
}
setExporting(true);
setError('');
setExportResult(null);
try {
const name = folderName.trim() || `ozon-${result.itemId ?? 'product'}`;
const built = buildProduct(result, selected, getEdits());
const res = await writeProductFolder(name, built.product, built.sources, built.files);
setExportResult(res);
message.success('已导出到本地');
} catch (err) {
setError(`导出失败: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setExporting(false);
}
};
const handleUpload = async () => {
if (!result) return;
if (selectedCount === 0) {
setError('请至少勾选一张图片');
return;
}
setUploading(true);
setError('');
setUploadResult(null);
try {
const payload = buildMaterialsPayload(result, selected, getEdits());
const resp = await chrome.runtime.sendMessage({
action: 'uploadMaterials',
baseUrl: settings?.baseUrl ?? 'http://127.0.0.1:8800',
token: settings?.token ?? '',
payload,
});
if (!resp?.ok) throw new Error(resp?.error ?? '上传失败');
setUploadResult(resp.data);
message.success('已上传服务端,进入采集箱');
} catch (err) {
setError(`上传失败: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setUploading(false);
}
};
return (
<div style={{ minHeight: '100vh' }}>
{/* 顶部固定:标题 + 采集按钮 */}
<div style={{ position: 'sticky', top: 0, zIndex: 20, background: '#f5f5f5', padding: '12px 12px 8px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ marginBottom: 8 }}>
<Title level={4} style={{ margin: 0 }}>
🪆
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
Ozon ·
</Text>
</div>
{/* 采集按钮 */}
<Button
type="primary"
block
size="large"
icon={<ScanOutlined />}
loading={status === '采集中'}
onClick={handleScan}
>
{status === '采集中' ? '采集中…' : '开始采集当前页'}
</Button>
</div>
<div style={{ padding: '0 12px 12px' }}>
{/* 服务端设置 */}
<Collapse
ghost
size="small"
style={{ marginTop: 8 }}
items={[
{
key: 'settings',
label: (
<Space size={4}>
<SettingOutlined />
<span style={{ fontSize: 12 }}></span>
</Space>
),
children: (
<div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}></Text>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://127.0.0.1:8800"
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>访 Token</Text>
<Input.Password
value={appToken}
onChange={(e) => setAppToken(e.target.value)}
placeholder="后续加账户体系时再填"
/>
</div>
<Button size="small" onClick={onSaveSettings}>
</Button>
</div>
),
},
]}
/>
{error && <Alert type="error" showIcon message={error} style={{ marginTop: 8 }} />}
{result && (
<>
<Card size="small" style={{ marginTop: 12 }} title="采集信息(可修改)">
<div style={{ marginBottom: 8 }}>
<Space wrap size={4}>
<Tag color="purple">{result.platform.toUpperCase()}</Tag>
{result.itemId && <Tag>{result.itemId}</Tag>}
<Tag color="blue">:{result.source}</Tag>
</Space>
</div>
<Form form={form} layout="vertical" size="small">
<Form.Item label="标题" name="title">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Row gutter={8}>
<Col span={12}>
<Form.Item label="价格" name="price">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="品牌" name="brand">
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item label="包装重量" name="packWeight">
<Input placeholder="如 3.5 кг" />
</Form.Item>
<Form.Item label="包装尺寸(长 × 宽 × 高)">
<Space.Compact block>
<Form.Item name="packLen" noStyle>
<Input placeholder="长" />
</Form.Item>
<Form.Item name="packWidth" noStyle>
<Input placeholder="宽" />
</Form.Item>
<Form.Item name="packHeight" noStyle>
<Input placeholder="高" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item label="卖点" name="sellingPoints">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Form.Item label="描述" name="desc">
<Input.TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
{/* 参数表(左侧参数名只读,右侧参数值可编辑) */}
{params.length > 0 && (
<Collapse
ghost
size="small"
items={[
{
key: 'params',
label: <Text style={{ fontSize: 12 }}>{params.length} </Text>,
children: (
<div>
{params.map((p, i) => (
<Row key={i} gutter={8} align="middle" style={{ marginBottom: 6 }}>
<Col span={10}>
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all', display: 'block' }}>
{p.key || '—'}
</Text>
</Col>
<Col span={14}>
<Input
size="small"
value={p.value}
onChange={(e) => {
const next = [...params];
next[i] = { ...next[i], value: e.target.value };
setParams(next);
}}
/>
</Col>
</Row>
))}
</div>
),
},
]}
/>
)}
</Form>
</Card>
{/* 图片分组 */}
<Card size="small" style={{ marginTop: 12 }} title={`图片素材(已选 ${selectedCount} 张)`}>
{groups.map((g) => {
const allOn = g.items.every((i) => selected.has(i.key));
return (
<div key={g.key} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<Text strong style={{ fontSize: 12 }}>
{g.name} ({g.items.length})
</Text>
<a style={{ fontSize: 12 }} onClick={() => toggleGroup(g.items)}>
{allOn ? '取消全选' : '全选'}
</a>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{g.items.map((img) => {
const on = selected.has(img.key);
return (
<div
key={img.key}
onClick={() => toggleOne(img.key)}
style={{
position: 'relative',
width: 64,
height: 64,
border: on ? `2px solid ${token.colorPrimary}` : '1px solid #e0e0e0',
borderRadius: 8,
overflow: 'hidden',
cursor: 'pointer',
background: '#f5f5f5',
}}
>
{img.type === 'video' ? (
<div style={{ position: 'relative', width: '100%', height: '100%', background: '#eee' }}>
{img.thumbUrl && !/\.(mp4|webm|m3u8|mov|avi)(\?|$)/i.test(img.thumbUrl) ? (
<img src={img.thumbUrl} alt="视频封面" style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
) : null}
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.25)' }}>
<span style={{ color: '#fff', fontSize: 20, lineHeight: 1 }}></span>
</div>
</div>
) : (
<img src={img.thumbUrl} alt={img.variantName ?? g.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
)}
{on && (
<div style={{ position: 'absolute', inset: 0, background: `rgba(139,92,246,0.15)` }}>
<span style={{ position: 'absolute', top: 2, right: 5, color: token.colorPrimary, fontSize: 14 }}></span>
</div>
)}
{img.variantName && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(0,0,0,0.5)', color: '#fff', fontSize: 9, padding: '1px 2px', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{img.variantName}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</Card>
{/* 警告 */}
{result.warnings.length > 0 && (
<div style={{ marginTop: 12 }}>
{result.warnings.map((w, i) => (
<Alert key={i} type="warning" showIcon message={w} style={{ marginBottom: 4 }} />
))}
</div>
)}
<div style={{ position: 'sticky', bottom: 0, background: '#f5f5f5', padding: '12px 0', zIndex: 10, marginTop: 12, borderTop: '1px solid #f0f0f0' }}>
{/* 本地文件夹名 + 导出/上传(固定在底部) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}></Text>
<Input
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
placeholder="留空用商品标题"
size="small"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<div style={{ fontSize: 11, color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{rootLabel ? `保存到:${rootLabel}` : '未选择保存目录'}
</div>
<Button size="small" onClick={handlePickDir}>
{rootLabel ? '更换目录' : '选择目录'}
</Button>
</div>
<Row gutter={8}>
<Col span={12}>
<Button
block
icon={<DownloadOutlined />}
onClick={handleExport}
loading={exporting}
disabled={selectedCount === 0}
>
</Button>
</Col>
<Col span={12}>
<Button
block
type="primary"
icon={<CloudUploadOutlined />}
onClick={handleUpload}
loading={uploading}
disabled={selectedCount === 0}
>
</Button>
</Col>
</Row>
{exportResult && (
<Alert
type={exportResult.failed.length ? 'warning' : 'success'}
showIcon
style={{ marginTop: 8 }}
message={`已写入 ${exportResult.written} 张图片到「${exportResult.folderName}${exportResult.failed.length ? `${exportResult.failed.length} 张失败` : ''}`}
/>
)}
{uploadResult && (
<Alert
type="success"
showIcon
style={{ marginTop: 8 }}
message={`已上传服务端,商品已进入采集箱(素材 ${uploadResult.assets_queued} 张,后台转存中)`}
/>
)}
</div>
</>
)}
{!result && (
<div style={{ marginTop: 16, padding: 12, background: '#fafafa', borderRadius: 8, fontSize: 12, color: '#888' }}>
<div>💡 使</div>
<ol style={{ margin: '4px 0 0 18px', padding: 0 }}>
<li> Ozon ru/kz/by</li>
<li></li>
<li> / </li>
</ol>
</div>
)}
</div>
</div>
);
}
function Root() {
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#8b5cf6',
borderRadius: 8,
},
}}
>
<AntdApp>
<Panel />
</AntdApp>
</ConfigProvider>
);
}
const root = createRoot(document.getElementById('root')!);
root.render(<Root />);
export default Root;
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>套娃采集助手</title>
<style>
/* 放大侧边栏宽度(Chrome side panel 受 min-width 约束) */
html, body, #root {
min-width: 460px;
margin: 0;
padding: 0;
}
body {
background: #f5f5f5;
}
/* 缩小表单各项上下间距 */
.ant-form-item {
margin-bottom: 6px;
}
.ant-form-item .ant-form-item-label {
padding-bottom: 2px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "taowa-collector",
"version": "0.2.0",
"type": "module",
"private": true,
"scripts": {
"dev": "wxt",
"build": "wxt build",
"zip": "wxt zip"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"antd": "^6.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/chrome": "^0.0.268",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/wicg-file-system-access": "^2023.10.7",
"typescript": "^5.5.3",
"wxt": "^0.19.0"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
}
+4419
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
/**
* 用真实保存的 Ozon 页面验证采集逻辑(不依赖浏览器)
* 用法:先 esbuild 打包再 node 运行(见 README / 命令注释)
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { extractOzonState } from '../src/collector/ozon-state';
import { extractJsonLd } from '../src/collector/jsonld';
import { toOriginalUrl, toThumbUrl, pickBestFromSrcset } from '../src/collector/url';
class FakeEl {
id: string;
attrs: Record<string, string>;
constructor(id: string, attrs: Record<string, string>) {
this.id = id;
this.attrs = attrs;
}
getAttribute(name: string): string | null {
return this.attrs[name] ?? null;
}
get textContent(): string {
return this.attrs['text'] ?? '';
}
}
function buildFakeDoc(html: string) {
const stateEls: FakeEl[] = [];
const re = /<div\s+id="state-([^"]+)"\s+data-state='(.*?)'\s*>/gs;
let m: RegExpExecArray | null;
while ((m = re.exec(html))) {
stateEls.push(new FakeEl('state-' + m[1], { 'data-state': m[2] }));
}
const ldEls: FakeEl[] = [];
const re2 = /<script[^>]*type="application\/ld\+json"[^>]*>(.*?)<\/script>/gs;
while ((m = re2.exec(html))) {
ldEls.push(new FakeEl('', { text: m[1] }));
}
return {
querySelectorAll(sel: string): FakeEl[] {
if (sel === 'div[id^="state-"]') return stateEls;
if (sel === 'script[type="application/ld+json"]') return ldEls;
return [];
},
};
}
function check(name: string, actual: unknown, expected: unknown): void {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
const ok = a === e;
console.log(`${ok ? '✅' : '❌'} ${name}`);
if (!ok) {
console.log(' expected:', e);
console.log(' actual :', a);
}
}
for (const fn of ['../reference/ozon1.html', '../reference/ozon2.html']) {
console.log(`\n========== ${fn} ==========`);
const html = readFileSync(join(process.cwd(), fn), 'utf8');
(globalThis as any).document = buildFakeDoc(html);
const state = extractOzonState();
console.log('state:', JSON.stringify({
title: state.title,
price: state.price,
originalPrice: state.originalPrice,
rating: state.rating,
reviewCount: state.reviewCount,
gallery: state.galleryImages.length,
videos: state.videos,
variants: state.skuVariants,
characteristics: state.characteristics,
}, null, 1));
const ld = extractJsonLd();
console.log('jsonld:', JSON.stringify(ld));
}
// ── URL 工具验证 ──
console.log('\n========== url tools ==========');
const rules = [
{ match: /\/wc\d+\//, replace: '/' },
{ match: /\/c\d+\//, replace: '/' },
{ match: /(?<!:)\/{2,}/g, replace: '/' },
{ match: /[?&](width|height|size|quality|w|h)=[^&]+/g, replace: '' },
];
check('wc1000 → 原图', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-5/wc1000/9290076089.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg');
check('wc140 → 原图', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-q/wc140/9290076002.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-q/9290076002.jpg');
check('c50 → 原图', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-5/c50/9290076089.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg');
check('原图不变', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg');
check('带 query 尺寸', toOriginalUrl('https://cdn.x.com/a.jpg?width=200&h=300', rules), 'https://cdn.x.com/a.jpg');
check('缩略图', toThumbUrl('https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg'), 'https://ir.ozone.ru/s3/multimedia-1-5/wc200/9290076089.jpg');
check('已带标记不再缩略', toThumbUrl('https://ir.ozone.ru/s3/multimedia-1-5/wc50/9290076089.jpg'), 'https://ir.ozone.ru/s3/multimedia-1-5/wc50/9290076089.jpg');
check('srcset 取最大', pickBestFromSrcset('https://ir.ozone.ru/s3/a/wc50/1.jpg 1x, https://ir.ozone.ru/s3/a/wc100/1.jpg 2x'), 'https://ir.ozone.ru/s3/a/wc100/1.jpg');
console.log('\n全部验证结束');
+104
View File
@@ -0,0 +1,104 @@
/**
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
* 契约对齐 server 端 /api/materials(见 docs/v2/api.md)。
*/
import type { ScanResult } from '../collector/scan';
import type { TextEdits } from '../export/builder';
export interface MaterialsPayload {
product_id: string | null;
source: {
platform: string;
itemId: string | null;
url: string;
collectedAt: number;
};
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
images: Array<{
groupKey: string;
groupName: string;
variantName?: string | null;
url: string;
index: number;
type: string;
dedupeKey?: string | null;
}>;
refererOrigin?: string;
}
/** 用(可能已二次修改的)文本 + 已勾选图片,组装 /api/materials 请求体 */
export function buildMaterialsPayload(
result: ScanResult,
selectedKeys: Set<string>,
edits?: TextEdits,
): MaterialsPayload {
const text = (kind: string) => result.texts.find((t) => t.kind === kind)?.content ?? '';
const title = edits?.title ?? text('title');
const price = edits?.price ?? text('price');
const brand = edits?.brand ?? text('brand');
const desc = edits?.desc ?? text('desc');
const sellingPoints = edits?.sellingPoints ?? text('selling_point');
const params: Array<{ key: string; value: string }> = [
...(edits?.params ?? result.texts.find((t) => t.kind === 'params')?.pairs ?? []),
];
// 包装重量 / 包装尺寸合并进参数(后端存到 raw.paramsstudio 里再映射为 Ozon 字段)
if (edits?.weight) params.push({ key: '包装重量', value: edits.weight });
const dimSuffix = edits?.dimsUnit === 'mm' ? ' mm' : ' cm';
if (edits?.dims?.l) params.push({ key: '包装长度', value: `${edits.dims.l}${dimSuffix}` });
if (edits?.dims?.w) params.push({ key: '包装宽度', value: `${edits.dims.w}${dimSuffix}` });
if (edits?.dims?.h) params.push({ key: '包装高度', value: `${edits.dims.h}${dimSuffix}` });
const texts: MaterialsPayload['texts'] = [];
if (title) texts.push({ kind: 'title', content: title });
if (price) texts.push({ kind: 'price', content: price });
if (brand) texts.push({ kind: 'brand', content: brand });
if (params.length) texts.push({ kind: 'params', content: '', pairs: params });
if (sellingPoints) texts.push({ kind: 'selling_point', content: sellingPoints });
if (desc) texts.push({ kind: 'desc', content: desc });
const images = result.images
.filter((img) => selectedKeys.has(img.key))
.map((img) => ({
groupKey: img.groupKey,
groupName: img.groupName,
variantName: img.variantName ?? null,
url: img.url,
index: img.index,
type: img.type,
dedupeKey: img.url,
}));
return {
product_id: null,
source: {
platform: result.platform,
itemId: result.itemId,
url: result.url,
collectedAt: result.scannedAt,
},
texts,
images,
refererOrigin: 'https://www.ozon.ru',
};
}
export async function uploadMaterials(
baseUrl: string,
token: string,
payload: MaterialsPayload,
): Promise<{ product_id: string; stage: string; assets_queued: number }> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`; // 单用户宽松模式:token 可空
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/materials`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
}
return data;
}
+54
View File
@@ -0,0 +1,54 @@
/**
* DOM 工具 - 等待元素、Shadow DOM 穿透
* 从 extension-v1 移植
*/
/** 等待任一选择器出现(MutationObserver + 超时) */
export function waitForAny(
selectors: string[],
timeoutMs = 10_000
): Promise<Element | null> {
const hit = () => selectors.map((s) => document.querySelector(s)).find(Boolean) ?? null;
const found = hit();
if (found) return Promise.resolve(found);
return new Promise((resolve) => {
const timer = setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeoutMs);
const observer = new MutationObserver(() => {
const el = hit();
if (el) {
clearTimeout(timer);
observer.disconnect();
resolve(el);
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
});
}
/** 穿透 Shadow DOM 查询元素(Ozon 部分组件用了 Web Components */
export function queryAllDeep(selectors: string[]): Element[] {
const out: Element[] = [];
for (const sel of selectors) {
let nodes: NodeListOf<Element>;
try {
nodes = document.querySelectorAll(sel);
} catch {
continue; // 选择器写错不能拖垮整个扫描
}
nodes.forEach((el) => {
if (el.shadowRoot) {
out.push(...Array.from(el.shadowRoot.querySelectorAll('img, video, source')));
} else {
out.push(el);
}
});
}
return out;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 图片提取 - 主图、SKU、详情图、视频
* 从 extension-v1 移植,新增:
* - srcset 处理(Ozon 画廊是 <img srcset> / <picture><source>
* - toOriginalUrl 传平台规则(Ozon /wc\d+/
*/
import {
toAbsoluteUrl,
toOriginalUrl,
urlInBrackets,
looksLikeImageUrl,
dedupeKey,
pickBestFromSrcset,
} from './url';
import { queryAllDeep } from './dom';
import type { ImageGroupKey, SiteProfile, SrcProp } from '../profiles/types';
export interface ImageMaterial {
key: string; // 'main-001'
groupKey: ImageGroupKey; // 'main'
groupName: string; // '主图'
variantName?: string; // SKU 规格名(仅 sku 组)
url: string; // 已还原为原图
thumbUrl: string; // 页面上的原始小图地址
index: number;
type: 'img' | 'video';
width?: number;
height?: number;
}
/** 从元素上读出图片地址与名称,按 srcProps 顺序降级 */
function readImageSource(
el: Element,
srcProps: SrcProp[],
nameSelectors?: string[]
): { url: string; name: string; imgEl: HTMLImageElement | null } {
let url = '';
let name = '';
let imgEl: HTMLImageElement | null = el instanceof HTMLImageElement ? el : null;
for (const prop of srcProps) {
if (url) break;
if (prop === 'backgroundImage') {
if (el.tagName === 'IMG') {
const img = el as HTMLImageElement;
url = img.currentSrc || img.src || '';
name = img.alt || '';
} else {
const bg = getComputedStyle(el).backgroundImage || '';
const cand = (urlInBrackets(bg) || bg).replace(/['"]/g, '');
if (looksLikeImageUrl(cand)) url = cand;
}
continue;
}
if (prop === 'srcset') {
// <img srcset> 或 <source srcset>
const raw = el.getAttribute('srcset') || (el as any).srcset || '';
if (raw) url = pickBestFromSrcset(raw);
continue;
}
const raw = (el as any)[prop] || el.getAttribute(prop);
if (raw) {
// srcset 场景下 currentSrc 才是实际加载的那张
url = prop === 'src' ? ((el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || '') : raw;
}
}
// 选择器命中的是容器、图在子节点上
if (!url && el.tagName !== 'IMG') {
const inner = el.querySelector('img, source');
if (inner) {
const srcset = inner.getAttribute('srcset');
url = srcset
? pickBestFromSrcset(srcset)
: inner.getAttribute('data-src') || (inner as HTMLImageElement).currentSrc || (inner as HTMLImageElement).src || '';
if (inner instanceof HTMLImageElement) imgEl = inner;
if (!name && inner instanceof HTMLImageElement) name = inner.alt || '';
}
}
// 名称统一取(SKU 规格名)
if (!name && nameSelectors?.length) {
for (const sel of nameSelectors) {
const t = el.querySelector(sel)?.textContent?.trim();
if (t) {
name = t;
break;
}
}
}
return { url: url ? toAbsoluteUrl(url) : '', name, imgEl };
}
export function collectImages(profile: SiteProfile): ImageMaterial[] {
const result: ImageMaterial[] = [];
for (const group of profile.imageGroups) {
const srcProps = group.srcProps ?? profile.defaultSrcProps;
// 去重按组独立:一张图同时是主图和 SKU 图是正常的
const seen = new Set<string>();
const activeSet = new Set(group.activeSelectors ? queryAllDeep(group.activeSelectors) : []);
for (const el of queryAllDeep(group.selectors)) {
if (activeSet.has(el)) continue;
if (group.excludeWithin?.some((sel) => el.closest(sel))) continue;
const { url: rawUrl, name, imgEl } = readImageSource(el, srcProps, group.nameSelectors);
if (!rawUrl) continue;
if (group.type === 'video' && !/\.(mp4|avi|mov|wmv|m3u8|webm)(\?|$)/i.test(rawUrl) && !/^blob:/i.test(rawUrl)) {
continue;
}
const url = group.type === 'img' ? toOriginalUrl(rawUrl, profile.originalUrlRules) : rawUrl;
// 尺寸过滤
if (group.type === 'img' && (group.minWidth || group.minHeight)) {
const measured = imgEl ?? (el as HTMLElement);
const w = (measured as HTMLImageElement).naturalWidth || (measured as HTMLElement).offsetWidth || 0;
const h = (measured as HTMLImageElement).naturalHeight || (measured as HTMLElement).offsetHeight || 0;
if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue;
}
const k = group.key === 'sku' ? `${dedupeKey(url, profile.originalUrlRules)}::${name}` : dedupeKey(url, profile.originalUrlRules);
if (seen.has(k)) continue;
seen.add(k);
result.push({
key: `${group.key}-${String(result.filter((r) => r.groupKey === group.key).length + 1).padStart(3, '0')}`,
groupKey: group.key,
groupName: group.name,
variantName: group.key === 'sku' ? name || undefined : undefined,
url,
thumbUrl: rawUrl,
index: result.length,
type: group.type,
});
}
}
return result;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* JSON-LD 提取器(schema.org/Product
*
* Ozon 是 SSR 站点,商品页 HTML 里带 application/ld+json
* 是 DOM 之外最稳定的结构化来源(比哈希类名稳定一个数量级)。
*
* 参考实现(毛子ERP)也解析 application/ld+json 取 description / offers.url。
*/
export interface JsonLdProduct {
title?: string;
description?: string;
brand?: string;
sku?: string;
price?: string;
currency?: string;
images: string[];
rating?: string;
reviewCount?: string;
}
function asString(v: unknown): string | undefined {
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
return undefined;
}
function findProduct(node: unknown): any | null {
if (Array.isArray(node)) {
for (const item of node) {
const r = findProduct(item);
if (r) return r;
}
return null;
}
if (!node || typeof node !== 'object') return null;
const obj = node as Record<string, unknown>;
const type = obj['@type'];
const types = Array.isArray(type) ? type : [type];
if (types.some((t) => t === 'Product')) return obj;
// @graph 包裹
if (Array.isArray(obj['@graph'])) {
for (const g of obj['@graph']) {
const r = findProduct(g);
if (r) return r;
}
}
return null;
}
function collectImages(node: unknown, out: string[]): void {
if (!node) return;
if (typeof node === 'string') {
if (/^(https?:)?\/\/.+/i.test(node) && !out.includes(node)) out.push(node);
return;
}
if (Array.isArray(node)) {
node.forEach((n) => collectImages(n, out));
return;
}
if (typeof node === 'object') {
for (const v of Object.values(node as Record<string, unknown>)) {
collectImages(v, out);
}
}
}
export function extractJsonLd(): JsonLdProduct | null {
try {
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
for (const script of Array.from(scripts)) {
const text = script.textContent?.trim();
if (!text) continue;
let data: unknown;
try {
data = JSON.parse(text);
} catch {
continue;
}
const product = findProduct(data);
if (!product) continue;
const offers = Array.isArray(product.offers) ? product.offers[0] : product.offers;
const brandName = product.brand?.name ?? (typeof product.brand === 'string' ? product.brand : undefined);
const images: string[] = [];
if (product.image) collectImages(product.image, images);
return {
title: asString(product.name),
description: asString(product.description),
brand: asString(brandName),
sku: asString(product.sku),
price: asString(offers?.price),
currency: asString(offers?.priceCurrency),
images,
rating: asString(product.aggregateRating?.ratingValue),
reviewCount: asString(product.aggregateRating?.reviewCount),
};
}
} catch (err) {
console.warn('[JSON-LD] 提取失败:', err);
}
return null;
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Ozon 内部页 JSON API 提取器(补充路径)
*
* 参考实现(毛子ERP)的采集核心是直接请求 Ozon 自己的页数据接口:
*
* GET {origin}/api/entrypoint-api.bx/page/json/v2?url=/product/{id}/
* → { widgetStates: { "webCharacteristics-…": "...", "webGallery-…": "...", ... } }
*
* ★ 关键点(毛子ERP 的做法,也是本文件修复点):
* - 默认页 `/product/{id}/` 里带 **webCharacteristics(全量「特征」)**
* SSR 里的 webShortCharacteristics 只给前 5 项(limit:5)。
* - 描述页 `/product/{id}/?layout_container=pdpPage2column&layout_page_index=2`
* 里带 webDescription(富文本描述)。
* 所以要两个 URL 都请求、合并,才能拿到完整参数表 + 描述。
*
* ★ 图片只从画廊类 widget 收(白名单),绝不递归全部 widgetStates
* 避免「为您推荐 / 一起购买」等 carousel 图混入。
*/
export interface OzonPageData {
title?: string;
price?: string;
oldPrice?: string;
description?: string;
/** 主图画廊(仅来自画廊 widget) */
images: string[];
videos: string[];
/** 参数表(kv */
characteristics: Array<{ key: string; value: string }>;
}
const IMG_EXT = /\.(jpg|jpeg|png|webp|gif|avif)(\?|$)/i;
const VID_EXT = /\.(mp4|m3u8|webm|mov)(\?|$)/i;
function parseWidgetState(v: unknown): unknown {
if (typeof v !== 'string') return v;
try {
return JSON.parse(v);
} catch {
return v;
}
}
function parseWidgetStates(widgetStates: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (!widgetStates || typeof widgetStates !== 'object') return out;
for (const [k, v] of Object.entries(widgetStates as Record<string, unknown>)) {
out[k] = parseWidgetState(v);
}
return out;
}
function pushUnique(arr: string[], v: string): void {
if (v && !arr.includes(v)) arr.push(v);
}
/** 递归收集画廊 widget 内的图片/视频 URL(只在这个 widget 内走) */
function collectMedia(node: unknown, images: string[], videos: string[]): void {
if (!node) return;
if (typeof node === 'string') {
if (IMG_EXT.test(node)) pushUnique(images, node);
else if (VID_EXT.test(node)) pushUnique(videos, node);
return;
}
if (Array.isArray(node)) {
node.forEach((n) => collectMedia(n, images, videos));
return;
}
if (typeof node !== 'object') return;
for (const v of Object.values(node as Record<string, unknown>)) {
collectMedia(v, images, videos);
}
}
/** 从 characteristic 类 widget 里收参数表 */
function collectCharacteristics(node: unknown, out: Array<{ key: string; value: string }>): void {
if (!node || typeof node !== 'object') return;
const walk = (n: unknown): void => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
const obj = n as Record<string, unknown>;
for (const [k, v] of Object.entries(obj)) {
if (/characteristic|aspect/i.test(k) && Array.isArray(v)) {
for (const row of v) {
if (!row || typeof row !== 'object') continue;
const r = row as Record<string, unknown>;
// { title: {textRs:[{content}]}, values:[{text}] }Ozon 实测结构)
const key = readText(r.title);
if (key && Array.isArray(r.values)) {
const vals = r.values
.map((x) => (x && typeof x === 'object' ? readText((x as Record<string, unknown>).text) : ''))
.filter(Boolean);
if (vals.length) out.push({ key, value: vals.join(', ') });
continue;
}
// { key/value } / { name/value } / { title/text }
const k2 = (r.key ?? r.name ?? r.title) as string | undefined;
const v2 = (r.value ?? r.text) as string | undefined;
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
out.push({ key: k2, value: v2 });
}
}
} else if (/characteristic|aspect/i.test(k) && typeof v === 'object') {
walk(v);
}
}
};
walk(node);
}
function readText(node: unknown): string {
if (!node) return '';
if (typeof node === 'string') return node.trim();
if (typeof node !== 'object') return '';
// { textRs: [{ type, content }] } / { content } / { text }
const obj = node as Record<string, unknown>;
if (Array.isArray(obj.textRs)) {
return obj.textRs
.map((t) => (t && typeof t === 'object' ? (t as Record<string, unknown>).content ?? '' : ''))
.join('')
.trim();
}
if (typeof obj.content === 'string') return obj.content.trim();
if (typeof obj.text === 'string') return obj.text.trim();
return '';
}
/** 从描述类 widget 里收富文本描述 */
function collectDescription(node: unknown, out: { description?: string }): void {
if (!node || typeof node !== 'object') return;
const obj = node as Record<string, unknown>;
if (typeof obj.richAnnotationJson === 'string') {
try {
const rich = JSON.parse(obj.richAnnotationJson);
out.description = richToString(rich);
} catch {
out.description = obj.richAnnotationJson;
}
return;
}
if (typeof obj.description === 'string') {
out.description = obj.description;
return;
}
}
/** richAnnotationJson(富文本块数组)→ 纯文本 */
function richToString(rich: unknown): string {
if (!rich) return '';
if (typeof rich === 'string') return rich;
const texts: string[] = [];
const walk = (n: unknown): void => {
if (!n) return;
if (typeof n === 'string') {
texts.push(n);
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
if (k === 'text' && typeof v === 'string') texts.push(v);
else if (k !== 'type') walk(v);
}
}
};
walk(rich);
return texts.join('\n').trim();
}
/** 解析单个 widgetStates → 部分 OzonPageData */
function parsePage(widgets: Record<string, unknown>): OzonPageData {
const images: string[] = [];
const videos: string[] = [];
const characteristics: Array<{ key: string; value: string }> = [];
const desc: { description?: string } = {};
let title: string | undefined;
let price: string | undefined;
let oldPrice: string | undefined;
for (const [wkey, wval] of Object.entries(widgets)) {
const key = wkey.toLowerCase();
// 图片/视频:只收主画廊 widgetwebGallery),
// 不能按 "gallery" 子串匹配 —— webReviewGallery 是「买家照片和视频」,会混入
if (key.startsWith('webgallery')) {
collectMedia(wval, images, videos);
}
// 参数表(含全量 webCharacteristics
if (/(characteristic|aspect)/.test(key)) {
collectCharacteristics(wval, characteristics);
}
// 描述
if (/(description|richcontent)/.test(key)) {
collectDescription(wval, desc);
}
// 标题 / 价格(各自的 widget)
if (/heading|title/.test(key) && !title) {
const v = (wval as Record<string, unknown>)?.title ?? (wval as Record<string, unknown>)?.name;
if (typeof v === 'string' && v && !/^https?:/i.test(v)) title = v;
}
if (/webprice/.test(key) && !price) {
const p = (wval as Record<string, unknown>)?.price;
if (typeof p === 'string') price = p;
const op = (wval as Record<string, unknown>)?.originalPrice;
if (typeof op === 'string') oldPrice = op;
}
}
return {
title,
price,
oldPrice,
description: desc.description,
images,
videos,
characteristics: dedupePairs(characteristics),
};
}
async function fetchPage(url: string): Promise<Record<string, unknown> | null> {
try {
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const json = (await res.json()) as { widgetStates?: unknown };
return parseWidgetStates(json.widgetStates);
} catch (err) {
console.warn('[Ozon API] 请求失败:', url, err);
return null;
}
}
export async function fetchOzonPageData(itemId: string): Promise<OzonPageData | null> {
// 默认页(标题/价格/画廊 + 全量特征 webCharacteristics+ 描述页(富文本描述)
const urls = [
`/product/${itemId}/`,
`/product/${itemId}/?layout_container=pdpPage2column&layout_page_index=2`,
];
const merged: OzonPageData = { images: [], videos: [], characteristics: [] };
let gotAny = false;
for (const target of urls) {
const widgets = await fetchPage(
`${location.origin}/api/entrypoint-api.bx/page/json/v2?url=${encodeURIComponent(target)}`,
);
if (!widgets) continue;
const p = parsePage(widgets);
gotAny = true;
merged.title = merged.title || p.title;
merged.price = merged.price || p.price;
merged.oldPrice = merged.oldPrice || p.oldPrice;
merged.description = merged.description || p.description;
for (const img of p.images) if (!merged.images.includes(img)) merged.images.push(img);
for (const v of p.videos) if (!merged.videos.includes(v)) merged.videos.push(v);
for (const c of p.characteristics) merged.characteristics.push(c);
}
merged.characteristics = dedupePairs(merged.characteristics);
return gotAny &&
(merged.images.length || merged.title || merged.price || merged.characteristics.length || merged.description)
? merged
: null;
}
function dedupePairs(pairs: Array<{ key: string; value: string }>): Array<{ key: string; value: string }> {
const seen = new Set<string>();
const out: Array<{ key: string; value: string }> = [];
for (const p of pairs) {
const k = `${p.key}::${p.value}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(p);
}
return out;
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Ozon SSR widget state 提取器(主路径)
*
* Ozon 页面把每个 widget 的 JSON state 内嵌在 DOM 里:
* <div id="state-webGallery-3311626-default-1" data-state='{...}'>
* content script 直接读 data-state 即可,无需访问页面 JSmain world)。
*
* 结构已在真实页面实测(reference/ozon1.html、ozon2.html):
* - webGallery: coverImage / images[{src,alt}](原图)/ videos[{url,coverUrl}]
* - webPrice: price / originalPrice / cardPrice(如 "108,26 ¥"
* - webProductHeading: title
* - webShortCharacteristics / webDetailedCharacteristics: characteristics[]
* - webAspects: aspects[].variants[].data.{searchableText, coverImage}SKU 变体)
* - webReviewProductScore: totalScore / reviewsCount
*
* ★ 白名单机制:只读上面这几个 widget 的 state。
* 绝不遍历全页 —— "为您推荐 / 一起购买" 等其它商品 carousel 的 state
* webRecommendedProducts / webCarousel / 类似 widget)根本不会被读到。
*/
import { toAbsoluteUrl } from './url';
export interface OzonVariant {
name: string;
image?: string; // 可能为 undefined(纯文字规格,如尺码)
}
export interface BreadcrumbItem {
name: string; // 类目名称(如"扑满"、"儿童房"
href: string; // 原始链接(/category/kopilki-15056/ 或 ?category=7041
searchCategoryId?: number; // Ozon 搜索类目 ID(从 ?category=xxx 解析)
slug?: string; // URL slug(从 /category/xxx-123/ 解析,含数字 ID
}
export interface OzonStateData {
title?: string;
price?: string;
originalPrice?: string;
rating?: string;
reviewCount?: string;
galleryImages: string[]; // 原图(无尺寸标记)
videos: string[];
videoCovers: string[];
skuVariants: OzonVariant[];
characteristics: Array<{ key: string; value: string }>;
breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径
}
/** 允许读取的 widget 前缀白名单 */
const ALLOWED_WIDGETS = [
'webGallery-',
'webPrice-',
'webProductHeading-',
'webShortCharacteristics-',
'webDetailedCharacteristics-',
'webCharacteristics-',
'webAspects-',
'webReviewProductScore-',
'breadCrumbs-', // 面包屑类目路径
];
function pushUnique(arr: string[], v: string): void {
const abs = toAbsoluteUrl(v);
if (abs && !arr.includes(abs)) arr.push(abs);
}
function readTextRs(node: unknown): string {
// 提取 textRs / descriptionRs 里的展示文本。
// 规则:content/text 字段的值收进文本;递归进入数组/对象找嵌套的 content/text
// 跳过 type/font/color/id/href 等样式与元数据字段(type=newLine 除外)。
if (node == null) return '';
if (typeof node === 'string') return node.trim();
if (typeof node !== 'object') return '';
const texts: string[] = [];
const walk = (n: unknown): void => {
if (!n) return;
if (typeof n === 'string') {
texts.push(n);
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
if (k === 'type' && (v === 'newLine' || v === 'lineBreak')) {
texts.push('\n');
} else if (k === 'content' || k === 'text') {
walk(v);
} else if (v && typeof v === 'object') {
walk(v);
}
// 其它原始值(font/color/id/type='text' 等)直接跳过
}
}
};
walk(node);
return texts.join('').trim();
}
function parseCharacteristics(chars: unknown): Array<{ key: string; value: string }> {
if (!Array.isArray(chars)) return [];
const out: Array<{ key: string; value: string }> = [];
for (const c of chars) {
if (!c || typeof c !== 'object') continue;
const row = c as Record<string, unknown>;
// 结构 A{ title: { textRs: [...] }, values: [{ text: ... }] }(实测)
const key = readTextRs(row.title);
if (Array.isArray(row.values)) {
const vals = row.values
.map((v) => (v && typeof v === 'object' ? readTextRs((v as Record<string, unknown>).text) : ''))
.map((t) => t.replace(/,\s*$/, '')) // 源数据值自带尾逗号(如 "音乐, "
.filter(Boolean);
if (key && vals.length) out.push({ key, value: vals.join(', ') });
continue;
}
// 结构 B{ key, value } / { name, value } / { title, text }
const k2 = (row.key ?? row.name ?? row.title) as string | undefined;
const v2 = (row.value ?? row.text) as string | undefined;
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
out.push({ key: k2, value: v2 });
}
}
return out;
}
export function extractOzonState(): OzonStateData {
const data: OzonStateData = {
galleryImages: [],
videos: [],
videoCovers: [],
skuVariants: [],
characteristics: [],
breadcrumbs: [],
};
const seenChars = new Set<string>();
const els = document.querySelectorAll('div[id^="state-"]');
for (const el of Array.from(els)) {
const id = el.id.slice('state-'.length);
if (!ALLOWED_WIDGETS.some((p) => id.startsWith(p))) continue;
const raw = el.getAttribute('data-state');
if (!raw) continue;
let state: unknown;
try {
state = JSON.parse(raw);
} catch {
continue;
}
if (!state || typeof state !== 'object') continue;
const s = state as Record<string, unknown>;
if (id.startsWith('webGallery-')) {
if (typeof s.coverImage === 'string') pushUnique(data.galleryImages, s.coverImage);
if (Array.isArray(s.images)) {
for (const img of s.images) {
const src = img && typeof (img as Record<string, unknown>).src === 'string'
? (img as Record<string, unknown>).src as string
: undefined;
if (src) pushUnique(data.galleryImages, src);
}
}
if (Array.isArray(s.videos)) {
for (const v of s.videos) {
const rec = v as Record<string, unknown>;
if (typeof rec.url === 'string') pushUnique(data.videos, rec.url);
if (typeof rec.coverUrl === 'string') pushUnique(data.videoCovers, rec.coverUrl);
}
}
} else if (id.startsWith('webPrice-')) {
if (typeof s.price === 'string') data.price = s.price;
if (typeof s.originalPrice === 'string') data.originalPrice = s.originalPrice;
if (!data.price && typeof s.cardPrice === 'string') data.price = s.cardPrice;
} else if (id.startsWith('webProductHeading-')) {
if (typeof s.title === 'string') data.title = s.title;
} else if (
id.startsWith('webShortCharacteristics-') ||
id.startsWith('webDetailedCharacteristics-') ||
id.startsWith('webCharacteristics-')
) {
for (const c of parseCharacteristics(s.characteristics)) {
const k = `${c.key}::${c.value}`;
if (!seenChars.has(k)) {
seenChars.add(k);
data.characteristics.push(c);
}
}
} else if (id.startsWith('webAspects-')) {
if (Array.isArray(s.aspects)) {
for (const aspect of s.aspects) {
const a = aspect as Record<string, unknown>;
if (!Array.isArray(a.variants)) continue;
for (const v of a.variants) {
const rec = v as Record<string, unknown>;
const d = rec.data as Record<string, unknown> | undefined;
const name = typeof d?.searchableText === 'string' ? d.searchableText
: typeof d?.title === 'string' ? d.title : '';
const image = typeof d?.coverImage === 'string' ? d.coverImage : undefined;
if (name) data.skuVariants.push({ name, image });
}
}
}
} else if (id.startsWith('webReviewProductScore-')) {
if (typeof s.totalScore === 'number') data.rating = String(s.totalScore);
if (typeof s.reviewsCount === 'number') data.reviewCount = String(s.reviewsCount);
} else if (id.startsWith('breadCrumbs-')) {
// breadCrumbs widget state: { breadcrumbs: [{text, link, crumbType}] }
if (Array.isArray(s.breadcrumbs) && data.breadcrumbs.length === 0) {
for (const crumb of s.breadcrumbs) {
const c = crumb as Record<string, unknown>;
const name = typeof c.text === 'string' ? c.text.trim() : '';
const href = typeof c.link === 'string' ? c.link : '';
if (!name || !href) continue;
// 解析 ?category=7041highlight 样式链接)
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
// 解析 /category/kopilki-15056/(末尾带数字 ID 的 slug
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
}
// 如果 widget state 没有面包屑(旧版页面),尝试读 DOM 渲染的 ol
if (data.breadcrumbs.length === 0) {
const ol = document.querySelector('[class*="breadCrumbs"] ol, nav ol, ol[class*="breadcrumb"]');
if (ol) {
for (const a of Array.from(ol.querySelectorAll('a[href]'))) {
const href = a.getAttribute('href') ?? '';
const name = a.textContent?.trim() ?? '';
if (!name) continue;
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
return data;
}
+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,
};
}
+110
View File
@@ -0,0 +1,110 @@
/**
* 文本提取 - 标题、价格、参数表、卖点、描述、品牌
* 从 extension-v1 移植(DOM 兜底路径)
*/
import type { SiteProfile, TextRule } from '../profiles/types';
export interface TextMaterial {
kind: TextRule['kind'];
content: string;
pairs?: Array<{ key: string; value: string }>; // table 模式的结构化结果
}
function clean(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
function extractOne(rule: TextRule): TextMaterial | null {
for (const sel of rule.selectors) {
let nodes: NodeListOf<Element>;
try {
nodes = document.querySelectorAll(sel);
} catch {
continue;
}
if (!nodes.length) continue;
// table 模式:参数表
if (rule.extract === 'table') {
const pairs: Array<{ key: string; value: string }> = [];
nodes.forEach((row) => {
const k = clean(row.querySelector(rule.tableKeySelector ?? '')?.textContent ?? '');
const v = clean(row.querySelector(rule.tableValueSelector ?? '')?.textContent ?? '');
if (k && v) pairs.push({ key: k.replace(/[:]$/, ''), value: v });
});
if (pairs.length) {
return {
kind: rule.kind,
content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'),
pairs,
};
}
continue;
}
// join 模式:标题被拆成多个 span
if (rule.extract === 'join') {
let text = '';
nodes.forEach((n) => {
text += n.textContent ?? '';
});
text = clean(text);
if (text) return { kind: rule.kind, content: text };
continue;
}
// first 模式:只取第一个
const first = clean(nodes[0].textContent ?? '');
if (first) return { kind: rule.kind, content: first };
}
return null;
}
export function collectTexts(profile: SiteProfile): {
materials: TextMaterial[];
missingRequired: string[];
} {
const materials: TextMaterial[] = [];
const missingRequired: string[] = [];
for (const rule of profile.textRules) {
const m = extractOne(rule);
if (m) materials.push(m);
else if (rule.required) missingRequired.push(rule.kind);
}
return { materials, missingRequired };
}
/** 合并去重:以 kind 为键,结构化来源优先,DOM 来源兜底。
* 参数表(params)特殊处理:两边的 pairs 做并集合并(按 key 去重),
* 因为「关于商品」只给前几项,完整「特征」在 DOM 里,需要合并才能拿全。
*/
export function mergeTexts(
primary: TextMaterial[],
fallback: TextMaterial[]
): TextMaterial[] {
const map = new Map<string, TextMaterial>();
for (const m of [...primary, ...fallback]) {
if (m.kind === 'params') {
const existing = map.get('params');
if (!existing) {
map.set('params', { ...m, pairs: [...(m.pairs ?? [])] });
} else {
const merged = [...(existing.pairs ?? [])];
const seen = new Set(merged.map((p) => p.key));
for (const p of m.pairs ?? []) {
if (!seen.has(p.key)) {
merged.push(p);
seen.add(p.key);
}
}
existing.pairs = merged;
existing.content = merged.map((p) => `${p.key}: ${p.value}`).join('\n');
}
continue;
}
if (!map.has(m.kind)) map.set(m.kind, m);
}
return Array.from(map.values());
}
+132
View File
@@ -0,0 +1,132 @@
/**
* URL 工具链
* 从 extension-v1 移植,新增:
* - toOriginalUrl 支持平台自定义规则(Ozon 的 /wc\d+/ 路径段尺寸标记)
* - pickBestFromSrcset:从 srcset 里挑最大尺寸候选
*/
const IMG_EXT = /\.(jpg|jpeg|png|gif|bmp|heic|webp|avif)$/i;
export interface UrlRule {
match: RegExp;
replace: string;
}
/**
* 缩略图 URL → 原图 URL
* 先走平台规则(Ozon 的 /wc\d+/ → /wc1200/),
* 再走阿里系通用规则:xxx.jpg_400x400.jpg → xxx.jpg
*/
export function toOriginalUrl(url: string, rules?: UrlRule[]): string {
let out = url;
for (const r of rules ?? []) {
// 带 g 标志的正则(query 清洗)要反复 replace,不带 g 的只替换一次
if (r.match.global) {
out = out.replace(r.match, r.replace);
} else if (r.match.test(out)) {
out = out.replace(r.match, r.replace);
}
}
const m = out.match(/^(.+?\.(jpg|jpeg|png|gif|bmp|heic|webp|avif))_/i);
return m ? m[1] : out;
}
/** url("https://...") → https://... */
export function urlInBrackets(s: string): string {
if (!s?.trim()) return '';
return s.match(/\((.*?)\)/)?.[1]?.replace(/['"]/g, '') ?? '';
}
export function isDataUrl(u: string): boolean {
return /^data:image/.test(u);
}
/** 协议相对 // / 根相对 / / 相对路径 → 绝对 URL */
export function toAbsoluteUrl(u: string): string {
if (!u) return u;
if (isDataUrl(u) || u.startsWith('blob:')) return u;
const proto = u.startsWith('http:') ? 'http' : 'https';
if (/^\/\//.test(u)) return `${proto}:${u}`;
if (/^\//.test(u)) return `${location.origin}${u}`;
if (!/^(.*):/.test(u)) return `${location.origin}/${u}`;
return u;
}
/** 去重用的归一化 key:还原原图 + 剥 query/hash */
export function dedupeKey(url: string, rules?: UrlRule[]): string {
const base = toOriginalUrl(url, rules);
try {
const u = new URL(base);
u.search = '';
u.hash = '';
return u.toString();
} catch {
return base;
}
}
export function looksLikeImageUrl(u: string): boolean {
if (isDataUrl(u)) return true;
try {
return IMG_EXT.test(new URL(u).pathname);
} catch {
return IMG_EXT.test(u);
}
}
/**
* 从 srcset 里挑最大尺寸候选。
* 支持两种语法:
* "a.jpg 100w, b.jpg 200w, c.jpg 300w" → c.jpg
* "a.jpg 1x, b.jpg 2x" → 最后一个
* "a.jpg 400w, b.jpg 800w, c.jpg 1200w, d.jpg" → 最后一个(无描述符 = 兜底最大)
*/
export function pickBestFromSrcset(srcset: string): string {
if (!srcset) return '';
const parts = srcset.split(',').map((p) => p.trim()).filter(Boolean);
if (!parts.length) return '';
let best = '';
let bestSize = -1;
for (const part of parts) {
const seg = part.split(/\s+/);
const url = seg[0];
const desc = seg[1] ?? '';
let size = -1;
const w = desc.match(/^(\d+)w$/);
const x = desc.match(/^(\d+(?:\.\d+)?)x$/);
if (w) size = Number(w[1]);
else if (x) size = Math.round(Number(x[1]) * 1000);
else size = 0; // 无描述符,通常是最小的兜底,但也可能是唯一候选
if (size >= bestSize) {
bestSize = size;
best = url;
}
}
return best;
}
/**
* Ozon CDN 原图 → wc200 缩略图(侧边栏预览用,省流量)
* 实测结构(reference/ozon1.html):
* https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg
* → https://ir.ozone.ru/s3/multimedia-1-5/wc200/9290076089.jpg
* 已带尺寸标记(/wc\d+/、/c\d+/)或非 multimedia 路径的 URL 原样返回。
*/
export function toThumbUrl(url: string): string {
const m = url.match(/^(https?:\/\/[^/]+\/s3\/[^/]+\/)([^/]+)$/);
if (m && !/\/wc\d+\//.test(url) && !/\/c\d+\//.test(url)) {
return `${m[1]}wc200/${m[2]}`;
}
return url;
}
/** 清洗文件名非法字符(Windows 兼容) */
export function cleanFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, ' ')
.replace(/\s+/g, '_')
.substring(0, 80);
}
+167
View File
@@ -0,0 +1,167 @@
/**
* 商品文件夹构建器 —— ScanResult → product.json / sources.json / 待写图片清单
* 契约见 docs/contracts/product-json.md
*/
import type { ScanResult, ImageMaterial } from '../collector/scan';
import type { ProductJson, SourcesJson } from '../schema/product';
import { cleanFilename } from '../collector/url';
export interface BuiltProduct {
folderName: string;
product: ProductJson;
sources: SourcesJson;
/** 待写图片:相对路径 + 源 URL */
files: Array<{ relativePath: string; url: string }>;
}
/** 用户在表单里二次修改后的文本(覆盖采集原文) */
export interface TextEdits {
title?: string;
price?: string;
brand?: string;
desc?: string;
sellingPoints?: string;
params?: Array<{ key: string; value: string }>;
/** 包装重量(原样字符串,如 "3.5 кг" */
weight?: string;
/** 包装尺寸(长/宽/高) */
dims?: { l: string; w: string; h: string };
/** 包装尺寸单位(上传时保留单位,避免 mm/cm 混淆) */
dimsUnit?: 'mm' | 'cm';
}
function extractExt(img: ImageMaterial): string {
try {
const m = new URL(img.url).pathname.match(/\.(jpg|jpeg|png|webp|gif|avif|mp4|mov|m3u8|webm)$/i);
if (m) return m[1].toLowerCase();
} catch {
/* ignore */
}
return img.type === 'video' ? 'mp4' : 'jpg';
}
/** "1 290 ₽" → "1290" */
function extractNumericPrice(text: string): string {
const m = text.replace(/\s/g, '').replace(',', '.').match(/(\d+(?:\.\d+)?)/);
return m ? m[1] : '';
}
/** "3.5 кг" → 3.5 */
function parseNumber(text: string): number | null {
const m = (text ?? '').replace(',', '.').match(/(\d+(?:\.\d+)?)/);
return m ? parseFloat(m[1]) : null;
}
/** 解析包装重量,返回 { weight, unit },单位自动识别 kg/g */
function parseWeight(text?: string): { weight: number | null; unit: 'g' | 'kg' } {
const s = (text ?? '').toLowerCase();
const num = parseNumber(s);
if (num == null) return { weight: null, unit: 'g' };
if (s.includes('кг') || s.includes('kg')) return { weight: num, unit: 'kg' };
if (s.includes('г') || s.includes('g')) return { weight: num, unit: 'g' };
return { weight: num, unit: 'g' };
}
/** 解析包装尺寸,单位自动识别 cm/mm */
function parseDimUnit(text?: string): 'cm' | 'mm' {
const s = (text ?? '').toLowerCase();
if (s.includes('мм') || s.includes('mm')) return 'mm';
return 'cm';
}
export function buildProduct(
result: ScanResult,
selectedKeys: Set<string>,
edits?: TextEdits
): BuiltProduct {
const text = (kind: string) => result.texts.find((t) => t.kind === kind)?.content ?? '';
const title = edits?.title ?? text('title');
const priceText = edits?.price ?? text('price');
const brand = edits?.brand ?? text('brand');
const desc = edits?.desc ?? text('desc');
const sellingPoints = edits?.sellingPoints ?? text('selling_point');
const params =
edits?.params ?? result.texts.find((t) => t.kind === 'params')?.pairs;
const folderName = cleanFilename(title || `ozon-${result.itemId ?? 'product'}`) || 'ozon-product';
const now = new Date().toISOString();
const selected = result.images.filter((img) => selectedKeys.has(img.key));
const images: ProductJson['_images'] = { main: [], sku: [], detail: [], video: [] };
const files: BuiltProduct['files'] = [];
const dedupeKeys: string[] = [];
const counts: Record<string, number> = {};
for (const img of selected) {
counts[img.groupKey] = (counts[img.groupKey] ?? 0) + 1;
const ext = extractExt(img);
const base = `${img.groupKey}-${String(counts[img.groupKey]).padStart(3, '0')}`;
const variantSuffix = img.groupKey === 'sku' && img.variantName ? `-${cleanFilename(img.variantName)}` : '';
const filename = `${base}${variantSuffix}.${ext}`;
const relativePath = `images/${img.groupKey}/${filename}`;
images[img.groupKey].push({
file: relativePath,
sourceUrl: img.url,
variantName: img.variantName,
w: img.width,
h: img.height,
});
files.push({ relativePath, url: img.url });
dedupeKeys.push(img.url);
}
const weightInfo = parseWeight(edits?.weight);
const dimUnit = parseDimUnit(edits?.dims?.l || edits?.dims?.w || edits?.dims?.h);
const product: ProductJson = {
_meta: { schemaVersion: 1, stage: 'collected', createdAt: now, updatedAt: now },
offer_id: '',
name: title,
description: desc,
description_category_id: null,
type_id: null,
price: extractNumericPrice(priceText),
old_price: '',
currency_code: 'RUB',
vat: '0',
depth: parseNumber(edits?.dims?.l ?? ''),
width: parseNumber(edits?.dims?.w ?? ''),
height: parseNumber(edits?.dims?.h ?? ''),
dimension_unit: dimUnit,
weight: weightInfo.weight,
weight_unit: weightInfo.unit,
images: [],
primary_image: '',
images360: [],
color_image: '',
attributes: [],
complex_attributes: [],
_images: images,
_raw: {
title,
price: priceText,
params,
desc,
sellingPoints,
brand,
},
};
const sources: SourcesJson = {
sources: [
{
platform: result.platform as 'ozon',
itemId: result.itemId,
url: result.url,
collectedAt: now,
counts: { ...result.stats },
},
],
dedupeKeys,
};
return { folderName, product, sources, files };
}
+103
View File
@@ -0,0 +1,103 @@
/**
* File System Access 写盘 —— 生成完整商品文件夹
*
* 目录结构(契约见 docs/contracts/product-json.md):
* <商品名>/
* ├── product.json
* ├── sources.json
* └── images/{main,sku,detail,video}/main-001.jpg …
*
* 图片字节统一走 background 代理 fetch(绕 CORS / 防盗链)。
*/
import { loadRootDir, saveRootDir } from './idb';
import type { ProductJson, SourcesJson } from '../schema/product';
export interface ExportResult {
folderName: string;
written: number;
failed: Array<{ file: string; error: string }>;
}
/** 取(或让用户选)根目录,并确保读写权限 */
export async function ensureRootDir(): Promise<FileSystemDirectoryHandle> {
let handle = await loadRootDir();
if (!handle) {
handle = await window.showDirectoryPicker({ mode: 'readwrite' });
await saveRootDir(handle);
return handle;
}
let perm = await handle.queryPermission({ mode: 'readwrite' });
if (perm !== 'granted') {
perm = await handle.requestPermission({ mode: 'readwrite' });
}
if (perm !== 'granted') throw new Error('目录读写权限被拒绝');
return handle;
}
/** 重新选择根目录(忽略已保存的,强制弹出选择器) */
export async function chooseRootDir(): Promise<FileSystemDirectoryHandle> {
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
await saveRootDir(handle);
return handle;
}
/** 通过 background 代理取图,返回 Blob */
async function fetchImageBlob(url: string): Promise<Blob> {
const resp = await chrome.runtime.sendMessage({ action: 'fetchImage', url });
if (!resp?.ok) throw new Error(resp?.error ?? '图片下载失败');
const res = await fetch(resp.dataUrl);
if (!res.ok) throw new Error(`解码失败 HTTP ${res.status}`);
return res.blob();
}
export async function writeProductFolder(
folderName: string,
product: ProductJson,
sources: SourcesJson,
files: Array<{ relativePath: string; url: string }>,
onProgress?: (done: number, total: number) => void
): Promise<ExportResult> {
const root = await ensureRootDir();
const productDir = await root.getDirectoryHandle(folderName, { create: true });
// product.json
const pj = await productDir.getFileHandle('product.json', { create: true });
const w1 = await pj.createWritable();
await w1.write(JSON.stringify(product, null, 2));
await w1.close();
// sources.json
const sj = await productDir.getFileHandle('sources.json', { create: true });
const w2 = await sj.createWritable();
await w2.write(JSON.stringify(sources, null, 2));
await w2.close();
// images/
const imagesDir = await productDir.getDirectoryHandle('images', { create: true });
const total = files.length;
let written = 0;
const failed: Array<{ file: string; error: string }> = [];
for (let i = 0; i < files.length; i++) {
const f = files[i];
const parts = f.relativePath.split('/'); // "images/main/main-001.jpg"
const group = parts[1];
const filename = parts[2];
try {
const blob = await fetchImageBlob(f.url);
const groupDir = await imagesDir.getDirectoryHandle(group, { create: true });
const fh = await groupDir.getFileHandle(filename, { create: true });
const w = await fh.createWritable();
await w.write(blob);
await w.close();
written++;
} catch (err) {
failed.push({ file: f.relativePath, error: err instanceof Error ? err.message : String(err) });
}
onProgress?.(i + 1, total);
}
return { folderName, written, failed };
}
+63
View File
@@ -0,0 +1,63 @@
/**
* IndexedDB 封装 —— 持久化 FileSystemDirectoryHandle
*
* chrome.storage 存不了 FileSystemDirectoryHandle(它不是 JSON 可序列化类型),
* 必须用 IndexedDBstructured clone 支持)。存一次后跨会话免重复授权。
*/
const DB_NAME = 'ozon-seller-kit';
const STORE = 'handles';
const ROOT_KEY = 'SH_ROOT_DIR';
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(STORE)) {
req.result.createObjectStore(STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function idbSet(key: string, value: unknown): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(value, key);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
});
}
export async function idbGet<T>(key: string): Promise<T | null> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).get(key);
req.onsuccess = () => {
db.close();
resolve((req.result as T) ?? null);
};
req.onerror = () => {
db.close();
reject(req.error);
};
});
}
export async function saveRootDir(handle: FileSystemDirectoryHandle): Promise<void> {
await idbSet(ROOT_KEY, handle);
}
export async function loadRootDir(): Promise<FileSystemDirectoryHandle | null> {
return idbGet<FileSystemDirectoryHandle>(ROOT_KEY);
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Profile 路由 - 根据 URL 匹配平台
*/
import type { SiteProfile } from './types';
import { profileOzon } from './ozon';
const PROFILES: SiteProfile[] = [profileOzon];
export function matchProfile(url: string): SiteProfile | null {
for (const p of PROFILES) {
if (p.urlPatterns.some((re) => re.test(url))) {
return p;
}
}
return null;
}
export { profileOzon };
export type { SiteProfile };
+162
View File
@@ -0,0 +1,162 @@
/**
* Ozon 商品页采集配置
*
* 选择器已在真实页面实测(reference/ozon1.html、ozon2.html2026-08-15):
* - webProductHeading → <h1> 标题
* - webGallery → 主图(<img srcset>wc50/wc100 缩略图)
* - webAspects → SKU 变体(颜色/尺码选择器)
* - webShortCharacteristics / webDetailedCharacteristics → 参数表("关于商品"区)
* - webPrice → 价格(DOM 结构复杂,价格主路径走 data-state
*
* ★ 主采集路径是 structuredozon-state.ts 读 SSR data-state + JSON-LD + API),
* 本文件的 DOM 选择器只是兜底 + 详情图补充。
*/
import type { SiteProfile } from './types';
export const profileOzon: SiteProfile = {
id: 'ozon',
name: 'Ozon',
urlPatterns: [
// 新版: https://www.ozon.ru/product/slug-123456789/
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/product\/[^/]+-\d+\/?/,
// 旧版: https://www.ozon.ru/context/detail/id/123456789/
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/context\/detail\/id\/\d+/,
],
extractItemId: (url) => {
const m = url.match(/\/product\/[^/]+-(\d+)\/?/);
if (m?.[1]) return m[1];
const m2 = url.match(/\/context\/detail\/id\/(\d+)/);
return m2?.[1] ?? null;
},
readySelectors: [
'[data-widget="webProductHeading"]',
'[data-widget="webGallery"]',
'h1',
],
readyTimeoutMs: 8_000,
// Ozon 画廊图片是 <img srcset>,懒加载真实地址在 srcset / currentSrc / src
defaultSrcProps: ['srcset', 'currentSrc', 'src', 'data-src'],
refererOrigin: 'https://www.ozon.ru',
textRules: [
{
kind: 'title',
selectors: [
'[data-widget="webProductHeading"] h1',
'h1[itemprop="name"]',
'h1',
],
extract: 'first',
required: true,
},
{
kind: 'price',
selectors: [
'[data-widget="webPrice"] span',
'span[itemprop="price"]',
'[data-widget="webPrice"]',
],
extract: 'first',
},
{
kind: 'params',
selectors: [
'[data-widget="webDetailedCharacteristics"] dl',
'[data-widget="webCharacteristics"] dl',
'[data-widget="webShortCharacteristics"] dl',
'[data-widget="webAspects"] dl',
'#section-characteristics dl',
],
extract: 'table',
tableKeySelector: 'dt, [class*="key"], [class*="Key"], [class*="label"]',
tableValueSelector: 'dd, [class*="value"], [class*="Value"]',
},
{
kind: 'selling_point',
selectors: [
'[data-widget="webShortCharacteristics"]',
'[data-widget="webFeatures"]',
'[data-widget="webAO"]',
],
extract: 'join',
},
{
kind: 'desc',
selectors: [
'[data-widget="webDescription"]',
'[data-widget="webRichContent"]',
'#section-description',
],
extract: 'join',
},
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
selectors: [
'[data-widget="webGallery"] img',
'[data-widget="webGallery"] source',
'[data-widget="webPhotoGallery"] img',
],
// 不设 minWidth:画廊缩略图 naturalWidth 可能很小,原图靠 toOriginalUrl 还原
},
{
key: 'sku',
name: 'SKU图片',
type: 'img',
selectors: [
// 实测:变体选择器在 webAspectswebDetailSKU 其实是"复制 SKU"按钮,没有图)
'[data-widget="webAspects"] img',
'[data-widget="webVariants"] img',
],
nameSelectors: [
'span[class*="Value"]',
'span[class*="Text"]',
'span',
],
minWidth: 16,
minHeight: 16,
},
{
key: 'detail',
name: '详情图',
type: 'img',
selectors: [
'[data-widget="webDescription"] img',
'[data-widget="webRichContent"] img',
'[data-widget="webFeatures"] img',
'#section-description img',
],
minWidth: 300,
minHeight: 100,
},
{
key: 'video',
name: '视频',
type: 'video',
selectors: [
'[data-widget="webGallery"] video',
'[data-widget="webVideo"] video',
],
},
],
// 实测 CDNir.ozone.ru):尺寸标记是路径段 /wc\d+/wc50…wc1000)和 /c\d+/c50/c600
// 去掉标记即为原图(页面本身就有无标记的原始 URL)。
originalUrlRules: [
{ match: /\/wc\d+\//, replace: '/' },
{ match: /\/c\d+\//, replace: '/' },
// 去掉尺寸段后路径里会有双斜杠(不动 https:// 的 //
{ match: /(?<!:)\/{2,}/g, replace: '/' },
// 兼容 query 参数形式的尺寸(?width=200&h=300 逐个剥掉)
{ match: /[?&](width|height|size|quality|w|h)=[^&]+/g, replace: '' },
],
};
+69
View File
@@ -0,0 +1,69 @@
/**
* Site Profile - 平台采集配置(声明式)
*
* 与 extension-v1 同一套抽象,新增 Ozon 需要的文本类型:
* selling_point(卖点 / About this item)、brand(品牌)。
*
* 采集引擎(collector/)完全通用,加一个新平台只需新增一个 profile。
*/
export type TextKind =
| 'title'
| 'price'
| 'params'
| 'selling_point'
| 'desc'
| 'brand';
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video';
export type SrcProp =
| 'data-lazyload-src'
| 'data-src'
| 'srcset'
| 'currentSrc'
| 'src'
| 'backgroundImage';
export interface TextRule {
kind: TextKind;
/** 多套选择器,逐个尝试直到命中 */
selectors: string[];
extract: 'join' | 'first' | 'table';
/** table 模式的 key/value 子选择器 */
tableKeySelector?: string;
tableValueSelector?: string;
required?: boolean;
}
export interface ImageGroupRule {
key: ImageGroupKey;
name: string;
type: 'img' | 'video';
selectors: string[];
/** 覆盖 defaultSrcProps */
srcProps?: SrcProp[];
/** SKU 规格名来源 */
nameSelectors?: string[];
/** 画廊"当前高亮"元素(排除) */
activeSelectors?: string[];
/** 位于这些容器内的图片一律跳过(el.closest 判断) */
excludeWithin?: string[];
minWidth?: number;
minHeight?: number;
}
export interface SiteProfile {
id: string;
name: string;
urlPatterns: RegExp[];
extractItemId: (url: string) => string | null;
readySelectors: string[];
readyTimeoutMs?: number;
defaultSrcProps: SrcProp[];
textRules: TextRule[];
imageGroups: ImageGroupRule[];
/** 图片 URL 还原原图规则(缺省用通用 CDN 后缀规则) */
originalUrlRules?: Array<{ match: RegExp; replace: string }>;
refererOrigin?: string;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Product JSON - 商品文件夹契约(TS 侧)
* 对应 server/schemas/product.pyPydantic 为真源)
* 详见 docs/contracts/product-json.md
*/
export type Stage = 'collected' | 'edited' | 'published';
export interface ProductJson {
_meta: {
schemaVersion: 1;
stage: Stage;
createdAt: string; // ISO 8601
updatedAt: string;
};
// Ozon 字段(对齐 ImportProductsV3
offer_id: string; // 自己的货号,采集阶段恒空
name: string;
description: string;
description_category_id: number | null;
type_id: number | null;
price: string; // 采到的竞品价,仅参考
old_price: string;
currency_code: 'RUB' | 'CNY';
vat: string;
depth: number | null;
width: number | null;
height: number | null;
dimension_unit: 'mm' | 'cm';
weight: number | null;
weight_unit: 'g' | 'kg';
images: string[]; // 发布时才填公网 URL
primary_image: string;
images360: string[];
color_image: string;
attributes: any[]; // 工作台映射后才填
complex_attributes: any[];
// 本地扩展字段(下划线前缀,提交 Ozon 前剥离)
_images: {
main: ImageMeta[];
sku: ImageMeta[];
detail: ImageMeta[];
video: ImageMeta[];
};
_raw: {
title: string;
price: string;
params?: Array<{ key: string; value: string }>;
desc?: string;
sellingPoints?: string;
brand?: string;
};
_pricing?: any; // 工作台计价结果
}
export interface ImageMeta {
file: string; // 相对路径:images/main/main-001.jpg
sourceUrl: string; // 源站 URL(可能失效)
variantName?: string; // SKU 规格名
w?: number;
h?: number;
}
export interface SourcesJson {
sources: Array<{
platform: 'ozon' | '1688' | 'taobao';
itemId: string | null;
url: string;
collectedAt: string; // ISO 8601
counts: Record<string, number>;
}>;
dedupeKeys: string[]; // URL 去重指纹
}
+23
View File
@@ -0,0 +1,23 @@
/**
* 服务端设置(上传用):后端地址 + Bearer Token,持久化到 chrome.storage.local。
*/
export interface BackendSettings {
baseUrl: string;
token: string;
}
const KEY = 'taowa_backend_settings';
const DEFAULT: BackendSettings = {
baseUrl: 'http://127.0.0.1:8800',
token: '',
};
export async function loadSettings(): Promise<BackendSettings> {
const r = await chrome.storage.local.get(KEY);
return { ...DEFAULT, ...(r[KEY] ?? {}) };
}
export async function saveSettings(s: BackendSettings): Promise<void> {
await chrome.storage.local.set({ [KEY]: s });
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"exclude": ["node_modules", ".output"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
name: '套娃采集助手',
description: '套娃(Matryoshka)· Ozon 商品页采集,支持导出到本地或上传服务端',
permissions: [
'storage',
'sidePanel',
'activeTab',
'scripting' // 执行 content script 函数需要
],
host_permissions: [
// 商品页 + 图片/视频 CDN(实测:ir.ozone.ru / io.ozone.ru / v-1.ozone.ru / cdn1.ozonusercontent.com
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
'https://*.ozone.ru/*',
'https://*.ozonusercontent.com/*',
// 本机后端(上传用);生产换成你的公网域名
'http://127.0.0.1:8800/*',
'http://localhost:8800/*'
],
action: {
default_title: '套娃采集'
}
},
modules: ['react']
});