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
+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_model: str = "wan2.7-image-pro"
# RightAPIOpenAI 兼容中转,gpt-image 系列)
# RightAPIOpenAI 兼容中转,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
View File
@@ -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")
+83 -51
View File
@@ -234,71 +234,103 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
return dl.content
# ── ProviderRightAPIgpt-imageOpenAI 兼容中转)──────────────────────
# ── ProviderRightAPIgpt-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/editsmultipart),无参考图走 /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 里拆出 originhttps://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: