diff --git a/.env.example b/.env.example index f16ec0f..addadad 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 703cd06..6e72a21 100644 --- a/README.md +++ b/README.md @@ -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 配置状态 | diff --git a/docs/rightapi-调用排查与修复方案.md b/docs/rightapi-调用排查与修复方案.md index 88ac779..3092178 100644 --- a/docs/rightapi-调用排查与修复方案.md +++ b/docs/rightapi-调用排查与修复方案.md @@ -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,提示词层不动: diff --git a/extension/entrypoints/sidepanel/App.tsx b/extension/entrypoints/sidepanel/App.tsx index 7d0fd15..9ca1151 100644 --- a/extension/entrypoints/sidepanel/App.tsx +++ b/extension/entrypoints/sidepanel/App.tsx @@ -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('gpt-image-2'); + /** 生图模型(默认 gpt-image-2-vip,走 RightAPI provider) */ + const [model, setModel] = useState('gpt-image-2-vip'); /** 用户改写的风格提示词(按风格 id 存,切风格不丢) */ const [stylePrompts, setStylePrompts] = useState>({}); /** 生图要求(最高优先级,强制约束,覆盖其他设定) */ @@ -156,6 +157,10 @@ const App: React.FC = () => { // 生成 const [suite, setSuite] = useState(null); const [generating, setGenerating] = useState(false); + // 导出采集图片 + const [exporting, setExporting] = useState(false); + // 导出生成结果 ZIP + const [exportingZip, setExportingZip] = useState(false); const pollRef = useRef | 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} 张。生成需要几分钟,可在下方查看进度。 - {WEAK_FIDELITY_MODELS.has(model) && ( -
- ⚠️ 「{model}」为官逆通道,商品还原度不稳定,可能生成与原商品不符的图片。建议先只出 1 - 张确认效果,正式套图请改用 gpt-image-2。 -
- )} ), 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 = () => { ))} )} + {allImages.some(i => i.type === 'img') && ( +
+ + 下载已勾选图片({allImages.filter(i => i.type === 'img' && selectedKeys.has(i.key)).length} 张) +
+ )} @@ -765,17 +844,35 @@ const App: React.FC = () => { >
{plan.map((p, idx) => ( -
+
togglePlanRow(idx)} + >
{p.title} {p.variant_name && {p.variant_name}} {p.detail && {p.detail}} {p.prompt_hint && 🎯 {p.prompt_hint}}
- setPlanCount(idx, v)} /> + e.stopPropagation()}> + setPlanCount(idx, v)} /> +
))}
+
+ + 取消勾选后全部为 0,点选需要的方案行即可单独出图 +
{planSource === 'ai' && ( ) } diff --git a/extension/entrypoints/sidepanel/index.html b/extension/entrypoints/sidepanel/index.html index 385f302..19d497f 100644 --- a/extension/entrypoints/sidepanel/index.html +++ b/extension/entrypoints/sidepanel/index.html @@ -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 { diff --git a/extension/src/api/client.ts b/extension/src/api/client.ts index 6d1c3e6..923b2a2 100644 --- a/extension/src/api/client.ts +++ b/extension/src/api/client.ts @@ -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 { + 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 { + 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(); +} diff --git a/extension/src/collector/1688-state.ts b/extension/src/collector/1688-state.ts index c3be247..13439b4 100644 --- a/extension/src/collector/1688-state.ts +++ b/extension/src/collector/1688-state.ts @@ -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, }; } diff --git a/extension/src/collector/dom.ts b/extension/src/collector/dom.ts index 1d27357..87aaa1a 100644 --- a/extension/src/collector/dom.ts +++ b/extension/src/collector/dom.ts @@ -55,20 +55,23 @@ export function queryAllDeep(selectors: string[]): Element[] { /** * 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。 - * 有界滚动:步进 + 等待页面高度增长,页面不再变高或达到步数上限即停—— - * 防止底部「为你推荐」无限加载把采集卡死。滚完恢复原位。 + * 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征), + * 等待页面高度增长,页面不再变高或达到步数上限即停——防止底部「为你推荐」无限加载把采集卡死。 + * 滚完恢复原位。 */ export async function autoScrollToBottom( opts: { stepPx?: number; stepMs?: number; maxSteps?: number } = {} ): Promise { - 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) { diff --git a/extension/src/collector/merge.ts b/extension/src/collector/merge.ts index 023cf59..5260960 100644 --- a/extension/src/collector/merge.ts +++ b/extension/src/collector/merge.ts @@ -98,22 +98,3 @@ export function finalize( source, }; } - -/** 经 background 拉取文本(跨域资源:1688 详情 CDN / mtop API) */ -export function bgFetchText(url: string, referer?: string): Promise { - 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)); -} diff --git a/extension/src/collector/platforms/1688.ts b/extension/src/collector/platforms/1688.ts index 0fc37c5..b2df583 100644 --- a/extension/src/collector/platforms/1688.ts +++ b/extension/src/collector/platforms/1688.ts @@ -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 { @@ -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 采集)'); diff --git a/extension/src/collector/platforms/taobao.ts b/extension/src/collector/platforms/taobao.ts index 98dca47..2ab2e0f 100644 --- a/extension/src/collector/platforms/taobao.ts +++ b/extension/src/collector/platforms/taobao.ts @@ -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 { +export async function scanTaobao(profile: SiteProfile, _itemId: string | null): Promise { 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; } diff --git a/extension/src/collector/scan.ts b/extension/src/collector/scan.ts index 9a76405..fc5a286 100644 --- a/extension/src/collector/scan.ts +++ b/extension/src/collector/scan.ts @@ -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'; diff --git a/extension/src/collector/taobao-mtop.ts b/extension/src/collector/taobao-mtop.ts deleted file mode 100644 index 44aff8d..0000000 --- a/extension/src/collector/taobao-mtop.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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)); -} diff --git a/extension/src/collector/taobao-state.ts b/extension/src/collector/taobao-state.ts index 1366118..8d3a758 100644 --- a/extension/src/collector/taobao-state.ts +++ b/extension/src/collector/taobao-state.ts @@ -113,25 +113,3 @@ export function taobaoStateFromBridge(keys: Record): 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, - }; -} diff --git a/extension/src/profiles/taobao.ts b/extension/src/profiles/taobao.ts index f5a7a3d..c850a8b 100644 --- a/extension/src/profiles/taobao.ts +++ b/extension/src/profiles/taobao.ts @@ -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, diff --git a/extension/wxt.config.ts b/extension/wxt.config.ts index 9d92110..3ba4b4e 100644 --- a/extension/wxt.config.ts +++ b/extension/wxt.config.ts @@ -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/*', // 本机后端(上传 / 生成套图用);生产换成你的公网域名 diff --git a/server/api/export.py b/server/api/export.py new file mode 100644 index 0000000..12d9965 --- /dev/null +++ b/server/api/export.py @@ -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"'}, + ) diff --git a/server/config.py b/server/config.py index 0d6f651..5bddc6a 100644 --- a/server/config.py +++ b/server/config.py @@ -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 = "" diff --git a/server/main.py b/server/main.py index 0a83b55..f6c2dda 100644 --- a/server/main.py +++ b/server/main.py @@ -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") diff --git a/server/services/generator.py b/server/services/generator.py index d74ec40..adba373 100644 --- a/server/services/generator.py +++ b/server/services/generator.py @@ -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: