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
+1 -3
View File
@@ -27,12 +27,10 @@ DASHSCOPE_API_KEY=
DASHSCOPE_BASE_URL= DASHSCOPE_BASE_URL=
DASHSCOPE_MODEL=wan2.7-image-pro DASHSCOPE_MODEL=wan2.7-image-pro
# --- RightAPIgpt-imageOpenAI 兼容中转)--- # --- RightAPIgpt-image / nano-bananaOpenAI 兼容中转)---
RIGHTAPI_API_KEY= RIGHTAPI_API_KEY=
RIGHTAPI_BASE_URL=https://rightapi.ai/draw RIGHTAPI_BASE_URL=https://rightapi.ai/draw
RIGHTAPI_IMAGE_MODEL=gpt-image-2 RIGHTAPI_IMAGE_MODEL=gpt-image-2
# 出图质量:auto | low | medium | highhigh 单张约 1-5 分钟,超时自动兜底 600s)
RIGHTAPI_IMAGE_QUALITY=high
# --- DeepSeek(出图方案规划器)--- # --- DeepSeek(出图方案规划器)---
DEEPSEEK_API_KEY= DEEPSEEK_API_KEY=
+1
View File
@@ -100,6 +100,7 @@ pnpm build # 产物在 .output/chrome-mv3
| POST | `/api/generate` | 无状态一键生成(`{texts, images, plan, style_set, platform}`,不落商品库) | | POST | `/api/generate` | 无状态一键生成(`{texts, images, plan, style_set, platform}`,不落商品库) |
| GET | `/api/suites/{id}` | 任务状态 + 已生成图 URL | | GET | `/api/suites/{id}` | 任务状态 + 已生成图 URL |
| GET | `/api/suites/{id}/zip` | 导出 ZIP(按方案标题命名) | | GET | `/api/suites/{id}/zip` | 导出 ZIP(按方案标题命名) |
| POST | `/api/export-images` | 导出采集图片 ZIP`{title, images}`,内部按分组名建文件夹) |
| GET | `/api/proxy-image?url=` | 图片代理(绕源站防盗链) | | GET | `/api/proxy-image?url=` | 图片代理(绕源站防盗链) |
| GET | `/api/health` | 健康检查 + provider 配置状态 | | GET | `/api/health` | 健康检查 + provider 配置状态 |
+2 -2
View File
@@ -1,6 +1,6 @@
# RightAPIgpt-image / nano-banana)调用方式排查报告 # RightAPIgpt-image / nano-banana)调用方式排查报告
> 2026-08-20 · 状态:**待确认**(确认后再改代码 > 2026-08-20 · 状态:**已实施**(§6 已落地到 `server/services/generator.py` 与配置
> 结论先行:**是调用方式不对**。现行代码把参考图用 multipart 传给未在文档中的 > 结论先行:**是调用方式不对**。现行代码把参考图用 multipart 传给未在文档中的
> `/v1/images/edits` 端点;该中转已于 2026-07-14 全面切换"统一异步模式",文档中的 > `/v1/images/edits` 端点;该中转已于 2026-07-14 全面切换"统一异步模式",文档中的
> 正确用法是 `/v1/images/generations` + JSON `image`data-URI 数组)+ `async: true` > 正确用法是 `/v1/images/generations` + JSON `image`data-URI 数组)+ `async: true`
@@ -108,7 +108,7 @@ wan2.6-image 走 DashScope 正常链路(参考图确实送达)仍重造了
与本次 RightAPI 调用方式无关,建议后续单独评估(比如套餐默认模型换成 与本次 RightAPI 调用方式无关,建议后续单独评估(比如套餐默认模型换成
wan2.7-image-pro 或 qwen-image-3.0-pro,两者参考遵循更强)。 wan2.7-image-pro 或 qwen-image-3.0-pro,两者参考遵循更强)。
## 6. 修复方案(确认后实施) ## 6. 修复方案(实施)
只改 `server/services/generator.py` 的 RightAPI provider,提示词层不动: 只改 `server/services/generator.py` 的 RightAPI provider,提示词层不动:
+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 { App as AntApp, ConfigProvider, Popover, Progress, Select } from 'antd';
import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined, CloseOutlined } from '@ant-design/icons'; import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined, CloseOutlined } from '@ant-design/icons';
import type { ScanResult, ImageMaterial } from '../../src/collector/scan'; import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
import { cleanFilename } from '../../src/collector/url';
import { import {
buildGeneratePayload, suiteZipUrl, uploadImage, buildGeneratePayload, downloadSuiteZip, uploadImage, exportImages,
DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS, WEAK_FIDELITY_MODELS, DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS,
type PlanItem, type SuiteInfo, type PlanItem, type SuiteInfo,
} from '../../src/api/client'; } from '../../src/api/client';
import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings'; 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 [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon');
const [styleSet, setStyleSet] = useState(1); const [styleSet, setStyleSet] = useState(1);
/** 生图模型(默认 gpt-image-2,走 RightAPI provider */ /** 生图模型(默认 gpt-image-2-vip,走 RightAPI provider */
const [model, setModel] = useState<string>('gpt-image-2'); const [model, setModel] = useState<string>('gpt-image-2-vip');
/** 用户改写的风格提示词(按风格 id 存,切风格不丢) */ /** 用户改写的风格提示词(按风格 id 存,切风格不丢) */
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({}); const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
/** 生图要求(最高优先级,强制约束,覆盖其他设定) */ /** 生图要求(最高优先级,强制约束,覆盖其他设定) */
@@ -156,6 +157,10 @@ const App: React.FC = () => {
// 生成 // 生成
const [suite, setSuite] = useState<SuiteInfo | null>(null); const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false); 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 pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 规划请求序号:重新采集时递增,用于丢弃重置后才返回的过期规划响应 */ /** 规划请求序号:重新采集时递增,用于丢弃重置后才返回的过期规划响应 */
const planSeqRef = useRef(0); const planSeqRef = useRef(0);
@@ -438,12 +443,6 @@ const App: React.FC = () => {
{STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label} {totalPlanned} {' '} {STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label} {totalPlanned} {' '}
{selectedKeys.size} {selectedKeys.size}
</div> </div>
{WEAK_FIDELITY_MODELS.has(model) && (
<div style={{ marginTop: 8, color: '#d4380d', fontWeight: 600 }}>
{model} 1
gpt-image-2
</div>
)}
</div> </div>
), ),
okText: '开始生成', cancelText: '取消', okText: '开始生成', cancelText: '取消',
@@ -451,9 +450,27 @@ const App: React.FC = () => {
}); });
}; };
const handleExport = () => { const handleExport = async () => {
if (!suite) return; 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) => { const toggleKey = (key: string) => {
@@ -465,9 +482,50 @@ const App: React.FC = () => {
/** 全部可选图片:采集结果 + 手动上传(上传图单独一组「upload」) */ /** 全部可选图片:采集结果 + 手动上传(上传图单独一组「upload」) */
const allImages: ImageMaterial[] = result ? [...result.images, ...uploadedImages] : uploadedImages; 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[] => const groupImages = (groupKey: string): ImageMaterial[] =>
allImages.filter(i => i.groupKey === groupKey); 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→详情→上传,与展示顺序一致) */ /** 预览用的全量图序列(主图→SKU→详情→上传,与展示顺序一致) */
const collectedPreviewList: string[] = (['main', 'sku', 'detail', 'upload'] as const) const collectedPreviewList: string[] = (['main', 'sku', 'detail', 'upload'] as const)
.flatMap(g => groupImages(g).map(i => i.url)); .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)); 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 */ /** 手动上传:点击触发隐藏的 file input */
const handleUpload = () => fileRef.current?.click(); const handleUpload = () => fileRef.current?.click();
@@ -739,6 +810,14 @@ const App: React.FC = () => {
))} ))}
</div> </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> </Section>
</div> </div>
@@ -765,17 +844,35 @@ const App: React.FC = () => {
> >
<div className="plan-list"> <div className="plan-list">
{plan.map((p, idx) => ( {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"> <div className="plan-main">
<span className="plan-title">{p.title}</span> <span className="plan-title">{p.title}</span>
{p.variant_name && <span className="variant-chip">{p.variant_name}</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.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>} {p.prompt_hint && <span className="plan-detail plan-hint" title={p.prompt_hint}>🎯 {p.prompt_hint}</span>}
</div> </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> </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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '10px 0 4px' }}>
{planSource === 'ai' && ( {planSource === 'ai' && (
<button <button
@@ -875,8 +972,8 @@ const App: React.FC = () => {
title="生成结果" title="生成结果"
extra={ extra={
suite && ['done', 'partial'].includes(suite.status) && ( suite && ['done', 'partial'].includes(suite.status) && (
<button className="btn btn-sm" onClick={handleExport}> <button className="btn btn-sm" onClick={handleExport} disabled={exportingZip}>
<DownloadOutlined /> ZIP <DownloadOutlined /> {exportingZip ? '导出中…' : '导出 ZIP'}
</button> </button>
) )
} }
+17 -4
View File
@@ -14,6 +14,7 @@
--primary: #8b5cf6; /* 紫(ozon-seller-kit v2 主题色) */ --primary: #8b5cf6; /* 紫(ozon-seller-kit v2 主题色) */
--primary-hover: #7c3aed; --primary-hover: #7c3aed;
--primary-ring: rgba(139, 92, 246, 0.12); --primary-ring: rgba(139, 92, 246, 0.12);
--primary-soft: #a78bfa; /* 主题色同色系偏淡(未勾选描边/✓) */
--green: #52c41a; --green: #52c41a;
--red: #ff4d4f; --red: #ff4d4f;
--warn-bg: #fffbe6; --warn-bg: #fffbe6;
@@ -49,6 +50,12 @@
.section-images .section-head { padding: 0 16px; } .section-images .section-head { padding: 0 16px; }
.section-images .img-groups { padding: 2px 8px 0 16px; } .section-images .img-groups { padding: 2px 8px 0 16px; }
.section-images .empty { margin: 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; } .divider { border-top: 1px solid var(--border); margin: 12px 0; }
/* ── 顶部 ── */ /* ── 顶部 ── */
@@ -174,11 +181,11 @@
.img-cell.on { border-color: var(--primary); } .img-cell.on { border-color: var(--primary); }
.img-cell .tick { .img-cell .tick {
position: absolute; top: 5px; left: 5px; width: 18px; height: 18px; 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; 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 { .img-cell .variant {
position: absolute; bottom: 0; left: 0; right: 0; position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 11px; background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 11px;
@@ -262,9 +269,15 @@
.plan-row { .plan-row {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px; 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-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-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; } .plan-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.variant-chip { .variant-chip {
+39 -7
View File
@@ -62,12 +62,6 @@ export const STYLE_SET_OPTIONS = [
}, },
] as const; ] as const;
/**
* 官逆/弱保真通道模型:链路不透传保真参数(input_fidelity 等)、参考图被弱化,
* 商品还原度不稳定,生成前需警示用户。链路性质决定,prompt 只能缓解不能根除。
*/
export const WEAK_FIDELITY_MODELS = new Set(['gpt-image-2-vip']);
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */ /** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
export const PLATFORM_OPTIONS = [ export const PLATFORM_OPTIONS = [
{ value: 'ozon', label: 'Ozon' }, { value: 'ozon', label: 'Ozon' },
@@ -107,7 +101,7 @@ export const IMAGE_MODEL_OPTIONS = [
{ {
value: 'gpt-image-2-vip', value: 'gpt-image-2-vip',
label: 'gpt-image-2-vip', label: 'gpt-image-2-vip',
desc: 'GPT 官逆低价通道,构图与文字渲染强,但商品还原不稳定(官逆链路限制),试错可用,正式出图建议 gpt-image-2', desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图',
}, },
{ {
value: 'nano-banana', value: 'nano-banana',
@@ -242,6 +236,20 @@ export function suiteZipUrl(baseUrl: string, suiteId: string): string {
return `${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}/zip`; 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(补充参考图用) */ /** 手动上传本地图片到服务端,返回可访问 URL(补充参考图用) */
export async function uploadImage( export async function uploadImage(
baseUrl: string, baseUrl: string,
@@ -259,3 +267,27 @@ export async function uploadImage(
if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`); if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
return data; 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 }>; videos: Array<{ url: string; cover?: string }>;
skus: Sku1688[]; skus: Sku1688[];
params: Array<{ key: string; value: string }>; params: Array<{ key: string; value: string }>;
detailUrl?: string; // 详情数据 CDN 端点(图文详情的图片列表来源)
} }
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */ /** 深度查找指定键(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('') }); params.push({ key: 'SKU价格', value: priced.map(s => `${s.name.split(':').pop()} ${s.price}`).join('') });
} }
const detailUrl = deepFind(context, 'detailUrl');
const categoryIds = [ const categoryIds = [
temp.postCategoryId ? String(temp.postCategoryId) : '', temp.postCategoryId ? String(temp.postCategoryId) : '',
temp.topCategoryId ? String(temp.topCategoryId) : '', temp.topCategoryId ? String(temp.topCategoryId) : '',
@@ -166,6 +164,5 @@ export function extract1688State(context: unknown): State1688 | null {
videos, videos,
skus, skus,
params, 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( export async function autoScrollToBottom(
opts: { stepPx?: number; stepMs?: number; maxSteps?: number } = {} opts: { stepPx?: number; stepMs?: number; maxSteps?: number } = {}
): Promise<void> { ): Promise<void> {
const { stepPx = 900, stepMs = 260, maxSteps = 40 } = opts; const { stepPx = 500, stepMs = 400, maxSteps = 80 } = opts;
const startY = window.scrollY; const startY = window.scrollY;
let lastHeight = document.body.scrollHeight; let lastHeight = document.body.scrollHeight;
let stagnant = 0; // 连续不增长的步数 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++) { for (let i = 0; i < maxSteps; i++) {
window.scrollBy({ top: stepPx, behavior: 'auto' }); window.scrollBy({ top: Math.round(stepPx * rand(0.7, 1.3)), behavior: 'auto' });
await new Promise(r => setTimeout(r, stepMs)); 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 atBottom = window.scrollY + window.innerHeight >= document.body.scrollHeight - 4;
const h = document.body.scrollHeight; const h = document.body.scrollHeight;
if (h > lastHeight + 50) { if (h > lastHeight + 50) {
-19
View File
@@ -98,22 +98,3 @@ export function finalize(
source, 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 采集编排(平台文件): * 1688 采集编排(平台文件):
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径 * ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺 * —— 主图 / 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 { autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image'; import { collectImages, type ImageMaterial } from '../image';
@@ -11,7 +12,7 @@ import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl } from '../url'; import { toOriginalUrl } from '../url';
import { readWindowKeys } from '../../bridge/read-window'; import { readWindowKeys } from '../../bridge/read-window';
import { extract1688State, type State1688 } from '../1688-state'; 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'; import type { SiteProfile } from '../../profiles/types';
export async function scan1688(profile: SiteProfile, itemId: string | null): Promise<ScanResult> { 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'; source = 'state';
} }
// ② detailUrl → 详情图列表(绕开懒加载 // ② DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果
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 在这里进结果)
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom(); await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000); const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)'); if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
+8 -45
View File
@@ -2,21 +2,20 @@
* 淘宝/天猫 采集编排(平台文件): * 淘宝/天猫 采集编排(平台文件):
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径 * ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
* isolated world 读不到 window 变量,v1 直读是无效的) * 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 { autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image'; import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text'; import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl } from '../url';
import { readWindowKeys } from '../../bridge/read-window'; import { readWindowKeys } from '../../bridge/read-window';
import { buildFromSSR } from '../ssr-builder'; import { buildFromSSR } from '../ssr-builder';
import { taobaoStateFromBridge, taobaoStateFromMtop } from '../taobao-state'; import { taobaoStateFromBridge } from '../taobao-state';
import { fetchDescImages, fetchPcDetailData } from '../taobao-mtop';
import { finalize, mergeImages, type ScanResult } from '../merge'; import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types'; 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 primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = []; let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom'; let source: ScanResult['source'] = 'dom';
@@ -32,44 +31,8 @@ export async function scanTaobao(profile: SiteProfile, itemId: string | null): P
source = 'ssr'; source = 'ssr';
} }
// ② mtop:桥拿不到时兜底主数据;图文详情任何时候都尝试(绕开懒加载) // ② DOM 兜底 + 补充
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 兜底 + 补充
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom(); await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000); const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)'); 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 texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile); 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(', ')}`); if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result; return result;
} }
+2 -2
View File
@@ -3,8 +3,8 @@
* *
* 平台编排逻辑(各路径与合并策略)见: * 平台编排逻辑(各路径与合并策略)见:
* platforms/ozon.ts Ozon 四路径(SSR data-state / JSON-LD / 站内 API / DOM * platforms/ozon.ts Ozon 四路径(SSR data-state / JSON-LD / 站内 API / DOM
* platforms/taobao.ts 淘宝/天猫(桥读全局 / mtop 签名 API / DOM * platforms/taobao.ts 淘宝/天猫(桥读全局 SSR / DOM
* platforms/1688.ts 1688(桥读 window.context / detailUrl 详情 / DOM * platforms/1688.ts 1688(桥读 window.context SSR / DOM
* 共用合并器见 merge.ts。 * 共用合并器见 merge.ts。
*/ */
import { matchProfile } from '../profiles'; 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; 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*="comments--"]',
'[class*="userInfo--"]', '[class*="userInfo--"]',
'[class*="rate"]', '[class*="rate"]',
// 本店推荐:详情区底部的推荐卡片流(RecommendInfo-- 容器 / data-spm="recommends" /
// recommend-- 卡片区 / cardPic-- 卡片图盒),不是本商品的详情图,不能采
'[class*="RecommendInfo--"]',
'[data-spm="recommends"]',
'[class*="recommend--"]',
'[class*="cardPic--"]',
], ],
minWidth: 300, minWidth: 300,
minHeight: 100, minHeight: 100,
+2 -4
View File
@@ -8,7 +8,8 @@ export default defineConfig({
'storage', 'storage',
'sidePanel', 'sidePanel',
'activeTab', 'activeTab',
'scripting' // 执行 content script 函数需要 'scripting', // 执行 content script 函数需要
'downloads' // 导出采集图片 / 套图 ZIP 到本地
], ],
host_permissions: [ host_permissions: [
// Ozon 商品页 + 图片 CDN // Ozon 商品页 + 图片 CDN
@@ -21,9 +22,6 @@ export default defineConfig({
'https://item.taobao.com/*', 'https://item.taobao.com/*',
'https://detail.tmall.com/*', 'https://detail.tmall.com/*',
'https://*.alicdn.com/*', 'https://*.alicdn.com/*',
// mtop 开放接口(淘宝主数据/图文详情兜底)
'https://h5api.m.taobao.com/*',
'https://h5api.m.tmall.com/*',
// 1688 详情数据 CDNdescription.detailUrl // 1688 详情数据 CDNdescription.detailUrl
'https://itemcdn.tmall.com/*', 'https://itemcdn.tmall.com/*',
// 本机后端(上传 / 生成套图用);生产换成你的公网域名 // 本机后端(上传 / 生成套图用);生产换成你的公网域名
+120
View File
@@ -0,0 +1,120 @@
"""导出采集图片:把采集到的源站图片打包成 ZIP 下载到本地。
参考图 URL 可能是源站 CDN(需 Referer 绕过防盗链)或本地上传的 media 文件。
ZIP 内部结构沿用现有分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传),
文件名沿用采集 keymain-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。
"""
from __future__ import annotations
import io
import mimetypes
import re
import zipfile
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from api.proxy import guess_referer
from services import storage
router = APIRouter(prefix="/api", tags=["export"])
_EXT_BY_CTYPE = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"image/bmp": ".bmp",
}
class ExportImageItem(BaseModel):
url: str
groupName: str = "主图"
variantName: str | None = None
key: str = "" # 采集 key,如 main-001 / sku-002 / upload-001
class ExportImagesRequest(BaseModel):
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
images: list[ExportImageItem]
def _clean(name: str) -> str:
"""清洗文件夹/文件名非法字符(与插件 cleanFilename 同规则,Windows 兼容)。"""
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", (name or "").strip())
s = re.sub(r"\s+", "_", s)
return s.strip(" .")[:80]
def _ext(url: str, ctype: str) -> str:
"""由 content-type(优先)或 URL 后缀决定扩展名。"""
ctype = ctype.split(";")[0].strip().lower()
if ctype in _EXT_BY_CTYPE:
return _EXT_BY_CTYPE[ctype]
if ctype.startswith("image/"):
return "." + ctype.split("/")[-1]
path = url.split("?")[0].lower()
for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"):
if path.endswith(ext):
return ".jpg" if ext == ".jpeg" else ext
return ".jpg"
def _is_image(url: str, ctype: str) -> bool:
ctype = ctype.split(";")[0].strip().lower()
if ctype.startswith("image/"):
return True
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
async def _download(url: str) -> tuple[bytes, str]:
"""本地 media 文件直读磁盘;远程 URL 带 Referer 下载。"""
path = storage.local_path(url)
if path is not None:
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
return path.read_bytes(), mime
return await storage.download_bytes(url, referer=guess_referer(url))
@router.post("/export-images")
async def export_images(req: ExportImagesRequest):
if not req.images:
raise HTTPException(status_code=400, detail="没有可导出的图片")
root = _clean(req.title) or "采集图片"
buf = io.BytesIO()
used: set[str] = set()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for img in req.images:
try:
data, ctype = await _download(img.url)
except Exception: # noqa: BLE001
continue # 单张失败不中断整包
if not _is_image(img.url, ctype):
continue
ext = _ext(img.url, ctype)
base = _clean(img.key) or "image"
if img.variantName:
base += f"-{_clean(img.variantName)}"
filename = f"{base}{ext}"
if filename in used: # 同名加序号防覆盖
stem = filename[: -len(ext)]
n = 2
while f"{stem}-{n}{ext}" in used:
n += 1
filename = f"{stem}-{n}{ext}"
used.add(filename)
group = _clean(img.groupName) or "图片"
zf.writestr(f"{root}/{group}/{filename}", data)
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="collect.zip"'},
)
+1 -3
View File
@@ -41,14 +41,12 @@ class Settings(BaseSettings):
dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点 dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点
dashscope_model: str = "wan2.7-image-pro" dashscope_model: str = "wan2.7-image-pro"
# RightAPIOpenAI 兼容中转,gpt-image 系列) # RightAPIOpenAI 兼容中转,gpt-image / nano-banana 系列)
rightapi_api_key: str = "" rightapi_api_key: str = ""
rightapi_base_url: str = "https://rightapi.ai/draw" rightapi_base_url: str = "https://rightapi.ai/draw"
rightapi_image_model: str = "gpt-image-2" rightapi_image_model: str = "gpt-image-2"
rightapi_image_quality: str = "high" # auto | low | medium | high
rightapi_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试) rightapi_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
rightapi_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增 rightapi_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
rightapi_input_fidelity: str = "high" # edits 端点高保真档(high | low,留空关闭)
# DeepSeek(出图方案规划器) # DeepSeek(出图方案规划器)
deepseek_api_key: str = "" deepseek_api_key: str = ""
+2 -1
View File
@@ -7,7 +7,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from api import generate, proxy, suites, upload from api import export, generate, proxy, suites, upload
from config import get_settings from config import get_settings
from services.storage import media_root from services.storage import media_root
@@ -26,6 +26,7 @@ app.include_router(generate.router)
app.include_router(suites.router) app.include_router(suites.router)
app.include_router(proxy.router) app.include_router(proxy.router)
app.include_router(upload.router) app.include_router(upload.router)
app.include_router(export.router)
# 静态托管生成的图片 # 静态托管生成的图片
app.mount("/media", StaticFiles(directory=str(media_root())), name="media") app.mount("/media", StaticFiles(directory=str(media_root())), name="media")
+83 -51
View File
@@ -234,71 +234,103 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
return dl.content return dl.content
# ── ProviderRightAPIgpt-imageOpenAI 兼容中转)────────────────────── # ── ProviderRightAPIgpt-image / nano-banana,OpenAI 兼容中转)──────────
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429 # 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429
RETRYABLE_STATUS = {429, 500, 502, 503, 504} RETRYABLE_STATUS = {429, 500, 502, 503, 504}
# 中转对 input_fidelity 参数的支持探测:按模型记忆不支持该参数的模型(gpt-image 系列支持,
# nano-banana 系列可能不认;降级只影响触发过的模型,不牵连其他模型) async def _rightapi_poll_task(client: httpx.AsyncClient, headers: dict, origin: str,
_rightapi_fidelity_unsupported: set[str] = set() task_id: str, max_wait: int) -> dict:
"""轮询站点级任务查询接口 GET /v1/tasks/{task_id}(不带 /draw 前缀)。
实测要点(docs/rightapi-调用排查与修复方案.md §2.2):
- 完成响应**没有** status:"completed" 字段,完成判定 = 响应里出现 data;
- progress 基本不动(0~2),不能当进度条依据;
- 失败态 = status 为 failed / error / cancelled。
"""
poll_url = f"{origin}/v1/tasks/{task_id}"
elapsed, interval = 0, 3
while elapsed < max_wait:
resp = await client.get(poll_url, headers=headers, timeout=30)
_raise_api_error(resp, "RightAPI")
result = resp.json()
status = result.get("status", "")
if status in ("failed", "error", "cancelled"):
err = result.get("error") or {}
raise RuntimeError(f"RightAPI 任务失败: {err.get('message') or result}")
if "data" in result:
return result
await asyncio.sleep(interval)
elapsed += interval
interval = min(interval + 2, 10)
raise TimeoutError(f"RightAPI 异步任务超时 ({max_wait}s): task_id={task_id}")
def _rightapi_extract_image(result: dict) -> tuple[str | None, str | None]:
"""从轮询完成结果里取 (kind, payload)kind ∈ url | b64,未取到返回 (None, None)。
完成形状为 Images 协议:{"created":..., "data":[{"url": "..."}]}(实测只见 url)。
"""
data = result.get("data") or []
if data:
item = data[0] or {}
url = item.get("url") or ""
if url:
return ("url", url)
b64 = item.get("b64_json") or ""
if b64:
return ("b64", b64)
return (None, None)
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes: async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
"""RightAPI 各模型:有参考图走 /v1/images/editsmultipart),无参考图走 /v1/images/generations。 """RightAPI 各模型:统一走 /v1/images/generations(异步)
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400); 官方协议(docs.rightapi.ai/docs/rc_draw/2026-07 起统一异步):
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。 - POST /draw/v1/images/generations,请求体固定带 async:true,参考图放 image 数组(data-URI);
input_fidelity=high 是 gpt-image-1 的 edits 保真参数(gpt-image-2 官方已移除、默认高保真, - 返回 task_id 后轮询 GET /v1/tasks/{task_id}(站点级,不带 /draw);
官逆通道更是不识别);带上是为了兼容按 gpt-image-1 语义实现的中转,中转不认(400)则按模型 - 参数只有 model/prompt/n/size/imageSize/image/async;不传 quality/output_format/input_fidelity。
自动去掉重试并记住,该模型后续请求不再带 参考图沿用现选图逻辑(≤2 张,image 数组)。单张 1-5 分钟,轮询上限 poll_max_wait 兜底
""" """
base = s.rightapi_base_url.rstrip("/") base = s.rightapi_base_url.rstrip("/")
# 任务查询是站点级接口,不带 /draw:从 base 里拆出 originhttps://rightapi.ai/draw → https://rightapi.ai
origin = base.split("/draw", 1)[0].rstrip("/") or base
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"} headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
use_fidelity = (
bool(ref_images) and s.rightapi_input_fidelity and model not in _rightapi_fidelity_unsupported body = {
) "model": model,
"prompt": prompt,
"n": 1,
"size": size,
"async": True,
}
if ref_images:
body["image"] = [await _resolve_ref(u) for u in ref_images]
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client: async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
common = { resp = await client.post(
"model": model, f"{base}/v1/images/generations",
"prompt": prompt, headers={**headers, "Content-Type": "application/json"},
"size": size, json=body,
"quality": s.rightapi_image_quality, )
"output_format": "jpeg", # 与落盘 .jpg 后缀一致
"n": 1,
}
if use_fidelity:
common["input_fidelity"] = s.rightapi_input_fidelity
if ref_images:
files = []
for i, u in enumerate(ref_images):
data, mime = await _resolve_ref_bytes(u)
files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime)))
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
# 中转不认 input_fidelity:去掉参数重试一次(仅一次探测),降级只记到当前模型
if resp.status_code == 400 and use_fidelity and "input_fidelity" in resp.text:
_rightapi_fidelity_unsupported.add(model)
log.warning("RightAPI 模型 %s 不支持 input_fidelity 参数,已自动去掉并降级(该模型后续请求不再带)", model)
common.pop("input_fidelity", None)
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
else:
resp = await client.post(
f"{base}/v1/images/generations",
headers={**headers, "Content-Type": "application/json"},
json=common,
)
_raise_api_error(resp, "RightAPI") _raise_api_error(resp, "RightAPI")
item = resp.json()["data"][0] submitted = resp.json()
b64 = item.get("b64_json") or "" task_id = submitted.get("task_id") or ""
if b64: if task_id:
return base64.b64decode(b64.split(",", 1)[-1] if "," in b64 else b64) result = await _rightapi_poll_task(client, headers, origin, task_id, s.poll_max_wait)
img_url = item.get("url") or "" else:
if not img_url: # 极端兜底:个别中转可能同步返回 data(文档不保证,但防御处理)
raise RuntimeError(f"RightAPI 响应里没有图片数据: {item}") result = submitted
dl = await client.get(img_url, timeout=s.request_timeout)
dl.raise_for_status() kind, payload = _rightapi_extract_image(result)
return dl.content if kind == "b64" and payload:
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
if kind == "url" and payload:
dl = await client.get(payload, timeout=s.request_timeout)
dl.raise_for_status()
return dl.content
raise RuntimeError(f"RightAPI 任务完成但没有图片数据: {result}")
async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes: async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes: