#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 交付压缩包构建脚本(合同对齐)。 产物(位于 --out-dir,默认 .Codex/delivery/): 1) 给甲方/终端使用的交付压缩包 2) 交付清单.md 与 zip 并列的单独文件(不打入包内) zip 内部固定结构: README.md 包根目录唯一一份 README docs/screenshots/ README 引用的全部配图(解压可显示) 截图/界面截图/ 当前产品界面截图(中性文件名) 录屏/ 文档/环境依赖清单.md 文档/部署注意事项.md 用法见 --help。 """ from __future__ import annotations import argparse import os import re import shutil import sys import tempfile import zipfile from pathlib import Path def copy_file(src: Path, dst: Path): dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) def main() -> int: ap = argparse.ArgumentParser(description="构建交付压缩包与并列交付清单") ap.add_argument("--project-dir", required=True) ap.add_argument("--readme", default="README.md") ap.add_argument("--out-dir", default=".Codex/delivery") ap.add_argument("--assets-product", default=".Codex/delivery/assets/product") ap.add_argument("--recordings", default=".Codex/delivery/recordings") ap.add_argument("--dep-doc", default=".Codex/delivery/环境依赖清单.md") ap.add_argument("--notes-doc", default=".Codex/delivery/部署注意事项.md") ap.add_argument("--manifest-src", default=".Codex/delivery/manifest.md") ap.add_argument("--zip-name", default=None, help="默认 smart-parking-delivery-.zip") ap.add_argument("--video-name", default="full-feature-demo.mp4") args = ap.parse_args() root = Path(args.project_dir).resolve() out_dir = root / args.out_dir out_dir.mkdir(parents=True, exist_ok=True) readme_path = root / args.readme if not readme_path.exists(): print("[FAIL] README 不存在:%s" % readme_path) return 1 zip_name = args.zip_name or ("smart-parking-delivery-%s.zip" % _today()) zip_path = out_dir / zip_name with tempfile.TemporaryDirectory() as td: stage = Path(td) # 1) README(包根唯一一份) copy_file(readme_path, stage / "README.md") # 2) README 引用图片 -> docs/...(保持相对路径) readme_text = readme_path.read_text(encoding="utf-8", errors="ignore") img_refs = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", readme_text) copied_img = set() for ref in img_refs: ref = ref.strip() if "://" in ref or ref.startswith("#"): continue src_img = (readme_path.parent / ref).resolve() if not src_img.exists(): print("[FAIL] README 引用图片缺失:%s" % ref) return 1 rel = src_img.relative_to(root) if str(rel) not in copied_img: copy_file(src_img, stage / rel) copied_img.add(str(rel)) # 3) 产品界面截图(中性命名,不体现版本对比或来源宣传) product_assets = root / args.assets_product if product_assets.is_dir(): shutil.copytree(product_assets, stage / "截图" / "界面截图", dirs_exist_ok=True) # 4) 录屏(只取指定 mp4) rec_dir = root / args.recordings video = rec_dir / args.video_name if video.exists(): copy_file(video, stage / "录屏" / args.video_name) # 5) 文档目录:仅依赖清单 + 注意事项(无 README、无交付清单) for d in (args.dep_doc, args.notes_doc): p = root / d if p.exists(): copy_file(p, stage / "文档" / p.name) # 打包 if zip_path.exists(): zip_path.unlink() with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z: for p in sorted(stage.rglob("*")): if p.is_file(): z.write(p, p.relative_to(stage).as_posix()) # 交付清单:复制为与 zip 并列的单独文件 manifest_src = root / args.manifest_src checklist_path = out_dir / "交付清单.md" if manifest_src.exists(): shutil.copy2(manifest_src, checklist_path) # 校验:解压核对 README 图片 bad = _verify_zip(zip_path) if bad: print("[FAIL] 交付包校验未通过:%s" % bad) return 1 print("交付包:%s (%.2f MB)" % (zip_path, zip_path.stat().st_size/1024/1024)) if checklist_path.exists(): print("并列交付清单:%s" % checklist_path) return 0 def _today() -> str: from datetime import datetime return datetime.now().strftime("%Y%m%d") def _verify_zip(zip_path: Path) -> list[str]: problems: list[str] = [] with tempfile.TemporaryDirectory() as td: with zipfile.ZipFile(zip_path) as z: z.extractall(td) v = Path(td) names = [p.relative_to(v).as_posix() for p in v.rglob("*") if p.is_file()] readmes = [n for n in names if n.lower().endswith("readme.md")] if readmes != ["README.md"]: problems.append("README 必须且只能在包根一份,实际:%s" % readmes) if any("交付清单" in n for n in names): problems.append("交付清单不得打入包内") if any(re.search(r"(before|after|改造前|原版)", n, re.I) for n in names): problems.append("包内不得出现对比期/before/after 文件命名") docs = [n for n in names if n.startswith("文档/")] if any(n.lower().endswith("readme.md") for n in docs): problems.append("文档目录不得放 README") if (v / "README.md").exists(): txt = (v / "README.md").read_text(encoding="utf-8", errors="ignore") for ref in re.findall(r"!\[[^\]]*\]\(([^)]+)\)", txt): if "://" in ref or ref.startswith("#"): continue if not (v / ref).exists(): problems.append("README 图片解压后缺失:%s" % ref) forbidden_in_readme = [ "致谢", "原始开源项目", "原项目链接", "原作者", "基于开源", "He Rui", "CrazyHer", "campus-surveillance", ] for term in forbidden_in_readme: if term.lower() in txt.lower(): problems.append("README 出现不应主动展示的来源表述:%s" % term) return problems if __name__ == "__main__": sys.exit(main())