feat: 调用gpt\nano模型方式修改,功能优化,增加下载采集图片等功能
This commit is contained in:
+1
-3
@@ -27,12 +27,10 @@ DASHSCOPE_API_KEY=
|
||||
DASHSCOPE_BASE_URL=
|
||||
DASHSCOPE_MODEL=wan2.7-image-pro
|
||||
|
||||
# --- RightAPI(gpt-image,OpenAI 兼容中转)---
|
||||
# --- RightAPI(gpt-image / nano-banana,OpenAI 兼容中转)---
|
||||
RIGHTAPI_API_KEY=
|
||||
RIGHTAPI_BASE_URL=https://rightapi.ai/draw
|
||||
RIGHTAPI_IMAGE_MODEL=gpt-image-2
|
||||
# 出图质量:auto | low | medium | high(high 单张约 1-5 分钟,超时自动兜底 600s)
|
||||
RIGHTAPI_IMAGE_QUALITY=high
|
||||
|
||||
# --- DeepSeek(出图方案规划器)---
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
@@ -100,6 +100,7 @@ pnpm build # 产物在 .output/chrome-mv3
|
||||
| POST | `/api/generate` | 无状态一键生成(`{texts, images, plan, style_set, platform}`,不落商品库) |
|
||||
| GET | `/api/suites/{id}` | 任务状态 + 已生成图 URL |
|
||||
| GET | `/api/suites/{id}/zip` | 导出 ZIP(按方案标题命名) |
|
||||
| POST | `/api/export-images` | 导出采集图片 ZIP(`{title, images}`,内部按分组名建文件夹) |
|
||||
| GET | `/api/proxy-image?url=` | 图片代理(绕源站防盗链) |
|
||||
| GET | `/api/health` | 健康检查 + provider 配置状态 |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RightAPI(gpt-image / nano-banana)调用方式排查报告
|
||||
|
||||
> 2026-08-20 · 状态:**待确认**(确认后再改代码)
|
||||
> 2026-08-20 · 状态:**已实施**(§6 已落地到 `server/services/generator.py` 与配置)
|
||||
> 结论先行:**是调用方式不对**。现行代码把参考图用 multipart 传给未在文档中的
|
||||
> `/v1/images/edits` 端点;该中转已于 2026-07-14 全面切换"统一异步模式",文档中的
|
||||
> 正确用法是 `/v1/images/generations` + JSON `image`(data-URI 数组)+ `async: true`
|
||||
@@ -108,7 +108,7 @@ wan2.6-image 走 DashScope 正常链路(参考图确实送达)仍重造了
|
||||
与本次 RightAPI 调用方式无关,建议后续单独评估(比如套餐默认模型换成
|
||||
wan2.7-image-pro 或 qwen-image-3.0-pro,两者参考遵循更强)。
|
||||
|
||||
## 6. 修复方案(确认后实施)
|
||||
## 6. 修复方案(已实施)
|
||||
|
||||
只改 `server/services/generator.py` 的 RightAPI provider,提示词层不动:
|
||||
|
||||
|
||||
@@ -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/*',
|
||||
// 本机后端(上传 / 生成套图用);生产换成你的公网域名
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""导出采集图片:把采集到的源站图片打包成 ZIP 下载到本地。
|
||||
|
||||
参考图 URL 可能是源站 CDN(需 Referer 绕过防盗链)或本地上传的 media 文件。
|
||||
ZIP 内部结构沿用现有分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传),
|
||||
文件名沿用采集 key(main-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
@@ -41,14 +41,12 @@ class Settings(BaseSettings):
|
||||
dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点
|
||||
dashscope_model: str = "wan2.7-image-pro"
|
||||
|
||||
# RightAPI(OpenAI 兼容中转,gpt-image 系列)
|
||||
# RightAPI(OpenAI 兼容中转,gpt-image / nano-banana 系列)
|
||||
rightapi_api_key: str = ""
|
||||
rightapi_base_url: str = "https://rightapi.ai/draw"
|
||||
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_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
|
||||
rightapi_input_fidelity: str = "high" # edits 端点高保真档(high | low,留空关闭)
|
||||
|
||||
# DeepSeek(出图方案规划器)
|
||||
deepseek_api_key: str = ""
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
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 services.storage import media_root
|
||||
|
||||
@@ -26,6 +26,7 @@ app.include_router(generate.router)
|
||||
app.include_router(suites.router)
|
||||
app.include_router(proxy.router)
|
||||
app.include_router(upload.router)
|
||||
app.include_router(export.router)
|
||||
|
||||
# 静态托管生成的图片
|
||||
app.mount("/media", StaticFiles(directory=str(media_root())), name="media")
|
||||
|
||||
@@ -234,71 +234,103 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
|
||||
return dl.content
|
||||
|
||||
|
||||
# ── Provider:RightAPI(gpt-image,OpenAI 兼容中转)──────────────────────
|
||||
# ── Provider:RightAPI(gpt-image / nano-banana,OpenAI 兼容中转)──────────
|
||||
|
||||
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429)
|
||||
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
||||
|
||||
# 中转对 input_fidelity 参数的支持探测:按模型记忆不支持该参数的模型(gpt-image 系列支持,
|
||||
# nano-banana 系列可能不认;降级只影响触发过的模型,不牵连其他模型)
|
||||
_rightapi_fidelity_unsupported: set[str] = set()
|
||||
|
||||
async def _rightapi_poll_task(client: httpx.AsyncClient, headers: dict, origin: str,
|
||||
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:
|
||||
"""RightAPI 各模型:有参考图走 /v1/images/edits(multipart),无参考图走 /v1/images/generations。
|
||||
"""RightAPI 各模型:统一走 /v1/images/generations(异步)。
|
||||
|
||||
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400);
|
||||
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。
|
||||
input_fidelity=high 是 gpt-image-1 的 edits 保真参数(gpt-image-2 官方已移除、默认高保真,
|
||||
官逆通道更是不识别);带上是为了兼容按 gpt-image-1 语义实现的中转,中转不认(400)则按模型
|
||||
自动去掉重试并记住,该模型后续请求不再带。
|
||||
官方协议(docs.rightapi.ai/docs/rc_draw/,2026-07 起统一异步):
|
||||
- POST /draw/v1/images/generations,请求体固定带 async:true,参考图放 image 数组(data-URI);
|
||||
- 返回 task_id 后轮询 GET /v1/tasks/{task_id}(站点级,不带 /draw);
|
||||
- 参数只有 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("/")
|
||||
# 任务查询是站点级接口,不带 /draw:从 base 里拆出 origin(https://rightapi.ai/draw → https://rightapi.ai)
|
||||
origin = base.split("/draw", 1)[0].rstrip("/") or base
|
||||
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:
|
||||
common = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"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,
|
||||
)
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/generations",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
_raise_api_error(resp, "RightAPI")
|
||||
item = resp.json()["data"][0]
|
||||
b64 = item.get("b64_json") or ""
|
||||
if b64:
|
||||
return base64.b64decode(b64.split(",", 1)[-1] if "," in b64 else b64)
|
||||
img_url = item.get("url") or ""
|
||||
if not img_url:
|
||||
raise RuntimeError(f"RightAPI 响应里没有图片数据: {item}")
|
||||
dl = await client.get(img_url, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
submitted = resp.json()
|
||||
task_id = submitted.get("task_id") or ""
|
||||
if task_id:
|
||||
result = await _rightapi_poll_task(client, headers, origin, task_id, s.poll_max_wait)
|
||||
else:
|
||||
# 极端兜底:个别中转可能同步返回 data(文档不保证,但防御处理)
|
||||
result = submitted
|
||||
|
||||
kind, payload = _rightapi_extract_image(result)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user