@topmindspace/tms-skills 0.4.1 → 0.6.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.
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env python3
2
+ """md2x-html.py — Markdown 原稿 → 可直接复制的 X 长文 HTML(单文件)。
3
+
4
+ 用法:
5
+ python3 md2x-html.py 原稿.md --out X长文.html \\
6
+ --images 样张1.png 样张2.png ... [--cover cover-1500x600.png]
7
+
8
+ 约定(与 md2x.py 对齐):
9
+ - `[图N]` 独占一行 → 按顺序取 --images[N-1],转成内嵌 data-URI 的 <figure>;
10
+ 点击图片弹出 lightbox 放大查看原图(右键可另存/截图,不拦截右键菜单)
11
+ - ``` 围栏代码块(直出提示词)→ <blockquote> + 「复制提示词」按钮。
12
+ 原因:X 文章编辑器没有"粘贴 <pre> 即代码块"的识别,原生代码块只能走
13
+ Insert 菜单;<blockquote> 粘贴后即 X 原生引用块,格式不丢、读者全选即拷。
14
+ 支持嵌套围栏(```` 开栏不被内层 ``` 提前闭合);未闭合围栏在文末自动收尾
15
+ - `#` → h1(X 标题栏专用,复制全文时自动剔除);`##`/`###` → h2/h3
16
+ (对应 X 的"标题/副标题"两级)
17
+ - `- ` / `1. ` → ul/ol;`**x**` → strong;`` `x` `` → code(X 粘贴后变纯文本);
18
+ `---` → hr(X 粘贴可能丢弃,需手动 Insert → 分割线,见 references/x-html-format.md)
19
+ - GFM 表格 → ul.xtable(X 粘贴会丢弃 <table>,转列表保内容)
20
+ - frontmatter(--- ... ---)跳过,只取 title
21
+
22
+ 设计目标(对标 md2wechat 的公众号体验):
23
+ - 文本+格式:页顶「一键复制全文」按钮 → 剪贴板富文本 → 粘贴进 X 文章编辑器。
24
+ 复制前自动剔除按钮/UI 与 .no-paste 块(标题、封面预览),只剩 X 可识别的
25
+ 语义标签:h2/h3/p/ul/ol/blockquote/a/hr/img
26
+ - 图片:data-URI 内嵌(X 若支持则随粘贴带入);每张图带 [图N] 编号 + 「下载图片」
27
+ 按钮保底,图片没跟过去时按编号下载再上传,不用找文件、不用对顺序;
28
+ 点击图片 lightbox 放大看原图,方便核对、截图或右键另存
29
+ - 封面:X 有独立封面上传入口,始终单独文件;HTML 顶部仅作预览 + 下载链接
30
+
31
+ 只用 Python 标准库。
32
+ """
33
+
34
+ import argparse
35
+ import base64
36
+ import html
37
+ import os
38
+ import re
39
+ import sys
40
+
41
+ CSS = """
42
+ :root { color-scheme: light; }
43
+ * { box-sizing: border-box; }
44
+ body { margin: 0; background: #f7f9fa; color: #0f1419;
45
+ font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; }
46
+ .toolbar { position: sticky; top: 0; z-index: 50; background: rgba(255,255,255,.96);
47
+ border-bottom: 1px solid #eff3f4; padding: 10px 16px;
48
+ display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
49
+ .toolbar .hint { font-size: 13px; color: #536471; }
50
+ .toolbar .steps { font-size: 13px; color: #0f1419; font-weight: 700; }
51
+ .btn { appearance: none; border: 1px solid #cfd9de; background: #fff; color: #0f1419;
52
+ border-radius: 9999px; padding: 8px 18px; font-size: 14px; font-weight: 700;
53
+ cursor: pointer; }
54
+ .btn.primary { background: #0f1419; color: #fff; border-color: #0f1419; }
55
+ .btn:active { transform: scale(.97); }
56
+ .article { max-width: 680px; margin: 0 auto; background: #fff;
57
+ padding: 32px 28px 64px; }
58
+ .article h1 { font-size: 26px; line-height: 1.4; margin: 0 0 20px; }
59
+ .article h2 { font-size: 20px; margin: 34px 0 12px; padding-top: 6px; }
60
+ .article h3 { font-size: 18px; margin: 26px 0 10px; }
61
+ .article p { font-size: 17px; line-height: 1.85; margin: 0 0 14px; word-break: break-word; }
62
+ .article ul, .article ol { font-size: 17px; line-height: 1.85; margin: 0 0 14px; padding-left: 1.4em; }
63
+ .article hr { border: none; border-top: 1px solid #eff3f4; margin: 28px 0; }
64
+ .article a { color: #1d9bf0; }
65
+ .article code { background: #f1f3f4; border-radius: 4px; padding: 1px 6px;
66
+ font-size: .92em; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
67
+ .article ul.xtable { list-style: none; padding-left: 0; }
68
+ .article ul.xtable li { background: #f7f9fa; border: 1px solid #eff3f4;
69
+ border-radius: 8px; padding: 8px 12px; margin-bottom: 8px; }
70
+ .cover-note { background: #f7f9fa; border: 1px dashed #cfd9de; border-radius: 12px;
71
+ padding: 14px 16px; font-size: 14px; color: #536471; margin-bottom: 24px; }
72
+ .cover-note img { width: 100%; border-radius: 8px; display: block; margin-bottom: 10px;
73
+ cursor: zoom-in; }
74
+ figure.ximg { margin: 22px 0; }
75
+ figure.ximg img { width: 100%; border-radius: 10px; display: block;
76
+ border: 1px solid #eff3f4; cursor: zoom-in; }
77
+ figure.ximg figcaption { display: flex; justify-content: space-between; align-items: center;
78
+ margin-top: 8px; font-size: 13px; color: #536471; }
79
+ .prompt { position: relative; margin: 14px 0 20px; }
80
+ .prompt blockquote { margin: 0; background: #f7f9fa; border-left: 4px solid #1d9bf0;
81
+ border-radius: 0 10px 10px 0; padding: 16px 18px;
82
+ font-size: 14px; line-height: 1.8; }
83
+ .prompt blockquote code { background: none; padding: 0; font-size: 1em;
84
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
85
+ .prompt .btn { position: absolute; top: 8px; right: 8px; padding: 5px 12px;
86
+ font-size: 12px; }
87
+ .toast { position: fixed; left: 50%; bottom: 40px; transform: translateX(-50%);
88
+ background: #0f1419; color: #fff; font-size: 14px; padding: 10px 20px;
89
+ border-radius: 9999px; opacity: 0; transition: opacity .25s; pointer-events: none;
90
+ z-index: 200; }
91
+ /* 图片 lightbox:点击放大看原图;不拦截右键,可右键另存/截图 */
92
+ .lightbox { position: fixed; inset: 0; z-index: 100; background: rgba(10,14,20,.93);
93
+ display: none; align-items: center; justify-content: center; padding: 24px; }
94
+ .lightbox.open { display: flex; }
95
+ .lb-inner { max-width: 96vw; max-height: 94vh; display: flex; flex-direction: column;
96
+ gap: 10px; align-items: center; }
97
+ .lb-inner img { max-width: 94vw; max-height: 76vh; border-radius: 8px; background: #fff; }
98
+ .lb-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
99
+ .lb-cap { color: #cfd9de; font-size: 13px; }
100
+ .lb-tip { color: #8a93a6; font-size: 12px; }
101
+ """
102
+
103
+ JS = """
104
+ var lbFname = '';
105
+ function toast(msg){ var t=document.getElementById('toast'); t.textContent=msg;
106
+ t.style.opacity=1; setTimeout(function(){ t.style.opacity=0; }, 1800); }
107
+ function cleanClone(){
108
+ var el=document.getElementById('article');
109
+ var c=el.cloneNode(true);
110
+ var kill=c.querySelectorAll('button, .no-paste');
111
+ for(var i=0;i<kill.length;i++){ kill[i].parentNode.removeChild(kill[i]); }
112
+ return c;
113
+ }
114
+ async function copyArticle(){
115
+ var c=cleanClone();
116
+ var htm='<meta charset="utf-8">'+c.innerHTML;
117
+ try{
118
+ var item=new ClipboardItem({
119
+ 'text/html': new Blob([htm],{type:'text/html'}),
120
+ 'text/plain': new Blob([c.innerText],{type:'text/plain'})
121
+ });
122
+ await navigator.clipboard.write([item]);
123
+ toast('已复制全文(含格式),去 X 粘贴吧');
124
+ }catch(e){
125
+ try{ await navigator.clipboard.writeText(c.innerText); toast('已复制纯文本(富文本复制失败)'); }
126
+ catch(e2){ toast('复制失败,请手动全选复制'); }
127
+ }
128
+ }
129
+ async function copyPrompt(btn){
130
+ var code=btn.parentElement.querySelector('code').innerText;
131
+ try{ await navigator.clipboard.writeText(code); toast('提示词已复制,去 AI 生图工具粘贴'); }
132
+ catch(e){ toast('复制失败,请手动复制'); }
133
+ }
134
+ function dlImg(btn){
135
+ var fig=btn.closest('figure');
136
+ var img=fig ? fig.querySelector('img') : document.querySelector('#coverbox img');
137
+ if(!img){ toast('找不到图片'); return; }
138
+ var a=document.createElement('a');
139
+ a.href=img.src; a.download=btn.getAttribute('data-fname') || 'image.png';
140
+ document.body.appendChild(a); a.click(); a.remove();
141
+ toast('开始下载 '+(btn.getAttribute('data-fname')||'图片'));
142
+ }
143
+ /* lightbox:点击图片放大看原图。刻意不拦截右键,放大后可右键另存 / 直接截图。 */
144
+ function openLightbox(img){
145
+ var lb=document.getElementById('lightbox');
146
+ document.getElementById('lbimg').src=img.src;
147
+ document.getElementById('lbimg').alt=img.alt||'原图';
148
+ lbFname=img.getAttribute('data-fname')||'image.png';
149
+ document.getElementById('lbcap').textContent=(img.alt||'原图')+' · '+lbFname;
150
+ lb.classList.add('open');
151
+ document.body.style.overflow='hidden';
152
+ }
153
+ function closeLightbox(){
154
+ document.getElementById('lightbox').classList.remove('open');
155
+ document.body.style.overflow='';
156
+ }
157
+ function openFullsize(){
158
+ var src=document.getElementById('lbimg').src;
159
+ var parts=src.split(','), mime='image/png';
160
+ var m=parts[0].match(/:(.*?);/); if(m){ mime=m[1]; }
161
+ try{
162
+ var bin=atob(parts[1]), arr=new Uint8Array(bin.length);
163
+ for(var i=0;i<bin.length;i++){ arr[i]=bin.charCodeAt(i); }
164
+ var url=URL.createObjectURL(new Blob([arr],{type:mime}));
165
+ window.open(url,'_blank');
166
+ }catch(e){ toast('打开失败,可右键图片另存'); }
167
+ }
168
+ function dlLightbox(){
169
+ var a=document.createElement('a');
170
+ a.href=document.getElementById('lbimg').src; a.download=lbFname;
171
+ document.body.appendChild(a); a.click(); a.remove();
172
+ toast('开始下载 '+lbFname);
173
+ }
174
+ document.addEventListener('keydown', function(e){
175
+ if(e.key==='Escape'){ closeLightbox(); }
176
+ });
177
+ """
178
+
179
+ HTML_TMPL = """<!DOCTYPE html>
180
+ <html lang="zh-CN">
181
+ <head>
182
+ <meta charset="utf-8">
183
+ <meta name="viewport" content="width=device-width, initial-scale=1">
184
+ <title>@@TITLE@@</title>
185
+ <style>@@CSS@@</style>
186
+ </head>
187
+ <body>
188
+ <div class="toolbar">
189
+ <span class="steps">1. 标题填入 X 标题栏 → 2. 一键复制全文 → 3. 粘贴到 X 正文</span>
190
+ <button class="btn primary" onclick="copyArticle()">一键复制全文</button>
191
+ <span class="hint">图片随粘贴带入则直接用;没带入就按 [图N] 下载上传。封面始终单独上传。点击图片可放大查看原图。</span>
192
+ </div>
193
+ <article class="article" id="article">
194
+ @@BODY@@
195
+ </article>
196
+ <div class="lightbox" id="lightbox" onclick="if(event.target===this)closeLightbox()">
197
+ <div class="lb-inner">
198
+ <img id="lbimg" alt="原图">
199
+ <div class="lb-bar">
200
+ <span class="lb-cap" id="lbcap"></span>
201
+ <button class="btn" onclick="openFullsize()">新标签页打开原图</button>
202
+ <button class="btn" onclick="dlLightbox()">下载原图</button>
203
+ <button class="btn" onclick="closeLightbox()">关闭</button>
204
+ </div>
205
+ <div class="lb-tip">可右键图片另存 / 直接截图 · Esc 关闭</div>
206
+ </div>
207
+ </div>
208
+ <div class="toast" id="toast"></div>
209
+ <script>@@JS@@</script>
210
+ </body>
211
+ </html>
212
+ """
213
+
214
+ COVER_FNAME = "cover-1500x600.png"
215
+
216
+
217
+ def data_uri(path):
218
+ try:
219
+ with open(path, "rb") as f:
220
+ raw = f.read()
221
+ except OSError:
222
+ print("图片读取失败:%s" % path, file=sys.stderr)
223
+ sys.exit(1)
224
+ ext = os.path.splitext(path)[1].lower()
225
+ mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
226
+ "webp": "image/webp", "gif": "image/gif"}.get(ext.lstrip("."), "image/png")
227
+ return "data:%s;base64,%s" % (mime, base64.b64encode(raw).decode("ascii"))
228
+
229
+
230
+ def inline_md(s):
231
+ """行内 markdown → html:加粗、行内代码、裸链接。"""
232
+ s = html.escape(s)
233
+ s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
234
+ s = re.sub(r"`([^`]+?)`", r"<code>\1</code>", s)
235
+ s = re.sub(r"(https?://[^\s<>()\uff08\uff09\u3010\u3011\u300c\u300d\u300e\u300f]+)",
236
+ r'<a href="\1">\1</a>', s)
237
+ return s
238
+
239
+
240
+ def md_to_html(md_text, images, cover):
241
+ lines = md_text.split("\n")
242
+ out = []
243
+ cover_uri = data_uri(cover) if cover else None
244
+
245
+ i = 0
246
+ if lines and lines[0].strip() == "---":
247
+ i = 1
248
+ while i < len(lines) and lines[i].strip() != "---":
249
+ i += 1
250
+ i += 1
251
+
252
+ in_fence = None # 开栏的反引号串(如 '```');None 表示不在围栏内
253
+ code_buf = []
254
+ in_ul = False
255
+ in_ol = False
256
+ in_table = False
257
+ table_buf = []
258
+
259
+ def close_lists():
260
+ nonlocal in_ul, in_ol
261
+ if in_ul:
262
+ out.append("</ul>")
263
+ in_ul = False
264
+ if in_ol:
265
+ out.append("</ol>")
266
+ in_ol = False
267
+
268
+ def flush_table():
269
+ nonlocal in_table, table_buf
270
+ if not in_table:
271
+ return
272
+ rows = []
273
+ for r in table_buf:
274
+ cells = [c.strip() for c in r.strip().strip("|").split("|")]
275
+ rows.append(cells)
276
+ rows = [r for r in rows if not all(re.fullmatch(r":?-+:?", c or "-") for c in r)]
277
+ if rows:
278
+ out.append('<ul class="xtable">')
279
+ for j, r in enumerate(rows):
280
+ cells = " | ".join(inline_md(c) for c in r)
281
+ if j == 0:
282
+ out.append("<li><strong>%s</strong></li>" % cells)
283
+ else:
284
+ out.append("<li>%s</li>" % cells)
285
+ out.append("</ul>")
286
+ in_table = False
287
+ table_buf = []
288
+
289
+ def flush_prompt():
290
+ body = "\n".join(code_buf)
291
+ esc = html.escape(body).replace("\n", "<br>")
292
+ out.append(
293
+ '<div class="prompt"><blockquote><code>%s</code></blockquote>'
294
+ '<button class="btn" onclick="copyPrompt(this)">复制提示词</button>'
295
+ "</div>" % esc
296
+ )
297
+
298
+ while i < len(lines):
299
+ line = lines[i]
300
+ s = line.strip()
301
+
302
+ fm = re.match(r"^(`{3,})", s)
303
+ if fm:
304
+ fence = fm.group(1)
305
+ if in_fence is None:
306
+ # 开栏
307
+ close_lists()
308
+ flush_table()
309
+ in_fence = fence
310
+ code_buf = []
311
+ elif len(fence) >= len(in_fence) and s.strip("`") == "":
312
+ # 闭栏:纯反引号行且长度不短于开栏(内层短围栏视为代码内容)
313
+ flush_prompt()
314
+ in_fence = None
315
+ code_buf = []
316
+ else:
317
+ code_buf.append(line)
318
+ i += 1
319
+ continue
320
+ if in_fence is not None:
321
+ code_buf.append(line)
322
+ i += 1
323
+ continue
324
+
325
+ if re.match(r"^\|.*\|\s*$", s) and "|" in s.strip("|"):
326
+ if not in_table:
327
+ close_lists()
328
+ in_table = True
329
+ table_buf = []
330
+ table_buf.append(s)
331
+ i += 1
332
+ continue
333
+ else:
334
+ flush_table()
335
+
336
+ m = re.fullmatch(r"\[图(\d+)\]", s)
337
+ if m:
338
+ close_lists()
339
+ n = int(m.group(1))
340
+ if 1 <= n <= len(images):
341
+ uri = data_uri(images[n - 1])
342
+ fname = "图%d-%s" % (n, os.path.basename(images[n - 1]))
343
+ out.append(
344
+ '<figure class="ximg"><img src="%s" alt="图%d" data-fname="%s" '
345
+ 'onclick="openLightbox(this)">'
346
+ '<figcaption><span class="cap">[图%d]</span>'
347
+ '<button class="btn" data-fname="%s" '
348
+ 'onclick="dlImg(this)">下载图片</button></figcaption></figure>'
349
+ % (uri, n, html.escape(fname, quote=True), n,
350
+ html.escape(fname, quote=True))
351
+ )
352
+ else:
353
+ out.append("<p>[图%d](图片缺失)</p>" % n)
354
+ i += 1
355
+ continue
356
+
357
+ if s.startswith("### "):
358
+ close_lists()
359
+ out.append("<h3>%s</h3>" % inline_md(s[4:]))
360
+ i += 1
361
+ continue
362
+ if s.startswith("## "):
363
+ close_lists()
364
+ out.append("<h2>%s</h2>" % inline_md(s[3:]))
365
+ i += 1
366
+ continue
367
+ if s.startswith("# "):
368
+ close_lists()
369
+ out.append('<h1 class="no-paste">%s</h1>' % inline_md(s[2:]))
370
+ i += 1
371
+ continue
372
+
373
+ if s in ("---", "***"):
374
+ close_lists()
375
+ out.append("<hr>")
376
+ i += 1
377
+ continue
378
+
379
+ m = re.match(r"^(\d+)\.\s+(.*)$", s)
380
+ if m:
381
+ if in_ul:
382
+ out.append("</ul>")
383
+ in_ul = False
384
+ if not in_ol:
385
+ out.append("<ol>")
386
+ in_ol = True
387
+ out.append("<li>%s</li>" % inline_md(m.group(2)))
388
+ i += 1
389
+ continue
390
+
391
+ if s.startswith("- "):
392
+ if in_ol:
393
+ out.append("</ol>")
394
+ in_ol = False
395
+ if not in_ul:
396
+ out.append("<ul>")
397
+ in_ul = True
398
+ out.append("<li>%s</li>" % inline_md(s[2:]))
399
+ i += 1
400
+ continue
401
+
402
+ if not s:
403
+ close_lists()
404
+ i += 1
405
+ continue
406
+
407
+ close_lists()
408
+ out.append("<p>%s</p>" % inline_md(s))
409
+ i += 1
410
+
411
+ close_lists()
412
+ flush_table()
413
+ if in_fence is not None:
414
+ # 未闭合围栏:文末自动收尾,不丢内容
415
+ flush_prompt()
416
+
417
+ body = "\n".join(out)
418
+
419
+ cover_html = ""
420
+ if cover_uri:
421
+ cover_html = (
422
+ '<div class="cover-note no-paste" id="coverbox">'
423
+ '<img src="%s" alt="封面预览(点击放大)" data-fname="%s" onclick="openLightbox(this)">'
424
+ '<div>封面图预览(1500×600 · 5:2)。X 文章有独立封面上传入口,'
425
+ '请单独上传,不要随正文粘贴。<br>'
426
+ '<button class="btn" data-fname="%s" '
427
+ 'onclick="dlImg(this)">下载封面图</button></div></div>'
428
+ ) % (cover_uri, COVER_FNAME, COVER_FNAME)
429
+ return cover_html + "\n" + body
430
+
431
+
432
+ def main():
433
+ ap = argparse.ArgumentParser(description="Markdown 原稿 → X 长文 HTML(一键复制)")
434
+ ap.add_argument("md", help="原稿 markdown 路径")
435
+ ap.add_argument("--out", required=True, help="输出 HTML 路径")
436
+ ap.add_argument("--images", nargs="*", default=[], help="[图N] 对应的图片路径(按顺序)")
437
+ ap.add_argument("--cover", default=None, help="封面图路径(1500×600,5:2)")
438
+ args = ap.parse_args()
439
+
440
+ if not os.path.isfile(args.md):
441
+ print("输入文件不存在:%s" % args.md, file=sys.stderr)
442
+ sys.exit(1)
443
+ for p in args.images:
444
+ if not os.path.isfile(p):
445
+ print("图片不存在:%s" % p, file=sys.stderr)
446
+ sys.exit(1)
447
+ if args.cover and not os.path.isfile(args.cover):
448
+ print("封面图不存在:%s" % args.cover, file=sys.stderr)
449
+ sys.exit(1)
450
+
451
+ with open(args.md, encoding="utf-8") as f:
452
+ md_text = f.read()
453
+
454
+ # 标题取 frontmatter 之后的第一个 #(frontmatter 里可能有 # 开头的行)
455
+ body_lines = md_text.split("\n")
456
+ if body_lines and body_lines[0].strip() == "---":
457
+ j = 1
458
+ while j < len(body_lines) and body_lines[j].strip() != "---":
459
+ j += 1
460
+ body_lines = body_lines[j + 1:]
461
+ m = re.search(r"^#\s+(.+)$", "\n".join(body_lines), re.M)
462
+ title = m.group(1).strip() if m else "X长文"
463
+
464
+ body = md_to_html(md_text, args.images, args.cover)
465
+ page = HTML_TMPL.replace("@@TITLE@@", html.escape(title)) \
466
+ .replace("@@CSS@@", CSS) \
467
+ .replace("@@BODY@@", body) \
468
+ .replace("@@JS@@", JS)
469
+
470
+ out_dir = os.path.dirname(os.path.abspath(args.out))
471
+ os.makedirs(out_dir, exist_ok=True)
472
+ tmp = args.out + ".tmp"
473
+ try:
474
+ with open(tmp, "w", encoding="utf-8") as f:
475
+ f.write(page)
476
+ os.replace(tmp, args.out)
477
+ except OSError as e:
478
+ print("写入失败:%s" % e, file=sys.stderr)
479
+ sys.exit(1)
480
+ print("已生成:%s" % args.out)
481
+
482
+
483
+ if __name__ == "__main__":
484
+ main()