@openschool_01/skills 0.1.3 → 0.1.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/bin/openschool-skills.js +14 -2
- package/bundled-skills/wechat-assistant/skill/OPENSCHOOL-META.md +11 -10
- package/bundled-skills/wechat-assistant/skill/SKILL.md +70 -66
- package/bundled-skills/wechat-assistant/skill/config/wechat-publish-api.example.json +6 -0
- package/bundled-skills/wechat-assistant/skill/scripts/__pycache__/render_wechat_api_content.cpython-314.pyc +0 -0
- package/bundled-skills/wechat-assistant/skill/scripts/render_wechat_api_content.py +191 -0
- package/bundled-skills/wechat-assistant/skill/scripts/render_wechat_html.py +203 -139
- package/bundled-skills/wechat-assistant/skill/scripts/wechat_html_api_publish.py +288 -0
- package/package.json +1 -1
- package/registry/skills-registry.json +16 -78
package/bin/openschool-skills.js
CHANGED
|
@@ -63,8 +63,13 @@ function loadRegistry() {
|
|
|
63
63
|
const registryPath = fs.existsSync(BUNDLED_REGISTRY_PATH)
|
|
64
64
|
? BUNDLED_REGISTRY_PATH
|
|
65
65
|
: WORKSPACE_REGISTRY_PATH;
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
const raw = fs.readFileSync(registryPath, "utf8").replace(/^\uFEFF/u, "");
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
throw new Error(`技能注册表解析失败 (${registryPath}): ${message}`);
|
|
72
|
+
}
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
function getRegistryEntry(slug) {
|
|
@@ -295,6 +300,13 @@ function installFromClawHub(entry, workspaceDir) {
|
|
|
295
300
|
}
|
|
296
301
|
|
|
297
302
|
const targetDir = path.join(workspaceDir, "skills", entry.slug);
|
|
303
|
+
const normalizedInstalledSlug = clawhubSlug.includes("/") ? clawhubSlug.split("/").pop() : clawhubSlug;
|
|
304
|
+
const installedDir = path.join(workspaceDir, "skills", normalizedInstalledSlug || clawhubSlug);
|
|
305
|
+
|
|
306
|
+
if (!fs.existsSync(targetDir) && fs.existsSync(installedDir)) {
|
|
307
|
+
ensureDirectory(path.dirname(targetDir));
|
|
308
|
+
fs.renameSync(installedDir, targetDir);
|
|
309
|
+
}
|
|
298
310
|
|
|
299
311
|
if (!fs.existsSync(targetDir)) {
|
|
300
312
|
throw new Error(`ClawHub 安装完成后未找到技能目录: ${targetDir}`);
|
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
- 收录名称:公众号助手
|
|
4
4
|
- 收录方式:OpenSchool 严选整合
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
当前版本整合了公众号写作流程里最常用的三块能力:
|
|
5
|
+
- 当前版本:内容与草稿箱双模版
|
|
8
6
|
|
|
7
|
+
本版本重点能力:
|
|
9
8
|
1. 标题方向整理
|
|
10
|
-
2.
|
|
11
|
-
3.
|
|
12
|
-
|
|
13
|
-
本版本刻意不包含:
|
|
9
|
+
2. 正文写作与质量检查
|
|
10
|
+
3. 公众号预览 HTML 渲染(默认无尾注)
|
|
11
|
+
4. 可选上传草稿箱(用户显式提供 appid/appsecret 时执行)
|
|
14
12
|
|
|
13
|
+
默认不包含:
|
|
15
14
|
- 生图
|
|
16
|
-
-
|
|
17
|
-
-
|
|
15
|
+
- 封面自动生成
|
|
16
|
+
- 自动正式发布
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
上线约束:
|
|
19
|
+
- 不内置任何个人信息尾注
|
|
20
|
+
- 不在仓库存储真实公众号凭据
|
|
@@ -2,108 +2,112 @@
|
|
|
2
2
|
|
|
3
3
|
适用于把一个选题整理成可发布到微信公众号的完整内容稿。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
当前版本重点做 5 件事:
|
|
6
|
+
1. 选题和标题方向整理
|
|
7
|
+
2. 正文 Markdown 写作
|
|
8
|
+
3. 微信预览 HTML 渲染
|
|
9
|
+
4. 文章质量检查
|
|
10
|
+
5. 可选上传到公众号草稿箱(需要用户主动提供凭据)
|
|
11
|
+
|
|
12
|
+
默认不做:
|
|
14
13
|
- 生图
|
|
15
|
-
-
|
|
16
|
-
-
|
|
14
|
+
- 封面图自动生成
|
|
15
|
+
- 自动正式发布
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
默认不追加任何个人尾注信息。
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
## 什么时候用
|
|
21
20
|
|
|
22
21
|
- 写公众号文章
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
22
|
+
- 把直播内容整理成公众号稿件
|
|
23
|
+
- 生成公众号标题候选
|
|
24
|
+
- 输出可预览、可复制到后台的 HTML
|
|
25
|
+
- 需要通过 API 上传到草稿箱
|
|
27
26
|
|
|
28
|
-
##
|
|
27
|
+
## 推荐流程
|
|
29
28
|
|
|
30
|
-
### 1.
|
|
29
|
+
### 1. 收集输入
|
|
31
30
|
|
|
32
31
|
至少确认这 4 个信息:
|
|
33
|
-
|
|
34
32
|
- 文章主题
|
|
35
33
|
- 目标读者
|
|
36
|
-
-
|
|
34
|
+
- 读者当前最常见问题
|
|
37
35
|
- 这篇文章承诺解决什么
|
|
38
36
|
|
|
39
|
-
### 2.
|
|
40
|
-
|
|
41
|
-
优先输出 3 到 5 个标题,并解释它们分别适合:
|
|
42
|
-
|
|
43
|
-
- 偏方法拆解
|
|
44
|
-
- 偏观点判断
|
|
45
|
-
- 偏案例复盘
|
|
37
|
+
### 2. 先出标题方向
|
|
46
38
|
|
|
47
|
-
|
|
39
|
+
输出 3 到 5 个标题,并说明适用场景。
|
|
48
40
|
|
|
49
|
-
|
|
41
|
+
### 3. 再写正文 Markdown
|
|
50
42
|
|
|
51
|
-
|
|
43
|
+
正文建议:
|
|
44
|
+
- 4 到 6 个二级标题
|
|
45
|
+
- 先给判断,再解释
|
|
46
|
+
- 至少 1 个真实场景
|
|
47
|
+
- 至少 1 个常见误区
|
|
48
|
+
- 至少 1 组可执行动作
|
|
52
49
|
|
|
53
|
-
|
|
50
|
+
### 4. 渲染公众号 HTML(无尾注)
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
- 至少包含 1 个常见误区
|
|
59
|
-
- 至少包含 1 组可执行动作
|
|
60
|
-
- 结尾自然引向下一篇或下一步动作
|
|
52
|
+
```bash
|
|
53
|
+
python scripts/render_wechat_html.py --input "<article.md>" --output "<article>.html"
|
|
54
|
+
```
|
|
61
55
|
|
|
62
|
-
|
|
56
|
+
### 5. 质量检查(可选)
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
```bash
|
|
59
|
+
python scripts/audit_article_quality.py --input "<article.md>"
|
|
60
|
+
```
|
|
65
61
|
|
|
66
|
-
###
|
|
62
|
+
### 6. 上传草稿箱(可选)
|
|
67
63
|
|
|
68
|
-
|
|
64
|
+
当用户明确需要上传草稿箱时,使用:
|
|
69
65
|
|
|
70
66
|
```bash
|
|
71
|
-
python scripts/
|
|
67
|
+
python scripts/wechat_html_api_publish.py \
|
|
68
|
+
--markdown "<article.md>" \
|
|
69
|
+
--config "config/wechat-publish-api.example.json" \
|
|
70
|
+
--dry-run
|
|
72
71
|
```
|
|
73
72
|
|
|
74
|
-
|
|
73
|
+
确认 dry-run 输出没问题后,再去掉 `--dry-run`。
|
|
75
74
|
|
|
76
|
-
|
|
75
|
+
也可以直接传入账号参数:
|
|
77
76
|
|
|
78
77
|
```bash
|
|
79
|
-
python scripts/
|
|
78
|
+
python scripts/wechat_html_api_publish.py \
|
|
79
|
+
--markdown "<article.md>" \
|
|
80
|
+
--appid "<appid>" \
|
|
81
|
+
--appsecret "<appsecret>" \
|
|
82
|
+
--author "<author>"
|
|
80
83
|
```
|
|
81
84
|
|
|
85
|
+
## 安全约束
|
|
86
|
+
|
|
87
|
+
- 不要把用户的 appid/appsecret 写死进脚本。
|
|
88
|
+
- 不要把真实凭据提交到仓库。
|
|
89
|
+
- 默认使用示例配置文件,用户自行填写本地私密配置。
|
|
90
|
+
|
|
82
91
|
## 目录说明
|
|
83
92
|
|
|
84
|
-
- `references/title-guidelines.md
|
|
85
|
-
|
|
86
|
-
- `
|
|
87
|
-
|
|
88
|
-
- `
|
|
89
|
-
|
|
90
|
-
- `
|
|
91
|
-
|
|
92
|
-
- `scripts/
|
|
93
|
-
Markdown 转微信 HTML
|
|
94
|
-
- `scripts/audit_article_quality.py`
|
|
95
|
-
质量检查脚本
|
|
93
|
+
- `references/title-guidelines.md`:标题参考
|
|
94
|
+
- `references/writing-rules.md`:正文写作规则
|
|
95
|
+
- `assets/wechat-article-template.html`:公众号预览模板
|
|
96
|
+
- `config/wechat-style-config.json`:样式配置
|
|
97
|
+
- `config/wechat-publish-api.example.json`:草稿箱上传配置示例
|
|
98
|
+
- `scripts/render_wechat_html.py`:Markdown 转公众号预览 HTML(无尾注)
|
|
99
|
+
- `scripts/render_wechat_api_content.py`:Markdown 转 API 友好内容片段
|
|
100
|
+
- `scripts/wechat_html_api_publish.py`:调用公众号接口创建草稿
|
|
101
|
+
- `scripts/audit_article_quality.py`:质量检查
|
|
96
102
|
|
|
97
103
|
## 交付要求
|
|
98
104
|
|
|
99
105
|
默认交付:
|
|
100
|
-
|
|
101
|
-
- 3 到 5 个标题备选
|
|
106
|
+
- 3 到 5 个标题候选
|
|
102
107
|
- 1 篇 Markdown 正文
|
|
103
|
-
- 1 个 HTML
|
|
104
|
-
|
|
105
|
-
如果脚本已经执行,还要明确告诉用户:
|
|
108
|
+
- 1 个 HTML 预览文件
|
|
106
109
|
|
|
107
|
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
+
如果执行了草稿箱上传,还要明确返回:
|
|
111
|
+
- 是否 dry-run
|
|
112
|
+
- 草稿接口返回结果
|
|
113
|
+
- 产物文件路径
|
|
Binary file
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render markdown into WeChat Official Account API-friendly HTML fragment."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import html
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
WECHAT_INLINE = {
|
|
12
|
+
"p": "margin:0 0 18px;line-height:1.85em;font-size:16px;color:#222;text-align:justify;word-break:break-word;",
|
|
13
|
+
"h2": "margin:30px 0 14px;line-height:1.75em;font-size:17px;font-weight:700;color:#0f6fff;",
|
|
14
|
+
"h3": "margin:22px 0 12px;line-height:1.75em;font-size:15px;font-weight:700;color:#0f6fff;",
|
|
15
|
+
"li": "margin:0 0 10px;line-height:1.85em;font-size:16px;color:#222;",
|
|
16
|
+
"blockquote": "margin:0 0 18px;padding:10px 14px;background:#f7faff;border-left:3px solid #0f6fff;color:#355070;line-height:1.85em;font-size:15px;",
|
|
17
|
+
"pre": "margin:0 0 18px;padding:12px 14px;background:#f7f8fa;border:0;border-left:3px solid rgba(15,111,255,.45);overflow-x:auto;",
|
|
18
|
+
"code": "font-size:14px;line-height:1.75em;color:#333;font-family:Consolas,Menlo,monospace;white-space:pre-wrap;",
|
|
19
|
+
"sep": "margin:22px 0 20px;text-align:center;color:#bbb;letter-spacing:1px;",
|
|
20
|
+
"cta": "margin-top:28px;padding-top:14px;border-top:1px solid #e6e6e6;",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def convert_inline(text: str) -> str:
|
|
25
|
+
text = html.escape(text)
|
|
26
|
+
text = re.sub(r"`([^`]+)`", r"<code style=\"%s\">\1</code>" % WECHAT_INLINE["code"], text)
|
|
27
|
+
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong style=\"color:#0f6fff;font-weight:700;\">\1</strong>", text)
|
|
28
|
+
text = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", text)
|
|
29
|
+
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def markdown_to_api_blocks(content: str):
|
|
34
|
+
lines = content.splitlines()
|
|
35
|
+
title = ""
|
|
36
|
+
blocks = []
|
|
37
|
+
in_code = False
|
|
38
|
+
code = []
|
|
39
|
+
para = []
|
|
40
|
+
quote = []
|
|
41
|
+
list_items: list[str] = []
|
|
42
|
+
list_kind = ""
|
|
43
|
+
has_cta = False
|
|
44
|
+
|
|
45
|
+
def flush_para():
|
|
46
|
+
nonlocal para, has_cta
|
|
47
|
+
if not para:
|
|
48
|
+
return
|
|
49
|
+
txt = re.sub(r"\s+", " ", " ".join(p.strip() for p in para if p.strip())).strip()
|
|
50
|
+
para = []
|
|
51
|
+
if not txt:
|
|
52
|
+
return
|
|
53
|
+
if any(k in txt for k in ["留言", "评论", "关键词", "下一篇", "下篇", "领取"]):
|
|
54
|
+
has_cta = True
|
|
55
|
+
blocks.append(f'<p style="{WECHAT_INLINE["p"]}">{convert_inline(txt)}</p>')
|
|
56
|
+
|
|
57
|
+
def flush_quote():
|
|
58
|
+
nonlocal quote
|
|
59
|
+
if not quote:
|
|
60
|
+
return
|
|
61
|
+
txt = re.sub(r"\s+", " ", " ".join(q.strip() for q in quote if q.strip())).strip()
|
|
62
|
+
quote = []
|
|
63
|
+
if not txt:
|
|
64
|
+
return
|
|
65
|
+
blocks.append(f'<blockquote style="{WECHAT_INLINE["blockquote"]}">{convert_inline(txt)}</blockquote>')
|
|
66
|
+
|
|
67
|
+
def flush_list():
|
|
68
|
+
nonlocal list_items, list_kind
|
|
69
|
+
if not list_items:
|
|
70
|
+
return
|
|
71
|
+
tag = "ol" if list_kind == "ol" else "ul"
|
|
72
|
+
items = "".join(f'<li style="{WECHAT_INLINE["li"]}">{convert_inline(item)}</li>' for item in list_items)
|
|
73
|
+
blocks.append(f"<{tag}>{items}</{tag}>")
|
|
74
|
+
list_items = []
|
|
75
|
+
list_kind = ""
|
|
76
|
+
|
|
77
|
+
for raw in lines:
|
|
78
|
+
s = raw.rstrip()
|
|
79
|
+
st = s.strip()
|
|
80
|
+
if st.startswith("```"):
|
|
81
|
+
flush_para()
|
|
82
|
+
flush_quote()
|
|
83
|
+
flush_list()
|
|
84
|
+
if in_code:
|
|
85
|
+
code_html = html.escape("\n".join(code))
|
|
86
|
+
blocks.append(f'<pre style="{WECHAT_INLINE["pre"]}"><code style="{WECHAT_INLINE["code"]}">{code_html}</code></pre>')
|
|
87
|
+
code = []
|
|
88
|
+
in_code = False
|
|
89
|
+
else:
|
|
90
|
+
in_code = True
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if in_code:
|
|
94
|
+
code.append(s)
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if not st:
|
|
98
|
+
flush_para()
|
|
99
|
+
flush_quote()
|
|
100
|
+
flush_list()
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
if not title and st.startswith("# "):
|
|
104
|
+
flush_para()
|
|
105
|
+
flush_quote()
|
|
106
|
+
flush_list()
|
|
107
|
+
title = st[2:].strip()
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if st.startswith("## "):
|
|
111
|
+
flush_para()
|
|
112
|
+
flush_quote()
|
|
113
|
+
flush_list()
|
|
114
|
+
blocks.append(f'<h2 style="{WECHAT_INLINE["h2"]}">{convert_inline(st[3:])}</h2>')
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
if st.startswith("### "):
|
|
118
|
+
flush_para()
|
|
119
|
+
flush_quote()
|
|
120
|
+
flush_list()
|
|
121
|
+
blocks.append(f'<h3 style="{WECHAT_INLINE["h3"]}">{convert_inline(st[4:])}</h3>')
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if st.startswith("> "):
|
|
125
|
+
flush_para()
|
|
126
|
+
flush_list()
|
|
127
|
+
quote.append(st[2:])
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
if re.match(r"^\d+\.\s+", st):
|
|
131
|
+
flush_para()
|
|
132
|
+
flush_quote()
|
|
133
|
+
kind = "ol"
|
|
134
|
+
if list_kind and list_kind != kind:
|
|
135
|
+
flush_list()
|
|
136
|
+
list_kind = kind
|
|
137
|
+
list_items.append(re.sub(r"^\d+\.\s+", "", st))
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if re.match(r"^[-*]\s+", st):
|
|
141
|
+
flush_para()
|
|
142
|
+
flush_quote()
|
|
143
|
+
kind = "ul"
|
|
144
|
+
if list_kind and list_kind != kind:
|
|
145
|
+
flush_list()
|
|
146
|
+
list_kind = kind
|
|
147
|
+
list_items.append(re.sub(r"^[-*]\s+", "", st))
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
if st == "---":
|
|
151
|
+
flush_para()
|
|
152
|
+
flush_quote()
|
|
153
|
+
flush_list()
|
|
154
|
+
blocks.append(f'<p style="{WECHAT_INLINE["sep"]}">———</p>')
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
para.append(st)
|
|
158
|
+
|
|
159
|
+
flush_para()
|
|
160
|
+
flush_quote()
|
|
161
|
+
flush_list()
|
|
162
|
+
return title, "\n".join(blocks), has_cta
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def render_api_content(markdown: str, title_override: str = "") -> tuple[str, str, bool]:
|
|
166
|
+
title, body, has_cta = markdown_to_api_blocks(markdown)
|
|
167
|
+
title = title_override or title or "公众号文章"
|
|
168
|
+
cta = (
|
|
169
|
+
""
|
|
170
|
+
if has_cta
|
|
171
|
+
else f'<section style="{WECHAT_INLINE["cta"]}"><p style="{WECHAT_INLINE["p"]}color:#355070;">如果这篇内容对你有帮助,欢迎留言关键词,我会继续补充相关资料。</p></section>'
|
|
172
|
+
)
|
|
173
|
+
return title, body + cta, has_cta
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main() -> None:
|
|
177
|
+
parser = argparse.ArgumentParser()
|
|
178
|
+
parser.add_argument("--input", required=True)
|
|
179
|
+
parser.add_argument("--output", required=True)
|
|
180
|
+
parser.add_argument("--title", default="")
|
|
181
|
+
args = parser.parse_args()
|
|
182
|
+
|
|
183
|
+
markdown = Path(args.input).read_text(encoding="utf-8")
|
|
184
|
+
_, html_doc, _ = render_api_content(markdown, args.title)
|
|
185
|
+
out = Path(args.output)
|
|
186
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
out.write_text(html_doc, encoding="utf-8")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Render markdown into WeChat-friendly
|
|
2
|
+
"""Render markdown into WeChat-friendly preview HTML.
|
|
3
|
+
|
|
4
|
+
This version intentionally does NOT append personal footer/tail notes.
|
|
5
|
+
"""
|
|
3
6
|
|
|
4
7
|
from __future__ import annotations
|
|
5
8
|
|
|
@@ -9,163 +12,224 @@ import json
|
|
|
9
12
|
import re
|
|
10
13
|
from pathlib import Path
|
|
11
14
|
|
|
12
|
-
|
|
13
15
|
DEFAULT_STYLE = """
|
|
14
|
-
body { margin: 0; background: #ffffff; color: #
|
|
15
|
-
.wx-wrap { max-width: 720px; margin: 0 auto; padding:
|
|
16
|
-
.wx-title { margin: 0 0
|
|
17
|
-
.wx-subtitle { margin: 0 0
|
|
18
|
-
.wx-content p, .wx-content li { margin: 0 0 14px; font-size: 16px; line-height: 1.
|
|
19
|
-
.wx-content h2
|
|
20
|
-
.wx-content
|
|
21
|
-
.wx-content
|
|
22
|
-
.wx-content
|
|
23
|
-
.wx-content
|
|
24
|
-
.wx-
|
|
25
|
-
.wx-
|
|
26
|
-
.wx-
|
|
27
|
-
.wx-
|
|
28
|
-
.wx-
|
|
16
|
+
body { margin: 0; background: #ffffff; color: #1c2430; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; }
|
|
17
|
+
.wx-wrap { max-width: 720px; margin: 0 auto; padding: 26px 18px 42px; box-sizing: border-box; }
|
|
18
|
+
.wx-title { margin: 0 0 10px; font-size: 30px; line-height: 1.38; font-weight: 700; color: #0f172a; letter-spacing: .2px; }
|
|
19
|
+
.wx-subtitle { margin: 0 0 18px; font-size: 14px; line-height: 1.8; color: #6b7280; }
|
|
20
|
+
.wx-content p, .wx-content li { margin: 0 0 14px; font-size: 16px; line-height: 1.9; color: #1f2937; text-align: justify; text-justify: inter-ideograph; word-break: break-word; }
|
|
21
|
+
.wx-content h2 { margin: 28px 0 14px; padding: 10px 12px; border-left: 4px solid #0f6fff; border-radius: 6px; background: linear-gradient(90deg, rgba(15,111,255,.10), rgba(15,111,255,.02)); font-size: 20px; line-height: 1.6; font-weight: 700; color: #0f172a; }
|
|
22
|
+
.wx-content h3 { margin: 22px 0 10px; font-size: 17px; line-height: 1.7; font-weight: 700; color: #0f6fff; }
|
|
23
|
+
.wx-content strong { color: #0f172a; font-weight: 700; background-image: linear-gradient(transparent 62%, rgba(251,191,36,.35) 0); }
|
|
24
|
+
.wx-content ul, .wx-content ol { margin: 2px 0 16px 1.35em; padding: 0; }
|
|
25
|
+
.wx-content li { margin-bottom: 8px; }
|
|
26
|
+
.wx-content blockquote { margin: 0 0 16px; padding: 12px 14px; border-left: 3px solid rgba(15,111,255,.45); border-radius: 6px; background: #f8fbff; color: #334155; }
|
|
27
|
+
.wx-content pre { margin: 0 0 18px; padding: 13px 14px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; overflow-x: auto; }
|
|
28
|
+
.wx-content code { font-size: 14px; line-height: 1.75; color: #334155; font-family: "Consolas", "Menlo", monospace; white-space: pre-wrap; }
|
|
29
|
+
.wx-sep { margin: 16px 0 18px; font-size: 14px; line-height: 1.8; color: #9ca3af; text-align: center; letter-spacing: 1px; }
|
|
30
|
+
.wx-cta { margin-top: 24px; padding: 13px 14px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fafafa; }
|
|
31
|
+
.wx-cta p { margin: 0; font-size: 15px; line-height: 1.85; color: #374151; }
|
|
29
32
|
"""
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
def load_style(config_path: Path | None) -> str:
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
if not config_path or not config_path.exists():
|
|
37
|
+
return DEFAULT_STYLE
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
40
|
+
body_font_size = int(config.get("bodyFontSize", 16))
|
|
41
|
+
body_line_height = float(config.get("bodyLineHeight", 1.9))
|
|
42
|
+
content_width = int(config.get("contentWidth", 720))
|
|
43
|
+
accent = str(config.get("accentColor", "#0f6fff"))
|
|
44
|
+
text = str(config.get("textColor", "#1f2937"))
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
style = DEFAULT_STYLE.replace("720px", f"{content_width}px")
|
|
47
|
+
style = style.replace("#0f6fff", accent)
|
|
48
|
+
style = style.replace("#1f2937", text)
|
|
49
|
+
style = style.replace("font-size: 16px; line-height: 1.9;", f"font-size: {body_font_size}px; line-height: {body_line_height};", 1)
|
|
50
|
+
return style
|
|
46
51
|
|
|
47
52
|
|
|
48
53
|
def convert_inline(text: str) -> str:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
text = re.sub(r"!\[\[[^\]]+\]\]", "", text)
|
|
55
|
+
text = html.escape(text)
|
|
56
|
+
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
|
|
57
|
+
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
|
|
58
|
+
text = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", text)
|
|
59
|
+
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
|
|
60
|
+
return text
|
|
56
61
|
|
|
57
62
|
|
|
58
63
|
def markdown_to_blocks(content: str) -> tuple[str, str, bool]:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
64
|
+
lines = content.splitlines()
|
|
65
|
+
title = ""
|
|
66
|
+
blocks: list[str] = []
|
|
67
|
+
in_code = False
|
|
68
|
+
code_lines: list[str] = []
|
|
69
|
+
paragraph_lines: list[str] = []
|
|
70
|
+
quote_lines: list[str] = []
|
|
71
|
+
list_items: list[str] = []
|
|
72
|
+
list_kind = ""
|
|
73
|
+
has_cta = False
|
|
74
|
+
|
|
75
|
+
def flush_paragraph() -> None:
|
|
76
|
+
nonlocal paragraph_lines, has_cta
|
|
77
|
+
if not paragraph_lines:
|
|
78
|
+
return
|
|
79
|
+
text = re.sub(r"\s+", " ", " ".join(line.strip() for line in paragraph_lines if line.strip())).strip()
|
|
80
|
+
paragraph_lines = []
|
|
81
|
+
if not text:
|
|
82
|
+
return
|
|
83
|
+
if any(keyword in text for keyword in ["留言", "评论", "下一篇", "下篇", "领取"]):
|
|
84
|
+
has_cta = True
|
|
85
|
+
blocks.append(f"<p>{convert_inline(text)}</p>")
|
|
86
|
+
|
|
87
|
+
def flush_quote() -> None:
|
|
88
|
+
nonlocal quote_lines
|
|
89
|
+
if not quote_lines:
|
|
90
|
+
return
|
|
91
|
+
text = re.sub(r"\s+", " ", " ".join(line.strip() for line in quote_lines if line.strip())).strip()
|
|
92
|
+
quote_lines = []
|
|
93
|
+
if text:
|
|
94
|
+
blocks.append(f"<blockquote>{convert_inline(text)}</blockquote>")
|
|
95
|
+
|
|
96
|
+
def flush_list() -> None:
|
|
97
|
+
nonlocal list_items, list_kind
|
|
98
|
+
if not list_items:
|
|
99
|
+
return
|
|
100
|
+
tag = "ol" if list_kind == "ol" else "ul"
|
|
101
|
+
items = "".join(f"<li>{convert_inline(item)}</li>" for item in list_items)
|
|
102
|
+
blocks.append(f"<{tag}>{items}</{tag}>")
|
|
103
|
+
list_items = []
|
|
104
|
+
list_kind = ""
|
|
105
|
+
|
|
106
|
+
for raw_line in lines:
|
|
107
|
+
line = raw_line.rstrip()
|
|
108
|
+
stripped = line.strip()
|
|
109
|
+
|
|
110
|
+
if stripped.startswith("```"):
|
|
111
|
+
flush_paragraph()
|
|
112
|
+
flush_quote()
|
|
113
|
+
flush_list()
|
|
114
|
+
if in_code:
|
|
115
|
+
blocks.append("<pre><code>" + html.escape("\n".join(code_lines)) + "</code></pre>")
|
|
116
|
+
code_lines = []
|
|
117
|
+
in_code = False
|
|
118
|
+
else:
|
|
119
|
+
in_code = True
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if in_code:
|
|
123
|
+
code_lines.append(line)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if not stripped:
|
|
127
|
+
flush_paragraph()
|
|
128
|
+
flush_quote()
|
|
129
|
+
flush_list()
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if not title and stripped.startswith("# "):
|
|
133
|
+
flush_paragraph()
|
|
134
|
+
flush_quote()
|
|
135
|
+
flush_list()
|
|
136
|
+
title = stripped[2:].strip()
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if stripped.startswith("## "):
|
|
140
|
+
flush_paragraph()
|
|
141
|
+
flush_quote()
|
|
142
|
+
flush_list()
|
|
143
|
+
blocks.append(f"<h2>{convert_inline(stripped[3:])}</h2>")
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if stripped.startswith("### "):
|
|
147
|
+
flush_paragraph()
|
|
148
|
+
flush_quote()
|
|
149
|
+
flush_list()
|
|
150
|
+
blocks.append(f"<h3>{convert_inline(stripped[4:])}</h3>")
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
if stripped.startswith("> "):
|
|
154
|
+
flush_paragraph()
|
|
155
|
+
flush_list()
|
|
156
|
+
quote_lines.append(stripped[2:])
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
if re.match(r"^\d+\.\s+", stripped):
|
|
160
|
+
flush_paragraph()
|
|
161
|
+
flush_quote()
|
|
162
|
+
kind = "ol"
|
|
163
|
+
if list_kind and list_kind != kind:
|
|
164
|
+
flush_list()
|
|
165
|
+
list_kind = kind
|
|
166
|
+
list_items.append(re.sub(r"^\d+\.\s+", "", stripped))
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if re.match(r"^[-*]\s+", stripped):
|
|
170
|
+
flush_paragraph()
|
|
171
|
+
flush_quote()
|
|
172
|
+
kind = "ul"
|
|
173
|
+
if list_kind and list_kind != kind:
|
|
174
|
+
flush_list()
|
|
175
|
+
list_kind = kind
|
|
176
|
+
list_items.append(re.sub(r"^[-*]\s+", "", stripped))
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if stripped == "---":
|
|
180
|
+
flush_paragraph()
|
|
181
|
+
flush_quote()
|
|
182
|
+
flush_list()
|
|
183
|
+
blocks.append('<p class="wx-sep">———</p>')
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
paragraph_lines.append(stripped)
|
|
187
|
+
|
|
188
|
+
flush_paragraph()
|
|
189
|
+
flush_quote()
|
|
190
|
+
flush_list()
|
|
191
|
+
return title, "\n".join(blocks), has_cta
|
|
134
192
|
|
|
135
193
|
|
|
136
194
|
def render_html(markdown: str, title_override: str, style_config_path: Path | None) -> str:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
195
|
+
title, body, has_cta = markdown_to_blocks(markdown)
|
|
196
|
+
title = title_override or title or "公众号文章"
|
|
197
|
+
template_path = Path(__file__).resolve().parent.parent / "assets" / "wechat-article-template.html"
|
|
198
|
+
template = template_path.read_text(encoding="utf-8")
|
|
199
|
+
cta = (
|
|
200
|
+
""
|
|
201
|
+
if has_cta
|
|
202
|
+
else '<footer class="wx-cta"><p>如果这篇内容对你有帮助,欢迎留言关键词,我会继续补充同主题资料。</p></footer>'
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
output = template.replace("{{TITLE}}", html.escape(title))
|
|
206
|
+
output = output.replace("{{SUBTITLE_BLOCK}}", "")
|
|
207
|
+
output = output.replace("{{STYLE}}", load_style(style_config_path).strip())
|
|
208
|
+
output = output.replace("{{CONTENT}}", body)
|
|
209
|
+
output = output.replace("{{CTA_BLOCK}}", cta)
|
|
210
|
+
output = output.replace("{{FOOTER_BLOCK}}", "")
|
|
211
|
+
return output
|
|
152
212
|
|
|
153
213
|
|
|
154
214
|
def main() -> None:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
215
|
+
parser = argparse.ArgumentParser()
|
|
216
|
+
parser.add_argument("--input", required=True)
|
|
217
|
+
parser.add_argument("--output", required=True)
|
|
218
|
+
parser.add_argument("--title", default="")
|
|
219
|
+
parser.add_argument("--style-config", default="")
|
|
220
|
+
args = parser.parse_args()
|
|
221
|
+
|
|
222
|
+
style_config_path = (
|
|
223
|
+
Path(args.style_config)
|
|
224
|
+
if args.style_config
|
|
225
|
+
else Path(__file__).resolve().parent.parent / "config" / "wechat-style-config.json"
|
|
226
|
+
)
|
|
227
|
+
markdown = Path(args.input).read_text(encoding="utf-8")
|
|
228
|
+
output = render_html(markdown, args.title, style_config_path)
|
|
229
|
+
output_path = Path(args.output)
|
|
230
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
output_path.write_text(output, encoding="utf-8")
|
|
168
232
|
|
|
169
233
|
|
|
170
234
|
if __name__ == "__main__":
|
|
171
|
-
|
|
235
|
+
main()
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create WeChat Official Account draft from markdown or rendered HTML.
|
|
3
|
+
|
|
4
|
+
Security notes:
|
|
5
|
+
- Credentials are NOT hardcoded in this script.
|
|
6
|
+
- Provide appid/appsecret via args, env vars, or local config file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import mimetypes
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, Optional
|
|
18
|
+
from urllib import error, parse, request
|
|
19
|
+
|
|
20
|
+
from render_wechat_api_content import render_api_content
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def escape_attr(text: str) -> str:
|
|
24
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def strip_tags(text: str) -> str:
|
|
28
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
29
|
+
text = text.replace(" ", " ")
|
|
30
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract_title_from_html(full_html: str, fallback: str = "公众号文章") -> str:
|
|
34
|
+
match = re.search(r'<h1[^>]*class="[^"]*wx-title[^"]*"[^>]*>(.*?)</h1>', full_html, re.I | re.S)
|
|
35
|
+
if match:
|
|
36
|
+
title = strip_tags(match.group(1)).strip()
|
|
37
|
+
if title:
|
|
38
|
+
return title
|
|
39
|
+
match = re.search(r"<title>(.*?)</title>", full_html, re.I | re.S)
|
|
40
|
+
if match:
|
|
41
|
+
title = strip_tags(match.group(1)).strip()
|
|
42
|
+
if title:
|
|
43
|
+
return title
|
|
44
|
+
return fallback
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_main_content_from_html(full_html: str) -> str:
|
|
48
|
+
match = re.search(r'<section[^>]*class="[^"]*wx-content[^"]*"[^>]*>([\s\S]*?)</section>', full_html, re.I)
|
|
49
|
+
if not match:
|
|
50
|
+
raise ValueError("无法从 HTML 中提取 .wx-content 内容,请检查输入文件。")
|
|
51
|
+
return match.group(1).strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def iter_local_img_sources(content_html: str) -> list[str]:
|
|
55
|
+
return re.findall(r'<img[^>]+src="([^"]+)"', content_html, re.I)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_local_asset_path(src: str, html_path: Path) -> Optional[Path]:
|
|
59
|
+
if re.match(r"^https?://", src, re.I) or src.startswith("data:"):
|
|
60
|
+
return None
|
|
61
|
+
p = Path(src)
|
|
62
|
+
if not p.is_absolute():
|
|
63
|
+
p = (html_path.parent / p).resolve()
|
|
64
|
+
return p
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def replace_inline_images(content_html: str, mapping: Dict[str, str]) -> str:
|
|
68
|
+
out = content_html
|
|
69
|
+
for old, new in mapping.items():
|
|
70
|
+
out = out.replace(f'src="{old}"', f'src="{escape_attr(new)}"')
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def http_json(url: str, payload: Optional[dict] = None, method: str = "GET") -> dict:
|
|
75
|
+
data = None
|
|
76
|
+
headers = {"User-Agent": "OpenSchool-WeChat-Draft/1.0"}
|
|
77
|
+
if payload is not None:
|
|
78
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
79
|
+
headers["Content-Type"] = "application/json; charset=utf-8"
|
|
80
|
+
|
|
81
|
+
req = request.Request(url, data=data, headers=headers, method=method)
|
|
82
|
+
try:
|
|
83
|
+
with request.urlopen(req, timeout=60) as resp:
|
|
84
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
85
|
+
except error.HTTPError as exc:
|
|
86
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
87
|
+
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def http_upload_file(url: str, file_path: Path, field_name: str = "media") -> dict:
|
|
91
|
+
boundary = "----OpenSchoolBoundary7MA4YWxkTrZu0gW"
|
|
92
|
+
mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
|
93
|
+
|
|
94
|
+
parts: list[bytes] = []
|
|
95
|
+
parts.append(f"--{boundary}\r\n".encode())
|
|
96
|
+
parts.append(
|
|
97
|
+
(
|
|
98
|
+
f'Content-Disposition: form-data; name="{field_name}"; filename="{file_path.name}"\r\n'
|
|
99
|
+
f"Content-Type: {mime_type}\r\n\r\n"
|
|
100
|
+
).encode()
|
|
101
|
+
)
|
|
102
|
+
parts.append(file_path.read_bytes())
|
|
103
|
+
parts.append(f"\r\n--{boundary}--\r\n".encode())
|
|
104
|
+
data = b"".join(parts)
|
|
105
|
+
|
|
106
|
+
req = request.Request(
|
|
107
|
+
url,
|
|
108
|
+
data=data,
|
|
109
|
+
headers={
|
|
110
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
111
|
+
"User-Agent": "OpenSchool-WeChat-Draft/1.0",
|
|
112
|
+
},
|
|
113
|
+
method="POST",
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
with request.urlopen(req, timeout=120) as resp:
|
|
117
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
118
|
+
except error.HTTPError as exc:
|
|
119
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
120
|
+
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_access_token(appid: str, appsecret: str) -> str:
|
|
124
|
+
url = (
|
|
125
|
+
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential"
|
|
126
|
+
f"&appid={parse.quote(appid)}&secret={parse.quote(appsecret)}"
|
|
127
|
+
)
|
|
128
|
+
res = http_json(url)
|
|
129
|
+
token = res.get("access_token")
|
|
130
|
+
if not token:
|
|
131
|
+
raise RuntimeError(f"获取 access_token 失败: {json.dumps(res, ensure_ascii=False)}")
|
|
132
|
+
return token
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def upload_inline_image(access_token: str, file_path: Path) -> str:
|
|
136
|
+
url = f"https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token={parse.quote(access_token)}"
|
|
137
|
+
res = http_upload_file(url, file_path)
|
|
138
|
+
remote = res.get("url")
|
|
139
|
+
if not remote:
|
|
140
|
+
raise RuntimeError(f"上传正文图片失败: {json.dumps(res, ensure_ascii=False)}")
|
|
141
|
+
return remote
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def upload_cover_thumb(access_token: str, file_path: Path) -> str:
|
|
145
|
+
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={parse.quote(access_token)}&type=image"
|
|
146
|
+
res = http_upload_file(url, file_path)
|
|
147
|
+
media_id = res.get("media_id")
|
|
148
|
+
if not media_id:
|
|
149
|
+
raise RuntimeError(f"上传封面失败: {json.dumps(res, ensure_ascii=False)}")
|
|
150
|
+
return media_id
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def create_draft(access_token: str, article: dict) -> dict:
|
|
154
|
+
url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={parse.quote(access_token)}"
|
|
155
|
+
res = http_json(url, payload={"articles": [article]}, method="POST")
|
|
156
|
+
if res.get("errcode", 0) not in (0, None):
|
|
157
|
+
raise RuntimeError(f"创建草稿失败: {json.dumps(res, ensure_ascii=False)}")
|
|
158
|
+
return res
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_config(path: Optional[str]) -> dict:
|
|
162
|
+
if not path:
|
|
163
|
+
return {}
|
|
164
|
+
cfg_path = Path(path)
|
|
165
|
+
if not cfg_path.exists():
|
|
166
|
+
raise FileNotFoundError(f"配置文件不存在: {cfg_path}")
|
|
167
|
+
return json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main() -> int:
|
|
171
|
+
parser = argparse.ArgumentParser(description="Publish WeChat draft via Official Account API")
|
|
172
|
+
parser.add_argument("--markdown", default="", help="Markdown article path")
|
|
173
|
+
parser.add_argument("--html", default="", help="Rendered HTML path")
|
|
174
|
+
parser.add_argument("--config", default="", help="Path to wechat publish config json")
|
|
175
|
+
parser.add_argument("--appid", default="", help="WeChat appid (optional, overrides config)")
|
|
176
|
+
parser.add_argument("--appsecret", default="", help="WeChat appsecret (optional, overrides config)")
|
|
177
|
+
parser.add_argument("--title", default="", help="Override article title")
|
|
178
|
+
parser.add_argument("--author", default="", help="Override article author")
|
|
179
|
+
parser.add_argument("--digest", default="", help="Override article digest")
|
|
180
|
+
parser.add_argument("--cover", default="", help="Cover image path")
|
|
181
|
+
parser.add_argument("--content-source-url", default="", help="Source url in draft")
|
|
182
|
+
parser.add_argument("--need-open-comment", type=int, default=0)
|
|
183
|
+
parser.add_argument("--only-fans-can-comment", type=int, default=0)
|
|
184
|
+
parser.add_argument("--output-json", default="", help="Output result json path")
|
|
185
|
+
parser.add_argument("--dry-run", action="store_true", help="Build payload only")
|
|
186
|
+
args = parser.parse_args()
|
|
187
|
+
|
|
188
|
+
if not args.markdown and not args.html:
|
|
189
|
+
raise RuntimeError("必须提供 --markdown 或 --html 之一。")
|
|
190
|
+
|
|
191
|
+
config = load_config(args.config)
|
|
192
|
+
appid = args.appid or config.get("appid", "")
|
|
193
|
+
appsecret = args.appsecret or config.get("appsecret", "")
|
|
194
|
+
author = args.author or config.get("author", "")
|
|
195
|
+
content_source_url = args.content_source_url or config.get("content_source_url", "")
|
|
196
|
+
|
|
197
|
+
if args.markdown:
|
|
198
|
+
markdown = Path(args.markdown).read_text(encoding="utf-8")
|
|
199
|
+
title, content_html, _ = render_api_content(markdown, args.title)
|
|
200
|
+
html_path_for_assets = Path(args.markdown)
|
|
201
|
+
else:
|
|
202
|
+
html_text = Path(args.html).read_text(encoding="utf-8")
|
|
203
|
+
title = args.title or extract_title_from_html(html_text)
|
|
204
|
+
content_html = extract_main_content_from_html(html_text)
|
|
205
|
+
html_path_for_assets = Path(args.html)
|
|
206
|
+
|
|
207
|
+
digest = args.digest or strip_tags(content_html)[:120]
|
|
208
|
+
image_mapping: Dict[str, str] = {}
|
|
209
|
+
|
|
210
|
+
access_token = ""
|
|
211
|
+
if not args.dry_run and (iter_local_img_sources(content_html) or args.cover):
|
|
212
|
+
if not appid or not appsecret:
|
|
213
|
+
raise RuntimeError("需要提供 appid/appsecret 才能上传图片与创建草稿。")
|
|
214
|
+
access_token = get_access_token(appid, appsecret)
|
|
215
|
+
|
|
216
|
+
for src in iter_local_img_sources(content_html):
|
|
217
|
+
local_path = normalize_local_asset_path(src, html_path_for_assets)
|
|
218
|
+
if local_path is None:
|
|
219
|
+
continue
|
|
220
|
+
if not local_path.exists():
|
|
221
|
+
raise FileNotFoundError(f"正文图片不存在: {local_path}")
|
|
222
|
+
if args.dry_run:
|
|
223
|
+
image_mapping[src] = f"https://example.invalid/mock/{local_path.name}"
|
|
224
|
+
else:
|
|
225
|
+
image_mapping[src] = upload_inline_image(access_token, local_path)
|
|
226
|
+
|
|
227
|
+
if image_mapping:
|
|
228
|
+
content_html = replace_inline_images(content_html, image_mapping)
|
|
229
|
+
|
|
230
|
+
thumb_media_id = ""
|
|
231
|
+
if args.cover:
|
|
232
|
+
cover_path = Path(args.cover)
|
|
233
|
+
if not cover_path.exists():
|
|
234
|
+
raise FileNotFoundError(f"封面图不存在: {cover_path}")
|
|
235
|
+
if args.dry_run:
|
|
236
|
+
thumb_media_id = "MOCK_THUMB_MEDIA_ID"
|
|
237
|
+
else:
|
|
238
|
+
if not access_token:
|
|
239
|
+
if not appid or not appsecret:
|
|
240
|
+
raise RuntimeError("需要提供 appid/appsecret 才能上传封面。")
|
|
241
|
+
access_token = get_access_token(appid, appsecret)
|
|
242
|
+
thumb_media_id = upload_cover_thumb(access_token, cover_path)
|
|
243
|
+
|
|
244
|
+
article = {
|
|
245
|
+
"title": title,
|
|
246
|
+
"author": author,
|
|
247
|
+
"digest": digest,
|
|
248
|
+
"content": content_html,
|
|
249
|
+
"thumb_media_id": thumb_media_id,
|
|
250
|
+
"content_source_url": content_source_url,
|
|
251
|
+
"need_open_comment": args.need_open_comment,
|
|
252
|
+
"only_fans_can_comment": args.only_fans_can_comment,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if args.dry_run:
|
|
256
|
+
result = {
|
|
257
|
+
"mode": "dry-run",
|
|
258
|
+
"article": article,
|
|
259
|
+
"inline_image_replacements": image_mapping,
|
|
260
|
+
}
|
|
261
|
+
else:
|
|
262
|
+
if not appid or not appsecret:
|
|
263
|
+
raise RuntimeError("需要提供 appid/appsecret 才能创建草稿。")
|
|
264
|
+
if not access_token:
|
|
265
|
+
access_token = get_access_token(appid, appsecret)
|
|
266
|
+
result = create_draft(access_token, article)
|
|
267
|
+
result["article_preview"] = {
|
|
268
|
+
"title": title,
|
|
269
|
+
"author": author,
|
|
270
|
+
"digest": digest,
|
|
271
|
+
"thumb_media_id": thumb_media_id,
|
|
272
|
+
"inline_image_replacements": image_mapping,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
|
276
|
+
if args.output_json:
|
|
277
|
+
output_path = Path(args.output_json)
|
|
278
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
output_path.write_text(rendered, encoding="utf-8")
|
|
280
|
+
try:
|
|
281
|
+
print(rendered)
|
|
282
|
+
except UnicodeEncodeError:
|
|
283
|
+
sys.stdout.buffer.write((rendered + "\n").encode("utf-8"))
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
raise SystemExit(main())
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"status": "已上线",
|
|
9
9
|
"difficulty": "入门",
|
|
10
10
|
"installCommand": "npx @openschool_01/skills install clawdhub",
|
|
11
|
-
"packageName": "OpenSchool
|
|
11
|
+
"packageName": "OpenSchool Installer",
|
|
12
12
|
"summary": "适合作为新手接触技能生态的第一步,把技能的搜索、安装和更新入口先统一起来。",
|
|
13
13
|
"outcomes": [
|
|
14
14
|
"搜索技能",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"status": "已上线",
|
|
40
40
|
"difficulty": "入门",
|
|
41
41
|
"installCommand": "npx @openschool_01/skills install skill-vetter",
|
|
42
|
-
"packageName": "OpenSchool
|
|
42
|
+
"packageName": "OpenSchool Installer",
|
|
43
43
|
"summary": "适合新手在安装技能前先做基础安全检查,降低来源不明和权限过高带来的风险。",
|
|
44
44
|
"outcomes": [
|
|
45
45
|
"来源检查",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"status": "已上线",
|
|
71
71
|
"difficulty": "入门",
|
|
72
72
|
"installCommand": "npx @openschool_01/skills install automation-workflows",
|
|
73
|
-
"packageName": "OpenSchool
|
|
73
|
+
"packageName": "OpenSchool Installer",
|
|
74
74
|
"summary": "适合刚开始梳理自动化场景的人,用更清晰的方式把重复工作变成流程。",
|
|
75
75
|
"outcomes": [
|
|
76
76
|
"发现重复工作",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"status": "已上线",
|
|
102
102
|
"difficulty": "入门",
|
|
103
103
|
"installCommand": "npx @openschool_01/skills install openclaw-tavily-search",
|
|
104
|
-
"packageName": "OpenSchool
|
|
104
|
+
"packageName": "OpenSchool Installer",
|
|
105
105
|
"summary": "适合作为联网搜索的备用能力,在常规检索失效时继续兜底。",
|
|
106
106
|
"outcomes": [
|
|
107
107
|
"联网兜底",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"status": "已上线",
|
|
133
133
|
"difficulty": "入门",
|
|
134
134
|
"installCommand": "npx @openschool_01/skills install multi-search-engine",
|
|
135
|
-
"packageName": "OpenSchool
|
|
135
|
+
"packageName": "OpenSchool Installer",
|
|
136
136
|
"summary": "适合需要更稳搜索结果的人,通过多引擎对照减少单一来源偏差。",
|
|
137
137
|
"outcomes": [
|
|
138
138
|
"多源检索",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
"status": "已上线",
|
|
164
164
|
"difficulty": "入门",
|
|
165
165
|
"installCommand": "npx @openschool_01/skills install self-improving",
|
|
166
|
-
"packageName": "OpenSchool
|
|
166
|
+
"packageName": "OpenSchool Installer",
|
|
167
167
|
"summary": "适合作为长期使用中的自我优化工具,把一次次踩坑变成后续少走弯路的经验。",
|
|
168
168
|
"outcomes": [
|
|
169
169
|
"错误复盘",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
"status": "已上线",
|
|
195
195
|
"difficulty": "入门",
|
|
196
196
|
"installCommand": "npx @openschool_01/skills install obsidian",
|
|
197
|
-
"packageName": "OpenSchool
|
|
197
|
+
"packageName": "OpenSchool Installer",
|
|
198
198
|
"summary": "适合想把零散知识长期积累下来的人,让知识管理和工作过程更连贯。",
|
|
199
199
|
"outcomes": [
|
|
200
200
|
"笔记协作",
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
"status": "已上线",
|
|
226
226
|
"difficulty": "入门",
|
|
227
227
|
"installCommand": "npx @openschool_01/skills install ontology",
|
|
228
|
-
"packageName": "OpenSchool
|
|
228
|
+
"packageName": "OpenSchool Installer",
|
|
229
229
|
"summary": "适合希望把信息关系理清楚的人,让知识图谱和任务结构更清晰。",
|
|
230
230
|
"outcomes": [
|
|
231
231
|
"结构化关系",
|
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
"status": "已上线",
|
|
257
257
|
"difficulty": "入门",
|
|
258
258
|
"installCommand": "npx @openschool_01/skills install markdown-converter",
|
|
259
|
-
"packageName": "OpenSchool
|
|
259
|
+
"packageName": "OpenSchool Installer",
|
|
260
260
|
"summary": "适合把各类文件整理成统一 Markdown 工作流的人,方便后续复用和再加工。",
|
|
261
261
|
"outcomes": [
|
|
262
262
|
"文档转换",
|
|
@@ -287,7 +287,7 @@
|
|
|
287
287
|
"status": "已上线",
|
|
288
288
|
"difficulty": "入门",
|
|
289
289
|
"installCommand": "npx @openschool_01/skills install summarize",
|
|
290
|
-
"packageName": "OpenSchool
|
|
290
|
+
"packageName": "OpenSchool Installer",
|
|
291
291
|
"summary": "适合作为高频总结工具,把信息先快速收拢,再继续整理和输出。",
|
|
292
292
|
"outcomes": [
|
|
293
293
|
"网页总结",
|
|
@@ -306,8 +306,8 @@
|
|
|
306
306
|
"部分场景需要额外 API Key 或本地 CLI"
|
|
307
307
|
],
|
|
308
308
|
"sourceType": "clawhub",
|
|
309
|
-
"clawhubSlug": "summarize",
|
|
310
|
-
"clawhubUrl": "https://clawhub.ai/
|
|
309
|
+
"clawhubSlug": "summarize-pro",
|
|
310
|
+
"clawhubUrl": "https://clawhub.ai/mkpareek0315/summarize-pro"
|
|
311
311
|
},
|
|
312
312
|
{
|
|
313
313
|
"slug": "web-access",
|
|
@@ -352,7 +352,7 @@
|
|
|
352
352
|
"status": "已上线",
|
|
353
353
|
"difficulty": "进阶",
|
|
354
354
|
"installCommand": "npx @openschool_01/skills install word-docx",
|
|
355
|
-
"packageName": "OpenSchool
|
|
355
|
+
"packageName": "OpenSchool Installer",
|
|
356
356
|
"summary": "适合需要稳定处理 Word 文档的人,能更可靠地生成、检查和修改 DOCX 文件,尤其适合正式文档、方案和交付稿。",
|
|
357
357
|
"outcomes": [
|
|
358
358
|
"生成 DOCX 文档",
|
|
@@ -384,7 +384,7 @@
|
|
|
384
384
|
"status": "已上线",
|
|
385
385
|
"difficulty": "进阶",
|
|
386
386
|
"installCommand": "npx @openschool_01/skills install powerpoint-pptx",
|
|
387
|
-
"packageName": "OpenSchool
|
|
387
|
+
"packageName": "OpenSchool Installer",
|
|
388
388
|
"summary": "适合要稳定做演示文稿的人,可以围绕版式、模板、占位符、备注和图表来处理 PPTX,比只生成一堆页面更适合正式办公场景。",
|
|
389
389
|
"outcomes": [
|
|
390
390
|
"生成 PPTX 演示稿",
|
|
@@ -416,7 +416,7 @@
|
|
|
416
416
|
"status": "已上线",
|
|
417
417
|
"difficulty": "进阶",
|
|
418
418
|
"installCommand": "npx @openschool_01/skills install excel-xlsx",
|
|
419
|
-
"packageName": "OpenSchool
|
|
419
|
+
"packageName": "OpenSchool Installer",
|
|
420
420
|
"summary": "适合经常处理表格的人,能更稳定地创建和修改 Excel 工作簿,适用于公式、格式、日期类型和模板保留这类办公高频需求。",
|
|
421
421
|
"outcomes": [
|
|
422
422
|
"生成 XLSX 表格",
|
|
@@ -448,7 +448,7 @@
|
|
|
448
448
|
"status": "已上线",
|
|
449
449
|
"difficulty": "进阶",
|
|
450
450
|
"installCommand": "npx @openschool_01/skills install pdf-processing",
|
|
451
|
-
"packageName": "OpenSchool
|
|
451
|
+
"packageName": "OpenSchool Installer",
|
|
452
452
|
"summary": "适合日常办公里处理 PDF 的场景,能做提取、表格读取、表单填写和文档合并,比较贴近真实文档流转需求。",
|
|
453
453
|
"outcomes": [
|
|
454
454
|
"提取 PDF 文本",
|
|
@@ -532,67 +532,5 @@
|
|
|
532
532
|
],
|
|
533
533
|
"sourceType": "official",
|
|
534
534
|
"localPackageDir": "packages/skills/xiaohongshu-assistant"
|
|
535
|
-
},
|
|
536
|
-
{
|
|
537
|
-
"slug": "nano-banana-pro",
|
|
538
|
-
"name": "Nano Banana Pro",
|
|
539
|
-
"tagline": "把海报、封面和宣传图需求整理成更稳的生图提示词。",
|
|
540
|
-
"category": "content",
|
|
541
|
-
"badge": "OpenSchool 推荐",
|
|
542
|
-
"status": "已上线",
|
|
543
|
-
"difficulty": "进阶",
|
|
544
|
-
"installCommand": "npx @openschool_01/skills install nano-banana-pro",
|
|
545
|
-
"packageName": "@openschool_01/skill-nano-banana-pro",
|
|
546
|
-
"summary": "适合做海报、封面、宣传图和产品视觉的提示词整理,把模糊需求补成更容易出图的一版完整提示词。",
|
|
547
|
-
"outcomes": [
|
|
548
|
-
"主提示词生成",
|
|
549
|
-
"风格变体提示词",
|
|
550
|
-
"负面提示词",
|
|
551
|
-
"封面图需求整理"
|
|
552
|
-
],
|
|
553
|
-
"fitFor": [
|
|
554
|
-
"公众号封面",
|
|
555
|
-
"小红书配图",
|
|
556
|
-
"宣传海报",
|
|
557
|
-
"需要稳定生图提示词的内容团队"
|
|
558
|
-
],
|
|
559
|
-
"updatedAt": "2026-04-10",
|
|
560
|
-
"prerequisites": [
|
|
561
|
-
"当前版本是提示词版,不直接调用生图网站",
|
|
562
|
-
"适合先产出成品提示词再去即梦、可灵、Midjourney 等工具使用"
|
|
563
|
-
],
|
|
564
|
-
"sourceType": "official",
|
|
565
|
-
"localPackageDir": "packages/skills/nano-banana-pro"
|
|
566
|
-
},
|
|
567
|
-
{
|
|
568
|
-
"slug": "human-writing",
|
|
569
|
-
"name": "Human Writing",
|
|
570
|
-
"tagline": "把 AI 文案改成更像真人写的版本,少一点模板味,多一点判断感。",
|
|
571
|
-
"category": "content",
|
|
572
|
-
"badge": "OpenSchool 推荐",
|
|
573
|
-
"status": "已上线",
|
|
574
|
-
"difficulty": "进阶",
|
|
575
|
-
"installCommand": "npx @openschool_01/skills install human-writing",
|
|
576
|
-
"packageName": "@openschool_01/skill-human-writing",
|
|
577
|
-
"summary": "适合给公众号、小红书、口播稿和说明文去掉 AI 味,保留信息结构,但把表达变得更顺、更像真人写的。",
|
|
578
|
-
"outcomes": [
|
|
579
|
-
"去 AI 味改写",
|
|
580
|
-
"平台语气调优",
|
|
581
|
-
"两版改写输出",
|
|
582
|
-
"套话与空话清理"
|
|
583
|
-
],
|
|
584
|
-
"fitFor": [
|
|
585
|
-
"公众号润色",
|
|
586
|
-
"小红书文案优化",
|
|
587
|
-
"视频口播改写",
|
|
588
|
-
"需要更自然表达的内容团队"
|
|
589
|
-
],
|
|
590
|
-
"updatedAt": "2026-04-10",
|
|
591
|
-
"prerequisites": [
|
|
592
|
-
"适合基于已有文案精修",
|
|
593
|
-
"当前版本不负责事实核查,只负责表达优化"
|
|
594
|
-
],
|
|
595
|
-
"sourceType": "official",
|
|
596
|
-
"localPackageDir": "packages/skills/human-writing"
|
|
597
535
|
}
|
|
598
536
|
]
|