From d62998abc8593bb460dcd6588ab4dcd0dc1f0e7d Mon Sep 17 00:00:00 2001 From: leether Date: Mon, 8 Jun 2026 10:34:09 +0800 Subject: [PATCH] govern real CTA QR asset --- .github/workflows/ci.yml | 4 + assets/README.md | 23 +++-- core/cta_resource.py | 49 ++++++++++- cta_resources.json | 15 +++- docs/tasks/2026-06-08-govern-real-qr.md | 62 ++++++++++++++ scripts/verify_cta_resources.py | 108 ++++++++++++++++++++++++ 6 files changed, 247 insertions(+), 14 deletions(-) create mode 100644 docs/tasks/2026-06-08-govern-real-qr.md create mode 100644 scripts/verify_cta_resources.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb7360e..4f3a733 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,10 @@ jobs: run: | python scripts/smoke_imports.py + - name: CTA resource governance check + run: | + python scripts/verify_cta_resources.py + - name: JSON validation run: | python -c "import json; json.load(open('harness/video-rules.json'))" diff --git a/assets/README.md b/assets/README.md index 705a3ca..9c9e6d1 100644 --- a/assets/README.md +++ b/assets/README.md @@ -2,7 +2,11 @@ ## CTA 二维码 -`qr.png` — 真实微信群二维码(从 md2wechat 仓库复用) +`qr.png` — 真实微信群二维码(从 `leether/md2wechat:assets/qr.png` 复用) + +当前文件为了兼容 README 和旧调用路径保留历史文件名 `qr.png`,但文件内容是 +Pillow 识别的 `JPEG` 图片,尺寸为 `396×396`。治理时以 +`cta_resources.json` 中的 `media_sha256` 为准,不以扩展名判断真实性。 ### 用途 @@ -12,23 +16,26 @@ 如果二维码过期或需要更换: -1. 替换 `assets/qr.png` 为新图片(保持 300×300 以上分辨率) -2. 更新 `cta_resources.json` 中的 `target_url` 为新的群链接 -3. 重新运行 harness 校验二维码完整性 +1. 从可信来源复制新图片到 `assets/qr.png`(保持 300×300 以上分辨率) +2. 更新 `cta_resources.json` 中的 `checksum`、`media_sha256`、来源路径和图片元数据 +3. 运行 `python scripts/verify_cta_resources.py` +4. 再运行 harness 校验二维码完整性 ### 预注册配置 -已在 `cta_resources.json` 中注册为 `cta_qrcode_main`: +已在 `cta_resources.json` 中注册为 `cta_qrcode_main`。注册表记录公开治理元数据: ```json { "id": "cta_qrcode_main", "resource_type": "qrcode", - "target_url": "wechat://group", + "target_url": "wechat://group", "target_platform": "wechat", "target_account": "md2video交流群", - "media_path": "assets/qr.png" + "media_path": "assets/qr.png", + "source_repo": "leether/md2wechat", + "source_path": "assets/qr.png" } ``` -> **注意**:`target_url` 当前为占位符。如需指向具体链接,请替换后更新 checksum。 +> **隐私边界**:`target_url` 只表达平台语义,不把二维码实际 payload 写入仓库文本。 diff --git a/core/cta_resource.py b/core/cta_resource.py index b671eaf..38f76f9 100644 --- a/core/cta_resource.py +++ b/core/cta_resource.py @@ -40,7 +40,14 @@ class CTAResource: target_account: str # 账号名/ID media_path: str # 资源文件路径 media_type: str = "image" # image | video - checksum: str = "" # URL 的 SHA256 校验和 + checksum: str = "" # target_url 的短 SHA256 校验和 + media_sha256: str = "" # 资源文件内容 SHA256 + source_repo: str = "" # 资源来源仓库,如 leether/md2wechat + source_path: str = "" # 来源仓库内路径 + source_sha256: str = "" # 来源文件内容 SHA256 + media_format: str = "" # Pillow 识别的格式,如 JPEG/PNG + pixel_width: int = 0 # 图片宽度 + pixel_height: int = 0 # 图片高度 generated_at: str = "" # ISO 时间戳 notes: str = "" # 备注 valid: bool = True # 校验结果 @@ -96,6 +103,23 @@ def _compute_checksum(self, url: str) -> str: """计算 URL 校验和""" return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] + def _compute_media_sha256(self, path: Path) -> str: + """计算资源文件内容 SHA256""" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + def _inspect_image(self, path: Path) -> Dict[str, object]: + """读取图片格式和尺寸""" + with Image.open(path) as img: + return { + "media_format": img.format or "", + "pixel_width": img.width, + "pixel_height": img.height, + } + def generate_qrcode( self, target_url: str, @@ -192,6 +216,8 @@ def register_existing_image( target_platform: str, target_account: str, resource_id: str = "cta_qrcode_main", + source_repo: str = "", + source_path: str = "", ) -> CTAResource: """ 注册已有图片作为 CTA 二维码资源(如 assets/qr.png) @@ -202,6 +228,9 @@ def register_existing_image( if not path.exists(): raise FileNotFoundError(f"二维码图片不存在: {image_path}") + image_meta = self._inspect_image(path) + media_sha256 = self._compute_media_sha256(path) + resource = CTAResource( id=resource_id, resource_type="qrcode", @@ -211,6 +240,13 @@ def register_existing_image( media_path=str(path), media_type="image", checksum=self._compute_checksum(target_url), + media_sha256=media_sha256, + source_repo=source_repo, + source_path=source_path, + source_sha256=media_sha256 if source_repo or source_path else "", + media_format=str(image_meta["media_format"]), + pixel_width=int(image_meta["pixel_width"]), + pixel_height=int(image_meta["pixel_height"]), valid=True, notes=f"Registered existing image: {path.name}", ) @@ -239,12 +275,21 @@ def validate_consistency(self, video_topic: str) -> List[str]: """ errors = [] for r in self.resources: - if not r.target_url or not r.target_url.startswith(("http://", "https://")): + if not r.target_url or not r.target_url.startswith(("http://", "https://", "wechat://")): errors.append(f"[{r.id}] URL 格式非法: {r.target_url}") if not r.target_account: errors.append(f"[{r.id}] target_account 为空") if r.checksum != self._compute_checksum(r.target_url): errors.append(f"[{r.id}] checksum 不匹配,URL 可能已被篡改") + media_path = Path(r.media_path) + if media_path.is_absolute(): + errors.append(f"[{r.id}] media_path 必须使用仓库相对路径") + else: + full_media_path = self.registry_path.parent / media_path + if not full_media_path.exists(): + errors.append(f"[{r.id}] media_path 不存在: {r.media_path}") + elif r.media_sha256 and r.media_sha256 != self._compute_media_sha256(full_media_path): + errors.append(f"[{r.id}] media_sha256 不匹配,二维码文件可能已被替换") if not r.valid: errors.append(f"[{r.id}] 二维码可扫描性校验失败") return errors diff --git a/cta_resources.json b/cta_resources.json index e3cc10d..bd4612a 100644 --- a/cta_resources.json +++ b/cta_resources.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.1.0", "resource_count": 1, "resources": [ { @@ -10,9 +10,16 @@ "target_account": "md2video交流群", "media_path": "assets/qr.png", "media_type": "image", - "checksum": "e5c8e0e8f8f8e8e8", - "generated_at": "2026-06-07T06:58:00+08:00", - "notes": "从 md2wechat 仓库复用的真实微信群二维码", + "checksum": "cd3b440d9fe1c42f", + "media_sha256": "e41e839f2f1f83a39b9622b2dc22eaecba76319b4aca7f1d3b74dc2f10868f59", + "source_repo": "leether/md2wechat", + "source_path": "assets/qr.png", + "source_sha256": "e41e839f2f1f83a39b9622b2dc22eaecba76319b4aca7f1d3b74dc2f10868f59", + "media_format": "JPEG", + "pixel_width": 396, + "pixel_height": 396, + "generated_at": "2026-06-08T10:30:00+08:00", + "notes": "真实微信群二维码;2026-06-08 从 leether/md2wechat:assets/qr.png 复核并复制。不在注册表记录二维码 payload。", "valid": true } ] diff --git a/docs/tasks/2026-06-08-govern-real-qr.md b/docs/tasks/2026-06-08-govern-real-qr.md new file mode 100644 index 0000000..b022daa --- /dev/null +++ b/docs/tasks/2026-06-08-govern-real-qr.md @@ -0,0 +1,62 @@ +# Task Card: Govern Real CTA QR Asset + +Status: implemented +Created: 2026-06-08 +Implemented: 2026-06-08 + +## Context + +`md2video` uses a WeChat QR image in README and CTA endcard generation. The +real QR source is the sister repository `leether/md2wechat:assets/qr.png`. + +Before this task, `assets/qr.png` already matched the source QR byte-for-byte, +but `cta_resources.json` still contained a placeholder-style checksum and did +not record the media fingerprint, image metadata, or source provenance. + +## Scope + +- Re-copy and verify the real QR from `leether/md2wechat:assets/qr.png`. +- Keep the existing public path `assets/qr.png` for README and runtime + compatibility. +- Do not store the QR decoded payload in text files. +- Govern the public registry metadata through `cta_resources.json`. +- Add a CI-checkable validation script. + +## Implementation Notes + +- `assets/qr.png` content SHA256: + `e41e839f2f1f83a39b9622b2dc22eaecba76319b4aca7f1d3b74dc2f10868f59` +- Source provenance: + - `source_repo`: `leether/md2wechat` + - `source_path`: `assets/qr.png` + - source file commit observed locally: `20baad2 fix: include QR code image in repo for README display` + - `source_sha256`: same as `media_sha256` +- Actual image metadata: + - Format: `JPEG` + - Dimensions: `396x396` +- Historical filename `qr.png` is retained intentionally; validation uses + `media_sha256` and `media_format`, not the extension. +- `target_url` remains `wechat://group` as a platform semantic marker. The + actual QR payload is not written into repository text for privacy. + +## Validation + +Run: + +```bash +git diff --no-index --quiet ../md2wechat/assets/qr.png assets/qr.png +python scripts/verify_cta_resources.py +python -m py_compile $(git ls-files '*.py') +python scripts/smoke_imports.py +``` + +Expected: + +- `CTA_RESOURCE_OK` +- QR source and local asset have no diff +- Python syntax check passes +- Import and routing smoke check passes + +## CI + +`.github/workflows/ci.yml` now runs `python scripts/verify_cta_resources.py`. diff --git a/scripts/verify_cta_resources.py b/scripts/verify_cta_resources.py new file mode 100644 index 0000000..531f3f7 --- /dev/null +++ b/scripts/verify_cta_resources.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Validate CTA resource registry and local media fingerprints.""" + +import hashlib +import json +import sys +from pathlib import Path + +from PIL import Image, UnidentifiedImageError + + +REPO_ROOT = Path(__file__).resolve().parent.parent +REGISTRY_PATH = REPO_ROOT / "cta_resources.json" +ALLOWED_TARGET_PREFIXES = ("http://", "https://", "wechat://") + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def short_target_checksum(target_url: str) -> str: + return hashlib.sha256(target_url.encode("utf-8")).hexdigest()[:16] + + +def validate_registry() -> list[str]: + errors: list[str] = [] + + with open(REGISTRY_PATH, "r", encoding="utf-8") as f: + registry = json.load(f) + + resources = registry.get("resources", []) + if registry.get("resource_count") != len(resources): + errors.append("resource_count does not match resources length") + + for resource in resources: + rid = resource.get("id", "") + + target_url = resource.get("target_url", "") + if not target_url.startswith(ALLOWED_TARGET_PREFIXES): + errors.append(f"[{rid}] unsupported target_url scheme") + elif resource.get("checksum") != short_target_checksum(target_url): + errors.append(f"[{rid}] target_url checksum mismatch") + + media_path_value = resource.get("media_path", "") + media_path = Path(media_path_value) + if not media_path_value or media_path.is_absolute(): + errors.append(f"[{rid}] media_path must be a repo-relative path") + continue + + full_media_path = (REPO_ROOT / media_path).resolve() + try: + full_media_path.relative_to(REPO_ROOT) + except ValueError: + errors.append(f"[{rid}] media_path escapes the repository") + continue + + if not full_media_path.exists(): + errors.append(f"[{rid}] media file is missing: {media_path_value}") + continue + + media_sha256 = sha256_file(full_media_path) + if resource.get("media_sha256") != media_sha256: + errors.append(f"[{rid}] media_sha256 mismatch") + if resource.get("source_sha256") and resource["source_sha256"] != media_sha256: + errors.append(f"[{rid}] source_sha256 mismatch") + + try: + with Image.open(full_media_path) as img: + media_format = img.format or "" + pixel_width = img.width + pixel_height = img.height + except UnidentifiedImageError: + errors.append(f"[{rid}] media file is not a readable image") + continue + + if resource.get("media_format") != media_format: + errors.append(f"[{rid}] media_format mismatch") + if resource.get("pixel_width") != pixel_width: + errors.append(f"[{rid}] pixel_width mismatch") + if resource.get("pixel_height") != pixel_height: + errors.append(f"[{rid}] pixel_height mismatch") + + if not resource.get("source_repo") or not resource.get("source_path"): + errors.append(f"[{rid}] source_repo/source_path must be recorded") + + if resource.get("valid") is not True: + errors.append(f"[{rid}] valid must be true") + + return errors + + +def main() -> int: + errors = validate_registry() + if errors: + for error in errors: + print(f"CTA_RESOURCE_ERROR {error}", file=sys.stderr) + return 1 + + print("CTA_RESOURCE_OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())