feat: 调用gpt\nano模型方式修改,功能优化,增加下载采集图片等功能
This commit is contained in:
@@ -13,9 +13,10 @@ import { createRoot } from 'react-dom/client';
|
||||
import { App as AntApp, ConfigProvider, Popover, Progress, Select } from 'antd';
|
||||
import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
|
||||
import { cleanFilename } from '../../src/collector/url';
|
||||
import {
|
||||
buildGeneratePayload, suiteZipUrl, uploadImage,
|
||||
DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS, WEAK_FIDELITY_MODELS,
|
||||
buildGeneratePayload, downloadSuiteZip, uploadImage, exportImages,
|
||||
DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS,
|
||||
type PlanItem, type SuiteInfo,
|
||||
} from '../../src/api/client';
|
||||
import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings';
|
||||
@@ -141,8 +142,8 @@ const App: React.FC = () => {
|
||||
// 出图方案
|
||||
const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon');
|
||||
const [styleSet, setStyleSet] = useState(1);
|
||||
/** 生图模型(默认 gpt-image-2,走 RightAPI provider) */
|
||||
const [model, setModel] = useState<string>('gpt-image-2');
|
||||
/** 生图模型(默认 gpt-image-2-vip,走 RightAPI provider) */
|
||||
const [model, setModel] = useState<string>('gpt-image-2-vip');
|
||||
/** 用户改写的风格提示词(按风格 id 存,切风格不丢) */
|
||||
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
||||
/** 生图要求(最高优先级,强制约束,覆盖其他设定) */
|
||||
@@ -156,6 +157,10 @@ const App: React.FC = () => {
|
||||
// 生成
|
||||
const [suite, setSuite] = useState<SuiteInfo | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
// 导出采集图片
|
||||
const [exporting, setExporting] = useState(false);
|
||||
// 导出生成结果 ZIP
|
||||
const [exportingZip, setExportingZip] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
/** 规划请求序号:重新采集时递增,用于丢弃重置后才返回的过期规划响应 */
|
||||
const planSeqRef = useRef(0);
|
||||
@@ -438,12 +443,6 @@ const App: React.FC = () => {
|
||||
{STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 {totalPlanned} 张、参考图{' '}
|
||||
{selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。
|
||||
</div>
|
||||
{WEAK_FIDELITY_MODELS.has(model) && (
|
||||
<div style={{ marginTop: 8, color: '#d4380d', fontWeight: 600 }}>
|
||||
⚠️ 「{model}」为官逆通道,商品还原度不稳定,可能生成与原商品不符的图片。建议先只出 1
|
||||
张确认效果,正式套图请改用 gpt-image-2。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
okText: '开始生成', cancelText: '取消',
|
||||
@@ -451,9 +450,27 @@ const App: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const handleExport = async () => {
|
||||
if (!suite) return;
|
||||
chrome.tabs.create({ url: suiteZipUrl(settings.baseUrl, suite.id) });
|
||||
setExportingZip(true);
|
||||
try {
|
||||
const blob = await downloadSuiteZip(settings.baseUrl, settings.token, suite.id);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
await chrome.downloads.download({
|
||||
url: objectUrl,
|
||||
filename: `${cleanFilename(productTitle) || '套图'}.zip`,
|
||||
saveAs: false,
|
||||
});
|
||||
} finally {
|
||||
// 延迟释放,给下载任务足够时间读取 blob
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
} catch (e) {
|
||||
modal.error({ title: '导出失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
|
||||
} finally {
|
||||
setExportingZip(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleKey = (key: string) => {
|
||||
@@ -465,9 +482,50 @@ const App: React.FC = () => {
|
||||
/** 全部可选图片:采集结果 + 手动上传(上传图单独一组「upload」) */
|
||||
const allImages: ImageMaterial[] = result ? [...result.images, ...uploadedImages] : uploadedImages;
|
||||
|
||||
/** 导出文件名的商品标题(编辑态优先,回退采集标题) */
|
||||
const productTitle = titleEdit.trim()
|
||||
|| result?.texts.find(t => t.kind === 'title')?.content?.trim()
|
||||
|| '';
|
||||
|
||||
const groupImages = (groupKey: string): ImageMaterial[] =>
|
||||
allImages.filter(i => i.groupKey === groupKey);
|
||||
|
||||
/** 下载采集图片:只打包已勾选的图片(后端按分组名建文件夹),前端经 chrome.downloads 落盘 */
|
||||
const handleExportImages = async () => {
|
||||
const imgs = allImages.filter(i => i.type === 'img' && selectedKeys.has(i.key));
|
||||
if (imgs.length === 0) {
|
||||
modal.warning({ title: '请先勾选要下载的图片' });
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await exportImages(settings.baseUrl, settings.token, {
|
||||
title: productTitle,
|
||||
images: imgs.map(i => ({
|
||||
url: i.url,
|
||||
groupName: i.groupName,
|
||||
variantName: i.variantName ?? null,
|
||||
key: i.key,
|
||||
})),
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
await chrome.downloads.download({
|
||||
url: objectUrl,
|
||||
filename: `${cleanFilename(productTitle) || '采集图片'}.zip`,
|
||||
saveAs: false,
|
||||
});
|
||||
} finally {
|
||||
// 延迟释放,给下载任务足够时间读取 blob
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
} catch (e) {
|
||||
modal.error({ title: '导出失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 预览用的全量图序列(主图→SKU→详情→上传,与展示顺序一致) */
|
||||
const collectedPreviewList: string[] = (['main', 'sku', 'detail', 'upload'] as const)
|
||||
.flatMap(g => groupImages(g).map(i => i.url));
|
||||
@@ -484,6 +542,19 @@ const App: React.FC = () => {
|
||||
setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count } : p));
|
||||
};
|
||||
|
||||
/** 全部方案是否都启用(任一为 0 即视为未全选,复选框随方案状态联动) */
|
||||
const planAllEnabled = plan.every(p => p.count >= 1);
|
||||
|
||||
/** 批量开关:勾选 → 全部至少 1 张(用户已设 >1 的保留原数量);取消勾选 → 全部 0,方便单独勾选一种方案 */
|
||||
const togglePlanAll = (on: boolean) => {
|
||||
setPlan(prev => prev.map(p => on ? { ...p, count: Math.max(p.count, 1) } : { ...p, count: 0 }));
|
||||
};
|
||||
|
||||
/** 点击方案行:勾选(0 → 1)/ 取消勾选(>0 → 0),配合"全部方案"开关单独选一种 */
|
||||
const togglePlanRow = (idx: number) => {
|
||||
setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count: p.count > 0 ? 0 : 1 } : p));
|
||||
};
|
||||
|
||||
/** 手动上传:点击触发隐藏的 file input */
|
||||
const handleUpload = () => fileRef.current?.click();
|
||||
|
||||
@@ -739,6 +810,14 @@ const App: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{allImages.some(i => i.type === 'img') && (
|
||||
<div className="img-export-bar">
|
||||
<button className="btn btn-sm" onClick={handleExportImages} disabled={exporting}>
|
||||
<DownloadOutlined /> {exporting ? '下载中…' : '下载采集图片'}
|
||||
</button>
|
||||
<span className="hint">下载已勾选图片({allImages.filter(i => i.type === 'img' && selectedKeys.has(i.key)).length} 张)</span>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -765,17 +844,35 @@ const App: React.FC = () => {
|
||||
>
|
||||
<div className="plan-list">
|
||||
{plan.map((p, idx) => (
|
||||
<div key={idx} className={`plan-row ${p.count === 0 ? 'off' : ''}`}>
|
||||
<div
|
||||
key={idx}
|
||||
className={`plan-row ${p.count === 0 ? 'off' : ''}`}
|
||||
title="点击勾选/取消该方案(数量用右侧 ± 调整)"
|
||||
onClick={() => togglePlanRow(idx)}
|
||||
>
|
||||
<div className="plan-main">
|
||||
<span className="plan-title">{p.title}</span>
|
||||
{p.variant_name && <span className="variant-chip">{p.variant_name}</span>}
|
||||
{p.detail && <span className="plan-detail" title={p.detail}>{p.detail}</span>}
|
||||
{p.prompt_hint && <span className="plan-detail plan-hint" title={p.prompt_hint}>🎯 {p.prompt_hint}</span>}
|
||||
</div>
|
||||
<Stepper value={p.count} onChange={(v) => setPlanCount(idx, v)} />
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Stepper value={p.count} onChange={(v) => setPlanCount(idx, v)} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="plan-all-toggle">
|
||||
<label className="auto-chk" title="勾选:全部方案至少 1 张(>1 张保留原数量);取消勾选:全部改为 0,方便单独勾选一种方案">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={planAllEnabled}
|
||||
onChange={(e) => togglePlanAll(e.target.checked)}
|
||||
/>
|
||||
全部方案
|
||||
</label>
|
||||
<span className="hint">取消勾选后全部为 0,点选需要的方案行即可单独出图</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '10px 0 4px' }}>
|
||||
{planSource === 'ai' && (
|
||||
<button
|
||||
@@ -875,8 +972,8 @@ const App: React.FC = () => {
|
||||
title="生成结果"
|
||||
extra={
|
||||
suite && ['done', 'partial'].includes(suite.status) && (
|
||||
<button className="btn btn-sm" onClick={handleExport}>
|
||||
<DownloadOutlined /> 导出 ZIP
|
||||
<button className="btn btn-sm" onClick={handleExport} disabled={exportingZip}>
|
||||
<DownloadOutlined /> {exportingZip ? '导出中…' : '导出 ZIP'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
--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;
|
||||
@@ -49,6 +50,12 @@
|
||||
.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; }
|
||||
|
||||
/* ── 顶部 ── */
|
||||
@@ -174,11 +181,11 @@
|
||||
.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 #fff;
|
||||
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: #fff; font-size: 11px; transition: all .15s; cursor: pointer;
|
||||
color: var(--primary-soft); font-size: 11px; transition: all .15s; cursor: pointer;
|
||||
}
|
||||
.img-cell.on .tick { background: var(--primary); border-color: var(--primary); }
|
||||
.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;
|
||||
@@ -262,9 +269,15 @@
|
||||
.plan-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--card-soft);
|
||||
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 {
|
||||
|
||||
@@ -62,12 +62,6 @@ export const STYLE_SET_OPTIONS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 官逆/弱保真通道模型:链路不透传保真参数(input_fidelity 等)、参考图被弱化,
|
||||
* 商品还原度不稳定,生成前需警示用户。链路性质决定,prompt 只能缓解不能根除。
|
||||
*/
|
||||
export const WEAK_FIDELITY_MODELS = new Set(['gpt-image-2-vip']);
|
||||
|
||||
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
|
||||
export const PLATFORM_OPTIONS = [
|
||||
{ value: 'ozon', label: 'Ozon' },
|
||||
@@ -107,7 +101,7 @@ export const IMAGE_MODEL_OPTIONS = [
|
||||
{
|
||||
value: 'gpt-image-2-vip',
|
||||
label: 'gpt-image-2-vip',
|
||||
desc: 'GPT 官逆低价通道,构图与文字渲染强,但商品还原不稳定(官逆链路限制),试错可用,正式出图建议 gpt-image-2',
|
||||
desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana',
|
||||
@@ -242,6 +236,20 @@ 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,
|
||||
@@ -259,3 +267,27 @@ export async function uploadImage(
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface State1688 {
|
||||
videos: Array<{ url: string; cover?: string }>;
|
||||
skus: Sku1688[];
|
||||
params: Array<{ key: string; value: string }>;
|
||||
detailUrl?: string; // 详情数据 CDN 端点(图文详情的图片列表来源)
|
||||
}
|
||||
|
||||
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
|
||||
@@ -146,7 +145,6 @@ export function extract1688State(context: unknown): State1688 | null {
|
||||
params.push({ key: 'SKU价格', value: priced.map(s => `${s.name.split(':').pop()} ${s.price}`).join(';') });
|
||||
}
|
||||
|
||||
const detailUrl = deepFind(context, 'detailUrl');
|
||||
const categoryIds = [
|
||||
temp.postCategoryId ? String(temp.postCategoryId) : '',
|
||||
temp.topCategoryId ? String(temp.topCategoryId) : '',
|
||||
@@ -166,6 +164,5 @@ export function extract1688State(context: unknown): State1688 | null {
|
||||
videos,
|
||||
skus,
|
||||
params,
|
||||
detailUrl: typeof detailUrl === 'string' && /^https?:\/\//.test(detailUrl) ? detailUrl : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,20 +55,23 @@ export function queryAllDeep(selectors: string[]): Element[] {
|
||||
|
||||
/**
|
||||
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
|
||||
* 有界滚动:步进 + 等待页面高度增长,页面不再变高或达到步数上限即停——
|
||||
* 防止底部「为你推荐」无限加载把采集卡死。滚完恢复原位。
|
||||
* 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征),
|
||||
* 等待页面高度增长,页面不再变高或达到步数上限即停——防止底部「为你推荐」无限加载把采集卡死。
|
||||
* 滚完恢复原位。
|
||||
*/
|
||||
export async function autoScrollToBottom(
|
||||
opts: { stepPx?: number; stepMs?: number; maxSteps?: number } = {}
|
||||
): Promise<void> {
|
||||
const { stepPx = 900, stepMs = 260, maxSteps = 40 } = opts;
|
||||
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: stepPx, behavior: 'auto' });
|
||||
await new Promise(r => setTimeout(r, stepMs));
|
||||
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) {
|
||||
|
||||
@@ -98,22 +98,3 @@ export function finalize(
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/** 经 background 拉取文本(跨域资源:1688 详情 CDN / mtop API) */
|
||||
export function bgFetchText(url: string, referer?: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: 'fetchText', url, referer },
|
||||
(res: { ok?: boolean; text?: string }) => {
|
||||
if (chrome.runtime.lastError || !res?.ok) return resolve(null);
|
||||
resolve(res.text ?? null);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 从任意文本里容错提取图片 URL 列表 */
|
||||
export function extractImageUrls(text: string): string[] {
|
||||
const urls = text.match(/https?:\/\/[^"'\\\s]+\.(?:jpg|jpeg|png|webp)/gi) ?? [];
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* 1688 采集编排(平台文件):
|
||||
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
|
||||
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
|
||||
* ② description.detailUrl → 详情数据 CDN 端点(绕开懒加载,best-effort)
|
||||
* ③ DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
|
||||
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
|
||||
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑),
|
||||
* 详情图改为滚动加载后由 DOM 采集补齐。
|
||||
*/
|
||||
import { autoScrollToBottom, waitForAny } from '../dom';
|
||||
import { collectImages, type ImageMaterial } from '../image';
|
||||
@@ -11,7 +12,7 @@ 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 { bgFetchText, extractImageUrls, finalize, mergeImages, type ScanResult } from '../merge';
|
||||
import { finalize, mergeImages, type ScanResult } from '../merge';
|
||||
import type { SiteProfile } from '../../profiles/types';
|
||||
|
||||
export async function scan1688(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
|
||||
@@ -76,32 +77,8 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
|
||||
source = 'state';
|
||||
}
|
||||
|
||||
// ② detailUrl → 详情图列表(绕开懒加载)
|
||||
if (st?.detailUrl) {
|
||||
try {
|
||||
const text = await bgFetchText(st.detailUrl, 'https://detail.1688.com/');
|
||||
if (text) {
|
||||
let di = 0;
|
||||
for (const u of extractImageUrls(text).slice(0, 80)) {
|
||||
primaryImages.push({
|
||||
key: `detail-${String(++di).padStart(3, '0')}`,
|
||||
groupKey: 'detail',
|
||||
groupName: '详情图',
|
||||
url: toOriginalUrl(u),
|
||||
thumbUrl: u,
|
||||
index: primaryImages.length,
|
||||
type: 'img',
|
||||
});
|
||||
}
|
||||
if (di > 0) source = 'mixed';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[SuiteCollector] 1688 detailUrl 拉取异常:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ③ DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果)
|
||||
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
// ② DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果)
|
||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await autoScrollToBottom();
|
||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||
|
||||
@@ -2,21 +2,20 @@
|
||||
* 淘宝/天猫 采集编排(平台文件):
|
||||
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
|
||||
* (isolated world 读不到 window 变量,v1 直读是无效的)
|
||||
* ② mtop 签名 API 兜底:pcdetail.data.get(主数据)+ detail.getdesc(图文详情,绕开懒加载)
|
||||
* ③ DOM 兜底 + 补充
|
||||
* ② 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 { toOriginalUrl } from '../url';
|
||||
import { readWindowKeys } from '../../bridge/read-window';
|
||||
import { buildFromSSR } from '../ssr-builder';
|
||||
import { taobaoStateFromBridge, taobaoStateFromMtop } from '../taobao-state';
|
||||
import { fetchDescImages, fetchPcDetailData } from '../taobao-mtop';
|
||||
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> {
|
||||
export async function scanTaobao(profile: SiteProfile, _itemId: string | null): Promise<ScanResult> {
|
||||
let primaryTexts: TextMaterial[] = [];
|
||||
let primaryImages: ImageMaterial[] = [];
|
||||
let source: ScanResult['source'] = 'dom';
|
||||
@@ -32,44 +31,8 @@ export async function scanTaobao(profile: SiteProfile, itemId: string | null): P
|
||||
source = 'ssr';
|
||||
}
|
||||
|
||||
// ② mtop:桥拿不到时兜底主数据;图文详情任何时候都尝试(绕开懒加载)
|
||||
if (itemId && !ssrData) {
|
||||
try {
|
||||
const mtopData = await fetchPcDetailData(itemId);
|
||||
const fromMtop = taobaoStateFromMtop(mtopData);
|
||||
if (fromMtop && ((fromMtop.item.images ?? []).length > 0 || fromMtop.item.title)) {
|
||||
const built = buildFromSSR(fromMtop, profile);
|
||||
primaryTexts = built.texts;
|
||||
primaryImages = built.images as ImageMaterial[];
|
||||
source = 'api';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[SuiteCollector] mtop pcdetail 异常:', err);
|
||||
}
|
||||
}
|
||||
if (itemId) {
|
||||
try {
|
||||
const descUrls = await fetchDescImages(itemId);
|
||||
let di = 0;
|
||||
for (const u of descUrls.slice(0, 60)) {
|
||||
primaryImages.push({
|
||||
key: `detail-${String(++di).padStart(3, '0')}`,
|
||||
groupKey: 'detail',
|
||||
groupName: '详情图',
|
||||
url: toOriginalUrl(u),
|
||||
thumbUrl: u,
|
||||
index: primaryImages.length,
|
||||
type: 'img',
|
||||
});
|
||||
}
|
||||
if (di > 0 && source !== 'dom') source = 'mixed';
|
||||
} catch (err) {
|
||||
console.warn('[SuiteCollector] mtop getdesc 异常:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ③ DOM 兜底 + 补充
|
||||
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
// ② DOM 兜底 + 补充
|
||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await autoScrollToBottom();
|
||||
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
|
||||
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
|
||||
@@ -80,7 +43,7 @@ export async function scanTaobao(profile: SiteProfile, itemId: string | null): P
|
||||
|
||||
const texts = mergeTexts(primaryTexts, domTexts);
|
||||
const images = mergeImages(primaryImages, domImages, profile);
|
||||
const result = finalize(profile, itemId, texts, images, [], source);
|
||||
const result = finalize(profile, _itemId, texts, images, [], source);
|
||||
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* 平台编排逻辑(各路径与合并策略)见:
|
||||
* platforms/ozon.ts Ozon 四路径(SSR data-state / JSON-LD / 站内 API / DOM)
|
||||
* platforms/taobao.ts 淘宝/天猫(桥读全局 / mtop 签名 API / DOM)
|
||||
* platforms/1688.ts 1688(桥读 window.context / detailUrl 详情 / DOM)
|
||||
* platforms/taobao.ts 淘宝/天猫(桥读全局 SSR / DOM)
|
||||
* platforms/1688.ts 1688(桥读 window.context SSR / DOM)
|
||||
* 共用合并器见 merge.ts。
|
||||
*/
|
||||
import { matchProfile } from '../profiles';
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* 淘宝 mtop 签名 API(路径②,兜底/补数据)。
|
||||
*
|
||||
* 接口(竞品毛子ERP 验证可用):
|
||||
* mtop.taobao.pcdetail.data.get/1.0 —— 主数据(item/skuBase/skuCore)
|
||||
* mtop.taobao.detail.getdesc/7.0 —— 图文详情(绕过"用户必须点开详情tab")
|
||||
* 签名算法(公开):sign = md5(`${token}&${t}&${appKey}&${data}`),
|
||||
* token 取 cookie _m_h5_tk 的第一段(游客 cookie 也有)。
|
||||
* 请求经 background 代理(host_permissions 覆盖 h5api 域名,带 cookie)。
|
||||
*/
|
||||
|
||||
// ── 紧凑 MD5 实现(SparkMD5 核心逻辑)───────────────────────────────────
|
||||
|
||||
function md5cycle(x: number[], k: number[]): void {
|
||||
let [a, b, c, d] = x;
|
||||
const ff = (a: number, b: number, c: number, d: number, x: number, s: number, t: number) =>
|
||||
cmn((b & c) | (~b & d), a, b, x, s, t);
|
||||
const gg = (a: number, b: number, c: number, d: number, x: number, s: number, t: number) =>
|
||||
cmn((b & d) | (c & ~d), a, b, x, s, t);
|
||||
const hh = (a: number, b: number, c: number, d: number, x: number, s: number, t: number) =>
|
||||
cmn(b ^ c ^ d, a, b, x, s, t);
|
||||
const ii = (a: number, b: number, c: number, d: number, x: number, s: number, t: number) =>
|
||||
cmn(c ^ (b | ~d), a, b, x, s, t);
|
||||
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
|
||||
a = (((a + q) | 0) + ((x + t) | 0)) | 0;
|
||||
return (((a << s) | (a >>> (32 - s))) + b) | 0;
|
||||
}
|
||||
|
||||
a = ff(a, b, c, d, k[0], 7, -680876936);
|
||||
d = ff(d, a, b, c, k[1], 12, -389564586);
|
||||
c = ff(c, d, a, b, k[2], 17, 606105819);
|
||||
b = ff(b, c, d, a, k[3], 22, -1044525330);
|
||||
a = ff(a, b, c, d, k[4], 7, -176418897);
|
||||
d = ff(d, a, b, c, k[5], 12, 1200080426);
|
||||
c = ff(c, d, a, b, k[6], 17, -1473231341);
|
||||
b = ff(b, c, d, a, k[7], 22, -45705983);
|
||||
a = ff(a, b, c, d, k[8], 7, 1770035416);
|
||||
d = ff(d, a, b, c, k[9], 12, -1958414417);
|
||||
c = ff(c, d, a, b, k[10], 17, -42063);
|
||||
b = ff(b, c, d, a, k[11], 22, -1990404162);
|
||||
a = ff(a, b, c, d, k[12], 7, 1804603682);
|
||||
d = ff(d, a, b, c, k[13], 12, -40341101);
|
||||
c = ff(c, d, a, b, k[14], 17, -1502002290);
|
||||
b = ff(b, c, d, a, k[15], 22, 1236535329);
|
||||
|
||||
a = gg(a, b, c, d, k[1], 5, -165796510);
|
||||
d = gg(d, a, b, c, k[6], 9, -1069501632);
|
||||
c = gg(c, d, a, b, k[11], 14, 643717713);
|
||||
b = gg(b, c, d, a, k[0], 20, -373897302);
|
||||
a = gg(a, b, c, d, k[5], 5, -701558691);
|
||||
d = gg(d, a, b, c, k[10], 9, 38016083);
|
||||
c = gg(c, d, a, b, k[15], 14, -660478335);
|
||||
b = gg(b, c, d, a, k[4], 20, -405537848);
|
||||
a = gg(a, b, c, d, k[9], 5, 568446438);
|
||||
d = gg(d, a, b, c, k[14], 9, -1019803690);
|
||||
c = gg(c, d, a, b, k[3], 14, -187363961);
|
||||
b = gg(b, c, d, a, k[8], 20, 1163531501);
|
||||
a = gg(a, b, c, d, k[13], 5, -1444681467);
|
||||
d = gg(d, a, b, c, k[2], 9, -51403784);
|
||||
c = gg(c, d, a, b, k[7], 14, 1735328473);
|
||||
b = gg(b, c, d, a, k[12], 20, -1926607734);
|
||||
|
||||
a = hh(a, b, c, d, k[5], 4, -378558);
|
||||
d = hh(d, a, b, c, k[8], 11, -2022574463);
|
||||
c = hh(c, d, a, b, k[11], 16, 1839030562);
|
||||
b = hh(b, c, d, a, k[14], 23, -35309556);
|
||||
a = hh(a, b, c, d, k[1], 4, -1530992060);
|
||||
d = hh(d, a, b, c, k[4], 11, 1272893353);
|
||||
c = hh(c, d, a, b, k[7], 16, -155497632);
|
||||
b = hh(b, c, d, a, k[10], 23, -1094730640);
|
||||
a = hh(a, b, c, d, k[13], 4, 681279174);
|
||||
d = hh(d, a, b, c, k[0], 11, -358537222);
|
||||
c = hh(c, d, a, b, k[3], 16, -722521979);
|
||||
b = hh(b, c, d, a, k[6], 23, 76029189);
|
||||
a = hh(a, b, c, d, k[9], 4, -640364487);
|
||||
d = hh(d, a, b, c, k[12], 11, -421815835);
|
||||
c = hh(c, d, a, b, k[15], 16, 530742520);
|
||||
b = hh(b, c, d, a, k[2], 23, -995338651);
|
||||
|
||||
a = ii(a, b, c, d, k[0], 6, -198630844);
|
||||
d = ii(d, a, b, c, k[7], 10, 1126891415);
|
||||
c = ii(c, d, a, b, k[14], 15, -1416354905);
|
||||
b = ii(b, c, d, a, k[5], 21, -57434055);
|
||||
a = ii(a, b, c, d, k[12], 6, 1700485571);
|
||||
d = ii(d, a, b, c, k[3], 10, -1894986606);
|
||||
c = ii(c, d, a, b, k[10], 15, -1051523);
|
||||
b = ii(b, c, d, a, k[1], 21, -2054922799);
|
||||
a = ii(a, b, c, d, k[8], 6, 1873313359);
|
||||
d = ii(d, a, b, c, k[15], 10, -30611744);
|
||||
c = ii(c, d, a, b, k[6], 15, -1560198380);
|
||||
b = ii(b, c, d, a, k[13], 21, 1309151649);
|
||||
a = ii(a, b, c, d, k[4], 6, -145523070);
|
||||
d = ii(d, a, b, c, k[11], 10, -1120210379);
|
||||
c = ii(c, d, a, b, k[2], 15, 718787259);
|
||||
b = ii(b, c, d, a, k[9], 21, -343485551);
|
||||
|
||||
x[0] = (x[0] + a) | 0; x[1] = (x[1] + b) | 0; x[2] = (x[2] + c) | 0; x[3] = (x[3] + d) | 0;
|
||||
}
|
||||
|
||||
function md5blk(s: string): number[] {
|
||||
const md5blks: number[] = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
md5blks[i] = (s.charCodeAt(i * 4)) | (s.charCodeAt(i * 4 + 1) << 8) |
|
||||
(s.charCodeAt(i * 4 + 2) << 16) | (s.charCodeAt(i * 4 + 3) << 24);
|
||||
}
|
||||
return md5blks;
|
||||
}
|
||||
|
||||
function rhex(n: number): string {
|
||||
const hexChr = '0123456789abcdef';
|
||||
let s = '';
|
||||
for (let j = 0; j < 4; j++) {
|
||||
s += hexChr.charAt((n >> (j * 8 + 4)) & 0x0f) + hexChr.charAt((n >> (j * 8)) & 0x0f);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function md5(s: string): string {
|
||||
const n = s.length;
|
||||
const state = [1732584193, -271733879, -1732584194, 271733878];
|
||||
let i: number;
|
||||
for (i = 64; i <= n; i += 64) md5cycle(state, md5blk(s.substring(i - 64, i)));
|
||||
const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
const rest = s.substring(i - 64);
|
||||
for (i = 0; i < rest.length; i++) {
|
||||
tail[i >> 2] |= rest.charCodeAt(i) << ((i % 4) << 3);
|
||||
}
|
||||
tail[i >> 2] |= 0x80 << ((i % 4) << 3);
|
||||
if (i > 55) {
|
||||
md5cycle(state, tail);
|
||||
for (i = 0; i < 16; i++) tail[i] = 0;
|
||||
}
|
||||
tail[14] = n * 8;
|
||||
md5cycle(state, tail);
|
||||
return state.map(rhex).join('');
|
||||
}
|
||||
|
||||
// ── mtop 调用 ────────────────────────────────────────────────────────────
|
||||
|
||||
const APP_KEY = '12574478';
|
||||
|
||||
function mtopToken(): string {
|
||||
const m = document.cookie.match(/(?:^|;\s*)_m_h5_tk=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]).split('.')[0] : '';
|
||||
}
|
||||
|
||||
function buildMtopUrl(api: string, v: string, data: string): string {
|
||||
const host = /tmall\.com$/.test(location.hostname) ? 'h5api.m.tmall.com' : 'h5api.m.taobao.com';
|
||||
const t = Date.now();
|
||||
const token = mtopToken();
|
||||
const sign = md5(`${token}&${t}&${APP_KEY}&${data}`);
|
||||
return `https://${host}/h5/${api}/${v}/?jsv=2.6.1&appKey=${APP_KEY}&t=${t}` +
|
||||
`&sign=${sign}&api=${api}&v=${v}&type=json&dataType=json&timeout=20000` +
|
||||
`&AntiFlood=true&ecode=0&isSec=0&data=${encodeURIComponent(data)}`;
|
||||
}
|
||||
|
||||
/** 经 background 代理取文本(带 cookie),失败返回 null */
|
||||
async function bgFetchText(url: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: 'fetchText', url, credentials: true },
|
||||
(res: { ok?: boolean; text?: string }) => {
|
||||
if (chrome.runtime.lastError || !res?.ok) return resolve(null);
|
||||
resolve(res.text ?? null);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 主数据:item / skuBase / skuCore,返回响应里的 data 字段 */
|
||||
export async function fetchPcDetailData(itemId: string): Promise<unknown | null> {
|
||||
const data = JSON.stringify({ itemNumId: String(itemId) });
|
||||
const url = buildMtopUrl('mtop.taobao.pcdetail.data.get', '1.0', data);
|
||||
const text = await bgFetchText(url);
|
||||
if (!text) return null;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (json?.ret?.[0]?.includes('SUCCESS')) return json.data ?? null;
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 图文详情:容错提取响应文本里的图片 URL 列表 */
|
||||
export async function fetchDescImages(itemId: string): Promise<string[]> {
|
||||
const data = JSON.stringify({ itemNumId: String(itemId), type: '1' });
|
||||
const url = buildMtopUrl('mtop.taobao.detail.getdesc', '7.0', data);
|
||||
const text = await bgFetchText(url);
|
||||
if (!text) return [];
|
||||
const urls = text.match(/https?:\/\/[^"'\\\s]+\.(?:jpg|jpeg|png|webp)/gi) ?? [];
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
@@ -113,25 +113,3 @@ export function taobaoStateFromBridge(keys: Record<string, any>): SSRData | null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** mtop pcdetail.data.get 响应 → SSRData(竞品验证的接口,结构同 res) */
|
||||
export function taobaoStateFromMtop(data: unknown): SSRData | null {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const d = data as any;
|
||||
const item = d.item ?? d.itemDO;
|
||||
if (!item) return null;
|
||||
const images: string[] = (item.images ?? []).filter((u: unknown) => typeof u === 'string');
|
||||
const videos = (item.videos ?? []).map((v: any) => ({ url: v?.url ?? v?.videoUrl, videoThumbnailURL: v?.videoThumbnailURL ?? v?.coverUrl })).filter((v: any) => v.url);
|
||||
const params = paramsFromUnknown(d.params ?? d.propsList ?? d);
|
||||
return {
|
||||
item: {
|
||||
title: item.title ?? '',
|
||||
itemId: String(item.itemId ?? item.itemNumId ?? ''),
|
||||
images,
|
||||
videos,
|
||||
},
|
||||
skuBase: d.skuBase,
|
||||
params: { basicParamList: params, enhanceParamList: [] },
|
||||
price: d.skuCore?.price ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +123,12 @@ export const profileTaobao: SiteProfile = {
|
||||
'[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,
|
||||
|
||||
@@ -8,7 +8,8 @@ export default defineConfig({
|
||||
'storage',
|
||||
'sidePanel',
|
||||
'activeTab',
|
||||
'scripting' // 执行 content script 函数需要
|
||||
'scripting', // 执行 content script 函数需要
|
||||
'downloads' // 导出采集图片 / 套图 ZIP 到本地
|
||||
],
|
||||
host_permissions: [
|
||||
// Ozon 商品页 + 图片 CDN
|
||||
@@ -21,9 +22,6 @@ export default defineConfig({
|
||||
'https://item.taobao.com/*',
|
||||
'https://detail.tmall.com/*',
|
||||
'https://*.alicdn.com/*',
|
||||
// mtop 开放接口(淘宝主数据/图文详情兜底)
|
||||
'https://h5api.m.taobao.com/*',
|
||||
'https://h5api.m.tmall.com/*',
|
||||
// 1688 详情数据 CDN(description.detailUrl)
|
||||
'https://itemcdn.tmall.com/*',
|
||||
// 本机后端(上传 / 生成套图用);生产换成你的公网域名
|
||||
|
||||
Reference in New Issue
Block a user