feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -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 state(DOM data-state 属性)── 主路径,同步、白名单、无需网络
|
||||
webGallery / webPrice / webProductHeading / webShortCharacteristics / webAspects …
|
||||
│ 缺失
|
||||
▼
|
||||
② JSON-LD(schema.org/Product)── 标准化,稳定(标题/品牌/价格/评分)
|
||||
│ 缺失
|
||||
▼
|
||||
③ Ozon 页 JSON API(entrypoint-api.bx)── 补充完整参数表与富文本描述
|
||||
│ 缺失
|
||||
▼
|
||||
④ DOM 选择器(data-widget 区块)── 兜底 + 详情图补充
|
||||
```
|
||||
|
||||
★ **「为您推荐 / 一起购买」等其它商品 carousel 的图片不会被采集**:
|
||||
- ① 只读白名单 widget(webGallery / webAspects 等)的 data-state,绝不遍历全页;
|
||||
- ③ 只从画廊类 widget 收图片,不递归整个 widgetStates(旧版 bug 所在);
|
||||
- ④ DOM 选择器按 data-widget 区块作用域限定。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
extension-v2/
|
||||
├── entrypoints/
|
||||
│ ├── background.ts # 图片代理 fetch(绕 CORS / 防盗链)
|
||||
│ ├── sidepanel/ # 采集控制 UI
|
||||
│ └── content/index.ts # 注入商品页,暴露采集入口
|
||||
├── src/
|
||||
│ ├── profiles/ # ozon.ts(DOM 选择器 + 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)
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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()');
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+4419
File diff suppressed because it is too large
Load Diff
@@ -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全部验证结束');
|
||||
@@ -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.params,studio 里再映射为 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
// 图片/视频:只收主画廊 widget(webGallery),
|
||||
// 不能按 "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;
|
||||
}
|
||||
@@ -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 即可,无需访问页面 JS(main 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=7041(highlight 样式链接)
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 采集引擎入口 - 扫描当前页
|
||||
*
|
||||
* Ozon 四路径(优先级从高到低):
|
||||
* ① SSR widget state(DOM data-state 属性,同步、白名单、无需网络)★ 主路径
|
||||
* ② JSON-LD(schema.org/Product)
|
||||
* ③ Ozon 内部页 JSON API(entrypoint-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;
|
||||
// 组内重排 key(main-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,
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* IndexedDB 封装 —— 持久化 FileSystemDirectoryHandle
|
||||
*
|
||||
* chrome.storage 存不了 FileSystemDirectoryHandle(它不是 JSON 可序列化类型),
|
||||
* 必须用 IndexedDB(structured 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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Ozon 商品页采集配置
|
||||
*
|
||||
* 选择器已在真实页面实测(reference/ozon1.html、ozon2.html,2026-08-15):
|
||||
* - webProductHeading → <h1> 标题
|
||||
* - webGallery → 主图(<img srcset>,wc50/wc100 缩略图)
|
||||
* - webAspects → SKU 变体(颜色/尺码选择器)
|
||||
* - webShortCharacteristics / webDetailedCharacteristics → 参数表("关于商品"区)
|
||||
* - webPrice → 价格(DOM 结构复杂,价格主路径走 data-state)
|
||||
*
|
||||
* ★ 主采集路径是 structured(ozon-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: [
|
||||
// 实测:变体选择器在 webAspects(webDetailSKU 其实是"复制 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',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// 实测 CDN(ir.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: '' },
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Product JSON - 商品文件夹契约(TS 侧)
|
||||
* 对应 server/schemas/product.py(Pydantic 为真源)
|
||||
* 详见 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 去重指纹
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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']
|
||||
});
|
||||
Reference in New Issue
Block a user