@topmindspace/tms-skills 0.3.0 → 0.3.5
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 +186 -0
- package/README.en.md +10 -70
- package/README.md +11 -69
- package/package.json +1 -1
- package/top-ppt-html/README.en.md +19 -20
- package/top-ppt-html/README.md +36 -90
- package/top-ppt-html/SKILL.md +65 -60
- package/top-ppt-html/references/charts.md +0 -15
- package/top-ppt-html/references/components.md +0 -18
- package/top-ppt-html/references/design-system.md +2 -2
- package/top-ppt-html/references/icons.md +2 -2
- package/top-ppt-html/references/infographics.md +1 -5
- package/top-ppt-html/references/modes.md +4 -4
- package/top-ppt-html/references/tech-design.md +3 -3
- package/top-ppt-html/scripts/build_examples.py +4 -4
- package/top-ppt-html/scripts/build_pptx.js +240 -59
- package/top-ppt-html/scripts/check_triggers.py +10 -2
- package/top-ppt-html/scripts/extract_model.py +16 -5
- package/top-ppt-html/scripts/gen_channel_a.js +55 -10
- package/top-ppt-html/scripts/hydrate_charts.py +68 -13
- package/top-ppt-html/scripts/negative_tests.py +1 -1
- package/top-ppt-html/scripts/prepare_images.py +1 -1
- package/top-ppt-html/scripts/recommend_layout.py +26 -3
- package/top-ppt-html/scripts/render_from_model.py +78 -13
- package/top-ppt-html/scripts/scaffold_report.py +43 -3
- package/top-ppt-html/scripts/sync_runtime.py +32 -4
- package/top-ppt-html/scripts/test_feedback_gates.py +24 -0
- package/top-ppt-html/scripts/validate_pptx.py +10 -1
- package/top-ppt-html/scripts/validate_report.py +43 -7
- package/topmind-cover/README.en.md +12 -8
- package/topmind-cover/README.md +13 -9
- package/topmind-cover/SKILL.md +33 -21
- package/topmind-cover/assets/examples/README.md +16 -19
- 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/tutorial-steps-wechat.png +0 -0
- package/topmind-cover/assets/examples/tutorial-steps.png +0 -0
- package/topmind-cover/references/cover-styles.md +322 -143
- package/topmind-wechat-post/README.en.md +49 -1
- package/topmind-wechat-post/README.md +48 -2
- package/topmind-wechat-post/SKILL.md +35 -37
- package/topmind-wechat-post/references/element-spec.md +1 -1
- package/topmind-wechat-post/references/known-pits.md +2 -3
- package/topmind-wechat-post/references/typography-rules.md +1 -1
- package/topmind-wechat-post/references/workflow.md +3 -3
- package/topmind-wechat-post/references/writing-quality.md +8 -32
- package/topmind-wechat-post/scripts/md2wechat.py +125 -42
- package/topmind-wechat-post/scripts/negative_tests.py +32 -0
- package/topmind-x-article/README.en.md +40 -1
- package/topmind-x-article/README.md +42 -4
- package/topmind-x-article/SKILL.md +11 -5
- package/topmind-x-article/references/x-format.md +13 -10
- package/topmind-x-article/scripts/md2x.py +220 -40
- package/topmind-cover/assets/examples/big-poster-wechat.png +0 -0
- package/topmind-cover/assets/examples/big-poster.png +0 -0
- package/topmind-cover/assets/examples/cyber-glitch-wechat.png +0 -0
- package/topmind-cover/assets/examples/cyber-glitch.png +0 -0
- package/topmind-cover/assets/examples/guochao-wechat.png +0 -0
- package/topmind-cover/assets/examples/guochao.png +0 -0
- package/topmind-cover/assets/examples/tech-future-wechat.png +0 -0
- package/topmind-cover/assets/examples/tech-future.png +0 -0
|
@@ -217,7 +217,10 @@ def highlight(code, lang, scheme=None):
|
|
|
217
217
|
if not rules:
|
|
218
218
|
return html.escape(code, quote=False)
|
|
219
219
|
palette = scheme or CTX.get("hl_scheme") or HIGHLIGHT
|
|
220
|
-
pattern =
|
|
220
|
+
pattern = _HL_PATTERN_CACHE.get(key)
|
|
221
|
+
if pattern is None:
|
|
222
|
+
pattern = re.compile("|".join("(?P<%s>%s)" % (name, pat) for name, pat in rules), re.S)
|
|
223
|
+
_HL_PATTERN_CACHE[key] = pattern
|
|
221
224
|
out = []
|
|
222
225
|
pos = 0
|
|
223
226
|
for m in pattern.finditer(code):
|
|
@@ -335,6 +338,22 @@ HAN_RE = re.compile("[%s]" % HAN)
|
|
|
335
338
|
PANGU_A = re.compile(r"([%s])([A-Za-z0-9])" % HAN)
|
|
336
339
|
PANGU_B = re.compile(r"([A-Za-z0-9])([%s])" % HAN)
|
|
337
340
|
|
|
341
|
+
# render_inline() 的行内模式:预编译在模块级,避免每行重复走 re 缓存查找
|
|
342
|
+
# (666k 行级调用 × 10 个模式时,re.sub 的字符串模式查找是可观开销)。
|
|
343
|
+
INLINE_CODE_RE = re.compile(r"`([^`]+)`")
|
|
344
|
+
INLINE_IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
|
345
|
+
INLINE_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
|
|
346
|
+
INLINE_BADGE_RE = re.compile(r"\[!([^\]]+)\]")
|
|
347
|
+
INLINE_HL_RE = re.compile(r"==([^=]+)==")
|
|
348
|
+
INLINE_UNDER_RE = re.compile(r"\+\+([^+]+)\+\+")
|
|
349
|
+
INLINE_U_RE = re.compile(r"<u>(.+?)</u>")
|
|
350
|
+
INLINE_MARK_RE = re.compile(r"~~([^~]+)~~")
|
|
351
|
+
INLINE_STRONG_RE = re.compile(r"\*\*([^*]+)\*\*")
|
|
352
|
+
INLINE_EM_RE = re.compile(r"(?<![*\w])\*([^*\n]+)\*(?!\*)")
|
|
353
|
+
|
|
354
|
+
# highlight() 的语言联合模式缓存:同一语言的多段代码块只编译一次
|
|
355
|
+
_HL_PATTERN_CACHE = {}
|
|
356
|
+
|
|
338
357
|
|
|
339
358
|
def pangu(text):
|
|
340
359
|
text = PANGU_A.sub(r"\1 \2", text)
|
|
@@ -363,7 +382,7 @@ def render_inline(raw):
|
|
|
363
382
|
return CODE_TOKEN % (len(spans) - 1)
|
|
364
383
|
|
|
365
384
|
text = esc(raw)
|
|
366
|
-
text =
|
|
385
|
+
text = INLINE_CODE_RE.sub(lambda m: stash(m.group(1)), text)
|
|
367
386
|
|
|
368
387
|
if use_pangu:
|
|
369
388
|
text = pangu(text)
|
|
@@ -371,22 +390,37 @@ def render_inline(raw):
|
|
|
371
390
|
strong = 'font-weight:600;color:%s;' % theme["text_strong"]
|
|
372
391
|
em = 'font-style:normal;color:%s;' % theme["text_muted"]
|
|
373
392
|
|
|
374
|
-
# images
|
|
393
|
+
# images(行内图与独立成行图共用同一正则语义)
|
|
375
394
|
def img_repl(m):
|
|
376
395
|
alt, src = m.group(1), m.group(2)
|
|
396
|
+
# 行内图片同样走图片管线:本地文件解析 → 复制到 images/ → 登记清单,
|
|
397
|
+
# 否则 --embed-images 找不到文件、清单漏项、计数为 0(历史 bug:
|
|
398
|
+
# 段落里的  被原样保留相对路径,粘贴到公众号后整图消失)。
|
|
399
|
+
resolver = CTX.get("img_resolve")
|
|
400
|
+
if resolver and not re.match(r"^(https?:|data:|//)", src):
|
|
401
|
+
src = resolver(html.unescape(src))
|
|
377
402
|
caption = ' alt="%s"' % alt if alt else ""
|
|
378
403
|
return ('<img src="%s"%s style="max-width:100%%;height:auto;'
|
|
379
404
|
'border-radius:3px;vertical-align:middle;">') % (src, caption)
|
|
380
405
|
|
|
381
|
-
text =
|
|
406
|
+
text = INLINE_IMG_RE.sub(img_repl, text)
|
|
382
407
|
|
|
383
408
|
# links
|
|
384
409
|
def link_repl(m):
|
|
385
410
|
label, url = m.group(1), m.group(2)
|
|
386
411
|
if link_mode == "footnote" and footnotes is not None:
|
|
387
|
-
|
|
412
|
+
# url→序号字典:原写法每次链接都扫全表两次(O(n²)),长文链接多时明显变慢
|
|
413
|
+
fidx = CTX.get("footnote_idx")
|
|
414
|
+
if fidx is not None and url in fidx:
|
|
415
|
+
idx = fidx[url]
|
|
416
|
+
elif fidx is not None:
|
|
388
417
|
footnotes.append({"url": url, "label": label})
|
|
389
|
-
|
|
418
|
+
idx = len(footnotes)
|
|
419
|
+
fidx[url] = idx
|
|
420
|
+
else: # 外部直接调 render_inline 且未初始化索引时,走原来的线性查找
|
|
421
|
+
if url not in [f["url"] for f in footnotes]:
|
|
422
|
+
footnotes.append({"url": url, "label": label})
|
|
423
|
+
idx = [f["url"] for f in footnotes].index(url) + 1
|
|
390
424
|
return ('%s<sup style="font-size:11px;color:%s;'
|
|
391
425
|
'padding-left:1px;">[%d]</sup>'
|
|
392
426
|
% (label, theme["accent"], idx))
|
|
@@ -396,13 +430,12 @@ def render_inline(raw):
|
|
|
396
430
|
return ('<span style="font-size:13px;color:%s;word-break:break-all;">'
|
|
397
431
|
'%s(%s)</span>' % (theme["text_light"], label, url))
|
|
398
432
|
|
|
399
|
-
text =
|
|
433
|
+
text = INLINE_LINK_RE.sub(link_repl, text)
|
|
400
434
|
|
|
401
435
|
# badges: [!文本]
|
|
402
436
|
# 不用 display:inline-block:公众号对非常规 display 值支持不稳定,
|
|
403
437
|
# 徽章是纯行内元素,padding 足够撑开视觉。
|
|
404
|
-
text =
|
|
405
|
-
r"\[!([^\]]+)\]",
|
|
438
|
+
text = INLINE_BADGE_RE.sub(
|
|
406
439
|
r'<span style="font-size:11px;line-height:1.6;'
|
|
407
440
|
r'color:' + theme["accent"] + r';background:' + theme["accent_soft"] + r';'
|
|
408
441
|
r'padding:1px 6px;border-radius:2px;margin:0 2px;'
|
|
@@ -411,8 +444,7 @@ def render_inline(raw):
|
|
|
411
444
|
)
|
|
412
445
|
|
|
413
446
|
# ==高亮== before bold so inner markup still works
|
|
414
|
-
text =
|
|
415
|
-
r"==([^=]+)==",
|
|
447
|
+
text = INLINE_HL_RE.sub(
|
|
416
448
|
r'<strong style="font-weight:600;color:' + theme["text_strong"] + r';'
|
|
417
449
|
r'background:' + theme["accent_soft"] + r';padding:1px 3px;'
|
|
418
450
|
r'border-radius:2px;">\1</strong>',
|
|
@@ -424,20 +456,19 @@ def render_inline(raw):
|
|
|
424
456
|
underline = ('<span style="border-bottom:2px solid %s;font-weight:600;'
|
|
425
457
|
'color:%s;">\\1</span>'
|
|
426
458
|
% (theme["accent_light"], theme["text_strong"]))
|
|
427
|
-
text =
|
|
428
|
-
text =
|
|
459
|
+
text = INLINE_UNDER_RE.sub(underline, text)
|
|
460
|
+
text = INLINE_U_RE.sub(underline, text)
|
|
429
461
|
|
|
430
462
|
# ~~荧光笔~~:半高亮。用纯色浅底,不用 linear-gradient
|
|
431
463
|
# (渐变在公众号会被静默丢弃,退化成无底)。
|
|
432
|
-
text =
|
|
433
|
-
r"~~([^~]+)~~",
|
|
464
|
+
text = INLINE_MARK_RE.sub(
|
|
434
465
|
r'<span style="background:%s;font-weight:600;color:%s;">\1</span>'
|
|
435
466
|
% (theme["accent_soft"], theme["text_strong"]),
|
|
436
467
|
text,
|
|
437
468
|
)
|
|
438
469
|
|
|
439
|
-
text =
|
|
440
|
-
text =
|
|
470
|
+
text = INLINE_STRONG_RE.sub(r'<strong style="%s">\1</strong>' % strong, text)
|
|
471
|
+
text = INLINE_EM_RE.sub(r'<em style="%s">\1</em>' % em, text)
|
|
441
472
|
|
|
442
473
|
for idx, code in enumerate(spans):
|
|
443
474
|
text = text.replace(
|
|
@@ -454,12 +485,12 @@ def render_inline(raw):
|
|
|
454
485
|
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
455
486
|
FENCE_RE = re.compile(r"^```(\w*)\s*$")
|
|
456
487
|
HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})\s*$")
|
|
457
|
-
IMG_ONLY_RE = re.compile(r"^!\[([^\]]*)\]\(([^)\s]+)
|
|
488
|
+
IMG_ONLY_RE = re.compile(r"^!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)\s*$")
|
|
458
489
|
CAPTION_RE = re.compile(r"^\*(.+)\*\s*$")
|
|
459
490
|
TABLE_SEP_RE = re.compile(r"^\|?[\s:|-]+\|[\s:|-]*$")
|
|
460
491
|
CONTAINER_RE = re.compile(r"^:::+\s*(\w+)?\s*(.*)$")
|
|
461
492
|
|
|
462
|
-
CN_NUM = "
|
|
493
|
+
CN_NUM = "一二三四五六七八九十壹贰叁肆伍陆柒捌玖拾"
|
|
463
494
|
|
|
464
495
|
# 标题里手写的中文/阿拉伯序号。序号应由排版层生成(可换样式、可加引用),
|
|
465
496
|
# 写进标题文本后就变成了不可编程的字符串。这里统一剥离后重新编号。
|
|
@@ -741,10 +772,13 @@ def render_table_cards(head, body, theme):
|
|
|
741
772
|
|
|
742
773
|
|
|
743
774
|
def render_hr(theme):
|
|
744
|
-
# 宽度用百分比:公众号端 px
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
775
|
+
# 宽度用百分比:公众号端 px 固定值在不同屏宽下表现不一致。
|
|
776
|
+
# 不用 display:inline-block:audit_inline 会把它判为 WARN,而这是生成器
|
|
777
|
+
# 自己的输出、用户无从修复——自检永远带一条洗不掉的 WARN 等于没有自检。
|
|
778
|
+
# 块级 section + margin:0 auto 居中,视觉与原来完全一致。
|
|
779
|
+
return ('<section style="margin:32px 0;">'
|
|
780
|
+
'<section style="width:12%%;height:2px;background:%s;'
|
|
781
|
+
'margin:0 auto;"></section></section>' % theme["accent"])
|
|
748
782
|
|
|
749
783
|
|
|
750
784
|
def render_figure(src, caption, theme, alt=""):
|
|
@@ -880,6 +914,10 @@ def resolve_image(src, md_path, out_dir, assets, asset_root):
|
|
|
880
914
|
if os.path.exists(candidate):
|
|
881
915
|
abs_path = candidate
|
|
882
916
|
if not os.path.exists(abs_path):
|
|
917
|
+
# 本地文件缺失:登记缺失记录再返回原 src(HTML 保留占位 <img>,
|
|
918
|
+
# embed 会跳过并提示"未内嵌",上传清单与计数也不会漏掉这一项)
|
|
919
|
+
assets.append({"src": src, "local": None, "remote": False,
|
|
920
|
+
"missing": True})
|
|
883
921
|
return src
|
|
884
922
|
img_dir = os.path.join(out_dir, "images")
|
|
885
923
|
os.makedirs(img_dir, exist_ok=True)
|
|
@@ -1099,8 +1137,14 @@ def convert(md_text, md_path, out_dir, theme, link_mode="footnote",
|
|
|
1099
1137
|
CTX["theme"] = theme
|
|
1100
1138
|
CTX["link_mode"] = link_mode
|
|
1101
1139
|
CTX["footnotes"] = ctx["footnotes"]
|
|
1140
|
+
CTX["footnote_idx"] = {}
|
|
1102
1141
|
CTX["use_pangu"] = use_pangu
|
|
1103
1142
|
CTX["hl_scheme"] = build_code_scheme(theme)
|
|
1143
|
+
# 行内图片解析器:render_inline 里的  通过它走 resolve_image,
|
|
1144
|
+
# 与独立成行的图片共享「复制到 images/ + 登记清单」管线。
|
|
1145
|
+
abs_md = os.path.abspath(md_path)
|
|
1146
|
+
CTX["img_resolve"] = lambda s: resolve_image(
|
|
1147
|
+
s, abs_md, out_dir, ctx["assets"], ctx.get("asset_root"))
|
|
1104
1148
|
|
|
1105
1149
|
body = convert_blocks(lines, md_path, out_dir, theme, ctx)
|
|
1106
1150
|
|
|
@@ -1324,6 +1368,26 @@ def build_notice(embedded, n_images):
|
|
|
1324
1368
|
return head + tail
|
|
1325
1369
|
|
|
1326
1370
|
|
|
1371
|
+
# audit_inline 的值级检查:原来是 8 次全正文 re.search(re.I 下每次都扫完整 MB 级
|
|
1372
|
+
# body,大正文里 audit_inline 能占近一半耗时)。合并为一次单遍扫描:命中哪个分支
|
|
1373
|
+
# 报哪条,收集命中的分支序号后按原列表顺序输出——与原来逐条 search 的结果完全一致。
|
|
1374
|
+
_AUDIT_VALUE_PATTERNS = [
|
|
1375
|
+
("linear-gradient", "ERROR", "linear-gradient(渐变被静默丢弃,退化成无背景。改纯色)"),
|
|
1376
|
+
("box-shadow", "ERROR", "box-shadow(阴影被丢弃)"),
|
|
1377
|
+
("rgba\\(|hsla\\(", "ERROR", "rgba()/hsla() 颜色(透明度不被支持,改纯色十六进制)"),
|
|
1378
|
+
("var\\(--", "ERROR", "CSS 变量(不支持,值必须写死)"),
|
|
1379
|
+
("@media|@keyframes", "ERROR", "@media/@keyframes(媒体查询与动画不被支持)"),
|
|
1380
|
+
("\\sclass=", "ERROR", "class 属性(公众号会剥离,样式必须内联)"),
|
|
1381
|
+
("\\sid=", "WARN", "id 属性(正文里无意义,可能被剥离)"),
|
|
1382
|
+
("<img[^>]*width=\"", "WARN", "<img> 上的 width 属性(公众号不保证保留,宽度用内联 style)"),
|
|
1383
|
+
]
|
|
1384
|
+
_AUDIT_VALUE_RE = re.compile(
|
|
1385
|
+
"|".join("(?P<g%d>%s)" % (i, pat)
|
|
1386
|
+
for i, (pat, _lv, _msg) in enumerate(_AUDIT_VALUE_PATTERNS)),
|
|
1387
|
+
re.I,
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
|
|
1327
1391
|
def audit_inline(body):
|
|
1328
1392
|
"""正文的平台合规自检,返回 [(级别, 说明)],空列表即通过。
|
|
1329
1393
|
|
|
@@ -1383,18 +1447,15 @@ def audit_inline(body):
|
|
|
1383
1447
|
elif prop == "border-radius":
|
|
1384
1448
|
add("WARN", "border-radius(圆角可能被丢弃,降级为直角;属可接受的优雅降级,勿依赖圆角做视觉区分)")
|
|
1385
1449
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
(
|
|
1395
|
-
]:
|
|
1396
|
-
if re.search(pat, body, re.I):
|
|
1397
|
-
add(lv, msg)
|
|
1450
|
+
hit = set()
|
|
1451
|
+
for m in _AUDIT_VALUE_RE.finditer(body):
|
|
1452
|
+
for i in range(len(_AUDIT_VALUE_PATTERNS)):
|
|
1453
|
+
if m.group("g%d" % i) is not None:
|
|
1454
|
+
hit.add(i)
|
|
1455
|
+
break
|
|
1456
|
+
for i in sorted(hit):
|
|
1457
|
+
_pat, lv, msg = _AUDIT_VALUE_PATTERNS[i]
|
|
1458
|
+
add(lv, msg)
|
|
1398
1459
|
|
|
1399
1460
|
# ---- 图片宽度:小图被拉伸是高频问题
|
|
1400
1461
|
for m in re.finditer(r'<img[^>]*style="([^"]*)"', body):
|
|
@@ -1414,6 +1475,22 @@ def audit_inline(body):
|
|
|
1414
1475
|
return out
|
|
1415
1476
|
|
|
1416
1477
|
|
|
1478
|
+
def atomic_write(path, text):
|
|
1479
|
+
"""原子写文件:先写临时文件再 rename,写一半失败(磁盘满/断电)时
|
|
1480
|
+
不会留下半截产物——要么是旧文件,要么是没有文件,绝不残缺。"""
|
|
1481
|
+
tmp = path + ".tmp"
|
|
1482
|
+
try:
|
|
1483
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
1484
|
+
fh.write(text)
|
|
1485
|
+
os.replace(tmp, path)
|
|
1486
|
+
except BaseException:
|
|
1487
|
+
try:
|
|
1488
|
+
os.unlink(tmp)
|
|
1489
|
+
except OSError:
|
|
1490
|
+
pass
|
|
1491
|
+
raise
|
|
1492
|
+
|
|
1493
|
+
|
|
1417
1494
|
def write_manifest(out_dir, slug, title, assets, diagrams, theme, footnotes,
|
|
1418
1495
|
embedded=False):
|
|
1419
1496
|
lines = ["# 图片上传清单 · %s" % title, ""]
|
|
@@ -1435,9 +1512,12 @@ def write_manifest(out_dir, slug, title, assets, diagrams, theme, footnotes,
|
|
|
1435
1512
|
size = "%.0f KB" % kb if kb < 1024 else "%.1f MB" % (kb / 1024)
|
|
1436
1513
|
if a.get("remote"):
|
|
1437
1514
|
lines.append("| %d | %s(远程) | %s | `%s` |" % (idx, a["src"], size, a["src"]))
|
|
1515
|
+
elif a.get("missing"):
|
|
1516
|
+
lines.append("| %d | ⚠ 本地文件缺失:`%s` | — | `%s` |"
|
|
1517
|
+
% (idx, a["src"], a["src"]))
|
|
1438
1518
|
else:
|
|
1439
1519
|
lines.append("| %d | `%s` | %s | `%s` |"
|
|
1440
|
-
% (idx, a.get("basename"
|
|
1520
|
+
% (idx, a.get("basename") or "", size, a["src"]))
|
|
1441
1521
|
lines.append("")
|
|
1442
1522
|
lines.append("图片目录:`images/`(公众号单图建议 < 2 MB,宽度 1080 px 左右)")
|
|
1443
1523
|
lines.append("")
|
|
@@ -1472,8 +1552,7 @@ def write_manifest(out_dir, slug, title, assets, diagrams, theme, footnotes,
|
|
|
1472
1552
|
theme.get("h2_style", "number"),
|
|
1473
1553
|
theme.get("code_scheme", "mono")))
|
|
1474
1554
|
path = os.path.join(out_dir, "图片上传清单.md")
|
|
1475
|
-
|
|
1476
|
-
fh.write("\n".join(lines))
|
|
1555
|
+
atomic_write(path, "\n".join(lines))
|
|
1477
1556
|
return path
|
|
1478
1557
|
|
|
1479
1558
|
|
|
@@ -1522,8 +1601,13 @@ def main():
|
|
|
1522
1601
|
sys.exit("错误:找不到输入文件 %s" % args.input)
|
|
1523
1602
|
|
|
1524
1603
|
theme = load_theme(args.theme)
|
|
1525
|
-
|
|
1526
|
-
|
|
1604
|
+
try:
|
|
1605
|
+
with open(args.input, encoding="utf-8") as fh:
|
|
1606
|
+
md_text = fh.read()
|
|
1607
|
+
except UnicodeDecodeError:
|
|
1608
|
+
sys.exit("错误:输入文件不是有效的 UTF-8 编码 %s" % args.input)
|
|
1609
|
+
except OSError as e:
|
|
1610
|
+
sys.exit("错误:无法读取输入文件 %s(%s)" % (args.input, e))
|
|
1527
1611
|
|
|
1528
1612
|
os.makedirs(args.out_dir, exist_ok=True)
|
|
1529
1613
|
body, assets, diagrams, footnotes = convert(
|
|
@@ -1560,8 +1644,7 @@ def main():
|
|
|
1560
1644
|
embed_stats["count"] if embed_stats else len(assets)),
|
|
1561
1645
|
)
|
|
1562
1646
|
html_path = os.path.join(args.out_dir, "%s-公众号版.html" % args.slug)
|
|
1563
|
-
|
|
1564
|
-
fh.write(page)
|
|
1647
|
+
atomic_write(html_path, page)
|
|
1565
1648
|
|
|
1566
1649
|
manifest = write_manifest(args.out_dir, args.slug, title, assets, diagrams,
|
|
1567
1650
|
theme, footnotes, embed_stats is not None)
|
|
@@ -55,6 +55,38 @@ def main() -> None:
|
|
|
55
55
|
r = run("sync-status.py", ["--set", "定稿", str(td / "nope-pkg")])
|
|
56
56
|
check("sync-status 缺失包干净报错", r.returncode != 0 and "Traceback" not in r.stderr, r.stderr[:100])
|
|
57
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
|
+
|
|
58
90
|
if fails:
|
|
59
91
|
print(f"\n[negative_tests] {len(fails)} 项失败")
|
|
60
92
|
sys.exit(1)
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
Turn a Markdown draft into a "copy → paste → publish" X long-form (Article) package.
|
|
6
6
|
|
|
7
|
+
- Version: **v0.1.0** (same tag as `@topmindspace/tms-skills@0.3.5`)
|
|
8
|
+
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
11
|
```bash
|
|
@@ -23,10 +25,47 @@ python3 scripts/md2x.py <draft>.md --out <package>/X发布稿.txt
|
|
|
23
25
|
|
|
24
26
|
## Conversion rules
|
|
25
27
|
|
|
26
|
-
The X Article editor has weak, unstable markdown support, so `md2x.py` converts to plain text per `references/x-format.md`: headings → plain text lines, horizontal rules
|
|
28
|
+
The X Article editor has weak, unstable markdown support, so `md2x.py` converts to plain text per `references/x-format.md`: headings → plain text lines, horizontal rules → blank lines, emphasis markers removed, links → `text(url)`, images → `[图N]` (image list appended at the end), tables → "label:value" lists, blockquotes unquoted, `\` escapes restored.
|
|
27
29
|
|
|
28
30
|
Read the result through once by hand after converting.
|
|
29
31
|
|
|
32
|
+
## Minimal example
|
|
33
|
+
|
|
34
|
+
Input (10 lines of markdown):
|
|
35
|
+
|
|
36
|
+
```markdown
|
|
37
|
+
# Manus 2.0 发布了
|
|
38
|
+
|
|
39
|
+
**从零重建**的 Agent,新增了 Cue 个人助手。
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
| Token 消耗 | -23.2% |
|
|
44
|
+
| 耗时 | -28.2% |
|
|
45
|
+
|
|
46
|
+
详见[官方博客](https://manus.im/blog)。
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`python3 scripts/md2x.py draft.md --out X发布稿.txt` produces:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Manus 2.0 发布了
|
|
53
|
+
|
|
54
|
+
从零重建的 Agent,新增了 Cue 个人助手。
|
|
55
|
+
|
|
56
|
+
[图1]
|
|
57
|
+
|
|
58
|
+
Token 消耗:-23.2%
|
|
59
|
+
耗时:-28.2%
|
|
60
|
+
|
|
61
|
+
详见官方博客(https://manus.im/blog)。
|
|
62
|
+
|
|
63
|
+
—— 配图清单 ——
|
|
64
|
+
[图1] 发布会现场
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Copy the whole `X发布稿.txt` → paste into the X Article editor → upload images in `[图N]` order → publish.
|
|
68
|
+
|
|
30
69
|
## Publishing
|
|
31
70
|
|
|
32
71
|
X long-form posts are currently published by hand-pasting into the X Article editor; the API does not publish long-form. After publishing, do per the checklist: pin a first comment with the extra info, fetch the full text back for verification.
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
把 Markdown 原稿变成"复制 → 粘贴 → 发"的 X 长文(Article)发布包。
|
|
6
6
|
|
|
7
|
+
- 版本:**v0.1.0**(与 `@topmindspace/tms-skills@0.3.5` 同 tag)
|
|
8
|
+
|
|
7
9
|
## 安装
|
|
8
10
|
|
|
9
11
|
```bash
|
|
@@ -24,12 +26,48 @@ python3 scripts/md2x.py <原稿>.md --out <包>/X发布稿.txt
|
|
|
24
26
|
## 转换规则
|
|
25
27
|
|
|
26
28
|
X Article 编辑器对 markdown 支持弱且不稳定,`md2x.py` 按 `references/x-format.md`
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
表格→"项:值"列表。
|
|
29
|
+
转纯文本:标题→纯文本行、分隔线→空行、加粗/斜体/行内代码去标记、链接→`文字(url)`、
|
|
30
|
+
图片→`[图N]`(文末附配图清单)、表格→"项:值"列表、引用去 `>`、`\\` 转义还原。
|
|
30
31
|
|
|
31
32
|
转完必须人工通读一遍。
|
|
32
33
|
|
|
34
|
+
## 最小示例
|
|
35
|
+
|
|
36
|
+
输入(10 行 markdown):
|
|
37
|
+
|
|
38
|
+
```markdown
|
|
39
|
+
# Manus 2.0 发布了
|
|
40
|
+
|
|
41
|
+
**从零重建**的 Agent,新增了 Cue 个人助手。
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
| Token 消耗 | -23.2% |
|
|
46
|
+
| 耗时 | -28.2% |
|
|
47
|
+
|
|
48
|
+
详见[官方博客](https://manus.im/blog)。
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`python3 scripts/md2x.py 原稿.md --out X发布稿.txt` 得到:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
Manus 2.0 发布了
|
|
55
|
+
|
|
56
|
+
从零重建的 Agent,新增了 Cue 个人助手。
|
|
57
|
+
|
|
58
|
+
[图1]
|
|
59
|
+
|
|
60
|
+
Token 消耗:-23.2%
|
|
61
|
+
耗时:-28.2%
|
|
62
|
+
|
|
63
|
+
详见官方博客(https://manus.im/blog)。
|
|
64
|
+
|
|
65
|
+
—— 配图清单 ——
|
|
66
|
+
[图1] 发布会现场
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
复制 `X发布稿.txt` 全文 → 粘贴进 X Article 编辑器 → 按 `[图N]` 顺序上传配图 → 发布。
|
|
70
|
+
|
|
33
71
|
## 发布
|
|
34
72
|
|
|
35
73
|
X 长文目前走人工粘贴发布(X Article 编辑器),API 不发长文。
|
|
@@ -40,7 +78,7 @@ X 长文目前走人工粘贴发布(X Article 编辑器),API 不发长文
|
|
|
40
78
|
以下技能**不在本仓库**(一般随用户侧 workbuddy 环境提供)。缺失时对应路由能力
|
|
41
79
|
不可用,不影响本技能核心流程(原稿转文本、封面、发布清单):
|
|
42
80
|
|
|
43
|
-
- `topmind-x`:280
|
|
81
|
+
- `topmind-x`:280 字短推文发布(xurl 只覆盖短推文,不发长文)。
|
|
44
82
|
- `topmind-capture`:「只想存档不发布」时的收录路由。
|
|
45
83
|
|
|
46
84
|
## 开发
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: topmind-x-article
|
|
3
3
|
version: 0.1.0
|
|
4
|
-
description: "X 长文(Article)一键发布:Markdown 原稿 → 可直接粘贴的 X发布稿.txt + 封面图 + 发布清单。沉淀自实战。Use when X 长文、发 X 文章、X article、长文发 X。Do NOT use for 短推文(→ topmind-x
|
|
4
|
+
description: "X 长文(Article)一键发布:Markdown 原稿 → 可直接粘贴的 X发布稿.txt + 封面图 + 发布清单。沉淀自实战。Use when X 长文、发 X 文章、X article、长文发 X。Do NOT use for 短推文(→ topmind-x)、公众号(→ topmind-wechat-post)。"
|
|
5
5
|
action_category: write
|
|
6
6
|
triggers:
|
|
7
7
|
- X 长文
|
|
8
8
|
- 发 X 文章
|
|
9
9
|
- X article
|
|
10
10
|
- 长文发 X
|
|
11
|
+
- X 发长文
|
|
12
|
+
- 转纯文本
|
|
13
|
+
- X 纯文本
|
|
14
|
+
- 发 article
|
|
11
15
|
triggers_cn:
|
|
12
16
|
- 写 X 长文
|
|
13
17
|
- X 长文发布
|
|
18
|
+
- 推特长文
|
|
19
|
+
- twitter 长文
|
|
14
20
|
author: TopMindspace
|
|
15
21
|
license: MIT
|
|
16
22
|
homepage: https://github.com/topmindspace/tms-skills#readme
|
|
@@ -49,11 +55,10 @@ updated: 2026-09-29
|
|
|
49
55
|
└── 发布清单.md # 本次发布 checklist(含时间、链接)
|
|
50
56
|
```
|
|
51
57
|
|
|
52
|
-
##
|
|
58
|
+
## 实战沉淀
|
|
53
59
|
|
|
54
60
|
- 补充信息(邀请码、链接、勘误)**不挤正文**,发在发布后首条评论并置顶
|
|
55
|
-
-
|
|
56
|
-
- 配图逐张按 `[图N]` 顺序上传,传完对照清单点一遍
|
|
61
|
+
- 标题 ≤140 字符,首段 3 行内出钩子;配图逐张按 `[图N]` 顺序上传,传完对照清单点一遍
|
|
57
62
|
- 事实修正只改发布包,不回头改已发原文(除非勘误)
|
|
58
63
|
|
|
59
64
|
## When NOT to use
|
|
@@ -61,11 +66,12 @@ updated: 2026-09-29
|
|
|
61
66
|
- 280 字短推文 → `topmind-x`
|
|
62
67
|
- 公众号 → `topmind-wechat-post`
|
|
63
68
|
- 只想存档不发布 → `topmind-capture`
|
|
69
|
+
- 写稿(只有发布需求、无原稿时)→ 先走写作技能再回本技能
|
|
64
70
|
|
|
65
71
|
## 外部依赖
|
|
66
72
|
|
|
67
73
|
以下技能**不在本仓库**(一般随用户侧 workbuddy 环境提供);缺失时对应路由能力不可用,
|
|
68
74
|
本技能核心流程(原稿转文本、封面、发布清单)不受影响:
|
|
69
75
|
|
|
70
|
-
- `topmind-x`:280
|
|
76
|
+
- `topmind-x`:280 字短推文发布(xurl 只覆盖短推文,不发长文)。
|
|
71
77
|
- `topmind-capture`:「只想存档不发布」时的收录路由。
|
|
@@ -5,16 +5,19 @@ X Article 编辑器对 markdown 支持弱且不稳定。`md2x.py` 按以下规
|
|
|
5
5
|
|
|
6
6
|
| Markdown | 转后 |
|
|
7
7
|
|----------|------|
|
|
8
|
-
| `#`
|
|
9
|
-
|
|
|
10
|
-
|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
|
|
|
15
|
-
|
|
|
16
|
-
|
|
|
17
|
-
|
|
|
8
|
+
| `#` ~ `######` 标题 | 纯文本行,前后各一空行;标题内的加粗/链接等标记同样剥离 |
|
|
9
|
+
| `标题` + 下一行 `===`(Setext 一级标题) | 同上,转纯文本标题行 |
|
|
10
|
+
| `**加粗**` / `*斜体*` / `***粗斜体***` / `~~删除线~~` / `` `行内代码` `` | 去标记留文字(语气靠文字本身) |
|
|
11
|
+
| `[文字](url)` | `文字(url)`;url 里可含括号,`[文字](url "title")` 的 title 丢弃 |
|
|
12
|
+
| `[文字][ref]` / `[文字][]` / `[文字]`(引用式,有 `[ref]: url` 定义) | `文字(url)`;定义行不进正文 |
|
|
13
|
+
| `` / `![alt][ref]` | `[图N]`,文末附"配图清单"(`[图N] alt`;alt 为空记 `(未写图注)`) |
|
|
14
|
+
| `-` / `1.` 列表 | 保留原符号(含嵌套缩进);`- [ ]` / `- [x]` 任务列表都去勾选框留 `-`(完成状态不保留) |
|
|
15
|
+
| `>` 引用 | 去掉所有层级的 `>` 留文字;引用内的标题照转 |
|
|
16
|
+
| 表格(行首以 `|` 开头的标准写法;不带前导 `|` 的表格暂不转换) | 逐行转"项:值"列表(单元格内标记同样剥离);分隔行丢弃,复杂表请手动拆成段落 |
|
|
17
|
+
| `---` / `***` / `___` 分割线(允许空格,如 `* * *`) | 空行 |
|
|
18
|
+
| 代码块(``` 围栏;`~~~` 围栏暂不支持) | 去围栏,内容缩进 4 格;块内行原样保留(块内的 `---` 不再误判分割线) |
|
|
19
|
+
| `\` 转义(如 `\*`) | 去反斜杠留字符 |
|
|
20
|
+
| frontmatter | 整块丢弃(块内非空行须都含 `:` 才认定,文首的 `---` 分割线不会被误吞) |
|
|
18
21
|
|
|
19
22
|
## 写作时就按 X 想
|
|
20
23
|
|