@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,464 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ scan_ai_flavor.py — 中文 AI 味检测脚本
4
+
5
+ 用法:
6
+ python3 scan_ai_flavor.py <file> # 输出 Markdown 报告
7
+ python3 scan_ai_flavor.py --json <file> # 输出 JSON
8
+ python3 scan_ai_flavor.py -o report.md <file>
9
+ cat article.md | python3 scan_ai_flavor.py - # 管道
10
+
11
+ 设计目标:纯 stdlib,无第三方依赖,能在普通 Mac / Linux 上直接跑。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import re
18
+ import sys
19
+ from collections import Counter
20
+ from dataclasses import dataclass, field, asdict
21
+ from pathlib import Path
22
+ from typing import Iterable
23
+
24
+
25
+ # --------------------------------------------------------------------------- #
26
+ # 模式定义:每条 (类别代码, 类别名, 正则, 单次扣分, 严重度, 开头末尾加权, 备注)
27
+ # --------------------------------------------------------------------------- #
28
+
29
+
30
+ @dataclass
31
+ class Pattern:
32
+ code: str # e.g. "A1"
33
+ name: str # e.g. "开头套话"
34
+ regex: str # 正则字符串
35
+ penalty: int # 单次扣分(绝对值)
36
+ severity: str # high / mid / low
37
+ weighted: str = "" # "head" | "tail" | ""(加权区段)
38
+ note: str = ""
39
+
40
+ def compiled(self) -> re.Pattern:
41
+ return re.compile(self.regex)
42
+
43
+
44
+ # 头部加权的模式(命中位置 ≤ 200 字 权重 ×1.5)
45
+ HEAD_PATTERNS: list[Pattern] = [
46
+ Pattern("A1", "开头套话", r"在当今(社会|时代)", 8, "high", "head"),
47
+ Pattern("A1", "开头套话", r"随着[^,。]{2,30}的(发展|日益|不断)", 8, "high", "head"),
48
+ Pattern("A1", "开头套话", r"众所周知[,,]", 7, "high", "head"),
49
+ Pattern("A1", "开头套话", r"毋庸置疑[,,]|不可否认[,,]", 7, "high", "head"),
50
+ Pattern("A1", "开头套话", r"近年来[^,。]{0,15}日益", 6, "high", "head"),
51
+ Pattern("A1", "开头套话", r"伴随着[^,。]{2,30}的(快速|迅猛)", 8, "high", "head"),
52
+ Pattern("A1", "开头套话", r"在[^,。]{2,20}的背景下", 5, "mid", "head"),
53
+ Pattern("A1", "开头套话", r"一直以来[^,。]{2,20}都", 4, "low", "head"),
54
+ Pattern("A10", "模板口水", r"大家好[,,]", 6, "high", "head"),
55
+ Pattern("A10", "模板口水", r"本文将从[^,。]{0,30}个方面", 7, "high", "head"),
56
+ Pattern("A10", "模板口水", r"希望本文能够", 5, "mid", "head"),
57
+ Pattern("A10", "模板口水", r"如(有不当|有疏漏|有错误)[,,]", 4, "low", "head"),
58
+ ]
59
+
60
+ # 全文模式
61
+ BODY_PATTERNS: list[Pattern] = [
62
+ # A2 转折连接
63
+ Pattern("A2", "转折连接", r"此外[,,]", 2, "low"),
64
+ Pattern("A2", "转折连接", r"与此同时[,,]", 2, "low"),
65
+ Pattern("A2", "转折连接", r"不仅如此[,,]", 2, "low"),
66
+ Pattern("A2", "转折连接", r"综上所述[,,]", 4, "mid"),
67
+ Pattern("A2", "转折连接", r"总而言之[,,]", 4, "mid"),
68
+ Pattern("A2", "转折连接", r"一方面[^,。]{1,15}[,,另一方面]", 3, "low"),
69
+ Pattern("A2", "转折连接", r"既[^,。]{1,12}又[^,。]{1,12}还", 3, "low"),
70
+ Pattern("A2", "转折连接", r"无可厚非", 4, "mid"),
71
+ Pattern("A2", "转折连接", r"瑕不掩瑜", 4, "mid"),
72
+
73
+ # A3 形容词/官腔
74
+ Pattern("A3", "官腔形容词", r"至关重要", 5, "high"),
75
+ Pattern("A3", "官腔形容词", r"举足轻重", 5, "high"),
76
+ Pattern("A3", "官腔形容词", r"不可或缺", 4, "mid"),
77
+ Pattern("A3", "官腔形容词", r"深入(探讨|分析|研究)", 5, "high"),
78
+ Pattern("A3", "官腔形容词", r"高度重视", 5, "high"),
79
+ Pattern("A3", "官腔形容词", r"深入推进", 5, "high"),
80
+ Pattern("A3", "官腔形容词", r"扎实开展", 5, "high"),
81
+ Pattern("A3", "官腔形容词", r"积极探索", 4, "mid"),
82
+ Pattern("A3", "官腔形容词", r"有效落实", 4, "mid"),
83
+ Pattern("A3", "官腔形容词", r"持续优化", 4, "mid"),
84
+ Pattern("A3", "官腔形容词", r"凝聚共识", 5, "high"),
85
+ Pattern("A3", "官腔形容词", r"汇聚合力", 5, "high"),
86
+ Pattern("A3", "官腔形容词", r"协同推进", 4, "mid"),
87
+ Pattern("A3", "官腔形容词", r"全方位(提升|推进|优化)", 4, "mid"),
88
+ Pattern("A3", "官腔形容词", r"多角度(分析|审视)", 4, "mid"),
89
+ Pattern("A3", "官腔形容词", r"立体化", 3, "low"),
90
+ # 注:数字化/智能化 是领域术语,不作为 AI 味标志,跳过
91
+
92
+ # A5 排比/三段式
93
+ Pattern("A5", "排比三段式", r"这不仅[^,。]{2,20}[,,][^,。]{2,15}也[^,。]{2,15}[,,更]?", 6, "high"),
94
+ Pattern("A5", "排比三段式", r"从[^,。]{2,15}到[^,。]{2,15}[,,从][^,。]{2,15}到", 4, "mid"),
95
+
96
+ # A6 营销体
97
+ Pattern("A6", "营销黑话", r"赋能", 5, "high"),
98
+ Pattern("A6", "营销黑话", r"加持", 5, "high"),
99
+ Pattern("A6", "营销黑话", r"引爆", 5, "high"),
100
+ Pattern("A6", "营销黑话", r"破圈", 5, "high"),
101
+ Pattern("A6", "营销黑话", r"一站式", 4, "mid"),
102
+ Pattern("A6", "营销黑话", r"全链路", 4, "mid"),
103
+ Pattern("A6", "营销黑话", r"闭环", 4, "mid"),
104
+ Pattern("A6", "营销黑话", r"抓手", 5, "high"),
105
+ Pattern("A6", "营销黑话", r"底层逻辑", 5, "high"),
106
+ Pattern("A6", "营销黑话", r"顶层设计", 5, "high"),
107
+ Pattern("A6", "营销黑话", r"(打造|构建|重塑)[^,。]{2,15}(方案|生态|体系|平台)", 3, "low"),
108
+ Pattern("A6", "营销黑话", r"拉通|对齐|复盘", 3, "low"),
109
+ # 工程圈行话:用户明确点过——「踩坑」这类词读起来像圈内口令,不像人在说话
110
+ Pattern("A6", "工程圈行话", r"踩坑|避坑|干货|踩出来的坑|踩过的坑", 3, "low"),
111
+
112
+ # A7 模糊归因
113
+ Pattern("A7", "模糊归因", r"专家指出", 5, "high"),
114
+ Pattern("A7", "模糊归因", r"业内人士表示", 5, "high"),
115
+ Pattern("A7", "模糊归因", r"据相关(研究|调查|数据)表明", 5, "high"),
116
+ Pattern("A7", "模糊归因", r"大量数据表明", 5, "high"),
117
+ Pattern("A7", "模糊归因", r"有学者认为", 4, "mid"),
118
+ Pattern("A7", "模糊归因", r"专家(普遍|一致)认为", 4, "mid"),
119
+ Pattern("A7", "模糊归因", r"大家普遍认为", 3, "low"),
120
+
121
+ # A8 伪深度
122
+ Pattern("A8", "伪深度", r"(是|为)[^,。]{2,15}的(体现|证明|象征)", 5, "high"),
123
+ Pattern("A8", "伪深度", r"彰显了[^,。]{2,20}(意义|价值|精神)", 5, "high"),
124
+ Pattern("A8", "伪深度", r"凸显了[^,。]{2,20}(意义|价值)", 5, "high"),
125
+ Pattern("A8", "伪深度", r"反映了更深(层次)的", 4, "mid"),
126
+
127
+ # A9 假大空成语
128
+ Pattern("A9", "假大空成语", r"博大精深", 4, "mid"),
129
+ Pattern("A9", "假大空成语", r"源远流长", 4, "mid"),
130
+ Pattern("A9", "假大空成语", r"历久弥新", 4, "mid"),
131
+ Pattern("A9", "假大空成语", r"与时俱进", 3, "low"),
132
+ Pattern("A9", "假大空成语", r"开拓创新", 3, "low"),
133
+ Pattern("A9", "假大空成语", r"锐意进取", 3, "low"),
134
+ Pattern("A9", "假大空成语", r"举世(瞩目|无双)", 4, "mid"),
135
+
136
+ # A10 模板口水
137
+ Pattern("A10", "模板口水", r"感谢(您的)?(观看|阅读|支持)", 5, "high"),
138
+ Pattern("A10", "模板口水", r"以上就是[^,。]{2,30}(全部)?内容", 5, "high"),
139
+ Pattern("A10", "模板口水", r"欢迎(指正|交流|留言)", 4, "mid"),
140
+ ]
141
+
142
+ # 尾部加权
143
+ TAIL_PATTERNS: list[Pattern] = [
144
+ Pattern("A4", "结尾升华", r"让我们(共同|一起)", 8, "high", "tail"),
145
+ Pattern("A4", "结尾升华", r"未来可期", 6, "high", "tail"),
146
+ Pattern("A4", "结尾升华", r"任重道远", 6, "high", "tail"),
147
+ Pattern("A4", "结尾升华", r"砥砺前行|扬帆远航", 5, "high", "tail"),
148
+ Pattern("A4", "结尾升华", r"在[^,。]{2,20}的道路上(不断)?前行", 6, "high", "tail"),
149
+ Pattern("A4", "结尾升华", r"为[^,。]{2,15}贡献(自己)?的?力量", 6, "high", "tail"),
150
+ Pattern("A4", "结尾升华", r"谱写[^,。]{2,15}新篇章", 6, "high", "tail"),
151
+ Pattern("A4", "结尾升华", r"展望未来", 4, "mid", "tail"),
152
+ ]
153
+
154
+ ALL_PATTERNS = HEAD_PATTERNS + BODY_PATTERNS + TAIL_PATTERNS
155
+
156
+
157
+ # --------------------------------------------------------------------------- #
158
+ # 数据结构
159
+ # --------------------------------------------------------------------------- #
160
+
161
+
162
+ @dataclass
163
+ class Hit:
164
+ code: str
165
+ name: str
166
+ pattern: str
167
+ text: str
168
+ line: int
169
+ column: int
170
+ severity: str
171
+ penalty: int
172
+ raw_penalty: int # 加权前
173
+
174
+
175
+ @dataclass
176
+ class Report:
177
+ score: int
178
+ level: str
179
+ level_emoji: str
180
+ hits: list[Hit] = field(default_factory=list)
181
+ lexical_deduction: int = 0
182
+ structural_deduction: int = 0
183
+ stats: dict = field(default_factory=dict)
184
+ suggestions: list[str] = field(default_factory=list)
185
+
186
+
187
+ # --------------------------------------------------------------------------- #
188
+ # 扫描逻辑
189
+ # --------------------------------------------------------------------------- #
190
+
191
+
192
+ def scan_text(text: str) -> Report:
193
+ lines = text.splitlines()
194
+ total_chars = len(text)
195
+ total_lines = len(lines)
196
+ head_chars = "".join(lines[: max(1, total_lines // 5)]) # 前 20%
197
+ tail_chars = "".join(lines[-max(1, total_lines // 5):]) # 后 20%
198
+
199
+ hits: list[Hit] = []
200
+ seen_in_text: dict[tuple[str, str], int] = {} # (pattern, exact_text) → 第 N 次出现
201
+
202
+ for p in ALL_PATTERNS:
203
+ regex = p.compiled()
204
+ for m in regex.finditer(text):
205
+ # 计算行号/列号
206
+ start = m.start()
207
+ line_no = text.count("\n", 0, start) + 1
208
+ col_no = start - (text.rfind("\n", 0, start) + 1) + 1
209
+
210
+ # 计算加权:位置决定
211
+ region = "body"
212
+ if p.weighted == "head" and start < len(head_chars):
213
+ region = "head"
214
+ elif p.weighted == "tail" and start >= len(text) - len(tail_chars):
215
+ region = "tail"
216
+
217
+ raw = p.penalty
218
+ if region != "body":
219
+ raw = int(raw * 1.5)
220
+
221
+ # 重复衰减:同一模式第 N 次出现
222
+ key = (p.regex, m.group(0))
223
+ n = seen_in_text.get(key, 0) + 1
224
+ seen_in_text[key] = n
225
+ multiplier = min(n, 3) # 最多 3 次
226
+
227
+ final = raw * multiplier
228
+
229
+ hits.append(
230
+ Hit(
231
+ code=p.code,
232
+ name=p.name,
233
+ pattern=p.regex,
234
+ text=m.group(0),
235
+ line=line_no,
236
+ column=col_no,
237
+ severity=p.severity,
238
+ penalty=final,
239
+ raw_penalty=raw,
240
+ )
241
+ )
242
+
243
+ # 结构扣分
244
+ structural = 0
245
+ # 重复的段首加粗标签:**结论**:/ **踩坑提醒**:/ **注意**: …
246
+ # 这是模板化写作最典型的痕迹,词面词表抓不到,只能按"同一标签重复出现"判定。
247
+ # 判据取 ≥3 次:用两次属于正常强调,用三次以上就是在套模板。
248
+ lead_labels = re.findall(r"^\s*\*\*([^*\n]{1,10})\*\*\s*[::]", text, re.M)
249
+ repeated_labels: list[str] = []
250
+ if lead_labels:
251
+ counts = Counter(lead_labels)
252
+ repeated_labels = [k for k, v in counts.items() if v >= 3]
253
+ if total_lines >= 5:
254
+ # 破折号过多
255
+ dash_count = text.count("——")
256
+ if dash_count >= max(3, total_chars // 200):
257
+ structural -= 5
258
+ # 感叹号过多
259
+ if text.count("!!!") >= 1 or text.count("!!") >= 3:
260
+ structural -= 3
261
+ # 缺乏具体细节(无任何数字)
262
+ if not re.search(r"\d", text):
263
+ structural -= 12
264
+ # 缺乏个性化声音(无第一人称)
265
+ if not re.search(r"[我我们]", text):
266
+ structural -= 8
267
+ # 标题 emoji 滥用
268
+ emoji_count = len(re.findall(r"[\U0001F300-\U0001FAFF\U00002600-\U000027BF]", text))
269
+ if emoji_count >= 3:
270
+ structural -= min(9, (emoji_count - 2) * 3)
271
+ # 段首加粗标签模板化
272
+ if repeated_labels:
273
+ structural -= min(12, sum(lead_labels.count(k) for k in repeated_labels) * 2)
274
+
275
+ # 词面扣分汇总
276
+ # 注意:structural 累加时是负数(structural -= n),必须取绝对值再相加。
277
+ # 若直接相加,lexical + structural 会变成"加分",结构扣分整条维度静默失效。
278
+ lexical = sum(h.penalty for h in hits)
279
+ structural = abs(structural)
280
+ total_deduction = lexical + structural
281
+ score = max(0, min(100, 100 - total_deduction))
282
+
283
+ level, level_emoji = grade(score)
284
+
285
+ # 生成建议(Top 3)
286
+ suggestions = build_suggestions(hits, score)
287
+ if repeated_labels:
288
+ tips = "、".join(f"**{k}**:×{lead_labels.count(k)}" for k in repeated_labels)
289
+ suggestions.insert(0, f"段首加粗标签模板化 — {tips},同一标签重复 3 次以上就是在套模板,改成直陈句")
290
+
291
+ return Report(
292
+ score=score,
293
+ level=level,
294
+ level_emoji=level_emoji,
295
+ hits=hits,
296
+ lexical_deduction=lexical,
297
+ structural_deduction=structural,
298
+ stats={
299
+ "total_chars": total_chars,
300
+ "total_lines": total_lines,
301
+ "ai_pattern_count": len(hits),
302
+ "repeated_lead_labels": repeated_labels,
303
+ },
304
+ suggestions=suggestions,
305
+ )
306
+
307
+
308
+ def grade(score: int) -> tuple[str, str]:
309
+ if score >= 85:
310
+ return "人话", "🟢"
311
+ if score >= 70:
312
+ return "微 AI 味", "🟡"
313
+ if score >= 50:
314
+ return "中度 AI 味", "🟠"
315
+ if score >= 30:
316
+ return "重度 AI 味", "🔴"
317
+ return "完全 AI 体", "⚫"
318
+
319
+
320
+ def build_suggestions(hits: list[Hit], score: int) -> list[str]:
321
+ """按类别聚类,给出 3 条优先建议。"""
322
+ by_code: dict[str, list[Hit]] = {}
323
+ for h in hits:
324
+ by_code.setdefault(h.code, []).append(h)
325
+
326
+ # 优先级:A4>A3>A6>A7>A1>A8>A10>A5>A9
327
+ priority = ["A4", "A3", "A6", "A7", "A1", "A8", "A10", "A5", "A9"]
328
+ tips_map = {
329
+ "A1": "开头套话 — 删掉'在当今社会'等模板开头,直接说事实",
330
+ "A2": "转折词 — 用句号断开,靠上下文自然过渡",
331
+ "A3": "官腔形容词 — 换成大白话或具体数字",
332
+ "A4": "结尾升华 — 删掉'让我们共同'等空话,给具体计划",
333
+ "A5": "排比三段式 — 砍到两项或四项,别硬凑三段",
334
+ "A6": "营销黑话 — 改为具体动词,如'做了一个工具'",
335
+ "A7": "模糊归因 — 给具体名字/出处/数字",
336
+ "A8": "伪深度 — 删除'是 XX 的体现/证明'句式",
337
+ "A9": "假大空成语 — 用白话翻译一遍",
338
+ "A10": "模板口水 — 删掉口水句,直接进入正文",
339
+ }
340
+
341
+ suggestions = []
342
+ for code in priority:
343
+ if code in by_code:
344
+ n = len(by_code[code])
345
+ suggestions.append(f"{tips_map[code]}(命中 {n} 处)")
346
+ if len(suggestions) >= 3:
347
+ break
348
+
349
+ if not suggestions:
350
+ suggestions.append("未发现明显 AI 模式,但建议人工通读检查结构/个性化声音")
351
+
352
+ return suggestions
353
+
354
+
355
+ # --------------------------------------------------------------------------- #
356
+ # 输出格式
357
+ # --------------------------------------------------------------------------- #
358
+
359
+
360
+ def render_markdown(report: Report, source: str) -> str:
361
+ md: list[str] = []
362
+ md.append(f"## AI 味检测报告\n")
363
+ md.append(f"**文件:** `{source}` ")
364
+ md.append(f"**总分:** **{report.score}** / 100 {report.level_emoji} {report.level}\n")
365
+
366
+ md.append("### 分项评分")
367
+ md.append(f"- 词面命中扣分:**-{report.lexical_deduction}**")
368
+ md.append(f"- 结构问题扣分:**{report.structural_deduction}**")
369
+ md.append(f"- 总计:**-{report.lexical_deduction + abs(report.structural_deduction)}** → {report.score} 分\n")
370
+
371
+ md.append("### 基础统计")
372
+ md.append(f"- 字符数:{report.stats.get('total_chars', 0)}")
373
+ md.append(f"- 行数:{report.stats.get('total_lines', 0)}")
374
+ md.append(f"- AI 模式命中数:**{report.stats.get('ai_pattern_count', 0)}**")
375
+ reps = report.stats.get("repeated_lead_labels") or []
376
+ if reps:
377
+ md.append(f"- 重复段首加粗标签:**{'、'.join(reps)}**")
378
+ md.append("")
379
+
380
+ if report.hits:
381
+ md.append("### 命中位置(按严重度排序)")
382
+ md.append("| # | 类别 | 命中内容 | 位置 | 严重度 | 扣分 |")
383
+ md.append("|---|---|---|---|---|---|")
384
+ sorted_hits = sorted(report.hits, key=lambda h: -h.penalty)
385
+ for i, h in enumerate(sorted_hits[:30], 1):
386
+ preview = h.text if len(h.text) <= 25 else h.text[:25] + "…"
387
+ md.append(f"| {i} | {h.code} {h.name} | `{preview}` | 第 {h.line} 行 | {h.severity} | -{h.penalty} |")
388
+ if len(report.hits) > 30:
389
+ md.append(f"\n*(还有 {len(report.hits) - 30} 处命中未显示)*\n")
390
+ else:
391
+ md.append("### 命中位置")
392
+ md.append("无命中 🎉\n")
393
+
394
+ md.append("### Top 3 优先改写建议")
395
+ for s in report.suggestions:
396
+ md.append(f"1. {s}")
397
+ md.append("")
398
+
399
+ return "\n".join(md)
400
+
401
+
402
+ def render_json(report: Report) -> str:
403
+ return json.dumps(
404
+ {
405
+ "score": report.score,
406
+ "level": report.level,
407
+ "level_emoji": report.level_emoji,
408
+ "deductions": {
409
+ "lexical": report.lexical_deduction,
410
+ "structural": report.structural_deduction,
411
+ },
412
+ "stats": report.stats,
413
+ "hits": [asdict(h) for h in report.hits],
414
+ "suggestions": report.suggestions,
415
+ },
416
+ ensure_ascii=False,
417
+ indent=2,
418
+ )
419
+
420
+
421
+ # --------------------------------------------------------------------------- #
422
+ # CLI
423
+ # --------------------------------------------------------------------------- #
424
+
425
+
426
+ def main(argv: list[str]) -> int:
427
+ parser = argparse.ArgumentParser(
428
+ description="中文 AI 味检测器 — 给文章打 AI 味分数"
429
+ )
430
+ parser.add_argument("input", help="输入文件路径,或 '-' 表示 stdin")
431
+ parser.add_argument("--json", action="store_true", help="输出 JSON 格式")
432
+ parser.add_argument("-o", "--output", help="输出文件(默认 stdout)")
433
+ args = parser.parse_args(argv)
434
+
435
+ # 读取输入
436
+ if args.input == "-":
437
+ text = sys.stdin.read()
438
+ source = "<stdin>"
439
+ else:
440
+ p = Path(args.input)
441
+ if not p.exists():
442
+ print(f"❌ 文件不存在: {args.input}", file=sys.stderr)
443
+ return 1
444
+ text = p.read_text(encoding="utf-8")
445
+ source = str(p)
446
+
447
+ report = scan_text(text)
448
+
449
+ if args.json:
450
+ out = render_json(report)
451
+ else:
452
+ out = render_markdown(report, source)
453
+
454
+ if args.output:
455
+ Path(args.output).write_text(out, encoding="utf-8")
456
+ print(f"✅ 已写入 {args.output}({report.score} 分 / {report.level})")
457
+ else:
458
+ print(out)
459
+
460
+ return 0
461
+
462
+
463
+ if __name__ == "__main__":
464
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """sync-mapping.py — 校验「frontmatter ⇄ 目录名 ⇄ topstream 文件 ⇄ 派生视图」映射一致性。
4
+
5
+ frontmatter 是映射唯一真源,topic.md 与 topstream `docs/公众号映射.md` 是派生视图。
6
+ 派生视图靠手改,容易与真源漂移。本脚本只读校验,报告差异,不改文件。
7
+
8
+ Usage:
9
+ python3 sync-mapping.py # 校验全部包(dry-run)
10
+ python3 sync-mapping.py --topstream <path> # 额外校验 notes 文件是否存在
11
+ python3 sync-mapping.py --check-topic <topic.md> # 校验 topic.md 总表是否与磁盘一致
12
+ python3 sync-mapping.py --apply # 刷新 word_count(仅这一项可自动修)
13
+ """
14
+
15
+ import argparse
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+ def _default_base():
21
+ """交付包根:CLI --base → env TOPMIND_WECHAT_BASE → env TOPMIND_WORKSPACE 发现 → 惯例路径。"""
22
+ env = os.environ.get("TOPMIND_WECHAT_BASE") or os.environ.get("TOPMIND_WECHAT_PACKAGE_ROOT")
23
+ if env:
24
+ return os.path.abspath(os.path.expanduser(env))
25
+ ws = os.environ.get("TOPMIND_WORKSPACE")
26
+ if ws:
27
+ for rel in (
28
+ ("40-创作", "2026-公众号"),
29
+ ("20-专题", "2026-公众号"),
30
+ ("88-交付", "2026-公众号"),
31
+ ):
32
+ p = os.path.join(ws, *rel)
33
+ if os.path.isdir(p):
34
+ return p
35
+ return os.path.join(ws, "40-创作", "2026-公众号")
36
+ # 惯例:父工作区(可被 CLI 覆盖)
37
+ return os.path.join(
38
+ os.path.expanduser("~"), "TopWorkSpace", "topmind-workspace",
39
+ "40-创作", "2026-公众号")
40
+
41
+
42
+ def _default_topstream():
43
+ env = os.environ.get("TOPSTREAM_ROOT") or os.environ.get("TOPMIND_TOPSTREAM")
44
+ if env:
45
+ return os.path.abspath(os.path.expanduser(env))
46
+ return os.path.join(os.path.expanduser("~"), "TopWorkSpace", "topstream")
47
+
48
+ DEFAULT_BASE = _default_base()
49
+
50
+ VALID_STATUSES = ("草稿", "定稿", "已发布")
51
+ FINAL_STATUSES = ("定稿", "已发布")
52
+ RELEASED_SUFFIX = "-released"
53
+ DIR_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-([\u4e00-\u9fffA-Za-z0-9][\u4e00-\u9fffA-Za-z0-9-]*?)(-released)?$")
54
+
55
+
56
+ def split_fm(text):
57
+ if not text.startswith("---\n"):
58
+ return None, text
59
+ i = text.find("\n---", 3)
60
+ if i < 0:
61
+ return None, text
62
+ return text[4:i], text[i + 4:].lstrip("\n")
63
+
64
+
65
+ def get_field(fm, key):
66
+ m = re.search(r"^%s:\s*(.*)$" % re.escape(key), fm, re.M)
67
+ if not m:
68
+ return None
69
+ v = m.group(1).strip()
70
+ if v in ("", '""', "''"):
71
+ return ""
72
+ return v.strip('"').strip("'")
73
+
74
+
75
+ def cjk_count(body):
76
+ return len(re.findall(r"[\u4e00-\u9fff]", body))
77
+
78
+
79
+ def collect(base):
80
+ """返回 [(dirname, pkg_path, fm, body)]"""
81
+ out = []
82
+ if not os.path.isdir(base):
83
+ return out
84
+ for name in sorted(os.listdir(base)):
85
+ pkg = os.path.join(base, name)
86
+ draft = os.path.join(pkg, "公众号稿.md")
87
+ if not os.path.isdir(pkg) or not os.path.exists(draft):
88
+ continue
89
+ text = open(draft, encoding="utf-8").read()
90
+ fm, body = split_fm(text)
91
+ out.append((name, pkg, fm, body))
92
+ return out
93
+
94
+
95
+ def check(base, topstream, check_topic):
96
+ rows = collect(base)
97
+ if not rows:
98
+ print("✗ 专题下没有交付包:%s" % base)
99
+ return 1
100
+
101
+ errors, warns = [], []
102
+
103
+ for name, pkg, fm, body in rows:
104
+ tag = "[" + name + "]"
105
+ if fm is None:
106
+ errors.append("%s 无 frontmatter" % tag)
107
+ continue
108
+
109
+ direction = get_field(fm, "direction") or ""
110
+ status = get_field(fm, "status") or "草稿"
111
+ source_file = get_field(fm, "source_file") or ""
112
+ target_file = get_field(fm, "target_file") or ""
113
+ wc = get_field(fm, "word_count")
114
+
115
+ # 字段完备性
116
+ if direction not in ("forward", "reverse"):
117
+ errors.append("%s direction 非法:%r(应为 forward/reverse)" % (tag, direction))
118
+ if status not in VALID_STATUSES:
119
+ errors.append("%s status 非法:%r" % (tag, status))
120
+
121
+ if direction == "forward" and not source_file:
122
+ errors.append("%s 是 forward 但缺 source_file(应填 notes/xxx.md)" % tag)
123
+ if direction == "reverse" and target_file == "":
124
+ errors.append("%s 是 reverse 但 target_file 为空(应为 pending 或 notes/xxx.md)" % tag)
125
+ if direction == "forward" and source_file and not source_file.startswith("notes/"):
126
+ warns.append("%s source_file 应以 notes/ 开头:%s" % (tag, source_file))
127
+
128
+ # 目录名与状态
129
+ m = DIR_RE.match(name)
130
+ if not m:
131
+ errors.append("%s 目录名不符合 YYYY-MM-DD-<短名>[-released]:%s" % (tag, name))
132
+ else:
133
+ has_sfx = name.endswith(RELEASED_SUFFIX)
134
+ if status in FINAL_STATUSES and not has_sfx:
135
+ errors.append("%s status=%s 但目录缺 -released 后缀(用 sync-status.py --set 修)"
136
+ % (tag, status))
137
+ if status == "草稿" and has_sfx:
138
+ errors.append("%s status=草稿 但目录带 -released 后缀" % tag)
139
+
140
+ # 字数是否落盘最新
141
+ cn = cjk_count(body)
142
+ if wc and wc.isdigit() and int(wc) != cn:
143
+ warns.append("%s word_count=%s 与实际中文字 %d 不一致(sync-status.py --apply 刷新)"
144
+ % (tag, wc, cn))
145
+
146
+ # topstream 文件存在性
147
+ if topstream:
148
+ for field, rel in (("source_file", source_file), ("target_file", target_file)):
149
+ if rel and rel not in ("pending", "") and rel.startswith("notes/"):
150
+ p = os.path.join(topstream, rel)
151
+ if not os.path.exists(p):
152
+ errors.append("%s %s 指向不存在的文件:%s" % (tag, field, rel))
153
+
154
+ # 派生视图漂移:topic.md 总表里的目录名应与磁盘集合一致
155
+ if check_topic:
156
+ if not os.path.exists(check_topic):
157
+ errors.append("topic.md 不存在:%s" % check_topic)
158
+ else:
159
+ topic = open(check_topic, encoding="utf-8").read()
160
+ listed = set()
161
+ for x in re.findall(r"`(\d{4}-\d{2}-\d{2}-[^`]+?/)`", topic):
162
+ m = DIR_RE.match(x.rstrip("/"))
163
+ if m:
164
+ listed.add(m.group(0))
165
+ on_disk = set(n for n, _, _, _ in rows) # 文章包(含 公众号稿.md)
166
+ on_disk_all = set(n for n in os.listdir(base)
167
+ if os.path.isdir(os.path.join(base, n))) # 含策划包等
168
+ missing_in_topic = on_disk - listed
169
+ stale_in_topic = listed - on_disk_all
170
+ if missing_in_topic:
171
+ warns.append("磁盘有但 topic.md 总表未列出:%s" % "、".join(sorted(missing_in_topic)))
172
+ if stale_in_topic:
173
+ warns.append("topic.md 总表列了但磁盘不存在:%s" % "、".join(sorted(stale_in_topic)))
174
+
175
+ # 输出
176
+ print("映射校验:%s" % base)
177
+ print("-" * 80)
178
+ for name, pkg, fm, body in rows:
179
+ if fm is None:
180
+ continue
181
+ d = get_field(fm, "direction")
182
+ s = get_field(fm, "status")
183
+ sf = get_field(fm, "source_file") or "-"
184
+ tf = get_field(fm, "target_file") or "-"
185
+ print("%-40s %-8s %-4s src=%-28s tgt=%s" % (name, d, s, sf[:28], tf[:28]))
186
+ print("-" * 80)
187
+ if errors:
188
+ print("错误 %d:" % len(errors))
189
+ for e in errors:
190
+ print(" ✗ %s" % e)
191
+ print("")
192
+ if warns:
193
+ print("警告 %d:" % len(warns))
194
+ for w in warns:
195
+ print(" ! %s" % w)
196
+ print("")
197
+ if not errors and not warns:
198
+ print("✓ 映射一致,无差异")
199
+ return 1 if errors else 0
200
+
201
+
202
+ def main():
203
+ ap = argparse.ArgumentParser()
204
+ ap.add_argument("--base", default=DEFAULT_BASE)
205
+ ap.add_argument("--topstream", default=None,
206
+ help="topstream 仓库路径(默认 TOPSTREAM_ROOT 或 ~/TopWorkSpace/topstream)")
207
+ ap.add_argument("--check-topic", default=os.path.join(
208
+ DEFAULT_BASE, "topic.md"),
209
+ help="topic.md 路径,校验总表是否与磁盘一致")
210
+ ap.add_argument("--no-topstream", action="store_true", help="不校验 topstream")
211
+ ap.add_argument("--no-topic", action="store_true", help="不校验 topic.md")
212
+ args = ap.parse_args()
213
+
214
+ topstream = None if args.no_topstream else (args.topstream or _default_topstream())
215
+ check_topic = None if args.no_topic else args.check_topic
216
+ return check(args.base, topstream, check_topic)
217
+
218
+
219
+ if __name__ == "__main__":
220
+ sys.exit(main())