feat: add fission package download

This commit is contained in:
yangqianqian
2026-07-01 14:22:54 +08:00
parent 0894bba54e
commit 8c863efb7d
3 changed files with 227 additions and 4 deletions

View File

@@ -0,0 +1,156 @@
"""
fission_package_exporter.py — 裂变笔记包下载
把 FissionNote 表中的 N 套裂变结果打成 zip
note_01/文案.txt + 生成图 + 发布清单 + 说明。
"""
from __future__ import annotations
import json
import logging
import shutil
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any
from app.core.config import get_settings
logger = logging.getLogger(__name__)
def _safe_name(value: str | None, fallback: str = "img") -> str:
raw = (value or fallback).strip() or fallback
return "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in raw)[:40]
def _resolve_upload_path(url: str, upload_root: Path) -> Path:
if url.startswith("/uploads/"):
return upload_root / url.removeprefix("/uploads/")
if url.startswith("uploads/"):
return upload_root / url.removeprefix("uploads/")
return Path(url)
def _loads_json(value: str | None, fallback: Any) -> Any:
try:
return json.loads(value) if value else fallback
except (TypeError, ValueError):
return fallback
def _write_note_text(note_dir: Path, note: dict[str, Any]) -> None:
tags = note.get("tags") or []
if not isinstance(tags, list):
tags = []
image_plan = note.get("imagePlan") or []
lines = [
f"【标题】{note.get('title', '')}",
"",
"【封面钩子】",
str(note.get("coverTitle") or ""),
"",
"【正文】",
str(note.get("content") or ""),
]
if tags:
lines += ["", "【标签】", " ".join(str(t) for t in tags)]
if image_plan:
lines += ["", "【图片排版说明】"]
for idx, plan in enumerate(image_plan, start=1):
if not isinstance(plan, dict):
continue
parts = [
f"{idx}. {plan.get('role') or f'{idx}'}",
f"标题:{plan.get('title') or ''}",
f"叠字:{plan.get('overlayText') or ''}",
f"画面:{plan.get('text') or ''}",
]
lines.append(" / ".join(parts))
(note_dir / "文案.txt").write_text("\n".join(lines), encoding="utf-8")
def build_fission_package(ft, rows: list) -> str:
"""生成裂变下载 zip返回 zip 本地路径。"""
upload_root = Path(get_settings().UPLOAD_ABS_ROOT).resolve()
package_dir = upload_root / "packages" / "fission" / str(ft.workspace_id) / str(ft.id)
if package_dir.exists():
shutil.rmtree(package_dir)
package_dir.mkdir(parents=True, exist_ok=True)
note_dirs: list[Path] = []
checklist: list[str] = [
"📋 裂变笔记发布清单",
f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
f"裂变ID{ft.id}",
f"参考程度:{ft.reference_level}",
"",
]
missing_images: list[str] = []
for idx, row in enumerate(rows, start=1):
note = _loads_json(row.note_json, {})
images = _loads_json(row.images_json, [])
if not isinstance(note, dict):
note = {}
if not isinstance(images, list):
images = []
note_dir = package_dir / f"note_{idx:02d}"
note_dir.mkdir(parents=True, exist_ok=True)
note_dirs.append(note_dir)
_write_note_text(note_dir, note)
copied = 0
expected = 0
for img_idx, img in enumerate(images, start=1):
if not isinstance(img, dict) or img.get("error"):
continue
url = img.get("url") or img.get("image_url")
if not url:
continue
expected += 1
src = _resolve_upload_path(str(url), upload_root)
if not src.is_file():
missing_images.append(f"note_{idx:02d}: {url}")
continue
role = _safe_name(str(img.get("role") or f"img_{img_idx}"))
seq = img.get("seq") or img_idx
dst = note_dir / f"{int(seq):02d}_{role}.jpg"
shutil.copyfile(src, dst)
copied += 1
if expected and copied == 0:
raise ValueError(f"{idx} 套裂变图片读取失败,拒绝生成无图下载包")
title = str(note.get("title") or f"{idx}")
checklist.append(
f"note_{idx:02d} 标题:{title[:32]} 图片数:{copied} 分数:{row.score}"
)
if not note_dirs:
raise ValueError("裂变结果为空,无法下载")
if missing_images:
checklist += ["", "图片读取提示:"]
checklist += [f"- {item}" for item in missing_images[:20]]
if len(missing_images) > 20:
checklist.append(f"- 另有 {len(missing_images) - 20} 张图片缺失")
checklist += [
"",
"使用说明:",
"- 每个 note_xx 文件夹是一套可独立发布的裂变笔记。",
"- 文案.txt 内含标题、封面钩子、正文、标签和图片排版说明。",
"- 图片按 01/02/03 顺序上传,避免传错序。",
]
(package_dir / "📋裂变发布清单.txt").write_text("\n".join(checklist), encoding="utf-8")
zip_path = package_dir / "fission_package.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for note_dir in note_dirs:
for fpath in sorted(note_dir.iterdir()):
zf.write(fpath, arcname=f"{note_dir.name}/{fpath.name}")
zf.write(package_dir / "📋裂变发布清单.txt", arcname="📋裂变发布清单.txt")
logger.info("fission package built: %s notes=%d", zip_path, len(note_dirs))
return str(zip_path)