Files

121 lines
4.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""导出采集图片:把采集到的源站图片打包成 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"'},
)