feat: 采集插件整合进OSK
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { generateSuite, getSuite, planSuite } from '../src/api/client';
|
||||
import { reportProduct } from '../src/api/report';
|
||||
|
||||
// Background Service Worker —— 唯一出网口(生成 / 规划 / 轮询任务,绕 CORS)
|
||||
export default defineBackground(() => {
|
||||
console.log('[电商套图工作台] background started');
|
||||
|
||||
// 点击扩展图标 → 开关当前商品页的悬浮面板(页面无 content script 时忽略)
|
||||
chrome.action.onClicked.addListener(async (tab) => {
|
||||
if (tab.id == null) return;
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tab.id, { action: 'toggle-suite-panel' });
|
||||
} catch {
|
||||
// 非四站点页面,未注入面板
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.action === 'generateSuite') {
|
||||
generateSuite(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;
|
||||
}
|
||||
|
||||
if (msg?.action === 'planSuite') {
|
||||
planSuite(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;
|
||||
}
|
||||
|
||||
if (msg?.action === 'getSuite') {
|
||||
getSuite(msg.baseUrl, msg.token, msg.suiteId)
|
||||
.then((data) => sendResponse({ ok: true, data }))
|
||||
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 商品上报:采集结果 POST 到 ozon-seller-kit 后台 /api/materials
|
||||
if (msg?.action === 'reportProduct') {
|
||||
reportProduct(msg.reportBaseUrl, msg.payload)
|
||||
.then((data) => sendResponse({ ok: true, data }))
|
||||
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API)
|
||||
if (msg?.action === 'fetchText') {
|
||||
const headers: Record<string, string> = {};
|
||||
if (msg.referer) headers['Referer'] = msg.referer;
|
||||
fetch(msg.url, {
|
||||
headers,
|
||||
credentials: msg.credentials ? 'include' : 'omit',
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
sendResponse({ ok: true, text: await res.text() });
|
||||
})
|
||||
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// MAIN world 桥 —— 跑在页面主世界,读取页面 JS 变量(isolated world 读不到)。
|
||||
// 协议(对齐竞品 inject.js 模式):
|
||||
// isolated → MAIN: {type:'sc-bridge-req', requestId, keys: [...]} keys 含 '*' 时返回诊断键列表
|
||||
// MAIN → isolated: {type:'sc-bridge-res', requestId, payload: {key: value}}
|
||||
// MAIN 侧零业务逻辑:只读白名单键、JSON 序列化过滤后回传,不注入任何页面行为。
|
||||
export default defineContentScript({
|
||||
matches: [
|
||||
'https://*.ozon.ru/*', 'https://*.ozon.kz/*', 'https://*.ozon.by/*',
|
||||
'https://detail.1688.com/*',
|
||||
'https://item.taobao.com/*', 'https://detail.tmall.com/*',
|
||||
],
|
||||
world: 'MAIN',
|
||||
main() {
|
||||
window.addEventListener('message', (ev: MessageEvent) => {
|
||||
if (ev.source !== window) return;
|
||||
const d = ev.data as { type?: string; requestId?: string; keys?: string[] } | null;
|
||||
if (!d || d.type !== 'sc-bridge-req' || !d.requestId || !Array.isArray(d.keys)) return;
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (d.keys.includes('*')) {
|
||||
// 诊断模式:列出页面上可能有数据的全局键
|
||||
payload['__sc_window_keys__'] = Object.keys(window).filter(k =>
|
||||
/^(__|_)?[A-Za-z]/.test(k) && /(context|rawData|ICE|sku|item|g_config|DATA|state)/i.test(k)
|
||||
);
|
||||
}
|
||||
for (const k of d.keys) {
|
||||
if (k === '*') continue;
|
||||
try {
|
||||
const v = (window as unknown as Record<string, unknown>)[k];
|
||||
if (v !== undefined) payload[k] = JSON.parse(JSON.stringify(v)); // 过滤函数/循环引用
|
||||
} catch { /* 不可序列化的跳过 */ }
|
||||
}
|
||||
window.postMessage({ type: 'sc-bridge-res', requestId: d.requestId, payload }, '*');
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Content Script —— 注入四个平台的商品页,暴露采集入口
|
||||
import { scanCurrentPage } from '../../src/collector/scan';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: [
|
||||
// Ozon
|
||||
'https://*.ozon.ru/*',
|
||||
'https://*.ozon.kz/*',
|
||||
'https://*.ozon.by/*',
|
||||
// 1688
|
||||
'https://detail.1688.com/*',
|
||||
// 淘宝 / 天猫
|
||||
'https://item.taobao.com/*',
|
||||
'https://detail.tmall.com/*',
|
||||
],
|
||||
main() {
|
||||
console.log('[电商套图工作台] Content script loaded');
|
||||
|
||||
// 暴露采集入口到全局(供 side panel 调用 / console 调试)
|
||||
(window as any).__SuiteCollector = {
|
||||
scan: scanCurrentPage,
|
||||
};
|
||||
|
||||
console.log('[电商套图工作台] 就绪。Console 可测: await window.__SuiteCollector.scan()');
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
// Panel Content Script —— 商品页右下角悬浮按钮 + 页内悬浮面板
|
||||
//
|
||||
// 面板 = iframe 加载插件内置 sidepanel.html(扩展页面在 iframe 里仍有 chrome.* 权限,
|
||||
// 采集/生成/导出逻辑零改动);按钮与面板容器渲染在独立 Shadow DOM 中,
|
||||
// 不受商品页全局 CSS 影响,面板悬浮覆盖页面、不挤压原页面布局。
|
||||
import { matchProfile } from '../src/profiles/index';
|
||||
|
||||
/** 面板内 App.tsx → 宿主页的收起消息 */
|
||||
const PANEL_CLOSE_MSG = 'sc-panel-close';
|
||||
|
||||
const STYLES = `
|
||||
:host { all: initial; }
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: 700 20px/1 -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
|
||||
box-shadow: 0 4px 16px rgba(109, 40, 217, 0.45);
|
||||
z-index: 3;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.fab:hover { transform: scale(1.08); }
|
||||
.fab.hidden { display: none; }
|
||||
|
||||
/* 收起按钮:骑在面板左上边缘(一半在面板外)。必须在 iframe 外渲染——
|
||||
iframe 裁剪内容,iframe 内的元素永远溢不出面板边界 */
|
||||
.panel-close {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: min(880px, 100vw); /* 面板左边缘 = 视口右沿 - 面板宽度 */
|
||||
transform: translateX(50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
background: #fff;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
||||
z-index: 4;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease, transform 0.15s ease;
|
||||
}
|
||||
.panel-close.visible { opacity: 1; pointer-events: auto; }
|
||||
.panel-close:hover { color: #6d28d9; border-color: #6d28d9; transform: translateX(50%) scale(1.08); }
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: min(880px, 100vw);
|
||||
border-radius: 14px 0 0 14px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.panel.open { transform: translateX(0); pointer-events: auto; }
|
||||
|
||||
.panel iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
export default defineContentScript({
|
||||
matches: [
|
||||
// Ozon
|
||||
'https://*.ozon.ru/*',
|
||||
'https://*.ozon.kz/*',
|
||||
'https://*.ozon.by/*',
|
||||
// 1688
|
||||
'https://detail.1688.com/*',
|
||||
// 淘宝 / 天猫
|
||||
'https://item.taobao.com/*',
|
||||
'https://detail.tmall.com/*',
|
||||
],
|
||||
async main(ctx) {
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'suite-studio-panel',
|
||||
position: 'overlay',
|
||||
anchor: 'body',
|
||||
alignment: 'bottom-right',
|
||||
zIndex: 2147483646,
|
||||
css: STYLES,
|
||||
isolateEvents: true,
|
||||
onMount(container) {
|
||||
const fab = document.createElement('button');
|
||||
fab.className = 'fab hidden';
|
||||
fab.title = '电商套图工作台';
|
||||
fab.textContent = '套';
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'panel';
|
||||
// iframe 懒加载:首次展开才设 src,避免每个商品页都加载整个面板应用
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.title = '电商套图工作台';
|
||||
panel.append(iframe);
|
||||
|
||||
// 外置收起按钮(骑在面板左上边缘,一半在面板外)
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'panel-close';
|
||||
closeBtn.title = '收起面板(Esc)';
|
||||
closeBtn.innerHTML =
|
||||
'<svg width="12" height="12" viewBox="0 0 12 12" fill="none">' +
|
||||
'<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
||||
container.append(fab, panel, closeBtn);
|
||||
|
||||
const open = () => {
|
||||
if (!iframe.src) iframe.src = chrome.runtime.getURL('/sidepanel.html');
|
||||
panel.classList.add('open');
|
||||
closeBtn.classList.add('visible');
|
||||
fab.classList.add('hidden');
|
||||
// 聚焦进面板,键盘操作(Esc 关闭 / 预览翻页)直接可用
|
||||
iframe.focus();
|
||||
};
|
||||
const close = () => {
|
||||
panel.classList.remove('open');
|
||||
closeBtn.classList.remove('visible');
|
||||
if (isProductPage()) fab.classList.remove('hidden');
|
||||
};
|
||||
|
||||
fab.addEventListener('click', open);
|
||||
closeBtn.addEventListener('click', close);
|
||||
|
||||
// 面板内 App(Esc)→ 收起;✕ 按钮已外置到宿主层(closeBtn)
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.source === iframe.contentWindow && (e.data as any)?.type === PANEL_CLOSE_MSG) close();
|
||||
});
|
||||
|
||||
// 工具栏图标点击 → 开关面板
|
||||
chrome.runtime.onMessage.addListener((msg: any) => {
|
||||
if (msg?.action === 'toggle-suite-panel') {
|
||||
panel.classList.contains('open') ? close() : open();
|
||||
}
|
||||
});
|
||||
|
||||
// 仅商品详情页显示按钮;站内软导航后重判(WXT 内置事件,自动拦截 pushState/replaceState/popState)
|
||||
const refresh = () => {
|
||||
if (isProductPage()) {
|
||||
if (!panel.classList.contains('open')) fab.classList.remove('hidden');
|
||||
} else {
|
||||
fab.classList.add('hidden');
|
||||
close();
|
||||
}
|
||||
};
|
||||
ctx.addEventListener(window, 'wxt:locationchange', refresh);
|
||||
refresh();
|
||||
|
||||
return { open, close };
|
||||
},
|
||||
});
|
||||
ui.mount();
|
||||
},
|
||||
});
|
||||
|
||||
/** 是否为四站点支持的商品详情页(与采集 profile 一致) */
|
||||
function isProductPage(): boolean {
|
||||
return matchProfile(location.href) !== null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
<!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>
|
||||
:root {
|
||||
--bg: #f5f5f5; /* 页面背景(中性灰) */
|
||||
--card: #ffffff;
|
||||
--card-soft: #fafafa;
|
||||
--border: #f0f0f0;
|
||||
--border-strong: #e0e0e0;
|
||||
--primary: #8b5cf6; /* 紫(ozon-seller-kit v2 主题色) */
|
||||
--primary-hover: #7c3aed;
|
||||
--primary-ring: rgba(139, 92, 246, 0.12);
|
||||
--primary-soft: #a78bfa; /* 主题色同色系偏淡(未勾选描边/✓) */
|
||||
--green: #52c41a;
|
||||
--red: #ff4d4f;
|
||||
--warn-bg: #fffbe6;
|
||||
--warn-border: #ffe58f;
|
||||
--warn-text: #8c6d1f;
|
||||
--text: #262626;
|
||||
--text-2: #8c8c8c;
|
||||
}
|
||||
html, body, #root {
|
||||
min-width: 860px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* ── 页面骨架 ── */
|
||||
.page { padding: 16px 18px 22px; }
|
||||
/* 采集区两列等高:左右卡片拉伸到同一高度 */
|
||||
.two-col { display: flex; gap: 14px; align-items: stretch; margin-bottom: 14px; }
|
||||
.two-col .section { flex: 1; min-width: 0; margin-bottom: 0; display: flex; flex-direction: column; }
|
||||
.two-col .section .section-head { flex-shrink: 0; }
|
||||
/* 图片列表占满 section 除标题外的剩余高度;min-height:0 是 flex 子项内滚动的关键 */
|
||||
.img-groups { flex: 1 1 auto; min-height: 0; overflow-y: auto; max-height: 78vh; }
|
||||
/* 采集图片区:section 自身去掉左右 padding,标题行自持 padding;
|
||||
图片区左侧对齐标题,右侧只留窄缝给滚动条(滚动条贴卡片内缘,图片与滚动条之间有小间距) */
|
||||
.section-images { padding: 14px 0 !important; }
|
||||
.section-images .section-head { padding: 0 16px; }
|
||||
.section-images .img-groups { padding: 2px 8px 0 16px; }
|
||||
.section-images .empty { margin: 0 16px; }
|
||||
.img-export-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 10px 16px 2px; padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.img-export-bar .hint { font-size: 12px; color: var(--text-2); }
|
||||
.divider { border-top: 1px solid var(--border); margin: 12px 0; }
|
||||
|
||||
/* ── 顶部 ── */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding-bottom: 14px; margin-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.logo {
|
||||
width: 38px; height: 38px; border-radius: 9px;
|
||||
background: var(--primary); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 19px; font-weight: 700;
|
||||
}
|
||||
.topbar h1 { font-size: 17px; margin: 0; font-weight: 700; }
|
||||
.topbar .sub { font-size: 12px; color: var(--text-2); margin-top: 1px; }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border-strong);
|
||||
font-size: 14px; cursor: pointer; user-select: none;
|
||||
background: #fff; color: var(--text);
|
||||
transition: all .15s;
|
||||
}
|
||||
.btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn-primary {
|
||||
background: var(--primary); border-color: var(--primary); color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
|
||||
/* AI 智能规划:深靛紫渐变(智慧/深度感) */
|
||||
.btn-ai {
|
||||
background: linear-gradient(135deg, #4338ca 0%, #6d28d9 100%);
|
||||
border: none; color: #fff; font-weight: 600;
|
||||
box-shadow: 0 2px 10px rgba(88, 60, 210, 0.35);
|
||||
}
|
||||
.btn-ai:hover:not([disabled]) {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
color: #fff; box-shadow: 0 3px 14px rgba(88, 60, 210, 0.45);
|
||||
}
|
||||
/* 三个主操作按钮统一宽度 */
|
||||
.btn-main { width: 160px; }
|
||||
/* 「规划并生成」复选框 */
|
||||
.auto-chk {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 12.5px; color: var(--text-2); cursor: pointer; user-select: none;
|
||||
}
|
||||
.auto-chk input { accent-color: var(--primary); width: 14px; height: 14px; cursor: pointer; }
|
||||
.auto-chk:hover { color: var(--text); }
|
||||
.btn[disabled] { opacity: .5; cursor: not-allowed; }
|
||||
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
|
||||
.icon-btn {
|
||||
width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border-strong);
|
||||
background: #fff; cursor: pointer; color: var(--text-2);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.icon-btn:hover { color: var(--primary); border-color: var(--primary); }
|
||||
|
||||
/* ── 编号步骤卡片 ── */
|
||||
.section {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 14px 16px; margin-bottom: 14px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
.section-head { display: flex; align-items: baseline; gap: 9px; margin-bottom: 12px; }
|
||||
.section-no {
|
||||
font-size: 20px; font-weight: 800; color: var(--primary);
|
||||
font-variant-numeric: tabular-nums; line-height: 1;
|
||||
}
|
||||
.section-title { font-size: 15px; font-weight: 700; }
|
||||
.section-extra { margin-left: auto; font-size: 12px; color: var(--text-2); }
|
||||
|
||||
/* ── 字段 ── */
|
||||
.field { margin-bottom: 10px; }
|
||||
.field label { display: block; font-size: 12.5px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.field input, .field textarea {
|
||||
width: 100%; padding: 8px 11px; border: 1px solid var(--border-strong);
|
||||
border-radius: 6px; font-size: 14px; font-family: inherit; line-height: 1.5;
|
||||
background: var(--card-soft); color: var(--text); outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.field input:focus, .field textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-ring);
|
||||
background: #fff;
|
||||
}
|
||||
.kv-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
background: var(--card-soft); border-radius: 6px; overflow: hidden;
|
||||
}
|
||||
.kv-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.kv-table tr:last-child td { border-bottom: none; }
|
||||
.kv-table td.k { color: var(--text-2); white-space: nowrap; width: 1%; padding-right: 16px; }
|
||||
|
||||
/* ── 药丸选择 ── */
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 5px 13px; border-radius: 999px; border: 1px solid var(--border-strong);
|
||||
background: #fff; font-size: 13px; cursor: pointer; color: var(--text-2);
|
||||
user-select: none; transition: all .15s; line-height: 1.4;
|
||||
}
|
||||
.pill:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.pill.on {
|
||||
background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 图片网格 ── */
|
||||
.group-head { display: flex; align-items: center; gap: 8px; margin: 4px 0 9px; }
|
||||
.group-head .name { font-size: 13px; font-weight: 600; color: var(--text-2); }
|
||||
.group-head .count {
|
||||
font-size: 12px; color: var(--text-2); background: var(--card-soft);
|
||||
border: 1px solid var(--border); border-radius: 999px; padding: 0 8px;
|
||||
}
|
||||
.group-head .mini-check { margin-left: auto; font-size: 12px; color: var(--primary); cursor: pointer; user-select: none; }
|
||||
.group-head .mini-check:hover { text-decoration: underline; }
|
||||
.img-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
|
||||
.img-cell {
|
||||
position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden;
|
||||
border: 2px solid transparent; cursor: zoom-in; background: var(--card-soft);
|
||||
}
|
||||
.img-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.img-cell.on { border-color: var(--primary); }
|
||||
.img-cell .tick {
|
||||
position: absolute; top: 5px; left: 5px; width: 18px; height: 18px;
|
||||
border-radius: 50%; border: 1.5px solid var(--primary-soft);
|
||||
background: rgba(255, 255, 255, 0.55); display: flex; align-items: center; justify-content: center;
|
||||
color: var(--primary-soft); font-size: 11px; transition: all .15s; cursor: pointer;
|
||||
}
|
||||
.img-cell.on .tick { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.img-cell .variant {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 11px;
|
||||
padding: 1px 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── 生成结果(整行,6 列)── */
|
||||
.result-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
|
||||
.result-cell { position: relative; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
|
||||
.result-cell img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
|
||||
.result-cell .cap {
|
||||
font-size: 11.5px; text-align: center; padding: 3px 0;
|
||||
background: var(--card-soft); color: var(--text-2);
|
||||
border-top: 1px solid var(--border); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.result-cell.fail { opacity: .55; }
|
||||
.result-cell .fail-tag {
|
||||
position: absolute; top: 5px; right: 5px; font-size: 11px;
|
||||
background: var(--red); color: #fff; border-radius: 4px; padding: 0 5px;
|
||||
}
|
||||
|
||||
/* ── 目标平台切换条 ── */
|
||||
.platform-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 10px 16px; margin-bottom: 14px;
|
||||
}
|
||||
.platform-label { font-size: 13px; font-weight: 700; }
|
||||
.platform-spec { margin-left: auto; font-size: 12.5px; color: var(--text-2); }
|
||||
|
||||
/* 平台切换:Button.Group 形式,选中态用低饱和灰绿(不抢主题色) */
|
||||
.seg-group { display: inline-flex; }
|
||||
.seg-btn {
|
||||
padding: 7px 20px; font-size: 13.5px; font-family: inherit;
|
||||
min-width: 120px; text-align: center; /* 选中加粗会让文字变宽,固定宽度消除跳动 */
|
||||
background: #fff; border: 1px solid var(--border-strong); border-left-width: 0;
|
||||
color: var(--text-2); cursor: pointer; user-select: none; transition: all .15s;
|
||||
}
|
||||
.seg-group .seg-btn:first-child { border-left-width: 1px; border-radius: 8px 0 0 8px; }
|
||||
.seg-group .seg-btn:last-child { border-radius: 0 8px 8px 0; }
|
||||
.seg-btn:hover { color: var(--text); background: var(--card-soft); }
|
||||
.seg-btn.on {
|
||||
background: #eef0eb; border-color: #c9cec6; color: #3f453c; font-weight: 700;
|
||||
}
|
||||
.seg-group .seg-btn.on + .seg-btn { border-left-color: #c9cec6; }
|
||||
/* 三个平台药丸等宽:选中态加粗会让文字变宽,用固定 min-width 消除抖动 */
|
||||
.platform-bar .pill { min-width: 108px; text-align: center; }
|
||||
|
||||
/* ── 图片放大预览(画廊)── */
|
||||
.lightbox {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-direction: column; gap: 12px; cursor: zoom-out;
|
||||
}
|
||||
.lightbox img {
|
||||
max-width: 88%; max-height: 82%;
|
||||
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.lightbox-nav {
|
||||
position: absolute; top: 50%; transform: translateY(-50%);
|
||||
width: 40px; height: 64px; border: none; border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.12); color: #fff;
|
||||
font-size: 30px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background .15s; user-select: none;
|
||||
}
|
||||
.lightbox-nav:hover { background: rgba(255, 255, 255, 0.28); }
|
||||
.lightbox-nav.prev { left: 14px; }
|
||||
.lightbox-nav.next { right: 14px; }
|
||||
.lightbox-counter {
|
||||
position: absolute; top: 14px; right: 16px;
|
||||
background: rgba(0, 0, 0, 0.5); color: #fff;
|
||||
font-size: 13px; padding: 3px 10px; border-radius: 999px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lightbox-tip { color: rgba(255, 255, 255, 0.75); font-size: 12.5px; }
|
||||
|
||||
/* ── 出图方案 ── */
|
||||
.plan-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.plan-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--card-soft); cursor: pointer;
|
||||
}
|
||||
.plan-row:hover { border-color: var(--primary); }
|
||||
.plan-row.off { opacity: .45; }
|
||||
.plan-all-toggle {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 8px 2px 2px; padding-top: 6px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.plan-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
|
||||
.plan-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
|
||||
.variant-chip {
|
||||
flex-shrink: 0; font-size: 11.5px; padding: 0 8px; line-height: 1.8;
|
||||
border-radius: 999px; background: #f3efff; border: 1px solid #ddd3fa; color: #6d28d9;
|
||||
}
|
||||
.plan-detail {
|
||||
font-size: 12px; color: var(--text-2);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
/* 构图提示(prompt_hint):生图要求的落地处,用主题蓝区分 */
|
||||
.plan-detail.plan-hint { color: #4f6bed; }
|
||||
.stepper { display: inline-flex; align-items: center; gap: 0; flex-shrink: 0; }
|
||||
.step-btn {
|
||||
width: 24px; height: 24px; border: 1px solid var(--border-strong); background: #fff;
|
||||
border-radius: 5px; cursor: pointer; font-size: 14px; line-height: 1; color: var(--text);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.step-btn:hover:not([disabled]) { border-color: var(--primary); color: var(--primary); }
|
||||
.step-btn[disabled] { opacity: .35; cursor: not-allowed; }
|
||||
.step-num {
|
||||
min-width: 28px; text-align: center; font-size: 13.5px; font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ai-tag {
|
||||
background: var(--primary); color: #fff; font-size: 11px;
|
||||
border-radius: 4px; padding: 1px 6px; margin-right: 4px;
|
||||
}
|
||||
|
||||
.hint { font-size: 12.5px; color: var(--text-2); line-height: 1.6; }
|
||||
|
||||
/* ── 模型下拉选项 ── */
|
||||
.model-opt-name { font-size: 13.5px; font-weight: 600; color: var(--text); }
|
||||
.model-opt-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; }
|
||||
.ok-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
background: #f6ffed; border: 1px solid #b7eb8f; color: #389e0d;
|
||||
border-radius: 6px; padding: 4px 9px; font-size: 12.5px;
|
||||
}
|
||||
.warn-box {
|
||||
background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text);
|
||||
border-radius: 6px; padding: 7px 10px; font-size: 12.5px; margin-top: 6px; line-height: 1.6;
|
||||
}
|
||||
.empty {
|
||||
text-align: center; color: var(--text-2); font-size: 13px;
|
||||
padding: 22px 0; background: var(--card-soft); border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./App.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "ozon-collector-extension",
|
||||
"version": "0.1.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",
|
||||
"typescript": "^5.5.3",
|
||||
"wxt": "^0.19.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
|
||||
}
|
||||
Generated
+4411
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
spawn-sync: true
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 1688 提取器离线验证 —— 用 reference/1688.html 快照跑真实提取逻辑。
|
||||
*
|
||||
* 用法:node scripts/verify-1688.mjs [快照路径]
|
||||
* 依赖 esbuild 打包 TS 提取器(node_modules 里有)。
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const htmlPath = process.argv[2] ?? '/Users/joey/sites/seller-store/ozon-seller-kit/reference/1688.html';
|
||||
|
||||
// 1. 打包提取器(纯函数无 DOM 依赖);pnpm 布局下 esbuild bin 可能不在 .bin,动态查找
|
||||
function findEsbuild() {
|
||||
const candidates = [join(root, 'node_modules/.bin/esbuild')];
|
||||
try {
|
||||
const pnpmDir = join(root, 'node_modules/.pnpm');
|
||||
for (const d of readdirSync(pnpmDir)) {
|
||||
if (d.startsWith('esbuild@')) {
|
||||
candidates.push(join(pnpmDir, d, 'node_modules/esbuild/bin/esbuild'));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
candidates.push('esbuild'); // 全局兜底
|
||||
for (const c of candidates) {
|
||||
try { execSync(`${c} --version`, { stdio: 'pipe' }); return c; } catch { /* try next */ }
|
||||
}
|
||||
throw new Error('找不到可用的 esbuild');
|
||||
}
|
||||
const outFile = '/tmp/1688-state.bundle.mjs';
|
||||
execSync(`${JSON.stringify(findEsbuild())} src/collector/1688-state.ts --bundle --format=esm --outfile=${outFile}`, { cwd: root });
|
||||
const { extract1688State } = await import(`file://${outFile}`);
|
||||
|
||||
// 2. 从快照提取 script#3 并在 window 垫片里求值(还原 window.context)
|
||||
const html = readFileSync(htmlPath, 'utf8');
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(m => m[1]);
|
||||
const ctxScript = scripts.find(s => s.includes('window.context')) ?? scripts[3];
|
||||
const windowShim = {};
|
||||
new Function('window', 'document', 'location', ctxScript)(
|
||||
windowShim, { querySelector: () => null }, { hostname: 'detail.1688.com' },
|
||||
);
|
||||
const context = windowShim.context;
|
||||
if (!context) {
|
||||
console.error('✗ window.context 求值失败');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✓ window.context 就绪(keys: ${Object.keys(context).join(', ')})`);
|
||||
|
||||
// 3. 跑提取器并断言
|
||||
const st = extract1688State(context);
|
||||
const assert = (cond, msg) => { if (!cond) { console.error(`✗ ${msg}`); process.exit(1); } console.log(`✓ ${msg}`); };
|
||||
|
||||
assert(st !== null, '提取器返回非空');
|
||||
assert(!!st.title && st.title.length >= 5, `标题: ${st.title}`);
|
||||
assert(st.galleryImages.length >= 5, `主图 ${st.galleryImages.length} 张`);
|
||||
assert(st.videos.length >= 1 && /\.mp4/.test(st.videos[0].url), `视频: ${st.videos[0]?.url?.slice(0, 60) ?? '无'}…`);
|
||||
assert(st.skus.length >= 3, `SKU ${st.skus.length} 个(含规格名/图)`);
|
||||
assert(st.skus.every(s => /:/.test(s.name)), `SKU 名称带维度前缀: ${st.skus.slice(0, 3).map(s => s.name).join(' | ')}…`);
|
||||
assert(!!st.price && /\d/.test(st.price), `价格区间: ${st.price}`);
|
||||
assert(!!st.sales && /\d/.test(st.sales), `销量: ${st.sales}`);
|
||||
assert(!!st.shop && st.shop.length >= 2, `店铺: ${st.shop}`);
|
||||
const dimPair = st.params.find(p => p.key === '产品尺寸');
|
||||
assert(!!dimPair && /\d+×\d+×\d+/.test(dimPair.value), `产品尺寸: ${dimPair?.value}`);
|
||||
assert(st.params.some(p => p.key === '重量'), `重量: ${st.params.find(p => p.key === '重量')?.value}`);
|
||||
assert(!!st.detailUrl?.startsWith('https://'), `detailUrl: ${st.detailUrl?.slice(0, 70)}…`);
|
||||
const priced = st.skus.filter(s => s.price);
|
||||
assert(priced.length >= 1, `SKU 价格明细 ${priced.length} 条(如 ${priced[0]?.name} ${priced[0]?.price})`);
|
||||
|
||||
console.log('\n全部断言通过 ✅');
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
|
||||
* 契约对齐 server 端 /api/plan、/api/generate 与 /api/suites。
|
||||
*/
|
||||
import type { ImageMaterial } from '../collector/scan';
|
||||
|
||||
/** 服务端支持的套图类型(与 server/services/prompts/common.py 保持一致) */
|
||||
export const SUITE_TYPE_OPTIONS = [
|
||||
{ value: 'white_bg', label: '白底主图' },
|
||||
{ value: 'key_features', label: '核心卖点图' },
|
||||
{ value: 'selling_pt', label: '卖点图' },
|
||||
{ value: 'material', label: '材质图' },
|
||||
{ value: 'lifestyle', label: '场景展示图' },
|
||||
{ value: 'multi_scene', label: '多场景拼图' },
|
||||
{ value: 'ecommerce_detail', label: '电商详情图' },
|
||||
{ value: 'size_chart', label: '尺寸标注图' },
|
||||
{ value: 'sku_collection', label: 'SKU合集图' },
|
||||
{ value: 'custom', label: '创意图' },
|
||||
] as const;
|
||||
|
||||
/** 出图方案项:一类图 × 数量,可绑定 SKU 规格 */
|
||||
export interface PlanItem {
|
||||
kind: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
prompt_hint: string;
|
||||
count: number;
|
||||
variant_name?: string | null;
|
||||
}
|
||||
|
||||
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
|
||||
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map(t => ({
|
||||
kind: t.value, title: t.label, detail: '', prompt_hint: '', count: 1, variant_name: null,
|
||||
}));
|
||||
|
||||
/** 视觉风格(名称 + 默认提示词,提示词可在插件里改写,随生成请求覆盖后端模板) */
|
||||
export const STYLE_SET_OPTIONS = [
|
||||
{
|
||||
value: 1,
|
||||
label: '北欧极简',
|
||||
prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '清新明亮',
|
||||
prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '高级感深色',
|
||||
prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级',
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
label: '暖调生活',
|
||||
prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强',
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: '纯净棚拍',
|
||||
prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
|
||||
export const PLATFORM_OPTIONS = [
|
||||
{ value: 'ozon', label: 'Ozon' },
|
||||
{ value: 'wb', label: 'Wildberries' },
|
||||
{ value: 'cn', label: '中文' },
|
||||
] as const;
|
||||
|
||||
export type PlatformId = (typeof PLATFORM_OPTIONS)[number]['value'];
|
||||
|
||||
/** 生图模型(下拉可选 + 中文特点说明;服务端按模型名路由 provider) */
|
||||
export const IMAGE_MODEL_OPTIONS = [
|
||||
{
|
||||
value: 'qwen-image-3.0-pro',
|
||||
label: 'qwen-image-3.0-pro',
|
||||
desc: '同步生成,响应快、图文理解强,适合快速批量出图',
|
||||
},
|
||||
{
|
||||
value: 'wan2.7-image-pro',
|
||||
label: 'wan2.7-image-pro',
|
||||
desc: '异步精修,质感与细节更强,适合高质量电商大片',
|
||||
},
|
||||
{
|
||||
value: 'wan2.6-image',
|
||||
label: 'wan2.6-image',
|
||||
desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好',
|
||||
},
|
||||
{
|
||||
value: 'wan2.6-t2i',
|
||||
label: 'wan2.6-t2i',
|
||||
desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快',
|
||||
},
|
||||
{
|
||||
value: 'gpt-image-2',
|
||||
label: 'gpt-image-2',
|
||||
desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟',
|
||||
},
|
||||
{
|
||||
value: 'gpt-image-2-vip',
|
||||
label: 'gpt-image-2-vip',
|
||||
desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana',
|
||||
label: 'nano-banana',
|
||||
desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana-2',
|
||||
label: 'nano-banana-2',
|
||||
desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana-2-lite',
|
||||
label: 'nano-banana-2-lite',
|
||||
desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana-pro',
|
||||
label: 'nano-banana-pro',
|
||||
desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = {
|
||||
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
|
||||
wb: { lang: '俄文', ratio: '3:4', label: 'Wildberries' },
|
||||
cn: { lang: '中文', ratio: '1:1', label: '中文' },
|
||||
};
|
||||
|
||||
export interface SuiteImageInfo {
|
||||
type_id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
status: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface SuiteInfo {
|
||||
id: string;
|
||||
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
|
||||
style_set: number;
|
||||
platform: string;
|
||||
lang: string;
|
||||
ratio: string;
|
||||
provider: string;
|
||||
total?: number; // 计划生成总张数(后端返回;images 逐张追加,过程中 length < total)
|
||||
images: SuiteImageInfo[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/** 生成图水印选项(服务端在 AI 出图后合成;shape 对齐 server WatermarkOptions) */
|
||||
export interface WatermarkPayload {
|
||||
enabled: boolean;
|
||||
type: 'image' | 'text';
|
||||
text: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */
|
||||
export interface GeneratePayload {
|
||||
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
|
||||
images: Array<{ url: string; group_key: string; variant_name?: string | null }>;
|
||||
style_set: number;
|
||||
style_prompt?: string;
|
||||
requirements?: string | null;
|
||||
plan: PlanItem[];
|
||||
platform: string;
|
||||
model?: string | null;
|
||||
watermark?: WatermarkPayload;
|
||||
}
|
||||
|
||||
/** 组装无状态生成请求:编辑后的文本 + 已勾选图片(含手动上传)+ 出图方案 */
|
||||
export function buildGeneratePayload(
|
||||
images: ImageMaterial[],
|
||||
selectedKeys: Set<string>,
|
||||
texts: GeneratePayload['texts'],
|
||||
config: { style_set: number; style_prompt?: string; requirements?: string | null; plan: PlanItem[]; platform: string; model?: string | null; watermark?: WatermarkPayload },
|
||||
): GeneratePayload {
|
||||
const selected = images
|
||||
.filter((img) => selectedKeys.has(img.key))
|
||||
.map((img) => ({ url: img.url, group_key: img.groupKey, variant_name: img.variantName ?? null }));
|
||||
return { texts, images: selected, ...config };
|
||||
}
|
||||
|
||||
/** 出图方案规划请求体 */
|
||||
export interface PlanPayload {
|
||||
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
|
||||
sku_variants: string[];
|
||||
image_stats: Record<string, number>;
|
||||
platform: string;
|
||||
requirements?: string | null;
|
||||
}
|
||||
|
||||
/** AI 智能规划:DeepSeek 根据商品信息生成出图方案 */
|
||||
export async function planSuite(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
payload: PlanPayload,
|
||||
): Promise<{ summary: string; items: PlanItem[] }> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.detail || `规划失败 HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 无状态一键生成:后端直接用请求数据生图,不落商品库 */
|
||||
export async function generateSuite(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
payload: GeneratePayload,
|
||||
): Promise<{ suite_id: string }> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.detail || `提交失败 HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 查询套图任务状态(轮询用) */
|
||||
export async function getSuite(baseUrl: string, token: string, suiteId: string): Promise<SuiteInfo> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}`, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.detail || `查询失败 HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function suiteZipUrl(baseUrl: string, suiteId: string): string {
|
||||
return `${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}/zip`;
|
||||
}
|
||||
|
||||
/** 下载生成结果 ZIP:GET → blob(由调用方经 chrome.downloads 落盘,文件名用标题) */
|
||||
export async function downloadSuiteZip(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
suiteId: string,
|
||||
): Promise<Blob> {
|
||||
const res = await fetch(suiteZipUrl(baseUrl, suiteId), { headers: authHeaders(token) });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.detail || `下载失败 HTTP ${res.status}`);
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
/** 手动上传本地图片到服务端,返回可访问 URL(补充参考图用) */
|
||||
export async function uploadImage(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
file: File,
|
||||
): Promise<{ url: string; key: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token), // 不显式设 Content-Type,交给浏览器生成 boundary
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 导出采集图片请求体:后端打包成 ZIP(内部按分组名建文件夹,文件名沿用采集 key) */
|
||||
export interface ExportImagesPayload {
|
||||
title: string;
|
||||
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
|
||||
}
|
||||
|
||||
/** 导出采集图片:POST /api/export-images → ZIP blob(由调用方经 chrome.downloads 落盘) */
|
||||
export async function exportImages(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
payload: ExportImagesPayload,
|
||||
): Promise<Blob> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/export-images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.detail || `导出失败 HTTP ${res.status}`);
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 商品上报客户端:把采集结果 POST 到 ozon-seller-kit 后台 /api/materials,
|
||||
* 返回 product_id 后由调用方打开商品试算页。契约对齐主仓 server/schemas/collection.py。
|
||||
*/
|
||||
import type { TextMaterial } from '../collector/merge';
|
||||
|
||||
export interface ReportProductPayload {
|
||||
source: {
|
||||
platform: string;
|
||||
itemId: string | null;
|
||||
url: string;
|
||||
collectedAt?: number;
|
||||
};
|
||||
texts: TextMaterial[];
|
||||
images: Array<{
|
||||
groupKey: string;
|
||||
groupName: string;
|
||||
variantName: string | null;
|
||||
url: string;
|
||||
index: number;
|
||||
type: string;
|
||||
}>;
|
||||
/** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */
|
||||
refererOrigin?: string;
|
||||
}
|
||||
|
||||
export interface ReportProductResult {
|
||||
product_id: string;
|
||||
stage?: string;
|
||||
assets_queued?: number;
|
||||
}
|
||||
|
||||
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS) */
|
||||
export async function reportProduct(
|
||||
reportBaseUrl: string,
|
||||
payload: ReportProductPayload,
|
||||
): Promise<ReportProductResult> {
|
||||
const res = await fetch(`${reportBaseUrl.replace(/\/$/, '')}/api/materials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`);
|
||||
return data as ReportProductResult;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* isolated world 侧的桥客户端:请求 MAIN world 读取页面全局变量。
|
||||
* 自带重试(桥脚本可能比内容脚本晚注入),超时返回空对象——调用方降级 DOM。
|
||||
*/
|
||||
|
||||
let seq = 0;
|
||||
|
||||
export function readWindowKeys(keys: string[], timeoutMs = 1200): Promise<Record<string, any>> {
|
||||
const requestId = `sc-${Date.now()}-${seq++}`;
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const started = Date.now();
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('message', onMsg);
|
||||
clearTimeout(retryTimer);
|
||||
clearTimeout(giveUpTimer);
|
||||
};
|
||||
const onMsg = (ev: MessageEvent) => {
|
||||
if (ev.source !== window) return;
|
||||
const d = ev.data as { type?: string; requestId?: string; payload?: Record<string, any> } | null;
|
||||
if (d?.type === 'sc-bridge-res' && d.requestId === requestId) {
|
||||
done = true;
|
||||
cleanup();
|
||||
resolve(d.payload ?? {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
|
||||
const send = () => window.postMessage({ type: 'sc-bridge-req', requestId, keys }, '*');
|
||||
send();
|
||||
const retryTimer = setInterval(() => {
|
||||
if (done) return;
|
||||
if (Date.now() - started > timeoutMs) return;
|
||||
send();
|
||||
}, 250);
|
||||
const giveUpTimer = setTimeout(() => {
|
||||
if (done) return;
|
||||
cleanup();
|
||||
resolve({});
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 1688 SSR 状态提取器(主路径)——读取 MAIN world 桥回传的 window.context。
|
||||
*
|
||||
* 页面第 4 个内联 script 把完整商品数据挂在 window.context:
|
||||
* window.context.result.data.<模块名>.fields
|
||||
* 结构与 Ozon 的 data-state 同构(34 个模块)。本文件只做纯数据提取,
|
||||
* 不碰 DOM / URL,方便用 reference/1688.html 快照离线验证(scripts/verify-1688.mjs)。
|
||||
*/
|
||||
|
||||
export interface Sku1688 {
|
||||
name: string; // "规格型号:黑盒【27件套】"
|
||||
image?: string;
|
||||
price?: string; // 该 SKU 价格
|
||||
canBookCount?: number; // 该 SKU 库存
|
||||
length?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface State1688 {
|
||||
title?: string;
|
||||
price?: string; // "19.90-24.60"
|
||||
sales?: string; // 销量
|
||||
shop?: string; // 公司/店铺名
|
||||
unit?: string; // 单位(套/件)
|
||||
offerId?: string;
|
||||
categoryIds?: string[];
|
||||
galleryImages: string[]; // 原图
|
||||
videos: Array<{ url: string; cover?: string }>;
|
||||
skus: Sku1688[];
|
||||
params: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
|
||||
export function deepFind(root: unknown, key: string, maxNodes = 300_000): any {
|
||||
if (root == null || typeof root !== 'object') return undefined;
|
||||
const queue: unknown[] = [root];
|
||||
const seen = new Set<object>();
|
||||
let visited = 0;
|
||||
while (queue.length) {
|
||||
const cur = queue.shift();
|
||||
if (cur == null || typeof cur !== 'object') continue;
|
||||
if (++visited > maxNodes) return undefined;
|
||||
if (seen.has(cur as object)) continue;
|
||||
seen.add(cur as object);
|
||||
for (const [k, v] of Object.entries(cur as Record<string, unknown>)) {
|
||||
if (k === key) return v;
|
||||
if (v && typeof v === 'object') queue.push(v);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const num = (v: unknown): number | undefined => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
};
|
||||
|
||||
export function extract1688State(context: unknown): State1688 | null {
|
||||
if (!context || typeof context !== 'object') return null;
|
||||
|
||||
// ── gallery:主图 + 视频 ──
|
||||
const gallery = deepFind(context, 'gallery');
|
||||
const galleryFields = gallery?.fields ?? gallery ?? {};
|
||||
const mainImages: string[] = [];
|
||||
const pushImg = (u: unknown) => {
|
||||
if (typeof u === 'string' && /^https?:\/\//.test(u) && !mainImages.includes(u)) mainImages.push(u);
|
||||
};
|
||||
(galleryFields.mainImage ?? []).forEach(pushImg);
|
||||
(galleryFields.offerImgList ?? []).forEach((it: any) => typeof it === 'string' ? pushImg(it) : pushImg(it?.imgUrl ?? it?.url ?? it?.image));
|
||||
|
||||
const videos: Array<{ url: string; cover?: string }> = [];
|
||||
const videoObj = galleryFields.video;
|
||||
if (videoObj?.videoUrl) videos.push({ url: videoObj.videoUrl, cover: videoObj.coverUrl });
|
||||
(galleryFields.videos ?? []).forEach((v: any) => v?.videoUrl && videos.push({ url: v.videoUrl, cover: v.coverUrl }));
|
||||
|
||||
// ── tempModel(在 Root 模块里):标题/销量/公司/类目 ──
|
||||
const temp = deepFind(context, 'tempModel') ?? {};
|
||||
const title = typeof temp.offerTitle === 'string' ? temp.offerTitle : undefined;
|
||||
|
||||
// ── SKU:skuModel.skuProps 全维度展开 ──
|
||||
const skus: Sku1688[] = [];
|
||||
const skuModel = deepFind(context, 'skuModel');
|
||||
for (const prop of skuModel?.skuProps ?? []) {
|
||||
for (const v of prop?.value ?? []) {
|
||||
if (!v?.name) continue;
|
||||
skus.push({
|
||||
name: `${prop.prop ?? '规格'}:${v.name}`,
|
||||
image: typeof v.imageUrl === 'string' ? v.imageUrl : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 价格:区间 + 每 SKU 明细 ──
|
||||
const tradeModel = deepFind(context, 'tradeModel') ?? {};
|
||||
let price: string | undefined;
|
||||
if (typeof tradeModel.minPrice === 'string' && typeof tradeModel.maxPrice === 'string') {
|
||||
price = tradeModel.minPrice === tradeModel.maxPrice
|
||||
? `¥${tradeModel.minPrice}`
|
||||
: `¥${tradeModel.minPrice}-${tradeModel.maxPrice}`;
|
||||
}
|
||||
const skuMap = deepFind(context, 'skuMapOriginal') ?? [];
|
||||
const byName = new Map(skus.map(s => [s.name.split(':').pop() ?? s.name, s]));
|
||||
for (const row of skuMap) {
|
||||
const s = byName.get(row?.specAttrs);
|
||||
if (s) {
|
||||
if (typeof row.price === 'string') s.price = `¥${row.price}`;
|
||||
s.canBookCount = num(row.canBookCount);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 件重尺:每个 SKU 的长宽高/体积/重量 ──
|
||||
const packRows: any[] = deepFind(context, 'pieceWeightScaleInfo') ?? [];
|
||||
const params: Array<{ key: string; value: string }> = [];
|
||||
if (packRows.length) {
|
||||
for (const r of packRows) {
|
||||
const s = byName.get(r?.sku1);
|
||||
if (s) {
|
||||
s.length = num(r.length); s.width = num(r.width);
|
||||
s.height = num(r.height); s.weight = num(r.weight);
|
||||
}
|
||||
}
|
||||
const first = packRows[0];
|
||||
if (num(first.length) && num(first.width) && num(first.height)) {
|
||||
params.push({ key: '产品尺寸', value: `${first.length}×${first.width}×${first.height}cm` });
|
||||
}
|
||||
if (num(first.weight)) {
|
||||
params.push({ key: '重量', value: `${first.weight}g` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 参数表:productAttributes(模块可能服务端报错为空,DOM 兜底)──
|
||||
const attrs = deepFind(context, 'productAttributes');
|
||||
const attrFields = attrs?.fields ?? {};
|
||||
for (const row of attrFields.attributes ?? attrFields.props ?? []) {
|
||||
const k = typeof row?.name === 'string' ? row.name : row?.propertyName;
|
||||
const v = typeof row?.value === 'string' ? row.value : row?.valueName;
|
||||
if (k && v) params.push({ key: String(k), value: String(v) });
|
||||
}
|
||||
|
||||
// ── SKU 价格明细(少量时并入参数,供规划/尺寸图参考)──
|
||||
const priced = skus.filter(s => s.price);
|
||||
if (priced.length > 0 && priced.length <= 6) {
|
||||
params.push({ key: 'SKU价格', value: priced.map(s => `${s.name.split(':').pop()} ${s.price}`).join(';') });
|
||||
}
|
||||
|
||||
const categoryIds = [
|
||||
temp.postCategoryId ? String(temp.postCategoryId) : '',
|
||||
temp.topCategoryId ? String(temp.topCategoryId) : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (!title && mainImages.length === 0 && skus.length === 0) return null;
|
||||
|
||||
return {
|
||||
title,
|
||||
price,
|
||||
sales: temp.saledCount != null ? `${temp.saledCount}` : undefined,
|
||||
shop: typeof temp.companyName === 'string' ? temp.companyName : undefined,
|
||||
unit: typeof temp.offerUnit === 'string' ? temp.offerUnit : undefined,
|
||||
offerId: temp.offerId != null ? String(temp.offerId) : undefined,
|
||||
categoryIds,
|
||||
galleryImages: mainImages,
|
||||
videos,
|
||||
skus,
|
||||
params,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
|
||||
* 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征),
|
||||
* 等待页面高度增长,页面不再变高或达到步数上限即停——防止底部「为你推荐」无限加载把采集卡死。
|
||||
* 滚完恢复原位。
|
||||
*/
|
||||
export async function autoScrollToBottom(
|
||||
opts: { stepPx?: number; stepMs?: number; maxSteps?: number } = {}
|
||||
): Promise<void> {
|
||||
const { stepPx = 500, stepMs = 400, maxSteps = 80 } = opts;
|
||||
const startY = window.scrollY;
|
||||
let lastHeight = document.body.scrollHeight;
|
||||
let stagnant = 0; // 连续不增长的步数
|
||||
// 每步在 0.7~1.3 倍步长、0.7~1.5 倍间隔内随机抖动,模拟人工节奏
|
||||
const rand = (min: number, max: number) => min + Math.random() * (max - min);
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
window.scrollBy({ top: Math.round(stepPx * rand(0.7, 1.3)), behavior: 'auto' });
|
||||
await new Promise(r => setTimeout(r, Math.round(stepMs * rand(0.7, 1.5))));
|
||||
const atBottom = window.scrollY + window.innerHeight >= document.body.scrollHeight - 4;
|
||||
const h = document.body.scrollHeight;
|
||||
if (h > lastHeight + 50) {
|
||||
lastHeight = h;
|
||||
stagnant = 0; // 页面还在长(懒加载进来新内容),继续
|
||||
} else if (atBottom) {
|
||||
stagnant++;
|
||||
if (stagnant >= 2) break; // 到底且连续两步没有新内容,收工
|
||||
}
|
||||
}
|
||||
// 多数详情图是进入视口才加载,到底后再等一拍让 <img> 完成 src 替换
|
||||
await new Promise(r => setTimeout(r, stepMs));
|
||||
window.scrollTo({ top: startY, behavior: 'auto' });
|
||||
}
|
||||
@@ -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,100 @@
|
||||
/**
|
||||
* 通用合并器:各平台采集路径共用的结果装配逻辑。
|
||||
* 从 scan.ts 抽出(平台拆分),平台文件只负责各路径的数据获取。
|
||||
*/
|
||||
import type { ImageMaterial } from './image';
|
||||
import type { TextMaterial } from './text';
|
||||
import type { BreadcrumbItem } from './ozon-state';
|
||||
import type { SiteProfile } from '../profiles/types';
|
||||
|
||||
export interface ScanResult {
|
||||
platform: string;
|
||||
itemId: string | null;
|
||||
url: string;
|
||||
texts: TextMaterial[];
|
||||
images: ImageMaterial[];
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
scannedAt: number;
|
||||
stats: Record<string, number>; // 分组统计
|
||||
warnings: string[]; // 警告(如详情图为 0)
|
||||
source: 'state' | 'ssr' | 'jsonld' | 'api' | 'dom' | 'mixed'; // 主路径
|
||||
}
|
||||
|
||||
export type { ImageMaterial, TextMaterial };
|
||||
|
||||
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
|
||||
{ key: 'main', name: '主图' },
|
||||
{ key: 'sku', name: 'SKU图片' },
|
||||
{ key: 'detail', name: '详情图' },
|
||||
{ key: 'video', name: '视频' },
|
||||
];
|
||||
|
||||
/** 按组分组合并:靠前来源优先,靠后来源填缺,按 dedupeKey 去重后重排 index */
|
||||
export function mergeImages(
|
||||
primary: ImageMaterial[],
|
||||
fallback: ImageMaterial[],
|
||||
profile: SiteProfile
|
||||
): ImageMaterial[] {
|
||||
const byGroup = new Map<string, ImageMaterial[]>();
|
||||
const seen = new Set<string>();
|
||||
let counter = 0;
|
||||
|
||||
const push = (m: ImageMaterial) => {
|
||||
const k = m.groupKey === 'sku'
|
||||
? `${dedupeKey(m.url, profile.originalUrlRules)}::${m.variantName ?? ''}`
|
||||
: dedupeKey(m.url, profile.originalUrlRules);
|
||||
if (seen.has(k)) return;
|
||||
seen.add(k);
|
||||
const arr = byGroup.get(m.groupKey) ?? [];
|
||||
arr.push({ ...m, index: counter++ });
|
||||
byGroup.set(m.groupKey, arr);
|
||||
};
|
||||
|
||||
for (const m of primary) push(m);
|
||||
for (const m of fallback) push(m);
|
||||
|
||||
const out: ImageMaterial[] = [];
|
||||
for (const g of GROUP_ORDER) {
|
||||
const arr = byGroup.get(g.key);
|
||||
if (!arr) continue;
|
||||
arr.forEach((m, i) => {
|
||||
m.key = `${g.key}-${String(i + 1).padStart(3, '0')}`;
|
||||
m.groupName = g.name;
|
||||
});
|
||||
out.push(...arr);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
import { dedupeKey } from './url';
|
||||
|
||||
/** 汇总统计与警告,产出最终 ScanResult */
|
||||
export function finalize(
|
||||
profile: SiteProfile,
|
||||
itemId: string | null,
|
||||
texts: TextMaterial[],
|
||||
images: ImageMaterial[],
|
||||
breadcrumbs: BreadcrumbItem[],
|
||||
source: ScanResult['source']
|
||||
): ScanResult {
|
||||
const stats: Record<string, number> = {};
|
||||
for (const img of images) stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题');
|
||||
if (images.length === 0) warnings.push('未扫描到任何图片/视频');
|
||||
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
|
||||
|
||||
return {
|
||||
platform: profile.id,
|
||||
itemId,
|
||||
url: location.href,
|
||||
texts,
|
||||
images,
|
||||
breadcrumbs,
|
||||
scannedAt: Date.now(),
|
||||
stats,
|
||||
warnings,
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -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,95 @@
|
||||
/**
|
||||
* 1688 采集编排(平台文件):
|
||||
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
|
||||
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
|
||||
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
|
||||
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑),
|
||||
* 详情图改为滚动加载后由 DOM 采集补齐。
|
||||
*/
|
||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
||||
import { collectImages, type ImageMaterial } from '../image';
|
||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||
import { toOriginalUrl } from '../url';
|
||||
import { readWindowKeys } from '../../bridge/read-window';
|
||||
import { extract1688State, type State1688 } from '../1688-state';
|
||||
import { finalize, mergeImages, type ScanResult } from '../merge';
|
||||
import type { SiteProfile } from '../../profiles/types';
|
||||
|
||||
export async function scan1688(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
|
||||
let primaryTexts: TextMaterial[] = [];
|
||||
let primaryImages: ImageMaterial[] = [];
|
||||
let source: ScanResult['source'] = 'dom';
|
||||
let st: State1688 | null = null;
|
||||
|
||||
// ① 桥读 window.context
|
||||
const keys = await readWindowKeys(['context']);
|
||||
st = extract1688State(keys['context']);
|
||||
|
||||
if (st) {
|
||||
if (st.title) primaryTexts.push({ kind: 'title', content: st.title });
|
||||
if (st.price) primaryTexts.push({ kind: 'price', content: st.price });
|
||||
if (st.sales) primaryTexts.push({ kind: 'sales', content: st.sales });
|
||||
if (st.shop) primaryTexts.push({ kind: 'shop', content: st.shop });
|
||||
if (st.params.length) {
|
||||
primaryTexts.push({
|
||||
kind: 'params',
|
||||
content: st.params.map(p => `${p.key}: ${p.value}`).join('\n'),
|
||||
pairs: st.params,
|
||||
});
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
st.galleryImages.forEach(u => {
|
||||
primaryImages.push({
|
||||
key: `main-${String(idx + 1).padStart(3, '0')}`,
|
||||
groupKey: 'main',
|
||||
groupName: '主图',
|
||||
url: toOriginalUrl(u),
|
||||
thumbUrl: u,
|
||||
index: idx++,
|
||||
type: 'img',
|
||||
});
|
||||
});
|
||||
st.skus.forEach(s => {
|
||||
if (!s.image) return;
|
||||
primaryImages.push({
|
||||
key: `sku-${String(primaryImages.filter(m => m.groupKey === 'sku').length + 1).padStart(3, '0')}`,
|
||||
groupKey: 'sku',
|
||||
groupName: 'SKU图片',
|
||||
variantName: s.name || undefined,
|
||||
url: toOriginalUrl(s.image),
|
||||
thumbUrl: s.image,
|
||||
index: idx++,
|
||||
type: 'img',
|
||||
});
|
||||
});
|
||||
st.videos.forEach(v => {
|
||||
primaryImages.push({
|
||||
key: `video-${String(primaryImages.filter(m => m.groupKey === 'video').length + 1).padStart(3, '0')}`,
|
||||
groupKey: 'video',
|
||||
groupName: '视频',
|
||||
url: v.url,
|
||||
thumbUrl: v.cover ?? '',
|
||||
index: idx++,
|
||||
type: 'video',
|
||||
});
|
||||
});
|
||||
source = 'state';
|
||||
}
|
||||
|
||||
// ② DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果)
|
||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await autoScrollToBottom();
|
||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||
const { materials: domTexts, missingRequired } = collectTexts(profile);
|
||||
const domImages = collectImages(profile);
|
||||
|
||||
if (primaryImages.length > 0 && domImages.length > 0 && source === 'state') source = 'mixed';
|
||||
|
||||
const texts = mergeTexts(primaryTexts, domTexts);
|
||||
const images = mergeImages(primaryImages, domImages, profile);
|
||||
const result = finalize(profile, itemId, texts, images, [], source);
|
||||
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Ozon 采集编排(平台文件):四路径合并(来自 extension-v2 生产逻辑)。
|
||||
* ① SSR widget state(DOM data-state 属性,白名单)★主路径
|
||||
* ② JSON-LD(schema.org/Product)
|
||||
* ③ 站内页 JSON API(entrypoint-api.bx)
|
||||
* ④ DOM data-widget 选择器兜底 + 详情图补充
|
||||
*/
|
||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
||||
import { collectImages, type ImageMaterial } from '../image';
|
||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||
import { toOriginalUrl, toThumbUrl } from '../url';
|
||||
import { extractJsonLd } from '../jsonld';
|
||||
import { fetchOzonPageData, type OzonPageData } from '../ozon-api';
|
||||
import { extractOzonState, type OzonStateData } from '../ozon-state';
|
||||
import { finalize, mergeImages, type ScanResult } from '../merge';
|
||||
import type { SiteProfile } from '../../profiles/types';
|
||||
|
||||
interface StructuredBundle {
|
||||
title?: string;
|
||||
price?: string;
|
||||
brand?: string;
|
||||
description?: string;
|
||||
characteristics: Array<{ key: string; value: string }>;
|
||||
galleryImages: string[];
|
||||
videos: string[];
|
||||
videoCovers: string[];
|
||||
skuVariants: Array<{ name: string; image?: string }>;
|
||||
}
|
||||
|
||||
function mergeStructured(
|
||||
state: OzonStateData,
|
||||
jsonld: ReturnType<typeof extractJsonLd>,
|
||||
api: OzonPageData | null
|
||||
): StructuredBundle {
|
||||
const bundle: StructuredBundle = {
|
||||
title: state.title || jsonld?.title || api?.title,
|
||||
price: state.price || jsonld?.price || api?.price,
|
||||
brand: jsonld?.brand,
|
||||
description: api?.description || jsonld?.description,
|
||||
characteristics: [...state.characteristics],
|
||||
galleryImages: [...state.galleryImages],
|
||||
videos: [...state.videos],
|
||||
videoCovers: [...state.videoCovers],
|
||||
skuVariants: [...state.skuVariants],
|
||||
};
|
||||
|
||||
for (const u of api?.images ?? []) {
|
||||
if (!bundle.galleryImages.includes(u)) bundle.galleryImages.push(u);
|
||||
}
|
||||
for (const u of api?.videos ?? []) {
|
||||
if (!bundle.videos.includes(u)) bundle.videos.push(u);
|
||||
}
|
||||
const seenChars = new Set(bundle.characteristics.map((c) => `${c.key}::${c.value}`));
|
||||
for (const c of api?.characteristics ?? []) {
|
||||
const k = `${c.key}::${c.value}`;
|
||||
if (!seenChars.has(k)) {
|
||||
seenChars.add(k);
|
||||
bundle.characteristics.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function buildFromBundle(profile: SiteProfile, bundle: StructuredBundle): {
|
||||
texts: TextMaterial[];
|
||||
images: ImageMaterial[];
|
||||
} {
|
||||
const texts: TextMaterial[] = [];
|
||||
const images: ImageMaterial[] = [];
|
||||
|
||||
if (bundle.title) texts.push({ kind: 'title', content: bundle.title });
|
||||
if (bundle.price) texts.push({ kind: 'price', content: bundle.price });
|
||||
if (bundle.brand) texts.push({ kind: 'brand', content: bundle.brand });
|
||||
if (bundle.characteristics.length) {
|
||||
texts.push({
|
||||
kind: 'params',
|
||||
content: bundle.characteristics.map((p) => `${p.key}: ${p.value}`).join('\n'),
|
||||
pairs: bundle.characteristics,
|
||||
});
|
||||
}
|
||||
if (bundle.description) texts.push({ kind: 'desc', content: bundle.description });
|
||||
|
||||
let idx = 0;
|
||||
bundle.galleryImages.forEach((u, i) => {
|
||||
const orig = toOriginalUrl(u, profile.originalUrlRules);
|
||||
images.push({
|
||||
key: `main-${String(i + 1).padStart(3, '0')}`,
|
||||
groupKey: 'main',
|
||||
groupName: '主图',
|
||||
url: orig,
|
||||
thumbUrl: toThumbUrl(orig),
|
||||
index: idx++,
|
||||
type: 'img',
|
||||
});
|
||||
});
|
||||
|
||||
bundle.skuVariants.forEach((s, i) => {
|
||||
if (!s.image) return;
|
||||
const orig = toOriginalUrl(s.image, profile.originalUrlRules);
|
||||
images.push({
|
||||
key: `sku-${String(i + 1).padStart(3, '0')}`,
|
||||
groupKey: 'sku',
|
||||
groupName: 'SKU图片',
|
||||
variantName: s.name || undefined,
|
||||
url: orig,
|
||||
thumbUrl: toThumbUrl(orig),
|
||||
index: idx++,
|
||||
type: 'img',
|
||||
});
|
||||
});
|
||||
|
||||
bundle.videos.forEach((u, i) => {
|
||||
images.push({
|
||||
key: `video-${String(i + 1).padStart(3, '0')}`,
|
||||
groupKey: 'video',
|
||||
groupName: '视频',
|
||||
url: u,
|
||||
thumbUrl: bundle.videoCovers[i] ?? '',
|
||||
index: idx++,
|
||||
type: 'video',
|
||||
});
|
||||
});
|
||||
|
||||
return { texts, images };
|
||||
}
|
||||
|
||||
export async function scanOzon(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
|
||||
const state = extractOzonState();
|
||||
let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom';
|
||||
|
||||
const jsonld = extractJsonLd();
|
||||
|
||||
let api: OzonPageData | null = null;
|
||||
if (itemId) {
|
||||
try {
|
||||
api = await fetchOzonPageData(itemId);
|
||||
} catch (err) {
|
||||
console.warn('[SuiteCollector] API 提取异常:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const bundle = mergeStructured(state, jsonld, api);
|
||||
const structured = buildFromBundle(profile, bundle);
|
||||
if ((structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0) && source === 'dom') {
|
||||
source = 'mixed';
|
||||
}
|
||||
|
||||
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
|
||||
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await autoScrollToBottom();
|
||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
|
||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||
const domTexts = collectTexts(profile).materials;
|
||||
const domImages = collectImages(profile);
|
||||
|
||||
const texts = mergeTexts(structured.texts, domTexts);
|
||||
const images = mergeImages(structured.images, domImages, profile);
|
||||
return finalize(profile, itemId, texts, images, state.breadcrumbs, source);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 淘宝/天猫 采集编排(平台文件):
|
||||
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
|
||||
* (isolated world 读不到 window 变量,v1 直读是无效的)
|
||||
* ② DOM 兜底 + 补充
|
||||
* 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com),
|
||||
* 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。
|
||||
*/
|
||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
||||
import { collectImages, type ImageMaterial } from '../image';
|
||||
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
|
||||
import { readWindowKeys } from '../../bridge/read-window';
|
||||
import { buildFromSSR } from '../ssr-builder';
|
||||
import { taobaoStateFromBridge } from '../taobao-state';
|
||||
import { finalize, mergeImages, type ScanResult } from '../merge';
|
||||
import type { SiteProfile } from '../../profiles/types';
|
||||
|
||||
export async function scanTaobao(profile: SiteProfile, _itemId: string | null): Promise<ScanResult> {
|
||||
let primaryTexts: TextMaterial[] = [];
|
||||
let primaryImages: ImageMaterial[] = [];
|
||||
let source: ScanResult['source'] = 'dom';
|
||||
|
||||
// ① 桥读页面全局
|
||||
const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']);
|
||||
const ssrData = taobaoStateFromBridge(keys);
|
||||
if (ssrData && (ssrData.item.title || (ssrData.item.images ?? []).length > 0)) {
|
||||
const built = buildFromSSR(ssrData, profile);
|
||||
// ssr-builder 的本地类型 groupKey 是 string,这里对齐到 ImageGroupKey
|
||||
primaryTexts = built.texts;
|
||||
primaryImages = built.images as ImageMaterial[];
|
||||
source = 'ssr';
|
||||
}
|
||||
|
||||
// ② DOM 兜底 + 补充
|
||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await autoScrollToBottom();
|
||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||
const { materials: domTexts, missingRequired } = collectTexts(profile);
|
||||
const domImages = collectImages(profile);
|
||||
|
||||
if (primaryImages.length > 0 && domImages.length > 0) source = source === 'dom' ? source : 'mixed';
|
||||
|
||||
const texts = mergeTexts(primaryTexts, domTexts);
|
||||
const images = mergeImages(primaryImages, domImages, profile);
|
||||
const result = finalize(profile, _itemId, texts, images, [], source);
|
||||
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 统一采集引擎入口 - 只做路由:按平台分发到 platforms/ 下的平台文件。
|
||||
*
|
||||
* 平台编排逻辑(各路径与合并策略)见:
|
||||
* platforms/ozon.ts Ozon 四路径(SSR data-state / JSON-LD / 站内 API / DOM)
|
||||
* platforms/taobao.ts 淘宝/天猫(桥读全局 SSR / DOM)
|
||||
* platforms/1688.ts 1688(桥读 window.context SSR / DOM)
|
||||
* 共用合并器见 merge.ts。
|
||||
*/
|
||||
import { matchProfile } from '../profiles';
|
||||
import { readWindowKeys } from '../bridge/read-window';
|
||||
import { scanOzon } from './platforms/ozon';
|
||||
import { scanTaobao } from './platforms/taobao';
|
||||
import { scan1688 } from './platforms/1688';
|
||||
import type { ScanResult } from './merge';
|
||||
|
||||
export type { ScanResult };
|
||||
export type { ImageMaterial, TextMaterial } from './merge';
|
||||
|
||||
export async function scanCurrentPage(): Promise<ScanResult | null> {
|
||||
const profile = matchProfile(location.href);
|
||||
if (!profile) {
|
||||
console.warn('[SuiteCollector] 当前页面不支持采集:', location.href);
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemId = profile.extractItemId(location.href);
|
||||
console.log('[SuiteCollector] 开始采集:', profile.name, itemId, location.href);
|
||||
|
||||
let result: ScanResult | null = null;
|
||||
try {
|
||||
if (profile.id === 'ozon') result = await scanOzon(profile, itemId);
|
||||
else if (profile.id === 'taobao') result = await scanTaobao(profile, itemId);
|
||||
else result = await scan1688(profile, itemId);
|
||||
} catch (err) {
|
||||
console.error('[SuiteCollector] 采集异常:', err);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[SuiteCollector] 采集完成:', {
|
||||
platform: result.platform,
|
||||
texts: result.texts.map((t) => t.kind),
|
||||
images: result.images.length,
|
||||
stats: result.stats,
|
||||
warnings: result.warnings,
|
||||
source: result.source,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 诊断工具:列出页面全局数据键(在商品页 console 跑 __SuiteCollector.probe())
|
||||
export async function probeWindowKeys(): Promise<string[]> {
|
||||
const res = await readWindowKeys(['*'], 1500);
|
||||
return (res['__sc_window_keys__'] as string[]) ?? [];
|
||||
}
|
||||
|
||||
// 暴露到全局供 side panel / console 调用
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__SuiteCollector = {
|
||||
scan: scanCurrentPage,
|
||||
probe: probeWindowKeys,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 从 SSR JSON 构建 ScanResult
|
||||
*/
|
||||
import { toOriginalUrl } from './url';
|
||||
import type { SiteProfile } from '../profiles/types';
|
||||
import type { SSRData } from './ssr';
|
||||
|
||||
// 直接定义类型避免循环依赖
|
||||
interface TextMaterial {
|
||||
kind: 'title' | 'price' | 'params' | 'desc' | 'selling_point' | 'brand' | 'sales' | 'shop';
|
||||
content: string;
|
||||
pairs?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
interface ImageMaterial {
|
||||
key: string;
|
||||
groupKey: string;
|
||||
groupName: string;
|
||||
variantName?: string;
|
||||
url: string;
|
||||
thumbUrl: string;
|
||||
index: number;
|
||||
type: 'img' | 'video';
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
platform: string;
|
||||
itemId: string | null;
|
||||
url: string;
|
||||
texts: TextMaterial[];
|
||||
images: ImageMaterial[];
|
||||
scannedAt: number;
|
||||
stats: Record<string, number>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
|
||||
const texts: TextMaterial[] = [];
|
||||
const images: ImageMaterial[] = [];
|
||||
|
||||
// 1. 标题(必需)
|
||||
texts.push({
|
||||
kind: 'title',
|
||||
content: data.item.title
|
||||
});
|
||||
|
||||
// 2. 价格
|
||||
if (data.price?.priceText) {
|
||||
texts.push({
|
||||
kind: 'price',
|
||||
content: `¥${data.price.priceText}`
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 参数表
|
||||
const allParams = [
|
||||
...(data.params?.basicParamList || []),
|
||||
...(data.params?.enhanceParamList || [])
|
||||
];
|
||||
if (allParams.length > 0) {
|
||||
const pairs = allParams
|
||||
.filter(p => p.propertyName && p.valueName)
|
||||
.map(p => ({ key: p.propertyName, value: p.valueName }));
|
||||
|
||||
if (pairs.length > 0) {
|
||||
texts.push({
|
||||
kind: 'params',
|
||||
content: pairs.map(p => `${p.key}: ${p.value}`).join('\n'),
|
||||
pairs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 主图(item.images)
|
||||
let idx = 0;
|
||||
(data.item.images || []).forEach((url, i) => {
|
||||
if (!url) return;
|
||||
const origUrl = toOriginalUrl(url);
|
||||
images.push({
|
||||
key: `main-${String(i + 1).padStart(3, '0')}`,
|
||||
groupKey: 'main',
|
||||
groupName: '主图',
|
||||
url: origUrl,
|
||||
thumbUrl: url,
|
||||
index: idx++,
|
||||
type: 'img'
|
||||
});
|
||||
});
|
||||
|
||||
// 5. SKU 图(skuBase.props 全维度展开:颜色分类、尺码等)
|
||||
// 多维规格时名称带维度前缀("尺码:M"),单维保持原名("粉色")
|
||||
const skuPropsList = data.skuBase?.props ?? [];
|
||||
const multiDim = skuPropsList.length > 1;
|
||||
for (const skuProp of skuPropsList) {
|
||||
(skuProp.values ?? []).forEach(v => {
|
||||
if (!v.image) return; // 有些 SKU 没配图(如纯文字规格)
|
||||
const origUrl = toOriginalUrl(v.image);
|
||||
const name = multiDim ? `${skuProp.name}:${v.name}` : v.name;
|
||||
images.push({
|
||||
key: `sku-${String(images.filter(m => m.groupKey === 'sku').length + 1).padStart(3, '0')}`,
|
||||
groupKey: 'sku',
|
||||
groupName: 'SKU图片',
|
||||
variantName: name || undefined,
|
||||
url: origUrl,
|
||||
thumbUrl: v.image,
|
||||
index: idx++,
|
||||
type: 'img'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 6. 视频(item.videos)
|
||||
(data.item.videos || []).forEach((v, i) => {
|
||||
if (!v.url) return;
|
||||
images.push({
|
||||
key: `video-${String(i + 1).padStart(3, '0')}`,
|
||||
groupKey: 'video',
|
||||
groupName: '视频',
|
||||
url: v.url,
|
||||
thumbUrl: v.videoThumbnailURL || v.url,
|
||||
index: idx++,
|
||||
type: 'video'
|
||||
});
|
||||
});
|
||||
|
||||
// 统计各组数量
|
||||
const stats: Record<string, number> = {};
|
||||
for (const img of images) {
|
||||
stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// 生成警告
|
||||
const warnings: string[] = [];
|
||||
if (texts.length === 0) {
|
||||
warnings.push('未提取到任何文本');
|
||||
}
|
||||
if (images.length === 0) {
|
||||
warnings.push('未扫描到任何图片/视频');
|
||||
}
|
||||
// SSR 数据里没有详情图,需要 DOM 补充
|
||||
if (stats.detail === undefined) {
|
||||
warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集');
|
||||
}
|
||||
|
||||
return {
|
||||
platform: profile.id,
|
||||
itemId: data.item.itemId,
|
||||
url: location.href,
|
||||
texts,
|
||||
images,
|
||||
scannedAt: Date.now(),
|
||||
stats,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* SSR 数据提取器 - 淘宝/天猫页面内嵌 JSON
|
||||
*
|
||||
* 页面 HTML 里有完整商品数据挂在 window.__ICE_APP_CONTEXT__,
|
||||
* 包含标题、主图、SKU(图+名)、价格、参数,比 DOM 采集稳定 10 倍:
|
||||
* - 不受懒加载影响
|
||||
* - 不受改版影响(JSON 结构远比 CSS 类名稳定)
|
||||
* - 一次拿全所有 SKU,无需滚动
|
||||
*
|
||||
* 当前只支持淘宝/天猫(__ICE_APP_CONTEXT__),
|
||||
* 其他平台返回 null,触发 DOM 降级。
|
||||
*/
|
||||
|
||||
export interface SSRData {
|
||||
item: {
|
||||
title: string;
|
||||
itemId: string;
|
||||
images: string[];
|
||||
videos?: Array<{ url: string; videoThumbnailURL?: string }>;
|
||||
};
|
||||
skuBase?: {
|
||||
props: Array<{
|
||||
pid: string;
|
||||
name: string; // "颜色分类" / "商品规格"
|
||||
values: Array<{
|
||||
vid: string;
|
||||
name: string; // SKU 规格名
|
||||
image?: string; // SKU 图片
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
params?: {
|
||||
basicParamList?: Array<{ propertyName: string; valueName: string }>;
|
||||
enhanceParamList?: Array<{ propertyName: string; valueName: string }>;
|
||||
};
|
||||
price?: {
|
||||
priceText?: string;
|
||||
priceMoney?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从页面提取 SSR 数据(淘宝/天猫 __ICE_APP_CONTEXT__)
|
||||
*/
|
||||
export function extractSSRData(): SSRData | null {
|
||||
try {
|
||||
const ctx = (window as any).__ICE_APP_CONTEXT__;
|
||||
if (!ctx?.loaderData?.home?.data?.res) return null;
|
||||
|
||||
const res = ctx.loaderData.home.data.res;
|
||||
|
||||
// 基础结构验证
|
||||
if (!res.item?.title || !res.item?.itemId) return null;
|
||||
|
||||
// 提取参数(两个来源都试)
|
||||
const industryParams = res.plusViewVO?.industryParamVO;
|
||||
const extensionParams = res.componentsVO?.extensionInfoVO?.infos?.find(
|
||||
(i: any) => i.type === 'BASE_PROPS'
|
||||
);
|
||||
|
||||
return {
|
||||
item: {
|
||||
title: res.item.title,
|
||||
itemId: res.item.itemId,
|
||||
images: res.item.images || [],
|
||||
videos: res.item.videos
|
||||
},
|
||||
skuBase: res.skuBase,
|
||||
params: {
|
||||
basicParamList: industryParams?.basicParamList || extensionParams?.items || [],
|
||||
enhanceParamList: industryParams?.enhanceParamList || []
|
||||
},
|
||||
price: res.componentsVO?.priceVO?.price || res.componentsVO?.priceVO?.extraPrice
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[SSR] 提取失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 淘宝/天猫 SSR 状态提取 —— 通过 MAIN world 桥读取页面全局变量。
|
||||
*
|
||||
* 候选键(selectors-taobao.md §5.3 列出的待评估项):
|
||||
* __ICE_APP_CONTEXT__ ICE 框架上下文(loaderData.home.data.res,v1 已知结构)★主
|
||||
* __general_skupanel_cache_data 疑似完整 SKU 面板缓存(结构未知,容错深搜)
|
||||
* __ICE_DATA_LOADER__ ICE 框架数据层(容错深搜)
|
||||
*
|
||||
* 输出对齐 ssr.ts 的 SSRData,供 buildFromSSR 消费。
|
||||
*/
|
||||
import type { SSRData } from './ssr';
|
||||
|
||||
/** 从 __ICE_APP_CONTEXT__ 结构映射(原 v1 extractSSRData 的对象版) */
|
||||
function fromIceContext(ctx: unknown): SSRData | null {
|
||||
const res = (ctx as any)?.loaderData?.home?.data?.res;
|
||||
if (!res?.item?.title || !res?.item?.itemId) return null;
|
||||
const industryParams = res.plusViewVO?.industryParamVO;
|
||||
const extensionParams = res.componentsVO?.extensionInfoVO?.infos?.find(
|
||||
(i: any) => i.type === 'BASE_PROPS'
|
||||
);
|
||||
return {
|
||||
item: {
|
||||
title: res.item.title,
|
||||
itemId: res.item.itemId,
|
||||
images: res.item.images || [],
|
||||
videos: res.item.videos,
|
||||
},
|
||||
skuBase: res.skuBase,
|
||||
params: {
|
||||
basicParamList: industryParams?.basicParamList || extensionParams?.items || [],
|
||||
enhanceParamList: industryParams?.enhanceParamList || [],
|
||||
},
|
||||
price: res.componentsVO?.priceVO?.price || res.componentsVO?.priceVO?.extraPrice,
|
||||
};
|
||||
}
|
||||
|
||||
/** 容错:未知结构里深搜「SKU props 数组」(元素含 name + values/props 嵰 name/imageUrl) */
|
||||
function skuPropsFromUnknown(root: unknown): SSRData['skuBase'] | null {
|
||||
const candidates: any[] = [];
|
||||
const collect = (node: unknown, depth = 0) => {
|
||||
if (!node || typeof node !== 'object' || depth > 6 || candidates.length) return;
|
||||
if (Array.isArray(node)) {
|
||||
if (
|
||||
node.length >= 1 && node.every((x: any) => x && typeof x === 'object' &&
|
||||
typeof (x.prop ?? x.name) === 'string' && Array.isArray(x.values ?? x.props))
|
||||
) { candidates.push(node.map((x: any) => ({
|
||||
pid: String(x.pid ?? ''),
|
||||
name: x.prop ?? x.name,
|
||||
values: (x.values ?? x.props).map((v: any) => ({
|
||||
vid: String(v.vid ?? ''),
|
||||
name: v.name ?? v.valueName ?? '',
|
||||
image: v.image ?? v.imageUrl,
|
||||
})),
|
||||
}))); return;
|
||||
}
|
||||
node.forEach(n => collect(n, depth + 1));
|
||||
return;
|
||||
}
|
||||
for (const v of Object.values(node as Record<string, unknown>)) collect(v, depth + 1);
|
||||
};
|
||||
collect(root);
|
||||
return candidates.length ? { props: candidates[0] } : null;
|
||||
}
|
||||
|
||||
/** 容错:深搜参数数组(元素含 propertyName/valueName) */
|
||||
function paramsFromUnknown(root: unknown): Array<{ propertyName: string; valueName: string }> {
|
||||
const out: Array<{ propertyName: string; valueName: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
const walk = (node: unknown, depth = 0) => {
|
||||
if (!node || typeof node !== 'object' || depth > 6 || out.length > 60) return;
|
||||
if (Array.isArray(node)) {
|
||||
if (node.length >= 2 && node.every((x: any) => x && typeof x === 'object' &&
|
||||
typeof x.propertyName === 'string' && typeof x.valueName === 'string')) {
|
||||
for (const p of node) {
|
||||
const k = `${p.propertyName}=${p.valueName}`;
|
||||
if (!seen.has(k)) { seen.add(k); out.push({ propertyName: p.propertyName, valueName: p.valueName }); }
|
||||
}
|
||||
}
|
||||
node.forEach(n => walk(n, depth + 1));
|
||||
return;
|
||||
}
|
||||
for (const v of Object.values(node as Record<string, unknown>)) walk(v, depth + 1);
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function taobaoStateFromBridge(keys: Record<string, any>): SSRData | null {
|
||||
// 主路径:ICE 上下文
|
||||
const ice = fromIceContext(keys['__ICE_APP_CONTEXT__']);
|
||||
if (ice) {
|
||||
// 主路径缺 SKU 时用面板缓存补
|
||||
if (!ice.skuBase?.props?.length) {
|
||||
const fromCache = skuPropsFromUnknown(keys['__general_skupanel_cache_data'] ?? keys['__ICE_DATA_LOADER__']);
|
||||
if (fromCache?.props?.length) ice.skuBase = fromCache;
|
||||
}
|
||||
return ice;
|
||||
}
|
||||
|
||||
// 降级:只有面板缓存/数据层 —— 尽力拼一个最小 SSRData(标题给空,DOM 会补)
|
||||
const cacheRoot = keys['__general_skupanel_cache_data'] ?? keys['__ICE_DATA_LOADER__'];
|
||||
if (cacheRoot) {
|
||||
const skuBase = skuPropsFromUnknown(cacheRoot);
|
||||
const params = paramsFromUnknown(cacheRoot);
|
||||
if (skuBase?.props?.length || params.length) {
|
||||
return {
|
||||
item: { title: '', itemId: '', images: [], videos: [] },
|
||||
skuBase: skuBase ?? undefined,
|
||||
params: { basicParamList: params, enhanceParamList: [] },
|
||||
price: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 文本提取 - 标题、价格、参数表、卖点、描述、品牌
|
||||
* 从 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;
|
||||
|
||||
// cells 模式:兄弟键值对(antd Descriptions 的 th/td、dl 的 dt/dd)
|
||||
// selectors 命中的每个节点就是「键」,值取它的下一个兄弟元素
|
||||
if (rule.extract === 'cells') {
|
||||
const pairs: Array<{ key: string; value: string }> = [];
|
||||
nodes.forEach((k) => {
|
||||
const v = k.nextElementSibling;
|
||||
if (!v) return;
|
||||
const kc = clean(k.textContent ?? '');
|
||||
const vc = clean(v.textContent ?? '');
|
||||
if (kc && vc) pairs.push({ key: kc.replace(/[::]$/, ''), value: vc });
|
||||
});
|
||||
if (pairs.length) {
|
||||
return {
|
||||
kind: rule.kind,
|
||||
content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'),
|
||||
pairs,
|
||||
};
|
||||
}
|
||||
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,139 @@
|
||||
/**
|
||||
* 1688 采集配置(DOM 兜底路径)
|
||||
*
|
||||
* 新版(2026-08 实测,快照:宝宝平衡车)DOM 大改,锚点从业务类名换成稳定的
|
||||
* id / data-module 属性;旧选择器保留做兼容(旧版页面仍在线上轮转)。
|
||||
* 主路径(window.context)见 collector/platforms/1688.ts——DOM 只负责兜底
|
||||
* 和补充参数表(#productAttributes)与详情图(#detail)。
|
||||
*/
|
||||
import type { SiteProfile } from './types';
|
||||
|
||||
export const profile1688: SiteProfile = {
|
||||
id: '1688',
|
||||
name: '1688',
|
||||
|
||||
urlPatterns: [/^https:\/\/detail\.1688\.com\/offer\/\d+\.html/],
|
||||
|
||||
extractItemId: (url) => url.match(/\/offer\/(\d+)\.html/)?.[1] ?? null,
|
||||
|
||||
readySelectors: ['#productTitle', '.title-content', '#detail', '#dt-tab', '#screen', '#content'],
|
||||
readyTimeoutMs: 10_000,
|
||||
|
||||
// 懒加载真实地址在 data-* 上(顺序不能动)
|
||||
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
|
||||
|
||||
refererOrigin: 'https://www.1688.com',
|
||||
|
||||
textRules: [
|
||||
{
|
||||
kind: 'title',
|
||||
selectors: [
|
||||
'#productTitle .title-content', // 新版:data-module="od_title"
|
||||
'.title-content .title-text', // 旧版:标题拆多个 span,必须 join
|
||||
'.title-content h1',
|
||||
'.od-pc-offer-title',
|
||||
'h1',
|
||||
],
|
||||
extract: 'join',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
kind: 'price',
|
||||
selectors: ['.price-original', '.od-pc-offer-price-priceRange', '.price .value'],
|
||||
extract: 'first'
|
||||
},
|
||||
// 参数表(新版):#productAttributes 是 antd Descriptions 表格,
|
||||
// th(键)/td(值) 成对平铺在 tr 里——用 cells 模式取兄弟节点
|
||||
{
|
||||
kind: 'params',
|
||||
selectors: [
|
||||
'#productAttributes th.ant-descriptions-item-label',
|
||||
'#productAttributes th',
|
||||
],
|
||||
extract: 'cells'
|
||||
},
|
||||
// 参数表(旧版):行式键值表
|
||||
{
|
||||
kind: 'params',
|
||||
selectors: [
|
||||
'.offer-attr-list .offer-attr-item',
|
||||
'.od-pc-attribute-table tr',
|
||||
'.obj-content .table-tr'
|
||||
],
|
||||
extract: 'table',
|
||||
tableKeySelector: '.offer-attr-item-name, td:first-child, .table-th',
|
||||
tableValueSelector: '.offer-attr-item-value, td:last-child, .table-td'
|
||||
},
|
||||
{
|
||||
kind: 'desc',
|
||||
selectors: ['.de-description-detail', '#detailContentContainer', '.html-description'],
|
||||
extract: 'join'
|
||||
}
|
||||
],
|
||||
|
||||
imageGroups: [
|
||||
{
|
||||
key: 'main',
|
||||
name: '主图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
// 新版:模块锚点(data-module / module- 类名)
|
||||
'[data-module="od_picture_gallery"] img',
|
||||
'.module-od-picture-gallery img',
|
||||
// 旧版四套画廊变体
|
||||
'#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img',
|
||||
'#screen .od-gallery-turn-item-wrapper .od-gallery-img',
|
||||
'#content .od-scroller-item .v-image-cover',
|
||||
'#content .od-picture-gallery-list .v-image-cover',
|
||||
'#dt-tab img',
|
||||
'.detail-gallery-turn img.detail-gallery-img',
|
||||
'.img-list-wrapper img.od-gallery-img'
|
||||
],
|
||||
activeSelectors: [
|
||||
'.detail-gallery-turn-wrapper.prepic-active .detail-gallery-img',
|
||||
'.od-gallery-turn-item-wrapper.prepic-active .od-gallery-img',
|
||||
'.v-image-cover.image-item-active'
|
||||
],
|
||||
minWidth: 200,
|
||||
minHeight: 200
|
||||
},
|
||||
{
|
||||
key: 'sku',
|
||||
name: 'SKU图片',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'[data-module="od_sku_selection"] img', // 新版
|
||||
'.module-od-sku-selection img',
|
||||
'.pc-sku-wrapper .prop-item-inner-wrapper',
|
||||
'.sku-item-wrapper',
|
||||
'.specification-cell',
|
||||
'.sku-filter-button',
|
||||
'.expand-view-item',
|
||||
'.feature-item img'
|
||||
],
|
||||
srcProps: ['backgroundImage'],
|
||||
nameSelectors: ['.prop-name', '.sku-item-name', '.item-label', '.label-name', '.normal-text'],
|
||||
minWidth: 20,
|
||||
minHeight: 20
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
name: '详情图',
|
||||
type: 'img',
|
||||
selectors: [
|
||||
'#detail img', // 新版:详情容器(实测 69 张,含少量图标需过滤)
|
||||
'.de-description-detail img',
|
||||
'#detailContentContainer img',
|
||||
'.html-description img'
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100
|
||||
},
|
||||
{
|
||||
key: 'video',
|
||||
name: '视频',
|
||||
type: 'video',
|
||||
selectors: ['.lib-video video', 'video']
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Profile 路由 - 根据 URL 匹配平台(ozon / 1688 / 淘宝 / 天猫)
|
||||
*/
|
||||
import type { SiteProfile } from './types';
|
||||
import { profileOzon } from './ozon';
|
||||
import { profile1688 } from './1688';
|
||||
import { profileTaobao } from './taobao';
|
||||
|
||||
const PROFILES: SiteProfile[] = [profileOzon, profile1688, profileTaobao];
|
||||
|
||||
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, profile1688, profileTaobao };
|
||||
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,143 @@
|
||||
/**
|
||||
* 淘宝 / 天猫采集配置
|
||||
*
|
||||
* 选择器全部来自真实页面实测(2026-08-11,两个商品页各跑一轮反向探测):
|
||||
* 天猫 detail.tmall.com/item.htm?id=960057430812
|
||||
* 淘宝 item.taobao.com/item.htm?id=1060253247160
|
||||
* 两站 DOM 完全一致(同一套前端),一份 profile 覆盖。
|
||||
*
|
||||
* 类名是 CSS Modules 的 `语义前缀--哈希` 形式,哈希每次构建都变,
|
||||
* 所以一律用 `[class*="前缀--"]` 前缀匹配。
|
||||
*
|
||||
* 结尾那个 `--` 不能省——它把父容器和子元素区分开:
|
||||
* `generalParamsInfoItem--` 不会误命中 `generalParamsInfoItemTitle--`。
|
||||
*
|
||||
* 实测证据见 docs/extension/selectors-taobao.md
|
||||
*/
|
||||
import type { SiteProfile } from './types';
|
||||
|
||||
export const profileTaobao: SiteProfile = {
|
||||
id: 'taobao',
|
||||
name: '淘宝/天猫',
|
||||
|
||||
urlPatterns: [
|
||||
/^https:\/\/item\.taobao\.com\/item\.htm/,
|
||||
/^https:\/\/detail\.tmall\.com\/item\.htm/,
|
||||
],
|
||||
|
||||
extractItemId: (url) => url.match(/[?&]id=(\d+)/)?.[1] ?? null,
|
||||
|
||||
// 页面上没有 <h1>,别再拿它探活
|
||||
readySelectors: [
|
||||
'[class*="mainTitle--"]',
|
||||
'[class*="picGallery--"]',
|
||||
'#picGalleryEle',
|
||||
],
|
||||
readyTimeoutMs: 10_000,
|
||||
|
||||
// 阿里系 CDN 规则与 1688 相同
|
||||
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
|
||||
|
||||
refererOrigin: 'https://www.taobao.com',
|
||||
|
||||
textRules: [
|
||||
{
|
||||
kind: 'title',
|
||||
// mainTitle-- 是纯文本节点(探测里 imgs=0),最干净
|
||||
// ItemTitle-- / MainTitle-- 是外层容器,带图标,作兜底
|
||||
// 注意:属性选择器区分大小写,三个都得写
|
||||
selectors: [
|
||||
'[class*="mainTitle--"]',
|
||||
'[class*="MainTitle--"]',
|
||||
'[class*="ItemTitle--"]',
|
||||
],
|
||||
extract: 'first',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
kind: 'price',
|
||||
// highlightPrice-- 是当前实际售价,两站一致
|
||||
// priceWrap-- 是外层,会把"优惠前¥36.8"一起带进来,只作兜底
|
||||
selectors: [
|
||||
'[class*="highlightPrice--"]',
|
||||
'[class*="priceWrap--"]',
|
||||
],
|
||||
extract: 'first',
|
||||
},
|
||||
{
|
||||
kind: 'params',
|
||||
// generalParamsInfoItem-- 每项含 Title(键) + SubTitle(值)
|
||||
selectors: ['[class*="generalParamsInfoItem--"]'],
|
||||
extract: 'table',
|
||||
tableKeySelector: '[class*="ParamsInfoItemTitle--"]',
|
||||
tableValueSelector: '[class*="ParamsInfoItemSubTitle--"]',
|
||||
},
|
||||
// desc 故意不采:详情容器 detailInfo-- 里混着用户评价、参数、图文详情,
|
||||
// join 出来是一坨无法使用的字符串。1688/淘宝的中文文案对 Ozon 价值也低
|
||||
// (见 docs/extension/1688-taobao-implementation.md 采集优先级)。
|
||||
],
|
||||
|
||||
imageGroups: [
|
||||
{
|
||||
key: 'main',
|
||||
name: '主图',
|
||||
type: 'img',
|
||||
// picGallery-- 内含大图 + 缩略图,同一张图的两种尺寸
|
||||
// toOriginalUrl() 剥掉尺寸后缀后 dedupeKey 相同,会自动去重
|
||||
selectors: [
|
||||
'[class*="picGallery--"] img',
|
||||
'#picGalleryEle img',
|
||||
'[class*="thumbnailPic--"]',
|
||||
],
|
||||
// 不设 minWidth:缩略图 naturalWidth 只有 60 左右,
|
||||
// 按 200 过滤会把主图全误杀(原图靠 toOriginalUrl 还原)
|
||||
},
|
||||
{
|
||||
key: 'sku',
|
||||
name: 'SKU图片',
|
||||
type: 'img',
|
||||
// ★ 与 1688 不同:淘宝 SKU 是真实 <img>,不是 CSS 背景图
|
||||
// 探测证据:valueItem-- n=22 imgs=22(每项恰含一张 img)
|
||||
// 所以这里不能用 srcProps: ['backgroundImage']
|
||||
selectors: [
|
||||
'[class*="valueItem--"]',
|
||||
'[class*="valueItemImgWrap--"]',
|
||||
],
|
||||
nameSelectors: ['[class*="valueItemText--"]'],
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
name: '详情图',
|
||||
type: 'img',
|
||||
// 图文详情是懒加载的,需用户点开「图文详情」tab 或滚到底
|
||||
selectors: [
|
||||
'[class*="tabDetailWrap--"] img',
|
||||
'[class*="detailInfo--"] img',
|
||||
],
|
||||
// detailInfo-- 同时包着「用户评价」区,买家晒单图能有 400-800px,
|
||||
// 光靠 minWidth 滤不掉。这些图带水印、质量差,不能采
|
||||
excludeWithin: [
|
||||
'[class*="Comment--"]',
|
||||
'[class*="comments--"]',
|
||||
'[class*="userInfo--"]',
|
||||
'[class*="rate"]',
|
||||
// 本店推荐:详情区底部的推荐卡片流(RecommendInfo-- 容器 / data-spm="recommends" /
|
||||
// recommend-- 卡片区 / cardPic-- 卡片图盒),不是本商品的详情图,不能采
|
||||
'[class*="RecommendInfo--"]',
|
||||
'[data-spm="recommends"]',
|
||||
'[class*="recommend--"]',
|
||||
'[class*="cardPic--"]',
|
||||
],
|
||||
minWidth: 300,
|
||||
minHeight: 100,
|
||||
},
|
||||
{
|
||||
key: 'video',
|
||||
name: '视频',
|
||||
type: 'video',
|
||||
selectors: ['[class*="picGallery--"] video', 'video'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Site Profile - 平台采集配置(声明式)
|
||||
*
|
||||
* 与 extension-v1 同一套抽象,新增 Ozon 需要的文本类型:
|
||||
* selling_point(卖点 / About this item)、brand(品牌)。
|
||||
*
|
||||
* 采集引擎(collector/)完全通用,加一个新平台只需新增一个 profile。
|
||||
*/
|
||||
|
||||
export type TextKind =
|
||||
| 'title'
|
||||
| 'price'
|
||||
| 'params'
|
||||
| 'selling_point'
|
||||
| 'desc'
|
||||
| 'brand'
|
||||
| 'sales'
|
||||
| 'shop';
|
||||
|
||||
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video' | 'upload';
|
||||
|
||||
export type SrcProp =
|
||||
| 'data-lazyload-src'
|
||||
| 'data-src'
|
||||
| 'srcset'
|
||||
| 'currentSrc'
|
||||
| 'src'
|
||||
| 'backgroundImage';
|
||||
|
||||
export interface TextRule {
|
||||
kind: TextKind;
|
||||
/** 多套选择器,逐个尝试直到命中 */
|
||||
selectors: string[];
|
||||
/**
|
||||
* extract 模式:
|
||||
* join - 所有命中节点的文本拼接(标题被拆多个 span 时用)
|
||||
* first - 只取第一个命中节点
|
||||
* table - 行式键值表:selectors 命中行,tableKey/ValueSelector 在行内取键值
|
||||
* cells - 兄弟键值对:selectors 直接命中「键」节点,值取它的下一个兄弟元素
|
||||
* (适配 antd Descriptions 的 th/td 结构、dl 的 dt/dd 结构)
|
||||
*/
|
||||
extract: 'join' | 'first' | 'table' | 'cells';
|
||||
/** 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,77 @@
|
||||
/**
|
||||
* 服务端设置(上传/生成用):后端地址 + Bearer Token + 水印选项,持久化到 chrome.storage.local。
|
||||
* v2.1 并入 ozon-seller-kit 后新增「商品上报」配置:reportEnabled / reportBaseUrl / studioBaseUrl。
|
||||
*/
|
||||
|
||||
/** 生成图水印:服务端在 AI 出图后、落盘前合成(与生图模型无关)。默认复刻 ozonSeller。 */
|
||||
export interface WatermarkSettings {
|
||||
enabled: boolean;
|
||||
type: 'image' | 'text';
|
||||
text: string;
|
||||
opacity: number; // 1-100(%)
|
||||
}
|
||||
|
||||
export interface BackendSettings {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
watermark: WatermarkSettings;
|
||||
/** 商品上报(ozon-seller-kit 后台):开关 + 后台地址 + 试算页地址 */
|
||||
reportEnabled: boolean;
|
||||
reportBaseUrl: string;
|
||||
studioBaseUrl: string;
|
||||
}
|
||||
|
||||
const KEY = 'suite_backend_settings';
|
||||
|
||||
export const DEFAULT_BASE_URL = 'http://127.0.0.1:3300';
|
||||
export const DEFAULT_REPORT_BASE_URL = 'http://127.0.0.1:8800';
|
||||
export const DEFAULT_STUDIO_BASE_URL = 'http://localhost:8900';
|
||||
|
||||
const DEFAULT: BackendSettings = {
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
token: '',
|
||||
watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 },
|
||||
reportEnabled: true,
|
||||
reportBaseUrl: DEFAULT_REPORT_BASE_URL,
|
||||
studioBaseUrl: DEFAULT_STUDIO_BASE_URL,
|
||||
};
|
||||
|
||||
/** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置) */
|
||||
const MIGRATE: Record<string, string> = {
|
||||
'http://127.0.0.1:8810': DEFAULT_BASE_URL,
|
||||
'http://127.0.0.1:7000': DEFAULT_BASE_URL,
|
||||
'http://127.0.0.1:7200': DEFAULT_BASE_URL,
|
||||
'http://localhost:7000': DEFAULT_BASE_URL,
|
||||
'http://localhost:7200': DEFAULT_BASE_URL,
|
||||
'http://localhost:3300': DEFAULT_BASE_URL,
|
||||
};
|
||||
|
||||
export async function loadSettings(): Promise<BackendSettings> {
|
||||
const r = await chrome.storage.local.get(KEY);
|
||||
const saved = r[KEY] ?? {};
|
||||
const baseUrl = MIGRATE[saved.baseUrl] ?? saved.baseUrl ?? DEFAULT.baseUrl;
|
||||
// 水印子对象深合并:老版本存储里没有 watermark,避免整对象覆盖丢默认值
|
||||
const s: BackendSettings = {
|
||||
token: '',
|
||||
...saved,
|
||||
baseUrl,
|
||||
watermark: { ...DEFAULT.watermark, ...(saved.watermark ?? {}) },
|
||||
// 上报配置为 v2.1 新增字段:老存档缺失时兜底默认值
|
||||
reportEnabled: saved.reportEnabled ?? DEFAULT.reportEnabled,
|
||||
reportBaseUrl: saved.reportBaseUrl ?? DEFAULT.reportBaseUrl,
|
||||
studioBaseUrl: saved.studioBaseUrl ?? DEFAULT.studioBaseUrl,
|
||||
};
|
||||
if (baseUrl !== saved.baseUrl) await chrome.storage.local.set({ [KEY]: s }); // 迁移结果写回
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function saveSettings(s: BackendSettings): Promise<void> {
|
||||
// localhost 会被 Chrome 解析为 IPv6 ::1,若该端口被系统服务(如 macOS AirPlay)占用会 403,
|
||||
// 统一改写为 IPv4 的 127.0.0.1
|
||||
s = {
|
||||
...s,
|
||||
baseUrl: s.baseUrl.replace('//localhost:', '//127.0.0.1:'),
|
||||
reportBaseUrl: s.reportBaseUrl.replace('//localhost:', '//127.0.0.1:'),
|
||||
};
|
||||
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,53 @@
|
||||
import { defineConfig } from 'wxt';
|
||||
|
||||
export default defineConfig({
|
||||
manifest: {
|
||||
name: '电商套图工作台',
|
||||
description: '采集 Ozon / 1688 / 淘宝 / 天猫 商品信息与图片,一键生成电商套图并导出',
|
||||
permissions: [
|
||||
'storage',
|
||||
'sidePanel',
|
||||
'activeTab',
|
||||
'scripting', // 执行 content script 函数需要
|
||||
'downloads' // 导出采集图片 / 套图 ZIP 到本地
|
||||
],
|
||||
host_permissions: [
|
||||
// Ozon 商品页 + 图片 CDN
|
||||
'https://*.ozon.ru/*',
|
||||
'https://*.ozon.kz/*',
|
||||
'https://*.ozon.by/*',
|
||||
'https://*.ozonusercontent.com/*',
|
||||
// 1688 / 淘宝 / 天猫 + 阿里 CDN
|
||||
'https://detail.1688.com/*',
|
||||
'https://item.taobao.com/*',
|
||||
'https://detail.tmall.com/*',
|
||||
'https://*.alicdn.com/*',
|
||||
// 1688 详情数据 CDN(description.detailUrl)
|
||||
'https://itemcdn.tmall.com/*',
|
||||
// 本机后端(上传 / 生成套图用);生产换成你的公网域名
|
||||
'http://127.0.0.1:3300/*',
|
||||
'http://localhost:3300/*',
|
||||
// ozon-seller-kit 后台(商品上报 8800)+ 试算页(8900)
|
||||
'http://127.0.0.1:8800/*',
|
||||
'http://localhost:8800/*',
|
||||
'http://127.0.0.1:8900/*',
|
||||
'http://localhost:8900/*'
|
||||
],
|
||||
action: {
|
||||
default_title: '电商套图工作台'
|
||||
},
|
||||
// 页内悬浮面板用 iframe 加载 sidepanel.html,必须声明为 web accessible
|
||||
web_accessible_resources: [{
|
||||
resources: ['sidepanel.html'],
|
||||
matches: [
|
||||
'https://*.ozon.ru/*',
|
||||
'https://*.ozon.kz/*',
|
||||
'https://*.ozon.by/*',
|
||||
'https://detail.1688.com/*',
|
||||
'https://item.taobao.com/*',
|
||||
'https://detail.tmall.com/*',
|
||||
],
|
||||
}]
|
||||
},
|
||||
modules: ['react']
|
||||
});
|
||||
Reference in New Issue
Block a user