feat: 调用gpt\nano模型方式修改,功能优化,增加下载采集图片等功能

This commit is contained in:
R524809
2026-08-20 17:00:40 +08:00
parent 90b7c8737d
commit 95cb93a160
20 changed files with 411 additions and 407 deletions
+113 -16
View File
@@ -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>
)
}
+17 -4
View File
@@ -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 {
+39 -7
View File
@@ -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`;
}
/** 下载生成结果 ZIPGET → 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();
}
-3
View File
@@ -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,
};
}
+8 -5
View File
@@ -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) {
-19
View File
@@ -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));
}
+6 -29
View File
@@ -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 采集)');
+8 -45
View File
@@ -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;
}
+2 -2
View File
@@ -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';
-191
View File
@@ -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));
}
-22
View File
@@ -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,
};
}
+6
View File
@@ -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,
+2 -4
View File
@@ -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 详情数据 CDNdescription.detailUrl
'https://itemcdn.tmall.com/*',
// 本机后端(上传 / 生成套图用);生产换成你的公网域名