@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.
@@ -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())