@openschool_01/skills 0.1.2 → 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/human-writing/package.json +12 -0
- package/bundled-skills/human-writing/skill/OPENSCHOOL-META.md +7 -0
- package/bundled-skills/human-writing/skill/SKILL.md +57 -0
- package/bundled-skills/human-writing/skill/references/rewrite-rules.md +22 -0
- package/bundled-skills/nano-banana-pro/package.json +12 -0
- package/bundled-skills/nano-banana-pro/skill/OPENSCHOOL-META.md +7 -0
- package/bundled-skills/nano-banana-pro/skill/SKILL.md +62 -0
- package/bundled-skills/nano-banana-pro/skill/references/prompt-rules.md +22 -0
- 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 +64 -2
|
@@ -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())
|