"""导出采集图片:把采集到的源站图片打包成 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"'}, )