#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ gen_changelog.py — 生成 PocketBase 风格的 changelogs JSON(无需 PocketBase 服务) 用途: 你在本地维护一个 Markdown 源文件(按 `## <时间戳>` 分段写 changelog), 本脚本把它导出成前端 change-logs.html 所 fetch 的 tamabox.changelogs.json。 输出格式完全兼容 PocketBase list API 的返回结构,但无需部署任何后端服务, 因此不会在你的 1C1G 服务器上多占哪怕 1 MB 常驻内存。 输出位置:static/tamabox.changelogs.json —— 该文件被 static 包以 //go:embed 打入 二进制,由 flamego.Static 在 /static 路径同源提供,前端直接 fetch 同源路径, 不再依赖任何外部域名(去掉 api-direct.337845818.best 等外链,降低可用性/安全风险)。 源文件格式(dev/changelog-source.md 示例): ## 2024-03-04 21:45:55 新增:用户私信功能上线。 修复:移动端问题列表间距过窄。 ## 2024-02-29 00:00:55 优化:首页加载速度提升约 30%。 注意: - 时间戳支持 `YYYY-MM-DD HH:MM:SS` / `YYYY-MM-DD` / 带毫秒的 `...fffZ` / ISO 格式, 脚本会统一归一化为 PocketBase 风格的 `YYYY-MM-DD HH:MM:SS.fffZ`(UTC)。 - 正文里的换行默认会被转成 `
`,因为前端用 x-html 渲染;传 --no-br 可关闭。 - 导出时按时间倒序排列,最新一条排在最前(前端读 items[0] 即取最新)。 用法: python dev/gen_changelog.py python dev/gen_changelog.py 源文件.md 输出.json python dev/gen_changelog.py 源文件.md 输出.json --no-br """ import json import re import sys from datetime import datetime, timezone COLLECTION_ID = "tamabox" COLLECTION_NAME = "tamabox-changelogs" DEFAULT_SRC = "dev/changelog-source.md" DEFAULT_OUT = "static/tamabox.changelogs.json" TS_LINE = re.compile(r"^##\s+(.+?)\s*$") FULL_TS = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}Z$") def normalize_ts(raw: str) -> str: """把用户输入的时间归一化为 PocketBase 风格时间戳:YYYY-MM-DD HH:MM:SS.fffZ(UTC)。""" raw = raw.strip() if FULL_TS.match(raw): return raw for fmt in ("%Y-%m-%d %H:%M:%S.%fZ", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): try: dt = datetime.strptime(raw, fmt) return dt.strftime("%Y-%m-%d %H:%M:%S") + ".000Z" except ValueError: continue try: dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + ".000Z" except ValueError: sys.exit(f"[错误] 无法解析时间戳: {raw!r}") def parse_source(text: str): """按 `## <时间戳>` 分段,返回 [(ts, content), ...],顺序与源文件一致。""" entries, cur_ts, buf = [], None, [] for line in text.splitlines(): m = TS_LINE.match(line) if m: if cur_ts is not None: entries.append((cur_ts, "\n".join(buf).strip())) cur_ts, buf = m.group(1).strip(), [] elif cur_ts is not None: buf.append(line) if cur_ts is not None: entries.append((cur_ts, "\n".join(buf).strip())) if not entries: sys.exit("[错误] 源文件未找到任何 '## <时间戳>' 分段") return entries def build(entries, br=True): """把条目导出为 PocketBase list API 风格 JSON(最新在前)。""" parsed = [(normalize_ts(ts), c) for ts, c in entries] parsed.sort(key=lambda x: x[0], reverse=True) # 倒序:最新 published_at 在前 items = [] for ts, content in parsed: items.append({ "collectionId": COLLECTION_ID, "collectionName": COLLECTION_NAME, "content": content.replace("\n", "
\n") if br else content, "created": ts, "id": "none", "published_at": ts, "updated": ts, }) return { "page": 1, "perPage": len(items) or 1, "totalItems": len(items), "totalPages": 1, "items": items, } def main(): args = sys.argv[1:] br = "--no-br" not in args paths = [a for a in args if not a.startswith("--")] src = paths[0] if len(paths) > 0 else DEFAULT_SRC out = paths[1] if len(paths) > 1 else DEFAULT_OUT try: with open(src, encoding="utf-8") as f: text = f.read() except FileNotFoundError: sys.exit(f"[错误] 找不到源文件: {src}") data = build(parse_source(text), br=br) with open(out, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) latest = data["items"][0]["published_at"] if data["items"] else "N/A" print(f"[完成] 已生成 {out}({data['totalItems']} 条记录,最新发布于 {latest})") if __name__ == "__main__": main()