@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,323 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""md → X 长文可粘贴纯文本。
|
|
3
|
+
|
|
4
|
+
把 Markdown 原稿转成可直接粘贴进 X Article 编辑器的纯文本:
|
|
5
|
+
标题/正文保留,排版标记剥离,图片转为 [图N] 占位。
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
python3 md2x.py <输入.md> --out <输出/X发布稿.txt>
|
|
9
|
+
|
|
10
|
+
规则见 references/x-format.md。
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# --- 行内规则 ---------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
# markdown 转义:反斜杠 + ASCII 标点 → 标点本身(先占位保护,避免被 emphasis 误配对)
|
|
20
|
+
_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])")
|
|
21
|
+
_PUA0, _PUA1 = "\ue000", "\ue001"
|
|
22
|
+
|
|
23
|
+
HR_RE = re.compile(r"^(\*(\s*\*){2,}|-(\s*-){2,}|_(\s*_){2,})\s*$")
|
|
24
|
+
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
25
|
+
SETEXT_RE = re.compile(r"^=+\s*$")
|
|
26
|
+
TABLE_SEP_RE = re.compile(r"^:?-+:?$")
|
|
27
|
+
# 引用式定义:[label]: url "可选 title"(行首至多 3 空格)
|
|
28
|
+
REFDEF_RE = re.compile(r"^\s{0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+[\"'(].*[\"')])?\s*$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _protect_escapes(s):
|
|
32
|
+
"""把反斜杠转义换成私有用区占位符,返回 (新串, 还原表)。"""
|
|
33
|
+
store = []
|
|
34
|
+
|
|
35
|
+
def repl(m):
|
|
36
|
+
store.append(m.group(1))
|
|
37
|
+
return f"{_PUA0}{len(store) - 1}{_PUA1}"
|
|
38
|
+
|
|
39
|
+
return _UNESCAPE_RE.sub(repl, s), store
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _restore_escapes(s, store):
|
|
43
|
+
def repl(m):
|
|
44
|
+
return store[int(m.group(1))]
|
|
45
|
+
|
|
46
|
+
return re.sub(f"{_PUA0}(\\d+){_PUA1}", repl, s)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_paren(s, j):
|
|
50
|
+
"""s[j] == '(' 时,找与之匹配的 ')'(括号可嵌套)。
|
|
51
|
+
|
|
52
|
+
返回 (括号内文本, 右括号之后的位置);括号不平衡返回 None。
|
|
53
|
+
"""
|
|
54
|
+
depth = 0
|
|
55
|
+
k, n = j, len(s)
|
|
56
|
+
while k < n:
|
|
57
|
+
c = s[k]
|
|
58
|
+
if c == "(":
|
|
59
|
+
depth += 1
|
|
60
|
+
elif c == ")":
|
|
61
|
+
depth -= 1
|
|
62
|
+
if depth == 0:
|
|
63
|
+
return s[j + 1:k], k + 1
|
|
64
|
+
k += 1
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _split_url_title(inner):
|
|
69
|
+
"""把 `(url "title")` 括号内文本拆出 url(title 丢弃)。"""
|
|
70
|
+
inner = inner.strip()
|
|
71
|
+
m = re.match(r"^(\S+?)(?:\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\)))?$", inner)
|
|
72
|
+
return m.group(1) if m else inner
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _replace_images(s, images, defs):
|
|
76
|
+
""" / ![alt][ref] → [图N](alt 记入 images)。"""
|
|
77
|
+
res = []
|
|
78
|
+
i, n = 0, len(s)
|
|
79
|
+
while i < n:
|
|
80
|
+
if s.startswith("![", i):
|
|
81
|
+
k = s.find("]", i + 2)
|
|
82
|
+
if k == -1 or "]" in s[i + 2:k]:
|
|
83
|
+
res.append(s[i])
|
|
84
|
+
i += 1
|
|
85
|
+
continue
|
|
86
|
+
alt = s[i + 2:k]
|
|
87
|
+
j = k + 1
|
|
88
|
+
if j < n and s[j] == "(":
|
|
89
|
+
parsed = _parse_paren(s, j)
|
|
90
|
+
if not parsed:
|
|
91
|
+
res.append(s[i])
|
|
92
|
+
i += 1
|
|
93
|
+
continue
|
|
94
|
+
j = parsed[1] # url 允许为空;图片占位不依赖 url
|
|
95
|
+
elif j < n and s[j] == "[":
|
|
96
|
+
k2 = s.find("]", j + 1)
|
|
97
|
+
if k2 == -1:
|
|
98
|
+
res.append(s[i])
|
|
99
|
+
i += 1
|
|
100
|
+
continue
|
|
101
|
+
label = s[j + 1:k2] or alt # [alt][] 省略式:label 取 alt
|
|
102
|
+
if label.strip().lower() not in defs:
|
|
103
|
+
res.append(s[i])
|
|
104
|
+
i += 1
|
|
105
|
+
continue
|
|
106
|
+
j = k2 + 1
|
|
107
|
+
else:
|
|
108
|
+
res.append(s[i])
|
|
109
|
+
i += 1
|
|
110
|
+
continue
|
|
111
|
+
images.append(alt.strip())
|
|
112
|
+
res.append(f"[图{len(images)}]")
|
|
113
|
+
i = j
|
|
114
|
+
else:
|
|
115
|
+
res.append(s[i])
|
|
116
|
+
i += 1
|
|
117
|
+
return "".join(res)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _replace_links(s, defs):
|
|
121
|
+
"""[文字](url) / [文字][ref] / [文字][] / [文字] → 文字(url)。"""
|
|
122
|
+
# 1) 行内式:url 括号可嵌套
|
|
123
|
+
res = []
|
|
124
|
+
i, n = 0, len(s)
|
|
125
|
+
while i < n:
|
|
126
|
+
if s[i] == "[" and not (i > 0 and s[i - 1] == "!"):
|
|
127
|
+
k = s.find("](", i + 1)
|
|
128
|
+
if k != -1 and "]" not in s[i + 1:k]:
|
|
129
|
+
parsed = _parse_paren(s, k + 1)
|
|
130
|
+
if parsed:
|
|
131
|
+
url = _split_url_title(parsed[0])
|
|
132
|
+
if url:
|
|
133
|
+
res.append(f"{s[i + 1:k]}({url})")
|
|
134
|
+
i = parsed[1]
|
|
135
|
+
continue
|
|
136
|
+
res.append(s[i])
|
|
137
|
+
i += 1
|
|
138
|
+
s = "".join(res)
|
|
139
|
+
|
|
140
|
+
# 2) 引用式 [文字][label] / [文字][]((?<!\!) 避免误伤残留的坏图片写法)
|
|
141
|
+
def _ref(m):
|
|
142
|
+
label = (m.group(2) or m.group(1)).strip().lower()
|
|
143
|
+
url = defs.get(label)
|
|
144
|
+
return f"{m.group(1)}({url})" if url else m.group(0)
|
|
145
|
+
|
|
146
|
+
s = _INLINE_REF2_RE.sub(_ref, s)
|
|
147
|
+
|
|
148
|
+
# 3) 快捷引用 [文字](仅当 label 有定义时才转,避免误伤普通方括号)
|
|
149
|
+
def _shortcut(m):
|
|
150
|
+
url = defs.get(m.group(1).strip().lower())
|
|
151
|
+
return f"{m.group(1)}({url})" if url else m.group(0)
|
|
152
|
+
|
|
153
|
+
return _INLINE_REF1_RE.sub(_shortcut, s)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# _inline() 的行内模式:预编译在模块级,避免每行重复走 re 缓存查找
|
|
157
|
+
_INLINE_BI_RE = re.compile(r"\*\*\*(.+?)\*\*\*") # 粗斜体
|
|
158
|
+
_INLINE_B_RE = re.compile(r"\*\*(.+?)\*\*") # 加粗
|
|
159
|
+
_INLINE_BU_RE = re.compile(r"__(.+?)__")
|
|
160
|
+
_INLINE_I_RE = re.compile(r"\*(.+?)\*") # 斜体
|
|
161
|
+
_INLINE_CODE_RE = re.compile(r"`(.+?)`") # 行内代码
|
|
162
|
+
_INLINE_DEL_RE = re.compile(r"~~(.+?)~~") # 删除线
|
|
163
|
+
_INLINE_TASK0_RE = re.compile(r"^(\s*)- \[ \]\s+")
|
|
164
|
+
_INLINE_TASK1_RE = re.compile(r"^(\s*)- \[x\]\s+", re.I)
|
|
165
|
+
_INLINE_REF2_RE = re.compile(r"(?<!\!)\[([^\]]+)\]\[([^\]]*)\]")
|
|
166
|
+
_INLINE_REF1_RE = re.compile(r"(?<!\!)\[([^\]]+)\]")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _inline(s, defs):
|
|
170
|
+
"""行内标记剥离(用于正文行、标题文本、表格单元格)。"""
|
|
171
|
+
s = _INLINE_BI_RE.sub(r"\1", s) # 粗斜体
|
|
172
|
+
s = _INLINE_B_RE.sub(r"\1", s) # 加粗
|
|
173
|
+
s = _INLINE_BU_RE.sub(r"\1", s)
|
|
174
|
+
s = _INLINE_I_RE.sub(r"\1", s) # 斜体
|
|
175
|
+
s = _INLINE_CODE_RE.sub(r"\1", s) # 行内代码
|
|
176
|
+
s = _INLINE_DEL_RE.sub(r"\1", s) # 删除线
|
|
177
|
+
s = _replace_links(s, defs) # 链接(行内式/引用式)
|
|
178
|
+
s = _INLINE_TASK0_RE.sub(r"\1- ", s) # 任务列表
|
|
179
|
+
s = _INLINE_TASK1_RE.sub(r"\1- ", s)
|
|
180
|
+
return s
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _strip_frontmatter(md):
|
|
184
|
+
"""去掉文首 frontmatter 块。块内非空行必须都含 ':',否则视为正文(如文首分割线)。"""
|
|
185
|
+
m = re.match(r"^---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(\r?\n|$)", md, flags=re.S)
|
|
186
|
+
if m and all(not ln.strip() or ":" in ln for ln in m.group(1).splitlines()):
|
|
187
|
+
return md[m.end():]
|
|
188
|
+
return md
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def convert(md: str):
|
|
192
|
+
images = []
|
|
193
|
+
defs = {}
|
|
194
|
+
out_lines = []
|
|
195
|
+
in_code = False
|
|
196
|
+
fence_len = 0
|
|
197
|
+
|
|
198
|
+
md, esc_store = _protect_escapes(md)
|
|
199
|
+
md = _strip_frontmatter(md)
|
|
200
|
+
|
|
201
|
+
# 先收集引用式定义([label]: url),定义行不进正文
|
|
202
|
+
body = []
|
|
203
|
+
for raw in md.splitlines():
|
|
204
|
+
m = REFDEF_RE.match(raw)
|
|
205
|
+
if m:
|
|
206
|
+
defs[m.group(1).strip().lower()] = m.group(2)
|
|
207
|
+
else:
|
|
208
|
+
body.append(raw)
|
|
209
|
+
|
|
210
|
+
for raw in body:
|
|
211
|
+
line = raw.rstrip()
|
|
212
|
+
|
|
213
|
+
# 代码块:先处理围栏,块内行原样缩进(分割线规则不进代码块)。
|
|
214
|
+
# 围栏反引号数 >= 3:```` 开栏只能由 >= 4 反引号闭合(CommonMark),
|
|
215
|
+
# 否则 markdown 示例里的内层 ``` 会把外层栏提前"闭合",造成静默错排。
|
|
216
|
+
stripped = line.strip()
|
|
217
|
+
if stripped.startswith("```"):
|
|
218
|
+
k = 0
|
|
219
|
+
while k < len(stripped) and stripped[k] == "`":
|
|
220
|
+
k += 1
|
|
221
|
+
if not in_code:
|
|
222
|
+
in_code = True
|
|
223
|
+
fence_len = k
|
|
224
|
+
elif k >= fence_len and not stripped[k:].strip():
|
|
225
|
+
in_code = False
|
|
226
|
+
fence_len = 0
|
|
227
|
+
else:
|
|
228
|
+
# 栏内误写的短栏 / 带信息串的栏是代码内容,不是闭合栏
|
|
229
|
+
out_lines.append(" " + line)
|
|
230
|
+
continue
|
|
231
|
+
if in_code:
|
|
232
|
+
out_lines.append(" " + line)
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# 图片占位(行内式/引用式)
|
|
236
|
+
line = _replace_images(line, images, defs)
|
|
237
|
+
|
|
238
|
+
# 引用:剥掉所有层级的 >(放标题识别之前,引用内标题也能转)
|
|
239
|
+
line = re.sub(r"^(>\s?)+", "", line)
|
|
240
|
+
|
|
241
|
+
# 标题 → 纯文本行(标题内行文标记同样剥离)
|
|
242
|
+
m = HEADING_RE.match(line)
|
|
243
|
+
if m:
|
|
244
|
+
out_lines.append("")
|
|
245
|
+
out_lines.append(_inline(m.group(2).strip(), defs))
|
|
246
|
+
out_lines.append("")
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Setext 一级标题:上一行是正文则转为标题
|
|
250
|
+
if SETEXT_RE.match(line):
|
|
251
|
+
if out_lines and out_lines[-1].strip():
|
|
252
|
+
text = out_lines.pop()
|
|
253
|
+
out_lines.append("")
|
|
254
|
+
out_lines.append(text)
|
|
255
|
+
out_lines.append("")
|
|
256
|
+
else:
|
|
257
|
+
out_lines.append("")
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# 分割线(* * * / - - - / _ _ _ 允许空格)→ 空行
|
|
261
|
+
if HR_RE.match(line):
|
|
262
|
+
out_lines.append("")
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
# 表格行 → "项:值" 列表(单元格内行文标记同样剥离)
|
|
266
|
+
if "|" in line and line.strip().startswith("|"):
|
|
267
|
+
raw_cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
268
|
+
if all(TABLE_SEP_RE.match(c) for c in raw_cells):
|
|
269
|
+
continue
|
|
270
|
+
cells = [_inline(c, defs) for c in raw_cells]
|
|
271
|
+
label = cells[0]
|
|
272
|
+
rest = ",".join(c for c in cells[1:] if c)
|
|
273
|
+
out_lines.append(f"{label}:{rest}" if rest else label)
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
out_lines.append(_inline(line, defs))
|
|
277
|
+
|
|
278
|
+
# 压缩 3+ 空行为 2 个;去首尾空行(只 strip 换行,保留首行缩进——文档可能以代码块开头)
|
|
279
|
+
text = "\n".join(out_lines)
|
|
280
|
+
text = re.sub(r"\n{3,}", "\n\n", text).lstrip("\n").rstrip() + "\n"
|
|
281
|
+
|
|
282
|
+
# 文末附图注清单
|
|
283
|
+
if images:
|
|
284
|
+
text += "\n—— 配图清单 ——\n"
|
|
285
|
+
for i, alt in enumerate(images, 1):
|
|
286
|
+
text += f"[图{i}] {alt or '(未写图注)'}\n"
|
|
287
|
+
|
|
288
|
+
return _restore_escapes(text, esc_store), images
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main() -> None:
|
|
292
|
+
ap = argparse.ArgumentParser()
|
|
293
|
+
ap.add_argument("input", help="输入 Markdown")
|
|
294
|
+
ap.add_argument("--out", required=True, help="输出 X发布稿.txt")
|
|
295
|
+
args = ap.parse_args()
|
|
296
|
+
|
|
297
|
+
inp = Path(args.input)
|
|
298
|
+
if not inp.is_file():
|
|
299
|
+
if inp.is_dir():
|
|
300
|
+
sys.exit(f"错误:输入是目录不是文件 {inp}")
|
|
301
|
+
sys.exit(f"错误:找不到输入文件 {inp}")
|
|
302
|
+
try:
|
|
303
|
+
md = inp.read_text(encoding="utf-8-sig")
|
|
304
|
+
except UnicodeDecodeError:
|
|
305
|
+
sys.exit(f"错误:输入文件不是有效的 UTF-8 编码 {inp}")
|
|
306
|
+
except OSError as e:
|
|
307
|
+
sys.exit(f"错误:无法读取输入文件 {inp}({e})")
|
|
308
|
+
text, images = convert(md)
|
|
309
|
+
|
|
310
|
+
out = Path(args.out)
|
|
311
|
+
try:
|
|
312
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
out.write_text(text, encoding="utf-8")
|
|
314
|
+
except OSError as e:
|
|
315
|
+
sys.exit(f"错误:无法写入输出文件 {out}({e})")
|
|
316
|
+
|
|
317
|
+
chars = len(text.replace("\n", ""))
|
|
318
|
+
print(f"OK {out}")
|
|
319
|
+
print(f"正文约 {chars} 字,配图 {len(images)} 张")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""topmind-x-article 异常输入测试:md2x.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
|
+
MD2X = ROOT / "scripts" / "md2x.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(MD2X), *args], capture_output=True, text=True, timeout=30)
|
|
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-x-article")
|
|
34
|
+
with tempfile.TemporaryDirectory() as td:
|
|
35
|
+
td = Path(td)
|
|
36
|
+
|
|
37
|
+
# 1. 空文件
|
|
38
|
+
(td / "empty.md").write_text("", encoding="utf-8")
|
|
39
|
+
r = run([str(td / "empty.md"), "--out", str(td / "o1.txt")])
|
|
40
|
+
check("空文件不崩溃", r.returncode == 0 and "Traceback" not in r.stderr)
|
|
41
|
+
|
|
42
|
+
# 2. 只有 frontmatter
|
|
43
|
+
(td / "fm.md").write_text("---\ntitle: t\n---\n", encoding="utf-8")
|
|
44
|
+
r = run([str(td / "fm.md"), "--out", str(td / "o2.txt")])
|
|
45
|
+
check("纯 frontmatter 不崩溃", r.returncode == 0 and "Traceback" not in r.stderr)
|
|
46
|
+
|
|
47
|
+
# 3. 未闭合代码围栏
|
|
48
|
+
(td / "fence.md").write_text("# t\n\n```python\nprint(1)\n", encoding="utf-8")
|
|
49
|
+
r = run([str(td / "fence.md"), "--out", str(td / "o3.txt")])
|
|
50
|
+
check("未闭合围栏不崩溃", r.returncode == 0 and "Traceback" not in r.stderr)
|
|
51
|
+
|
|
52
|
+
# 4. 无分隔行的表格
|
|
53
|
+
(td / "tbl.md").write_text("# t\n\n| a | b |\n| 1 | 2 |\n", encoding="utf-8")
|
|
54
|
+
r = run([str(td / "tbl.md"), "--out", str(td / "o4.txt")])
|
|
55
|
+
check("异常表格不崩溃", r.returncode == 0 and "Traceback" not in r.stderr)
|
|
56
|
+
|
|
57
|
+
# 5. 不存在的输入 → 非零退出且无 Traceback
|
|
58
|
+
r = run([str(td / "nope.md"), "--out", str(td / "o5.txt")])
|
|
59
|
+
check("缺失输入干净报错", r.returncode != 0 and "Traceback" not in r.stderr)
|
|
60
|
+
|
|
61
|
+
# 6. 正常输入产出配图清单
|
|
62
|
+
(td / "ok.md").write_text("# t\n\n\n", encoding="utf-8")
|
|
63
|
+
r = run([str(td / "ok.md"), "--out", str(td / "o6.txt")])
|
|
64
|
+
out = (td / "o6.txt").read_text(encoding="utf-8") if (td / "o6.txt").exists() else ""
|
|
65
|
+
check("正常输入含配图清单", r.returncode == 0 and "[图1]" in out)
|
|
66
|
+
|
|
67
|
+
# 7. 嵌套围栏:```` 开栏不被内层 ``` 提前闭合(内层栏是代码内容)
|
|
68
|
+
(td / "nest.md").write_text(
|
|
69
|
+
"# t\n\n````markdown\n外层\n```python\nprint(1)\n```\n结束\n````\n",
|
|
70
|
+
encoding="utf-8")
|
|
71
|
+
r = run([str(td / "nest.md"), "--out", str(td / "o7.txt")])
|
|
72
|
+
out = (td / "o7.txt").read_text(encoding="utf-8") if (td / "o7.txt").exists() else ""
|
|
73
|
+
check("嵌套围栏不提前闭合",
|
|
74
|
+
r.returncode == 0 and " ```python" in out and "print(1)" in out)
|
|
75
|
+
|
|
76
|
+
# 8. 括号 URL 链接不断裂(与 md2wechat 对齐的守卫)
|
|
77
|
+
(td / "paren.md").write_text(
|
|
78
|
+
"# t\n\n[维基](https://zh.wikipedia.org/wiki/猫_(动物))\n", encoding="utf-8")
|
|
79
|
+
r = run([str(td / "paren.md"), "--out", str(td / "o8.txt")])
|
|
80
|
+
out = (td / "o8.txt").read_text(encoding="utf-8") if (td / "o8.txt").exists() else ""
|
|
81
|
+
check("括号 URL 不截断",
|
|
82
|
+
r.returncode == 0 and "猫_(动物)" in out)
|
|
83
|
+
|
|
84
|
+
if fails:
|
|
85
|
+
print(f"\n[negative_tests] {len(fails)} 项失败")
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
print("\n[negative_tests] 全部通过")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
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()
|