@topmindspace/tms-skills 0.1.18 → 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.
- package/CHANGELOG.md +66 -0
- package/README.en.md +6 -3
- package/README.md +6 -3
- package/package.json +5 -2
- package/top-ppt-html/README.en.md +2 -2
- package/top-ppt-html/README.md +1 -1
- package/top-ppt-html/SKILL.md +4 -4
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +24 -12
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +24 -12
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +24 -12
- package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.html +94 -165
- package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.model.json +32 -38
- package/top-ppt-html/assets/pptx-export.js +3 -2
- package/top-ppt-html/assets/style-gallery.html +2 -1
- package/top-ppt-html/assets/templates/architecture.html +38 -15
- package/top-ppt-html/assets/templates/engine.css +20 -9
- package/top-ppt-html/assets/templates/presentation.html +38 -17
- package/top-ppt-html/assets/templates/research.html +44 -23
- package/top-ppt-html/package-lock.json +2 -2
- package/top-ppt-html/package.json +1 -1
- package/top-ppt-html/references/components-atoms.md +1 -1
- package/top-ppt-html/references/content-rules.md +17 -11
- package/top-ppt-html/references/failure-modes.md +6 -4
- package/top-ppt-html/references/high-fidelity.md +14 -1
- package/top-ppt-html/references/icons.md +2 -2
- package/top-ppt-html/references/layout-grammar.md +3 -1
- package/top-ppt-html/references/layouts-research.md +1 -1
- package/top-ppt-html/references/pptx-export.md +2 -2
- package/top-ppt-html/references/tech-design.md +2 -0
- package/top-ppt-html/scripts/build_icon_assets.js +92 -0
- package/top-ppt-html/scripts/build_pptx.js +125 -29
- package/top-ppt-html/scripts/extract_model.py +21 -1
- package/top-ppt-html/scripts/icon_lib.js +53 -0
- package/top-ppt-html/scripts/icon_raster.js +86 -0
- package/top-ppt-html/scripts/layout-constants.json +4 -1
- package/top-ppt-html/scripts/lib_layout_regions.js +3 -1
- package/top-ppt-html/scripts/model-schema.json +7 -3
- package/top-ppt-html/scripts/package_skill.py +1 -0
- package/top-ppt-html/scripts/render_from_model.py +125 -24
- package/top-ppt-html/scripts/scaffold_report.py +47 -24
- package/top-ppt-html/scripts/sync_runtime.py +2 -1
- package/top-ppt-html/scripts/validate_pptx.py +14 -3
- package/top-ppt-html/scripts/validate_report.py +1 -1
- package/topmind-cover/README.md +40 -0
- package/topmind-cover/SKILL.md +66 -0
- package/topmind-cover/package-lock.json +16 -0
- package/topmind-cover/package.json +16 -0
- package/topmind-cover/references/cover-styles.md +40 -0
- package/topmind-cover/scripts/audit_css.py +39 -0
- package/topmind-cover/scripts/audit_docs.py +58 -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 +69 -0
- package/topmind-cover/scripts/negative_tests.py +71 -0
- package/topmind-cover/scripts/package_skill.py +188 -0
- package/topmind-wechat-post/README.md +49 -0
- package/topmind-wechat-post/SKILL.md +208 -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 +162 -0
- package/topmind-wechat-post/references/known-pits.md +86 -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 +106 -0
- package/topmind-wechat-post/references/workflow.md +102 -0
- package/topmind-wechat-post/references/writing-quality.md +129 -0
- package/topmind-wechat-post/scripts/audit_css.py +39 -0
- package/topmind-wechat-post/scripts/audit_docs.py +58 -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 +1602 -0
- package/topmind-wechat-post/scripts/negative_tests.py +65 -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.md +40 -0
- package/topmind-x-article/SKILL.md +62 -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 +24 -0
- package/topmind-x-article/references/x-format.md +24 -0
- package/topmind-x-article/scripts/audit_css.py +39 -0
- package/topmind-x-article/scripts/audit_docs.py +58 -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.py +121 -0
- package/topmind-x-article/scripts/negative_tests.py +74 -0
- package/topmind-x-article/scripts/package_skill.py +188 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 封面风格模板
|
|
2
|
+
|
|
3
|
+
选风格先看题材,再看系列延续性。同一系列固定一个模板,只换主体与标题字。
|
|
4
|
+
|
|
5
|
+
## 科技深空(默认 · AI/技术题材)
|
|
6
|
+
|
|
7
|
+
- 深色底(藏青/墨黑)+ 霓虹线条 + 细颗粒星空
|
|
8
|
+
- 主体:产品/人物半身,面光干净
|
|
9
|
+
- 标题:白色粗黑,字号压住上 1/3
|
|
10
|
+
- 例:Manus 2.0、Cue、AI 智能体题材
|
|
11
|
+
|
|
12
|
+
## 清新浅色(人物/生活/轻题材)
|
|
13
|
+
|
|
14
|
+
- 米白/浅灰底 + 柔光 + 大面积留白
|
|
15
|
+
- 主体居中,神态自然
|
|
16
|
+
- 标题:深灰细黑,落在下方 1/4
|
|
17
|
+
- 例:卡牌系列人物、随笔
|
|
18
|
+
|
|
19
|
+
## 国潮撞色(文化/历史题材)
|
|
20
|
+
|
|
21
|
+
- 朱红/黛青撞色 + 传统纹样暗纹
|
|
22
|
+
- 主体:国风人物或器物
|
|
23
|
+
- 标题:书法感粗体,竖排亦可
|
|
24
|
+
|
|
25
|
+
## 硬核评测(产品评测题材)
|
|
26
|
+
|
|
27
|
+
- 深灰底 + 产品特写 + 数据流装饰
|
|
28
|
+
- 标题:黄/白撞色,配 1 个核心数字(如"32%")
|
|
29
|
+
- 忌:数字必须与正文一致,承重数字先核实
|
|
30
|
+
|
|
31
|
+
## 通用 prompt 组装公式
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
{风格模板一句话},主体:{主体描述},
|
|
35
|
+
画面中央大标题“{≤10字标题}”,{暗底亮字/亮底深字},
|
|
36
|
+
横构图16:9,主体占画面40%以上,四周留白8%,
|
|
37
|
+
极简,不堆砌元素,电影感光影
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
标题字逐字写进 prompt;生成后第一件事就是检查标题字。
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""CSS 审计:本组技能无 CSS 资产;若未来加入 .css 文件,则做括号配平基础检查。"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
11
|
+
except Exception:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
15
|
+
NAME = ROOT.name
|
|
16
|
+
errors: list[str] = []
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
print(f"[audit_css] {NAME}")
|
|
21
|
+
css_files = [p for p in ROOT.rglob("*.css") if ".git" not in p.parts and "dist" not in p.parts]
|
|
22
|
+
if not css_files:
|
|
23
|
+
print(" ✓ 无 CSS 资产,跳过")
|
|
24
|
+
print(f"\n[audit_css] {NAME}: 通过")
|
|
25
|
+
return
|
|
26
|
+
for css in css_files:
|
|
27
|
+
text = css.read_text(encoding="utf-8")
|
|
28
|
+
balanced = text.count("{") == text.count("}")
|
|
29
|
+
print((" ✓ " if balanced else " ✗ ") + f"{css.name} 括号配平")
|
|
30
|
+
if not balanced:
|
|
31
|
+
errors.append(css.name)
|
|
32
|
+
if errors:
|
|
33
|
+
print(f"\n[audit_css] {NAME}: {len(errors)} 项失败")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
print(f"\n[audit_css] {NAME}: 全部通过")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""文档审计:references 完整性与内部链接可达性(任一不过 → 退出码 1)。"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
12
|
+
except Exception:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
NAME = ROOT.name
|
|
17
|
+
errors: list[str] = []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check(cond: bool, msg: str) -> None:
|
|
21
|
+
print((" ✓ " if cond else " ✗ ") + msg)
|
|
22
|
+
if not cond:
|
|
23
|
+
errors.append(msg)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> None:
|
|
27
|
+
print(f"[audit_docs] {NAME}")
|
|
28
|
+
md_files = [p for p in ROOT.rglob("*.md") if ".git" not in p.parts and "dist" not in p.parts]
|
|
29
|
+
check(len(md_files) >= 2, f"md 文档 ≥2(实 {len(md_files)},含 SKILL.md/README.md)")
|
|
30
|
+
|
|
31
|
+
refdir = ROOT / "references"
|
|
32
|
+
if refdir.is_dir():
|
|
33
|
+
refs = sorted(refdir.glob("*.md"))
|
|
34
|
+
check(len(refs) >= 1, f"references/*.md ≥1(实 {len(refs)})")
|
|
35
|
+
for md in refs:
|
|
36
|
+
check(md.stat().st_size > 0, f"references/{md.name} 非空")
|
|
37
|
+
|
|
38
|
+
for md in md_files:
|
|
39
|
+
text = md.read_text(encoding="utf-8")
|
|
40
|
+
rel = md.relative_to(ROOT).as_posix()
|
|
41
|
+
for link in set(re.findall(r"\]\(([^)]+)\)", text)):
|
|
42
|
+
if link.startswith(("http://", "https://", "#", "mailto:")):
|
|
43
|
+
continue
|
|
44
|
+
target = (md.parent / link.split("#")[0]).resolve()
|
|
45
|
+
if link and not target.exists():
|
|
46
|
+
# 允许指向脚本/资源等非 md 文件的相对链接缺失检查放宽到 md
|
|
47
|
+
if target.suffix == ".md":
|
|
48
|
+
check(False, f"{rel} 悬空链接: {link}")
|
|
49
|
+
check(True, "内部 md 链接可达")
|
|
50
|
+
|
|
51
|
+
if errors:
|
|
52
|
+
print(f"\n[audit_docs] {NAME}: {len(errors)} 项失败")
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
print(f"\n[audit_docs] {NAME}: 全部通过")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""技能入口审计:SKILL.md 结构完整性(任一不过 → 退出码 1)。"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
12
|
+
except Exception:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
NAME = ROOT.name
|
|
17
|
+
errors: list[str] = []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check(cond: bool, msg: str) -> None:
|
|
21
|
+
print((" ✓ " if cond else " ✗ ") + msg)
|
|
22
|
+
if not cond:
|
|
23
|
+
errors.append(msg)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> None:
|
|
27
|
+
print(f"[audit_skill] {NAME}")
|
|
28
|
+
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
m = re.search(r"^triggers:\s*\n((?: - .+\n?)+)", text, re.M)
|
|
31
|
+
triggers = re.findall(r"^ - (.+)$", m.group(1), re.M) if m else []
|
|
32
|
+
check(len(triggers) >= 1, f"triggers ≥1(实 {len(triggers)})")
|
|
33
|
+
|
|
34
|
+
check("When NOT to use" in text or "何时不用" in text, "含 When NOT to use / 何时不用")
|
|
35
|
+
|
|
36
|
+
has_workflow = bool(re.search(r"工作流|Workflow|用法|Usage", text))
|
|
37
|
+
check(has_workflow, "含工作流/用法说明")
|
|
38
|
+
|
|
39
|
+
fm = re.match(r"^---\r?\n([\s\S]*?)\r?\n---", text)
|
|
40
|
+
desc = ""
|
|
41
|
+
if fm:
|
|
42
|
+
dm = re.search(r'^description:\s*"([^"]*)"\s*$', fm.group(1), re.M)
|
|
43
|
+
if dm:
|
|
44
|
+
desc = dm.group(1).strip()
|
|
45
|
+
else:
|
|
46
|
+
dm = re.search(r"^description:\s*>\-\s*\n((?:[ \t]+.*\n?)+)", fm.group(1), re.M)
|
|
47
|
+
if dm:
|
|
48
|
+
desc = " ".join(l.strip() for l in dm.group(1).splitlines())
|
|
49
|
+
check("Use when" in desc or "用" in desc, "description 含使用场景")
|
|
50
|
+
check("Do NOT use" in desc or "不用" in desc, "description 含不适用场景")
|
|
51
|
+
check(len(desc) <= 1024, f"description ≤1024(实 {len(desc)})")
|
|
52
|
+
|
|
53
|
+
if errors:
|
|
54
|
+
print(f"\n[audit_skill] {NAME}: {len(errors)} 项失败")
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
print(f"\n[audit_skill] {NAME}: 全部通过")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""样式资产审计:主题/风格资源有效性(任一不过 → 退出码 1)。
|
|
4
|
+
|
|
5
|
+
本组技能无 CSS 样式表:若存在 assets/themes/*.json 则校验 JSON 可解析;
|
|
6
|
+
references 风格文档非空(已由 audit_docs 覆盖,此处做存在性断言)。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
16
|
+
except Exception:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
20
|
+
NAME = ROOT.name
|
|
21
|
+
errors: list[str] = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check(cond: bool, msg: str) -> None:
|
|
25
|
+
print((" ✓ " if cond else " ✗ ") + msg)
|
|
26
|
+
if not cond:
|
|
27
|
+
errors.append(msg)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
print(f"[audit_styles] {NAME}")
|
|
32
|
+
themes = sorted((ROOT / "assets" / "themes").glob("*.json")) if (ROOT / "assets" / "themes").is_dir() else []
|
|
33
|
+
if themes:
|
|
34
|
+
for t in themes:
|
|
35
|
+
try:
|
|
36
|
+
json.loads(t.read_text(encoding="utf-8"))
|
|
37
|
+
check(True, f"assets/themes/{t.name} JSON 可解析")
|
|
38
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
39
|
+
check(False, f"assets/themes/{t.name} 解析失败: {e}")
|
|
40
|
+
else:
|
|
41
|
+
check(True, "无主题 JSON 资产(本技能不依赖主题文件)")
|
|
42
|
+
|
|
43
|
+
refdir = ROOT / "references"
|
|
44
|
+
has_style_doc = refdir.is_dir() and any(refdir.glob("*.md"))
|
|
45
|
+
check(has_style_doc, "references 风格/格式文档存在")
|
|
46
|
+
|
|
47
|
+
if errors:
|
|
48
|
+
print(f"\n[audit_styles] {NAME}: {len(errors)} 项失败")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
print(f"\n[audit_styles] {NAME}: 全部通过")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""封面图裁剪:从 16:9 主图中央裁出公众号 2.35:1 版,并统一落盘命名。
|
|
3
|
+
|
|
4
|
+
用法:
|
|
5
|
+
python3 crop-cover.py <主图路径> --slug <slug> --out-dir <包>/images/
|
|
6
|
+
|
|
7
|
+
产出:
|
|
8
|
+
<out-dir>/00-封面.png 1200x675 (X / 公众号共用主文件)
|
|
9
|
+
<out-dir>/00-封面-公众号.png 900x383 (公众号封面大图,中央裁剪)
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from PIL import Image
|
|
17
|
+
except ImportError:
|
|
18
|
+
sys.exit("需要 Pillow:pip install pillow")
|
|
19
|
+
|
|
20
|
+
TARGETS = {
|
|
21
|
+
"00-封面.png": (1200, 675),
|
|
22
|
+
"00-封面-公众号.png": (900, 383),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> None:
|
|
27
|
+
ap = argparse.ArgumentParser()
|
|
28
|
+
ap.add_argument("src", help="16:9 主图路径")
|
|
29
|
+
ap.add_argument("--slug", default="cover")
|
|
30
|
+
ap.add_argument("--out-dir", required=True)
|
|
31
|
+
args = ap.parse_args()
|
|
32
|
+
|
|
33
|
+
src = Path(args.src)
|
|
34
|
+
out_dir = Path(args.out_dir)
|
|
35
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
if not src.is_file():
|
|
38
|
+
sys.exit(f"错误:找不到输入图片 {src}")
|
|
39
|
+
try:
|
|
40
|
+
img = Image.open(src).convert("RGB")
|
|
41
|
+
except Exception as e:
|
|
42
|
+
sys.exit(f"错误:无法读取图片 {src}({e})")
|
|
43
|
+
w, h = img.size
|
|
44
|
+
|
|
45
|
+
# 先按 16:9 中央裁一块基准
|
|
46
|
+
base_ratio = 16 / 9
|
|
47
|
+
if w / h > base_ratio:
|
|
48
|
+
cw, ch = int(h * base_ratio), h
|
|
49
|
+
else:
|
|
50
|
+
cw, ch = w, int(w / base_ratio)
|
|
51
|
+
x0, y0 = (w - cw) // 2, (h - ch) // 2
|
|
52
|
+
base = img.crop((x0, y0, x0 + cw, y0 + ch))
|
|
53
|
+
|
|
54
|
+
for name, (tw, th) in TARGETS.items():
|
|
55
|
+
ratio = tw / th
|
|
56
|
+
bw, bh = base.size
|
|
57
|
+
if bw / bh > ratio:
|
|
58
|
+
cw2, ch2 = int(bh * ratio), bh
|
|
59
|
+
else:
|
|
60
|
+
cw2, ch2 = bw, int(bw / ratio)
|
|
61
|
+
x1, y1 = (bw - cw2) // 2, (bh - ch2) // 2
|
|
62
|
+
out = base.crop((x1, y1, x1 + cw2, y1 + ch2)).resize((tw, th), Image.LANCZOS)
|
|
63
|
+
dest = out_dir / name
|
|
64
|
+
out.save(dest)
|
|
65
|
+
print(f"OK {dest} ({tw}x{th})")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
main()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""topmind-cover 异常输入测试:crop-cover.py 错误路径干净(任一失败 → 退出码 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
|
+
CROP = ROOT / "scripts" / "crop-cover.py"
|
|
18
|
+
PY = sys.executable
|
|
19
|
+
fails: list[str] = []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(args: list[str]) -> subprocess.CompletedProcess:
|
|
23
|
+
return subprocess.run([PY, str(CROP), *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-cover")
|
|
34
|
+
with tempfile.TemporaryDirectory() as td:
|
|
35
|
+
td = Path(td)
|
|
36
|
+
|
|
37
|
+
# 1. 缺失输入 → 非零退出,无 Traceback
|
|
38
|
+
r = run([str(td / "nope.png"), "--out-dir", str(td / "o1")])
|
|
39
|
+
check("缺失输入干净报错", r.returncode != 0 and "Traceback" not in r.stderr, r.stderr[:100])
|
|
40
|
+
|
|
41
|
+
# 2. 非图片文件 → 非零退出,无 Traceback
|
|
42
|
+
(td / "notimg.txt").write_text("hello", encoding="utf-8")
|
|
43
|
+
r = run([str(td / "notimg.txt"), "--out-dir", str(td / "o2")])
|
|
44
|
+
check("非图片干净报错", r.returncode != 0 and "Traceback" not in r.stderr, r.stderr[:100])
|
|
45
|
+
|
|
46
|
+
# 3. 正常图片产出双尺寸
|
|
47
|
+
try:
|
|
48
|
+
from PIL import Image
|
|
49
|
+
img = Image.new("RGB", (1600, 900), (20, 30, 60))
|
|
50
|
+
img.save(td / "ok.png")
|
|
51
|
+
r = run([str(td / "ok.png"), "--out-dir", str(td / "o3")])
|
|
52
|
+
got_a = (td / "o3" / "00-封面.png").exists()
|
|
53
|
+
got_b = (td / "o3" / "00-封面-公众号.png").exists()
|
|
54
|
+
check("正常图片产出双尺寸", r.returncode == 0 and got_a and got_b, r.stderr[:100])
|
|
55
|
+
if got_a:
|
|
56
|
+
w, h = Image.open(td / "o3" / "00-封面.png").size
|
|
57
|
+
check("主图 1200×675", (w, h) == (1200, 675), f"实 {w}×{h}")
|
|
58
|
+
if got_b:
|
|
59
|
+
w, h = Image.open(td / "o3" / "00-封面-公众号.png").size
|
|
60
|
+
check("公众号图 900×383", (w, h) == (900, 383), f"实 {w}×{h}")
|
|
61
|
+
except ImportError:
|
|
62
|
+
print(" - 跳过图片生成测试(无 Pillow)")
|
|
63
|
+
|
|
64
|
+
if fails:
|
|
65
|
+
print(f"\n[negative_tests] {len(fails)} 项失败")
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
print("\n[negative_tests] 全部通过")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
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,49 @@
|
|
|
1
|
+
# topmind-wechat-post · 公众号创作技能
|
|
2
|
+
|
|
3
|
+
公众号文章全生命周期:交付包、审校改写、质量三关、状态同步、微信内联排版与发布清单。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @topmindspace/tms-skills install topmind-wechat-post
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 用法
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
export TOPMIND_WORKSPACE=/path/to/workspace # 推荐;或用 --base 指定交付包根
|
|
15
|
+
|
|
16
|
+
# 新建交付包
|
|
17
|
+
python3 scripts/new-article.py --slug demo --title "标题" --direction reverse
|
|
18
|
+
|
|
19
|
+
# 排版体检 + 自动修复
|
|
20
|
+
python3 scripts/lint-wechat.py --input <包>/公众号稿.md --fix
|
|
21
|
+
|
|
22
|
+
# 去 AI 味扫描(目标 ≥85)
|
|
23
|
+
python3 scripts/scan_ai_flavor.py <包>/公众号稿.md
|
|
24
|
+
|
|
25
|
+
# Markdown → 全内联 HTML(必加 --embed-images,否则粘贴丢图)
|
|
26
|
+
python3 scripts/md2wechat.py --input <包>/公众号稿.md --out-dir <包> --slug demo --embed-images
|
|
27
|
+
|
|
28
|
+
# 状态同步
|
|
29
|
+
python3 scripts/sync-status.py --set 定稿 <包> --apply
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
脚本纯 Python 标准库,零依赖。
|
|
33
|
+
|
|
34
|
+
## 三条路径
|
|
35
|
+
|
|
36
|
+
- **forward**:底稿 → 公众号(审校改写)
|
|
37
|
+
- **reverse**:选题原创 → 公众号 → 可选回推
|
|
38
|
+
- **站外拉取**:在线精选站 → 按 reverse 处理
|
|
39
|
+
|
|
40
|
+
## 质量三关
|
|
41
|
+
|
|
42
|
+
事实(一手来源)/ 逻辑(结构一致)/ 文字(去 AI 味 ≥85)。
|
|
43
|
+
|
|
44
|
+
## 开发
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python3 scripts/package_skill.py --check
|
|
48
|
+
python3 scripts/negative_tests.py
|
|
49
|
+
```
|