@openschool_01/skills 0.1.3 → 0.1.4
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/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 +2 -2
|
@@ -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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"slug": "clawdhub",
|
|
4
4
|
"name": "clawdhub",
|
|
5
5
|
"tagline": "技能市场入口,帮你搜索、安装、更新和发布技能。",
|
|
6
|
-
"category": "
|
|
6
|
+
"category": "office",
|
|
7
7
|
"badge": "OpenSchool 推荐",
|
|
8
8
|
"status": "已上线",
|
|
9
9
|
"difficulty": "入门",
|
|
@@ -282,7 +282,7 @@
|
|
|
282
282
|
"slug": "summarize",
|
|
283
283
|
"name": "summarize",
|
|
284
284
|
"tagline": "快速总结网页、视频、PDF 等内容,方便继续加工。",
|
|
285
|
-
"category": "
|
|
285
|
+
"category": "office",
|
|
286
286
|
"badge": "OpenSchool 推荐",
|
|
287
287
|
"status": "已上线",
|
|
288
288
|
"difficulty": "入门",
|