Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'))"
Expand Down
23 changes: 15 additions & 8 deletions assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 为准,不以扩展名判断真实性。

### 用途

Expand All @@ -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 写入仓库文本
49 changes: 47 additions & 2 deletions core/cta_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 校验结果
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -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}",
)
Expand Down Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions cta_resources.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "1.0.0",
"version": "1.1.0",
"resource_count": 1,
"resources": [
{
Expand All @@ -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
}
]
Expand Down
62 changes: 62 additions & 0 deletions docs/tasks/2026-06-08-govern-real-qr.md
Original file line number Diff line number Diff line change
@@ -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`.
108 changes: 108 additions & 0 deletions scripts/verify_cta_resources.py
Original file line number Diff line number Diff line change
@@ -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", "<missing-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())
Loading