@topmindspace/topmind-writing-skills 0.7.1
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.
- package/CHANGELOG.md +931 -0
- package/LICENSE +21 -0
- package/README.en.md +124 -0
- package/README.md +124 -0
- package/bin/topmind-writing-skills.js +388 -0
- package/package.json +56 -0
- package/topmind-briefs/CHANGELOG.md +22 -0
- package/topmind-briefs/README.en.md +41 -0
- package/topmind-briefs/README.md +37 -0
- package/topmind-briefs/SKILL.md +101 -0
- package/topmind-briefs/dist/topmind-briefs.manifest.json +21 -0
- package/topmind-briefs/dist/topmind-briefs.zip +0 -0
- package/topmind-briefs/package-lock.json +13 -0
- package/topmind-briefs/package.json +1 -0
- package/topmind-briefs/references/examples.md +131 -0
- package/topmind-briefs/references/platform-specs.md +63 -0
- package/topmind-briefs/references/style-guide.md +60 -0
- package/topmind-briefs/scripts/audit_css.py +39 -0
- package/topmind-briefs/scripts/audit_docs.py +80 -0
- package/topmind-briefs/scripts/audit_skill.py +60 -0
- package/topmind-briefs/scripts/audit_styles.py +54 -0
- package/topmind-briefs/scripts/negative_tests.py +88 -0
- package/topmind-briefs/scripts/package_skill.py +188 -0
- package/topmind-cover/README.en.md +54 -0
- package/topmind-cover/README.md +54 -0
- package/topmind-cover/SKILL.md +134 -0
- package/topmind-cover/assets/examples/README.md +34 -0
- package/topmind-cover/assets/examples/bg-blur-alt.png +0 -0
- package/topmind-cover/assets/examples/bg-blur-wechat.png +0 -0
- package/topmind-cover/assets/examples/bg-blur.png +0 -0
- package/topmind-cover/assets/examples/big-type-wechat.png +0 -0
- package/topmind-cover/assets/examples/big-type.png +0 -0
- package/topmind-cover/assets/examples/brand-launch-wechat.png +0 -0
- package/topmind-cover/assets/examples/brand-launch.png +0 -0
- package/topmind-cover/assets/examples/gan-huo-wechat.png +0 -0
- package/topmind-cover/assets/examples/gan-huo.png +0 -0
- package/topmind-cover/assets/examples/ip-fun-wechat.png +0 -0
- package/topmind-cover/assets/examples/ip-fun.png +0 -0
- package/topmind-cover/assets/examples/magazine-wechat.png +0 -0
- package/topmind-cover/assets/examples/magazine.png +0 -0
- package/topmind-cover/assets/examples/minimal-wechat.png +0 -0
- package/topmind-cover/assets/examples/minimal.png +0 -0
- package/topmind-cover/assets/examples/news-flash-wechat.png +0 -0
- package/topmind-cover/assets/examples/news-flash.png +0 -0
- package/topmind-cover/assets/examples/overview.png +0 -0
- package/topmind-cover/assets/examples/paper-collage-alt.png +0 -0
- package/topmind-cover/assets/examples/paper-collage-wechat.png +0 -0
- package/topmind-cover/assets/examples/paper-collage.png +0 -0
- package/topmind-cover/assets/examples/tutorial-steps-wechat.png +0 -0
- package/topmind-cover/assets/examples/tutorial-steps.png +0 -0
- package/topmind-cover/assets/examples/white-clean-alt-wechat.png +0 -0
- package/topmind-cover/assets/examples/white-clean-alt.png +0 -0
- package/topmind-cover/assets/examples/white-clean-wechat.png +0 -0
- package/topmind-cover/assets/examples/white-clean.png +0 -0
- package/topmind-cover/package-lock.json +16 -0
- package/topmind-cover/package.json +16 -0
- package/topmind-cover/references/cover-prompts.md +436 -0
- package/topmind-cover/references/cover-styles.md +496 -0
- package/topmind-cover/references/impact-language.md +94 -0
- package/topmind-cover/references/ref-white-clean-1.png +0 -0
- package/topmind-cover/references/ref-white-clean-2.png +0 -0
- package/topmind-cover/scripts/audit_css.py +39 -0
- package/topmind-cover/scripts/audit_docs.py +80 -0
- package/topmind-cover/scripts/audit_skill.py +60 -0
- package/topmind-cover/scripts/audit_styles.py +54 -0
- package/topmind-cover/scripts/crop-cover.py +74 -0
- package/topmind-cover/scripts/negative_tests.py +71 -0
- package/topmind-cover/scripts/package_skill.py +188 -0
- package/topmind-wechat-post/README.en.md +111 -0
- package/topmind-wechat-post/README.md +111 -0
- package/topmind-wechat-post/SKILL.md +251 -0
- package/topmind-wechat-post/assets/themes/amber-review.json +35 -0
- package/topmind-wechat-post/assets/themes/graphite.json +35 -0
- package/topmind-wechat-post/assets/themes/minimal-ink.json +35 -0
- package/topmind-wechat-post/assets/themes/newsprint.json +35 -0
- package/topmind-wechat-post/assets/themes/tech-blue.json +35 -0
- package/topmind-wechat-post/package-lock.json +16 -0
- package/topmind-wechat-post/package.json +16 -0
- package/topmind-wechat-post/references/design.md +50 -0
- package/topmind-wechat-post/references/element-spec.md +163 -0
- package/topmind-wechat-post/references/known-pits.md +85 -0
- package/topmind-wechat-post/references/theme-map.md +139 -0
- package/topmind-wechat-post/references/typography-rules.md +62 -0
- package/topmind-wechat-post/references/wechat-constraints.md +111 -0
- package/topmind-wechat-post/references/workflow.md +102 -0
- package/topmind-wechat-post/references/writing-quality.md +105 -0
- package/topmind-wechat-post/scripts/audit_css.py +39 -0
- package/topmind-wechat-post/scripts/audit_docs.py +80 -0
- package/topmind-wechat-post/scripts/audit_skill.py +60 -0
- package/topmind-wechat-post/scripts/audit_styles.py +54 -0
- package/topmind-wechat-post/scripts/lint-wechat.py +446 -0
- package/topmind-wechat-post/scripts/md2wechat.py +1792 -0
- package/topmind-wechat-post/scripts/negative_tests.py +171 -0
- package/topmind-wechat-post/scripts/new-article.py +183 -0
- package/topmind-wechat-post/scripts/package_skill.py +188 -0
- package/topmind-wechat-post/scripts/push-to-topstream.py +226 -0
- package/topmind-wechat-post/scripts/scan_ai_flavor.py +464 -0
- package/topmind-wechat-post/scripts/sync-mapping.py +220 -0
- package/topmind-wechat-post/scripts/sync-status.py +187 -0
- package/topmind-x-article/README.en.md +109 -0
- package/topmind-x-article/README.md +104 -0
- package/topmind-x-article/SKILL.md +156 -0
- package/topmind-x-article/package-lock.json +16 -0
- package/topmind-x-article/package.json +16 -0
- package/topmind-x-article/references/publish-checklist.md +31 -0
- package/topmind-x-article/references/x-format.md +27 -0
- package/topmind-x-article/references/x-html-format.md +85 -0
- package/topmind-x-article/scripts/audit_css.py +39 -0
- package/topmind-x-article/scripts/audit_docs.py +80 -0
- package/topmind-x-article/scripts/audit_skill.py +60 -0
- package/topmind-x-article/scripts/audit_styles.py +54 -0
- package/topmind-x-article/scripts/md2x-html.py +522 -0
- package/topmind-x-article/scripts/md2x.py +323 -0
- package/topmind-x-article/scripts/negative_tests.py +91 -0
- package/topmind-x-article/scripts/package_skill.py +188 -0
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
# 6. md2wechat 行内图片走图片管线(复制/embed/清单/计数)
|
|
59
|
+
png = ("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
|
|
60
|
+
"+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
|
|
61
|
+
import base64 as _b64
|
|
62
|
+
(td / "a.png").write_bytes(_b64.b64decode(png))
|
|
63
|
+
(td / "inline.md").write_text(
|
|
64
|
+
"# t\n\n段落  后续文字。\n", encoding="utf-8")
|
|
65
|
+
r = run("md2wechat.py", ["--input", str(td / "inline.md"),
|
|
66
|
+
"--out-dir", str(td / "o6"), "--slug", "t6",
|
|
67
|
+
"--embed-images"])
|
|
68
|
+
html = (td / "o6" / "t6-公众号版.html").read_text(encoding="utf-8")
|
|
69
|
+
manifest = (td / "o6" / "图片上传清单.md").read_text(encoding="utf-8")
|
|
70
|
+
check("md2wechat 行内图片被内嵌", r.returncode == 0 and
|
|
71
|
+
"data:image/png;base64," in html, r.stdout[:200])
|
|
72
|
+
check("md2wechat 行内图片进清单", "a.png" in manifest)
|
|
73
|
+
|
|
74
|
+
# 7. md2wechat 大写数字序号同样被剥离
|
|
75
|
+
(td / "num.md").write_text(
|
|
76
|
+
"# t\n\n## 第叁章 大写序号\n\n正文。\n", encoding="utf-8")
|
|
77
|
+
r = run("md2wechat.py", ["--input", str(td / "num.md"),
|
|
78
|
+
"--out-dir", str(td / "o7"), "--slug", "t7"])
|
|
79
|
+
html = (td / "o7" / "t7-公众号版.html").read_text(encoding="utf-8")
|
|
80
|
+
check("md2wechat 大写数字序号剥离",
|
|
81
|
+
"第叁章" not in html and ">01</span>" in html)
|
|
82
|
+
|
|
83
|
+
# 8. md2wechat 分隔线不触发自家合规 WARN(inline-block)
|
|
84
|
+
(td / "hr.md").write_text("# t\n\n---\n\n正文。\n", encoding="utf-8")
|
|
85
|
+
r = run("md2wechat.py", ["--input", str(td / "hr.md"),
|
|
86
|
+
"--out-dir", str(td / "o8"), "--slug", "t8"])
|
|
87
|
+
check("md2wechat 分隔线无 inline-block 告警",
|
|
88
|
+
"inline-block" not in r.stdout, r.stdout[:200])
|
|
89
|
+
|
|
90
|
+
# 9. 括号 URL 不被截断(链接与图片;历史 bug:`.png)` 被截成
|
|
91
|
+
# `img(1` 导致真实文件记成缺失;`[维基](…/猫_(动物))` 的 URL 被截断、
|
|
92
|
+
# 正文多个悬挂 `)`;`[t](url "title")` 被原样保留)
|
|
93
|
+
(td / "img(1).png").write_bytes(_b64.b64decode(png))
|
|
94
|
+
(td / "paren.md").write_text(
|
|
95
|
+
"# t\n\n.png)\n\n"
|
|
96
|
+
"[维基](https://zh.wikipedia.org/wiki/猫_(动物)) 与 "
|
|
97
|
+
"[带title](https://example.com/a \"标题文字\")。\n",
|
|
98
|
+
encoding="utf-8")
|
|
99
|
+
r = run("md2wechat.py", ["--input", str(td / "paren.md"),
|
|
100
|
+
"--out-dir", str(td / "o9"), "--slug", "t9",
|
|
101
|
+
"--no-toc", "--signature", "off"])
|
|
102
|
+
html = (td / "o9" / "t9-公众号版.html").read_text(encoding="utf-8")
|
|
103
|
+
check("md2wechat 括号图片不截断",
|
|
104
|
+
'src="images/img(1).png"' in html and (td / "o9" / "images" / "img(1).png").exists(),
|
|
105
|
+
r.stdout[:200])
|
|
106
|
+
check("md2wechat 括号链接不截断",
|
|
107
|
+
"猫_(动物)" in html and ")</sup>" not in html,
|
|
108
|
+
r.stdout[:200])
|
|
109
|
+
check("md2wechat title 链接被转换",
|
|
110
|
+
"https://example.com/a" in html and '[带title](' not in html,
|
|
111
|
+
r.stdout[:200])
|
|
112
|
+
|
|
113
|
+
# 10. 脚注 URL 的 & 不双重转义(历史 bug:显示成 &)
|
|
114
|
+
(td / "amp.md").write_text(
|
|
115
|
+
"# t\n\n[和号](https://x.com/?a=1&b=2)\n", encoding="utf-8")
|
|
116
|
+
r = run("md2wechat.py", ["--input", str(td / "amp.md"),
|
|
117
|
+
"--out-dir", str(td / "o10"), "--slug", "t10",
|
|
118
|
+
"--no-toc", "--signature", "off"])
|
|
119
|
+
html = (td / "o10" / "t10-公众号版.html").read_text(encoding="utf-8")
|
|
120
|
+
check("md2wechat 脚注 URL 不双重转义",
|
|
121
|
+
"a=1&b=2" in html and "&amp;" not in html)
|
|
122
|
+
|
|
123
|
+
# 11. 坏主题 JSON 干净报错(历史 crash:json Traceback)
|
|
124
|
+
(td / "bad-theme.json").write_text("{bad json", encoding="utf-8")
|
|
125
|
+
(td / "t11.md").write_text("# t\n\n正文。\n", encoding="utf-8")
|
|
126
|
+
r = run("md2wechat.py", ["--input", str(td / "t11.md"),
|
|
127
|
+
"--out-dir", str(td / "o11"), "--slug", "t11",
|
|
128
|
+
"--theme", str(td / "bad-theme.json")])
|
|
129
|
+
check("md2wechat 坏主题 JSON 干净报错",
|
|
130
|
+
r.returncode != 0 and "Traceback" not in r.stderr, r.stderr[:100])
|
|
131
|
+
|
|
132
|
+
# 12. 目录当输入:文案明确(与 md2x 一致),非零干净退出
|
|
133
|
+
r = run("md2wechat.py", ["--input", str(td),
|
|
134
|
+
"--out-dir", str(td / "o12"), "--slug", "t12"])
|
|
135
|
+
check("md2wechat 目录输入文案正确",
|
|
136
|
+
r.returncode != 0 and "输入是目录不是文件" in r.stderr
|
|
137
|
+
and "Traceback" not in r.stderr, r.stderr[:100])
|
|
138
|
+
|
|
139
|
+
# 13. yaml/diff 代码块不高亮 crash(历史 crash:(?m) 拼进 alternation
|
|
140
|
+
# 报 "global flags not at the start",任何 ```yaml 块都 Traceback)
|
|
141
|
+
(td / "hl.md").write_text(
|
|
142
|
+
"# t\n\n```yaml\nkey: value\n```\n\n```diff\n+add\n-del\n```\n",
|
|
143
|
+
encoding="utf-8")
|
|
144
|
+
r = run("md2wechat.py", ["--input", str(td / "hl.md"),
|
|
145
|
+
"--out-dir", str(td / "o13"), "--slug", "t13"])
|
|
146
|
+
html = (td / "o13" / "t13-公众号版.html").read_text(encoding="utf-8")
|
|
147
|
+
check("md2wechat yaml/diff 代码块不崩溃",
|
|
148
|
+
r.returncode == 0 and "Traceback" not in r.stderr
|
|
149
|
+
and "key" in html, r.stderr[:100])
|
|
150
|
+
|
|
151
|
+
# 14. 嵌套围栏:```` 开栏不被内层 ``` 提前闭合
|
|
152
|
+
(td / "nest.md").write_text(
|
|
153
|
+
"# t\n\n````markdown\n外层\n```python\nprint(1)\n```\n结束\n````\n",
|
|
154
|
+
encoding="utf-8")
|
|
155
|
+
r = run("md2wechat.py", ["--input", str(td / "nest.md"),
|
|
156
|
+
"--out-dir", str(td / "o14"), "--slug", "t14",
|
|
157
|
+
"--no-toc", "--signature", "off"])
|
|
158
|
+
html = (td / "o14" / "t14-公众号版.html").read_text(encoding="utf-8")
|
|
159
|
+
body = html.split('<article id="wx-body"')[1]
|
|
160
|
+
check("md2wechat 嵌套围栏不提前闭合",
|
|
161
|
+
r.returncode == 0 and "````markdown" not in body
|
|
162
|
+
and body.count("MARKDOWN</section>") == 1, r.stderr[:100])
|
|
163
|
+
|
|
164
|
+
if fails:
|
|
165
|
+
print(f"\n[negative_tests] {len(fails)} 项失败")
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
print("\n[negative_tests] 全部通过")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
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()
|