@topmindspace/tms-skills 0.1.19 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.en.md +5 -2
  3. package/README.md +4 -1
  4. package/package.json +5 -2
  5. package/topmind-cover/README.md +40 -0
  6. package/topmind-cover/SKILL.md +66 -0
  7. package/topmind-cover/package-lock.json +16 -0
  8. package/topmind-cover/package.json +16 -0
  9. package/topmind-cover/references/cover-styles.md +40 -0
  10. package/topmind-cover/scripts/audit_css.py +39 -0
  11. package/topmind-cover/scripts/audit_docs.py +58 -0
  12. package/topmind-cover/scripts/audit_skill.py +60 -0
  13. package/topmind-cover/scripts/audit_styles.py +54 -0
  14. package/topmind-cover/scripts/crop-cover.py +69 -0
  15. package/topmind-cover/scripts/negative_tests.py +71 -0
  16. package/topmind-cover/scripts/package_skill.py +188 -0
  17. package/topmind-wechat-post/README.md +49 -0
  18. package/topmind-wechat-post/SKILL.md +208 -0
  19. package/topmind-wechat-post/assets/themes/amber-review.json +35 -0
  20. package/topmind-wechat-post/assets/themes/graphite.json +35 -0
  21. package/topmind-wechat-post/assets/themes/minimal-ink.json +35 -0
  22. package/topmind-wechat-post/assets/themes/newsprint.json +35 -0
  23. package/topmind-wechat-post/assets/themes/tech-blue.json +35 -0
  24. package/topmind-wechat-post/package-lock.json +16 -0
  25. package/topmind-wechat-post/package.json +16 -0
  26. package/topmind-wechat-post/references/design.md +50 -0
  27. package/topmind-wechat-post/references/element-spec.md +162 -0
  28. package/topmind-wechat-post/references/known-pits.md +86 -0
  29. package/topmind-wechat-post/references/theme-map.md +139 -0
  30. package/topmind-wechat-post/references/typography-rules.md +62 -0
  31. package/topmind-wechat-post/references/wechat-constraints.md +106 -0
  32. package/topmind-wechat-post/references/workflow.md +102 -0
  33. package/topmind-wechat-post/references/writing-quality.md +129 -0
  34. package/topmind-wechat-post/scripts/audit_css.py +39 -0
  35. package/topmind-wechat-post/scripts/audit_docs.py +58 -0
  36. package/topmind-wechat-post/scripts/audit_skill.py +60 -0
  37. package/topmind-wechat-post/scripts/audit_styles.py +54 -0
  38. package/topmind-wechat-post/scripts/lint-wechat.py +446 -0
  39. package/topmind-wechat-post/scripts/md2wechat.py +1602 -0
  40. package/topmind-wechat-post/scripts/negative_tests.py +65 -0
  41. package/topmind-wechat-post/scripts/new-article.py +183 -0
  42. package/topmind-wechat-post/scripts/package_skill.py +188 -0
  43. package/topmind-wechat-post/scripts/push-to-topstream.py +226 -0
  44. package/topmind-wechat-post/scripts/scan_ai_flavor.py +464 -0
  45. package/topmind-wechat-post/scripts/sync-mapping.py +220 -0
  46. package/topmind-wechat-post/scripts/sync-status.py +187 -0
  47. package/topmind-x-article/README.md +40 -0
  48. package/topmind-x-article/SKILL.md +62 -0
  49. package/topmind-x-article/package-lock.json +16 -0
  50. package/topmind-x-article/package.json +16 -0
  51. package/topmind-x-article/references/publish-checklist.md +24 -0
  52. package/topmind-x-article/references/x-format.md +24 -0
  53. package/topmind-x-article/scripts/audit_css.py +39 -0
  54. package/topmind-x-article/scripts/audit_docs.py +58 -0
  55. package/topmind-x-article/scripts/audit_skill.py +60 -0
  56. package/topmind-x-article/scripts/audit_styles.py +54 -0
  57. package/topmind-x-article/scripts/md2x.py +121 -0
  58. package/topmind-x-article/scripts/negative_tests.py +74 -0
  59. package/topmind-x-article/scripts/package_skill.py +188 -0
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """topmind-wechat-post 异常输入测试:各脚本错误路径干净(任一失败 → 退出码 1)。"""
4
+ from __future__ import annotations
5
+
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ try:
12
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
13
+ except Exception:
14
+ pass
15
+
16
+ ROOT = Path(__file__).resolve().parent.parent
17
+ SCRIPTS = ROOT / "scripts"
18
+ PY = sys.executable
19
+ fails: list[str] = []
20
+
21
+
22
+ def run(script: str, args: list[str]) -> subprocess.CompletedProcess:
23
+ return subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, timeout=60)
24
+
25
+
26
+ def check(name: str, cond: bool, detail: str = "") -> None:
27
+ print((" ✓ " if cond else " ✗ ") + name + (f" — {detail}" if detail and not cond else ""))
28
+ if not cond:
29
+ fails.append(name)
30
+
31
+
32
+ def main() -> None:
33
+ print("[negative_tests] topmind-wechat-post")
34
+ with tempfile.TemporaryDirectory() as td:
35
+ td = Path(td)
36
+
37
+ # 1. lint 空文件不崩溃
38
+ (td / "empty.md").write_text("", encoding="utf-8")
39
+ r = run("lint-wechat.py", ["--input", str(td / "empty.md")])
40
+ check("lint 空文件不崩溃", r.returncode == 0 and "Traceback" not in r.stderr, r.stderr[:100])
41
+
42
+ # 2. md2wechat 缺失输入 → 非零干净报错
43
+ r = run("md2wechat.py", ["--input", str(td / "nope.md"), "--out-dir", str(td), "--slug", "t"])
44
+ check("md2wechat 缺失输入干净报错", r.returncode != 0 and "Traceback" not in r.stderr, r.stderr[:100])
45
+
46
+ # 3. scan 空文件不崩溃
47
+ r = run("scan_ai_flavor.py", [str(td / "empty.md")])
48
+ check("scan 空文件不崩溃", r.returncode == 0 and "Traceback" not in r.stderr, r.stderr[:100])
49
+
50
+ # 4. new-article --help 正常
51
+ r = run("new-article.py", ["--help"])
52
+ check("new-article --help 正常", r.returncode == 0, r.stderr[:100])
53
+
54
+ # 5. sync-status 缺失包 → 非零干净报错
55
+ r = run("sync-status.py", ["--set", "定稿", str(td / "nope-pkg")])
56
+ check("sync-status 缺失包干净报错", r.returncode != 0 and "Traceback" not in r.stderr, r.stderr[:100])
57
+
58
+ if fails:
59
+ print(f"\n[negative_tests] {len(fails)} 项失败")
60
+ sys.exit(1)
61
+ print("\n[negative_tests] 全部通过")
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """new-article.py — 一键创建公众号文章交付包。
4
+
5
+ 创建 <base>/YYYY-MM-DD-<slug>/ 目录,内含:
6
+ - 公众号稿.md 带 frontmatter 的文章骨架(含写作提示与容器语法示例)
7
+ - images/ 图片目录(可选,从 --images 拷贝)
8
+ - diagrams/ mermaid 图表目录
9
+
10
+ Usage:
11
+ python3 new-article.py --slug gpt-6-review --title "GPT-6 深度评测" \
12
+ [--base <专题目录>] [--date 2026-09-04] [--images src1 src2 ...]
13
+
14
+ 默认 base = <topmind-workspace>/40-创作/2026-公众号
15
+ """
16
+
17
+ import argparse
18
+ import datetime
19
+ import os
20
+ import re
21
+ import shutil
22
+ import sys
23
+
24
+ def _default_base():
25
+ """交付包根:CLI --base → env TOPMIND_WECHAT_BASE → env TOPMIND_WORKSPACE 发现 → 惯例路径。"""
26
+ env = os.environ.get("TOPMIND_WECHAT_BASE") or os.environ.get("TOPMIND_WECHAT_PACKAGE_ROOT")
27
+ if env:
28
+ return os.path.abspath(os.path.expanduser(env))
29
+ ws = os.environ.get("TOPMIND_WORKSPACE")
30
+ if ws:
31
+ for rel in (
32
+ ("40-创作", "2026-公众号"),
33
+ ("20-专题", "2026-公众号"),
34
+ ("88-交付", "2026-公众号"),
35
+ ):
36
+ p = os.path.join(ws, *rel)
37
+ if os.path.isdir(p):
38
+ return p
39
+ return os.path.join(ws, "40-创作", "2026-公众号")
40
+ # 惯例:父工作区(可被 CLI 覆盖)
41
+ return os.path.join(
42
+ os.path.expanduser("~"), "TopWorkSpace", "topmind-workspace",
43
+ "40-创作", "2026-公众号")
44
+
45
+
46
+ def _default_topstream():
47
+ env = os.environ.get("TOPSTREAM_ROOT") or os.environ.get("TOPMIND_TOPSTREAM")
48
+ if env:
49
+ return os.path.abspath(os.path.expanduser(env))
50
+ return os.path.join(os.path.expanduser("~"), "TopWorkSpace", "topstream")
51
+
52
+ DEFAULT_BASE = _default_base()
53
+
54
+ SKELETON = """---
55
+ title: "{title}"
56
+ category: 40-创作
57
+ topic: 2026-公众号
58
+ source_type: {source_type}
59
+ captured_at: {now}
60
+ status: 草稿
61
+ direction: {direction}
62
+ source_file: {source_file}
63
+ target_file: {target_file}
64
+ word_count: 0
65
+ tags: [公众号, 排版]
66
+ note_role: bundle
67
+ ---
68
+
69
+ # {title}
70
+
71
+ <!-- 骨架说明(写作时逐条删除):
72
+ 1. 开头:3 句内给出「反差钩子」——一个具体事实/数字/场景,别写背景铺垫。
73
+ 2. 小标题用 ##(h2),正文段落 ≤ 110 字(约 4 行手机屏)。
74
+ 3. 重点句才加粗,加粗占比 < 20%;关键数据可用 ::: stat 容器。
75
+ 4. 外链会被公众号拦截,直接写 [文字](url),脚本自动转文末脚注。
76
+ 5. 中英文间距不用管,lint --fix 会自动补。
77
+ 6. 可用容器(::: 类型 然后空行,内容,空行,::: 收尾):
78
+ ::: note 补充说明(灰)
79
+ ::: tip 小技巧(绿)
80
+ ::: warn 注意事项(橙)
81
+ ::: danger 常见坑(红)
82
+ ::: pull 金句 / 核心观点(大字号居中)
83
+ ::: dialogue 对话(A: B: 交替缩进)
84
+ ::: stat 数据亮点(大数字)
85
+ [!新特性] 这样的行内徽章 / ==高亮重点==
86
+ -->
87
+
88
+ > 一句话导语:这篇文章解决什么问题、读者能带走什么。
89
+
90
+ ## 为什么值得聊
91
+
92
+ ## 核心内容
93
+
94
+ ### 分论点一
95
+
96
+ ### 分论点二
97
+
98
+ ## 我的看法
99
+
100
+ ::: pull
101
+ 把最想让读者记住的一句话放在这里。
102
+ :::
103
+
104
+ ## 小结
105
+
106
+ ---
107
+
108
+ *本文首发于我的公众号,转载请注明出处。*
109
+ """
110
+
111
+ # 目录名按专题规范:中文短名(可含英文/数字/连字符),如 个人免费基建全景、GrokBot多Agent调度
112
+ SLUG_RE = re.compile(r"^[\u4e00-\u9fffA-Za-z0-9][\u4e00-\u9fffA-Za-z0-9-]*$")
113
+
114
+
115
+ def main():
116
+ ap = argparse.ArgumentParser()
117
+ ap.add_argument("--slug", required=True,
118
+ help="包名关键词(中文短名,可含英文/数字/连字符),如 个人免费基建全景、GrokBot多Agent调度")
119
+ ap.add_argument("--title", required=True, help="文章标题")
120
+ ap.add_argument("--base", default=DEFAULT_BASE)
121
+ ap.add_argument("--date", default=datetime.date.today().isoformat())
122
+ ap.add_argument("--direction", default="reverse", choices=("forward", "reverse"),
123
+ help="forward=源自 topstream 底稿;reverse=本工作区选题,定稿后回推 topstream")
124
+ ap.add_argument("--source-file", default="",
125
+ help='forward 时填 topstream 的 notes/xxx.md')
126
+ ap.add_argument("--images", nargs="*", default=[],
127
+ help="要拷入 images/ 的源图片路径")
128
+ args = ap.parse_args()
129
+
130
+ if not SLUG_RE.match(args.slug):
131
+ print("✗ 目录名只允许中文、英文字母、数字、连字符:%s" % args.slug)
132
+ return 1
133
+
134
+ pkg = os.path.join(args.base, "%s-%s" % (args.date, args.slug))
135
+ if os.path.exists(pkg):
136
+ print("✗ 目录已存在:%s" % pkg)
137
+ return 1
138
+
139
+ os.makedirs(os.path.join(pkg, "images"), exist_ok=True)
140
+ os.makedirs(os.path.join(pkg, "diagrams"), exist_ok=True)
141
+
142
+ draft = os.path.join(pkg, "公众号稿.md")
143
+ if args.direction == "forward" and not args.source_file:
144
+ print("⚠ forward 未给 --source-file,将留空 source_file(回补 notes/xxx.md)")
145
+ src_file = args.source_file or '""'
146
+ tgt_file = '""' if args.direction == "forward" else "pending"
147
+ with open(draft, "w", encoding="utf-8") as fh:
148
+ fh.write(SKELETON.format(
149
+ title=args.title,
150
+ source_type="user-original" if args.direction == "reverse" else "ai-derived",
151
+ direction=args.direction,
152
+ source_file=src_file,
153
+ target_file=tgt_file,
154
+ now=datetime.datetime.now().astimezone().isoformat(timespec="seconds")))
155
+
156
+ copied = 0
157
+ for src in args.images:
158
+ if os.path.exists(src):
159
+ shutil.copy2(src, os.path.join(pkg, "images", os.path.basename(src)))
160
+ copied += 1
161
+ else:
162
+ print("! 跳过不存在的图片:%s" % src)
163
+
164
+ print("✓ 交付包已创建:%s" % pkg)
165
+ print(" 稿件骨架 : 公众号稿.md(含容器语法示例,写作时删掉注释)")
166
+ if copied:
167
+ print(" 图片 : %d 张 → images/" % copied)
168
+ print("")
169
+ print("下一步:")
170
+ print(" 1. 写完稿后体检(可加 --fix 自动补中英文间距):")
171
+ print(" python3 %s/lint-wechat.py --input '%s' --fix" % (
172
+ os.path.dirname(os.path.abspath(__file__)), draft))
173
+ print(" 2. 定稿时同步状态与目录名(加 --apply 生效):")
174
+ print(" python3 %s/sync-status.py --set 定稿 '%s' --apply" % (
175
+ os.path.dirname(os.path.abspath(__file__)), os.path.basename(pkg)))
176
+ print(" 3. 生成公众号 HTML:")
177
+ print(" python3 %s/md2wechat.py --input '%s' --out-dir '%s' --slug %s" % (
178
+ os.path.dirname(os.path.abspath(__file__)), draft, pkg, args.slug))
179
+ return 0
180
+
181
+
182
+ if __name__ == "__main__":
183
+ sys.exit(main())
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """技能分发包打包器(发布前完整性校验 + 发布清单)。
4
+
5
+ 用法:
6
+ python scripts/package_skill.py # 校验 → 打包 → 写发布清单
7
+ python scripts/package_skill.py --check # 只校验不打包(CI / 提交前自检)
8
+
9
+ 产物:
10
+ dist/<skill>.zip 分发包(解压目录名 = 技能名)
11
+ dist/<skill>.manifest.json 发布清单(版本 / 条目 / 大小 / SHA-256)
12
+
13
+ 校验(任一不过 → 退出码 1):
14
+ · SKILL.md frontmatter 含 name / description;name 与目录名一致;
15
+ description ≤ 1024 字符(平台截断阈值)
16
+ · README.md / package.json 存在;package.json name 与技能名一致
17
+ · scripts/*.py 全部可编译;references/*.md 非空
18
+ · SKILL.md 引用的 references/<file> 全部存在
19
+ · 无 scripts/_* / __pycache__ / .env 等不应进包的文件
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import re
26
+ import sys
27
+ import zipfile
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ try:
32
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
33
+ except Exception:
34
+ pass
35
+
36
+ ROOT = Path(__file__).resolve().parent.parent
37
+ NAME = ROOT.name
38
+ OUT = ROOT / "dist" / f"{NAME}.zip"
39
+ MANIFEST = ROOT / "dist" / f"{NAME}.manifest.json"
40
+ DESC_LIMIT = 1024
41
+
42
+ INCLUDE = ["SKILL.md", "README.md", "package.json", "assets/", "references/", "scripts/", "evals/", "agents/"]
43
+ EXCLUDE_RES = [
44
+ re.compile(r"(^|/)scripts/_"),
45
+ re.compile(r"(^|/)\.env($|\.)"),
46
+ re.compile(r"(^|/)__pycache__(/|$)"),
47
+ re.compile(r"(^|/)node_modules(/|$)"),
48
+ re.compile(r"(^|/)dist(/|$)"),
49
+ ]
50
+
51
+ errors: list[str] = []
52
+
53
+
54
+ def fail(msg: str) -> None:
55
+ errors.append(msg)
56
+ print(f" ✗ {msg}")
57
+
58
+
59
+ def ok(msg: str) -> None:
60
+ print(f" ✓ {msg}")
61
+
62
+
63
+ def read_frontmatter() -> dict:
64
+ text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
65
+ m = re.match(r"^---\r?\n([\s\S]*?)\r?\n---", text)
66
+ if not m:
67
+ fail("SKILL.md 缺 frontmatter")
68
+ return {}
69
+ fm = m.group(1)
70
+ name = re.search(r"^name:\s*(\S+)\s*$", fm, re.M)
71
+ desc = ""
72
+ dm = re.search(r'^description:\s*"([^"]*)"\s*$', fm, re.M)
73
+ if dm:
74
+ desc = dm.group(1).strip()
75
+ else:
76
+ dm = re.search(r"^description:\s*>\-\s*\n((?:[ \t]+.*\n?)+)", fm, re.M)
77
+ if dm:
78
+ desc = " ".join(line.strip() for line in dm.group(1).splitlines() if line.strip())
79
+ return {"name": name.group(1) if name else "", "description": desc}
80
+
81
+
82
+ def check() -> bool:
83
+ print(f"[check] {NAME}")
84
+ fm = read_frontmatter()
85
+ if fm.get("name") != NAME:
86
+ fail(f"frontmatter name={fm.get('name')!r} 与目录名 {NAME!r} 不一致")
87
+ else:
88
+ ok("name 与目录名一致")
89
+ desc = fm.get("description", "")
90
+ if not desc:
91
+ fail("description 为空")
92
+ elif len(desc) > DESC_LIMIT:
93
+ fail(f"description {len(desc)} 字符,超过 {DESC_LIMIT}")
94
+ else:
95
+ ok(f"description {len(desc)} 字符")
96
+
97
+ for f in ("README.md", "package.json"):
98
+ if not (ROOT / f).is_file():
99
+ fail(f"缺 {f}")
100
+ else:
101
+ ok(f"有 {f}")
102
+ try:
103
+ pkg = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
104
+ if pkg.get("name") != NAME:
105
+ fail(f"package.json name={pkg.get('name')!r} 与技能名不一致")
106
+ if not re.match(r"^\d+\.\d+\.\d+", str(pkg.get("version", ""))):
107
+ fail("package.json version 非 semver")
108
+ except (OSError, json.JSONDecodeError) as e:
109
+ fail(f"package.json 解析失败: {e}")
110
+
111
+ for p in ROOT.rglob("*"):
112
+ if not p.is_file() or ".git" in p.parts:
113
+ continue
114
+ rel = p.relative_to(ROOT).as_posix()
115
+ for rx in EXCLUDE_RES:
116
+ if rx.search(rel):
117
+ fail(f"不应进包的文件: {rel}")
118
+ break
119
+ ok("无禁用文件")
120
+
121
+ for py in sorted((ROOT / "scripts").glob("*.py")):
122
+ try:
123
+ compile(py.read_text(encoding="utf-8"), str(py), "exec")
124
+ except SyntaxError as e:
125
+ fail(f"{py.name} 编译失败: {e}")
126
+ ok("scripts/*.py 编译通过")
127
+
128
+ refdir = ROOT / "references"
129
+ if refdir.is_dir():
130
+ for md in sorted(refdir.glob("*.md")):
131
+ if md.stat().st_size == 0:
132
+ fail(f"references/{md.name} 为空")
133
+ skill_text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
134
+ for ref in sorted(set(re.findall(r"references/([\w\-.]+\.md)", skill_text))):
135
+ if not (refdir / ref).is_file():
136
+ fail(f"SKILL.md 引用的 references/{ref} 不存在")
137
+ ok("references 完整性通过")
138
+
139
+ if errors:
140
+ print(f"\n[check] {NAME}: {len(errors)} 项失败")
141
+ return False
142
+ print(f"\n[check] {NAME}: 全部通过")
143
+ return True
144
+
145
+
146
+ def package() -> None:
147
+ if not check():
148
+ sys.exit(1)
149
+ (ROOT / "dist").mkdir(exist_ok=True)
150
+ entries: list[str] = []
151
+ with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z:
152
+ for p in sorted(ROOT.rglob("*")):
153
+ if not p.is_file() or ".git" in p.parts:
154
+ continue
155
+ rel = p.relative_to(ROOT)
156
+ rel_posix = rel.as_posix()
157
+ if any(rx.search(rel_posix) for rx in EXCLUDE_RES):
158
+ continue
159
+ if not any(
160
+ rel_posix == inc.rstrip("/") or rel_posix.startswith(inc)
161
+ for inc in INCLUDE
162
+ if inc.endswith("/")
163
+ ) and rel_posix not in INCLUDE:
164
+ continue
165
+ z.write(p, arcname=f"{NAME}/{rel_posix}")
166
+ entries.append(rel_posix)
167
+ sha = hashlib.sha256(OUT.read_bytes()).hexdigest()
168
+ pkg = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
169
+ manifest = {
170
+ "name": NAME,
171
+ "version": pkg.get("version"),
172
+ "built_at": datetime.now(timezone.utc).isoformat(),
173
+ "files": len(entries),
174
+ "sha256": sha,
175
+ "entries": entries,
176
+ }
177
+ MANIFEST.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
178
+ print(f"\n[package] {OUT} ({len(entries)} 文件, sha256={sha[:12]}…)")
179
+
180
+
181
+ def main() -> None:
182
+ if "--check" in sys.argv[1:]:
183
+ sys.exit(0 if check() else 1)
184
+ package()
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """push-to-topstream.py — 反向回推:公众号稿 → topstream/notes 纯 Markdown。
4
+
5
+ 反向链路的最后一公里最容易出错:`::: 容器`、`==高亮==`、`[!徽章]` 等公众号专用语法
6
+ 绝不能写进 notes/(仓库原文保持纯 Markdown)。本脚本做「降级转换」并生成 README 索引条目。
7
+
8
+ 降级规则:
9
+ - 剥离 frontmatter
10
+ - `:::` 围栏行删除,容器内文本原样保留
11
+ - `==高亮==` → 去掉 `==`
12
+ - `[!徽章]` / `[!xxx]` → 整段删除
13
+ - `[文字](url)` 外链保留(GitHub markdown 支持)
14
+ - 中英文盘古之白保留(对 notes 同样适用)
15
+
16
+ Usage:
17
+ python3 push-to-topstream.py <包目录> [--target notes/xxx.md] [--apply]
18
+ # 默认 dry-run:打印降级预览 + 语法统计 + README 索引条目,不写盘
19
+ """
20
+
21
+ import argparse
22
+ import os
23
+ import re
24
+ import shutil
25
+ import sys
26
+
27
+ DEFAULT_BASE = os.path.join(
28
+ os.path.expanduser("~"), "TopWorkSpace", "topmind-workspace",
29
+ "40-创作", "2026-公众号")
30
+ DEFAULT_TOPSTREAM = os.path.join(os.path.expanduser("~"), "TopWorkSpace", "topstream")
31
+
32
+ CONTAINER_RE = re.compile(r"^:::\s*\w*\s*$")
33
+ HIGHLIGHT_RE = re.compile(r"==([^=]+)==")
34
+ BADGE_RE = re.compile(r"\[![^\]]*\]")
35
+
36
+
37
+ def split_fm(text):
38
+ if not text.startswith("---\n"):
39
+ return None, text
40
+ i = text.find("\n---", 3)
41
+ if i < 0:
42
+ return None, text
43
+ return text[4:i], text[i + 4:].lstrip("\n")
44
+
45
+
46
+ def get_field(fm, key, default=""):
47
+ if not fm:
48
+ return default
49
+ m = re.search(r"^%s:\s*(.*)$" % re.escape(key), fm, re.M)
50
+ if not m:
51
+ return default
52
+ v = m.group(1).strip()
53
+ return v.strip('"').strip("'") if v else default
54
+
55
+
56
+ def downgrade(body):
57
+ """公众号稿正文 → notes 纯 Markdown。返回 (文本, 统计)"""
58
+ stats = {"container": 0, "highlight": 0, "badge": 0}
59
+ out = []
60
+ for line in body.split("\n"):
61
+ if CONTAINER_RE.match(line.strip()):
62
+ stats["container"] += 1
63
+ continue
64
+ if BADGE_RE.search(line):
65
+ stats["badge"] += len(BADGE_RE.findall(line))
66
+ line = BADGE_RE.sub("", line).strip()
67
+ if not line:
68
+ continue
69
+ line, n = HIGHLIGHT_RE.subn(r"\1", line)
70
+ stats["highlight"] += n
71
+ out.append(line)
72
+ return "\n".join(out).strip("\n") + "\n", stats
73
+
74
+
75
+ IMG_REF_RE = re.compile(r"(!\[[^\]]*\]\()(images/[^)\s]+)(\))")
76
+
77
+
78
+ def push_assets(body_md, pkg_path, topstream, note_target, name_map, dry=False):
79
+ """把正文引用的本地图片搬进 topstream 并改写为仓库约定路径。
80
+
81
+ `notes/xxx.md` 里写 `images/xx.png` 在 GitHub 上是死链——仓库没有 `notes/images/`。
82
+ 仓库既有约定:图放 `assets/images/<note-slug>/`,正文用 `../assets/images/<slug>/xx.png`。
83
+ 返回 (新正文, 搬移明细, 未找到的源图)。
84
+ """
85
+ slug = os.path.splitext(os.path.basename(note_target))[0]
86
+ dst_dir = os.path.join(topstream, "assets", "images", slug)
87
+ moved, missing = [], []
88
+
89
+ def repl(m):
90
+ src = m.group(2)
91
+ base = os.path.basename(src)
92
+ src_path = os.path.join(pkg_path, src)
93
+ if not os.path.exists(src_path):
94
+ missing.append(src)
95
+ return m.group(0)
96
+ new_name = name_map.get(base, base)
97
+ if not dry: # dry-run 只看映射,不落盘
98
+ os.makedirs(dst_dir, exist_ok=True)
99
+ shutil.copy2(src_path, os.path.join(dst_dir, new_name))
100
+ moved.append((base, new_name))
101
+ return m.group(1) + "../assets/images/%s/%s" % (slug, new_name) + m.group(3)
102
+
103
+ return IMG_REF_RE.sub(repl, body_md), moved, missing
104
+
105
+
106
+ def make_readme_entry(title, target, tags):
107
+ tag_str = " ".join("`%s`" % t for t in tags)
108
+ return (
109
+ "- [**%s**](%s) \n"
110
+ " 描述:<一句话介绍这篇文章解决了什么问题、读者能带走什么> \n"
111
+ " %s\n" % (title, target, tag_str)
112
+ )
113
+
114
+
115
+ def main():
116
+ ap = argparse.ArgumentParser()
117
+ ap.add_argument("pkg", help="包目录名(相对 base)或绝对路径")
118
+ ap.add_argument("--base", default=DEFAULT_BASE)
119
+ ap.add_argument("--topstream", default=DEFAULT_TOPSTREAM)
120
+ ap.add_argument("--target", help="回推目标 notes/xxx.md(缺省读 frontmatter target_file)")
121
+ ap.add_argument("--apply", action="store_true", help="实际写盘(默认只预览)")
122
+ ap.add_argument("--force", action="store_true", help="目标已存在时覆盖")
123
+ ap.add_argument("--assets", action="store_true",
124
+ help="把正文图片搬进 <topstream>/assets/images/<note-slug>/ 并改写引用(GitHub 必需)")
125
+ ap.add_argument("--asset-slug", default="", help="覆盖资源子目录名(默认取 note 文件名)")
126
+ ap.add_argument("--asset-names", default="",
127
+ help='文件名重命名映射,如 "00-封面.jpg=01-cover.jpg,01-分工.png=02-division-of-labor.png"')
128
+ args = ap.parse_args()
129
+
130
+ pkg_path = args.pkg if os.path.isabs(args.pkg) else os.path.join(args.base, args.pkg)
131
+ draft = os.path.join(pkg_path, "公众号稿.md")
132
+ if not os.path.exists(draft):
133
+ print("✗ 找不到公众号稿:%s" % draft)
134
+ return 1
135
+
136
+ text = open(draft, encoding="utf-8").read()
137
+ fm, body = split_fm(text)
138
+ title = get_field(fm, "title") or get_field(fm, "title", "")
139
+ status = get_field(fm, "status", "草稿")
140
+ target_file = get_field(fm, "target_file", "")
141
+ tags = []
142
+ mt = re.search(r"^tags:\s*\[(.*)\]$", fm or "", re.M)
143
+ if mt:
144
+ tags = [t.strip().strip('"').strip("'") for t in mt.group(1).split(",") if t.strip()]
145
+ tags = [t for t in tags if t not in ("公众号", "排版")]
146
+
147
+ target = args.target or (target_file if target_file != "pending" else "")
148
+ if not target:
149
+ print("✗ 需要回推目标:--target notes/xxx.md(frontmatter target_file 仍为 pending)")
150
+ return 1
151
+ if not target.startswith("notes/"):
152
+ print("✗ target 应以 notes/ 开头:%s" % target)
153
+ return 1
154
+
155
+ body_md, stats = downgrade(body)
156
+
157
+ name_map = {}
158
+ for pair in args.asset_names.split(","):
159
+ if "=" in pair:
160
+ k, v = pair.split("=", 1)
161
+ name_map[k.strip()] = v.strip()
162
+
163
+ if args.assets:
164
+ if args.asset_slug:
165
+ target = os.path.join(os.path.dirname(target), args.asset_slug + ".md")
166
+ body_md, moved, missing = push_assets(
167
+ body_md, pkg_path, args.topstream, target, name_map,
168
+ dry=not args.apply)
169
+ else:
170
+ moved, missing = [], []
171
+
172
+ # 确保有 H1 标题开头
173
+ if not body_md.lstrip().startswith("# "):
174
+ body_md = "# %s\n\n%s" % (title, body_md)
175
+
176
+ out_path = os.path.join(args.topstream, target)
177
+
178
+ print("回推预览:%s" % os.path.basename(pkg_path))
179
+ print(" 标题 : %s" % title)
180
+ print(" 状态 : %s" % status)
181
+ print(" 目标 : %s" % target)
182
+ print(" 降级统计 : 容器 %d · 高亮 %d · 徽章 %d"
183
+ % (stats["container"], stats["highlight"], stats["badge"]))
184
+ if status != "定稿":
185
+ print(" ⚠ status=%s,回推前应先定稿(sync-status.py --set 定稿)" % status)
186
+ print("")
187
+ if moved or missing:
188
+ print(" 图片搬运 : %d 张 → assets/images/%s/"
189
+ % (len(moved), os.path.splitext(os.path.basename(target))[0]))
190
+ for zh, en in moved:
191
+ if zh != en:
192
+ print(" %s → %s" % (zh, en))
193
+ non_ascii = [en for _, en in moved if not en.isascii()]
194
+ if non_ascii:
195
+ print(" ⚠ 文件名含非 ASCII 字符:%s" % "、".join(non_ascii))
196
+ print(" 仓库约定用 ASCII(--asset-names 传映射,如 \"00-封面.jpg=01-cover.jpg\")")
197
+ if missing:
198
+ print(" ✗ 源图缺失(引用仍是 images/,会 404):%s" % "、".join(missing))
199
+ elif "images/" in body_md:
200
+ print(" ⚠ 正文引用了本地图但未加 --assets:GitHub 上 images/ 是死链,请补 --assets")
201
+ print("")
202
+ print("—— README 索引条目(复制到 topstream/README.md 的「研析心得」段)——")
203
+ print(make_readme_entry(title, target, tags))
204
+
205
+ if args.apply:
206
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
207
+ if os.path.exists(out_path) and not args.force:
208
+ print("✗ 目标已存在,加 --force 覆盖:%s" % out_path)
209
+ return 1
210
+ with open(out_path, "w", encoding="utf-8") as fh:
211
+ fh.write(body_md)
212
+ print("")
213
+ print("✓ 已写入 %s(%d 字节)" % (out_path, len(body_md.encode("utf-8"))))
214
+ print(" 下一步:把上面的 README 条目插进 topstream/README.md;")
215
+ print(" (图片已按上面明细落 assets/,正文引用已改写为 ../assets/...)")
216
+ print(" 然后回本包改 frontmatter:target_file 从 pending 改为 %s" % target)
217
+ print(" (可用:sync-status.py 之外手动改 target_file 字段,或用编辑器)")
218
+ else:
219
+ print("")
220
+ print("[dry-run] 加 --apply 实际写盘。写盘不会自动改 README 与 frontmatter,")
221
+ print(" 这两步由你/agent 手工完成,保证索引不漂移。")
222
+ return 0
223
+
224
+
225
+ if __name__ == "__main__":
226
+ sys.exit(main())