@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.
@@ -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. 微信 HTML 渲染与质量检查
12
-
13
- 本版本刻意不包含:
9
+ 2. 正文写作与质量检查
10
+ 3. 公众号预览 HTML 渲染(默认无尾注)
11
+ 4. 可选上传草稿箱(用户显式提供 appid/appsecret 时执行)
14
12
 
13
+ 默认不包含:
15
14
  - 生图
16
- - 封面生成
17
- - 微信 API 发布
15
+ - 封面自动生成
16
+ - 自动正式发布
18
17
 
19
- 这样可以先保证“写得出、改得动、落得下、能预览”这条链路稳定。
18
+ 上线约束:
19
+ - 不内置任何个人信息尾注
20
+ - 不在仓库存储真实公众号凭据
@@ -2,108 +2,112 @@
2
2
 
3
3
  适用于把一个选题整理成可发布到微信公众号的完整内容稿。
4
4
 
5
- 当前版本专注 4 件事:
6
-
7
- 1. 明确选题和读者视角
8
- 2. 生成更像公众号风格的标题方向
9
- 3. 写出 Markdown 正文
10
- 4. 渲染成微信公众号可用的 HTML
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
- - 输出微信公众号排版版式的 HTML
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
- - `references/title-guidelines.md`
41
+ ### 3. 再写正文 Markdown
50
42
 
51
- ### 3. 再写正文
43
+ 正文建议:
44
+ - 4 到 6 个二级标题
45
+ - 先给判断,再解释
46
+ - 至少 1 个真实场景
47
+ - 至少 1 个常见误区
48
+ - 至少 1 组可执行动作
52
49
 
53
- 正文默认要求:
50
+ ### 4. 渲染公众号 HTML(无尾注)
54
51
 
55
- - 使用 4 到 6 个二级标题
56
- - 先给判断,再展开解释
57
- - 至少包含 1 个真实场景
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
- - `references/writing-rules.md`
58
+ ```bash
59
+ python scripts/audit_article_quality.py --input "<article.md>"
60
+ ```
65
61
 
66
- ### 4. 渲染 HTML
62
+ ### 6. 上传草稿箱(可选)
67
63
 
68
- 正文 Markdown 完成后,运行:
64
+ 当用户明确需要上传草稿箱时,使用:
69
65
 
70
66
  ```bash
71
- python scripts/render_wechat_html.py --input "<article.md>" --output "<article>.html"
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
- ### 5. 做一次质量检查
73
+ 确认 dry-run 输出没问题后,再去掉 `--dry-run`。
75
74
 
76
- 如果用户在意发布质量,再运行:
75
+ 也可以直接传入账号参数:
77
76
 
78
77
  ```bash
79
- python scripts/audit_article_quality.py --input "<article.md>"
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
- - `references/writing-rules.md`
87
- 正文写作规则和结构要求
88
- - `assets/wechat-article-template.html`
89
- 微信 HTML 模板
90
- - `config/wechat-style-config.json`
91
- 样式配置
92
- - `scripts/render_wechat_html.py`
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
- - Markdown 文件路径
108
- - HTML 文件路径
109
- - 质量检查结果
110
+ 如果执行了草稿箱上传,还要明确返回:
111
+ - 是否 dry-run
112
+ - 草稿接口返回结果
113
+ - 产物文件路径
@@ -0,0 +1,6 @@
1
+ {
2
+ "appid": "YOUR_WECHAT_APPID",
3
+ "appsecret": "YOUR_WECHAT_APPSECRET",
4
+ "author": "OpenSchool",
5
+ "content_source_url": ""
6
+ }
@@ -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 article HTML."""
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: #111111; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; }
15
- .wx-wrap { max-width: 720px; margin: 0 auto; padding: 24px 18px 48px; box-sizing: border-box; }
16
- .wx-title { margin: 0 0 12px; font-size: 28px; line-height: 1.35; font-weight: 700; color: #111111; }
17
- .wx-subtitle { margin: 0 0 20px; font-size: 15px; line-height: 1.8; color: #666666; }
18
- .wx-content p, .wx-content li { margin: 0 0 14px; font-size: 16px; line-height: 1.8; color: #111111; text-align: justify; text-justify: inter-ideograph; word-break: break-word; }
19
- .wx-content h2, .wx-content h3 { margin: 26px 0 10px; font-size: 20px; line-height: 1.6; font-weight: 700; color: rgb(0,128,255); }
20
- .wx-content strong { color: rgb(0,128,255); font-weight: 700; }
21
- .wx-content ul, .wx-content ol { margin: 0 0 14px 1.4em; padding: 0; }
22
- .wx-content pre { margin: 0 0 18px; padding: 14px 16px; background: #f7f8fa; border-left: 3px solid rgba(0,128,255,.45); overflow-x: auto; border-radius: 6px; }
23
- .wx-content code { font-size: 14px; line-height: 1.7; color: #333333; font-family: "Consolas", "Menlo", monospace; white-space: pre-wrap; }
24
- .wx-sep { margin: 12px 0 16px; font-size: 16px; line-height: 1.8; color: #999999; text-align: center; }
25
- .wx-cta { margin-top: 28px; padding-top: 16px; border-top: 1px solid #ececec; }
26
- .wx-cta p { margin: 0; font-size: 15px; line-height: 1.8; color: #444444; }
27
- .wx-footer { margin-top: 44px; padding-top: 18px; border-top: 1px solid #f0f0f0; }
28
- .wx-footer p { margin: 0; font-size: 13px; line-height: 1.9; color: #a0a0a0; text-align: center; }
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
- if not config_path or not config_path.exists():
34
- return DEFAULT_STYLE
36
+ if not config_path or not config_path.exists():
37
+ return DEFAULT_STYLE
35
38
 
36
- config = json.loads(config_path.read_text(encoding="utf-8"))
37
- body_font_size = int(config.get("bodyFontSize", 16))
38
- body_line_height = float(config.get("bodyLineHeight", 1.8))
39
- content_width = int(config.get("contentWidth", 720))
40
- accent = str(config.get("accentColor", "rgb(0,128,255)"))
41
- text = str(config.get("textColor", "#111111"))
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
- return DEFAULT_STYLE.replace("720px", f"{content_width}px").replace(
44
- "rgb(0,128,255)", accent
45
- ).replace("#111111", text).replace("font-size: 16px; line-height: 1.8;", f"font-size: {body_font_size}px; line-height: {body_line_height};")
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
- text = re.sub(r"!\[\[[^\]]+\]\]", "", text)
50
- text = html.escape(text)
51
- text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
52
- text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
53
- text = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", text)
54
- text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
55
- return text
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
- lines = content.splitlines()
60
- title = ""
61
- blocks: list[str] = []
62
- in_code = False
63
- code_lines: list[str] = []
64
- paragraph_lines: list[str] = []
65
- has_cta = False
66
-
67
- def flush_paragraph() -> None:
68
- nonlocal paragraph_lines, has_cta
69
- if not paragraph_lines:
70
- return
71
- text = re.sub(r"\s+", " ", " ".join(line.strip() for line in paragraph_lines if line.strip())).strip()
72
- paragraph_lines = []
73
- if not text:
74
- return
75
- if any(keyword in text for keyword in ["留言", "评论", "下一篇", "下篇", "领取"]):
76
- has_cta = True
77
- blocks.append(f"<p>{convert_inline(text)}</p>")
78
-
79
- for raw_line in lines:
80
- line = raw_line.rstrip()
81
- stripped = line.strip()
82
-
83
- if stripped.startswith("```"):
84
- flush_paragraph()
85
- if in_code:
86
- blocks.append("<pre><code>" + html.escape("\n".join(code_lines)) + "</code></pre>")
87
- code_lines = []
88
- in_code = False
89
- else:
90
- in_code = True
91
- continue
92
-
93
- if in_code:
94
- code_lines.append(line)
95
- continue
96
-
97
- if not stripped:
98
- flush_paragraph()
99
- continue
100
-
101
- if not title and stripped.startswith("# "):
102
- title = stripped[2:].strip()
103
- continue
104
-
105
- if stripped.startswith("## "):
106
- flush_paragraph()
107
- blocks.append(f"<h2>{convert_inline(stripped[3:])}</h2>")
108
- continue
109
-
110
- if stripped.startswith("### "):
111
- flush_paragraph()
112
- blocks.append(f"<h3>{convert_inline(stripped[4:])}</h3>")
113
- continue
114
-
115
- if re.match(r"^\d+\.\s+", stripped):
116
- flush_paragraph()
117
- blocks.append("<ol><li>" + convert_inline(re.sub(r"^\d+\.\s+", "", stripped)) + "</li></ol>")
118
- continue
119
-
120
- if re.match(r"^[-*]\s+", stripped):
121
- flush_paragraph()
122
- blocks.append("<ul><li>" + convert_inline(re.sub(r"^[-*]\s+", "", stripped)) + "</li></ul>")
123
- continue
124
-
125
- if stripped == "---":
126
- flush_paragraph()
127
- blocks.append('<p class="wx-sep">···</p>')
128
- continue
129
-
130
- paragraph_lines.append(stripped)
131
-
132
- flush_paragraph()
133
- return title, "\n".join(blocks), has_cta
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
- title, body, has_cta = markdown_to_blocks(markdown)
138
- title = title_override or title or "公众号文章"
139
- template_path = Path(__file__).resolve().parent.parent / "assets" / "wechat-article-template.html"
140
- template = template_path.read_text(encoding="utf-8")
141
- cta = "" if has_cta else '<footer class="wx-cta"><p>如果这篇内容对你有帮助,下一步可以继续把同主题素材整理成系列文章。</p></footer>'
142
- footer = '<footer class="wx-footer"><p>由 OpenSchool 严选技能整理输出</p></footer>'
143
- subtitle_block = ""
144
-
145
- output = template.replace("{{TITLE}}", html.escape(title))
146
- output = output.replace("{{SUBTITLE_BLOCK}}", subtitle_block)
147
- output = output.replace("{{STYLE}}", load_style(style_config_path).strip())
148
- output = output.replace("{{CONTENT}}", body)
149
- output = output.replace("{{CTA_BLOCK}}", cta)
150
- output = output.replace("{{FOOTER_BLOCK}}", footer)
151
- return output
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
- parser = argparse.ArgumentParser()
156
- parser.add_argument("--input", required=True)
157
- parser.add_argument("--output", required=True)
158
- parser.add_argument("--title", default="")
159
- parser.add_argument("--style-config", default="")
160
- args = parser.parse_args()
161
-
162
- style_config_path = Path(args.style_config) if args.style_config else Path(__file__).resolve().parent.parent / "config" / "wechat-style-config.json"
163
- markdown = Path(args.input).read_text(encoding="utf-8")
164
- output = render_html(markdown, args.title, style_config_path)
165
- output_path = Path(args.output)
166
- output_path.parent.mkdir(parents=True, exist_ok=True)
167
- output_path.write_text(output, encoding="utf-8")
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
- main()
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
25
+
26
+
27
+ def strip_tags(text: str) -> str:
28
+ text = re.sub(r"<[^>]+>", "", text)
29
+ text = text.replace("&nbsp;", " ")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openschool_01/skills",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "OpenSchool curated skills installer CLI.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -3,7 +3,7 @@
3
3
  "slug": "clawdhub",
4
4
  "name": "clawdhub",
5
5
  "tagline": "技能市场入口,帮你搜索、安装、更新和发布技能。",
6
- "category": "starter",
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": "starter",
285
+ "category": "office",
286
286
  "badge": "OpenSchool 推荐",
287
287
  "status": "已上线",
288
288
  "difficulty": "入门",