yuanflow-cli 0.1.29 → 0.1.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yuanflow-cli",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "YuanFlow 自媒体 API CLI 与 Skill 安装器。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,9 +15,14 @@ description: 当用户需要公众号文章创作、文章改写、Markdown 排
15
15
 
16
16
  1. 用户是在已有文章上处理,还是完全重新创作。
17
17
  2. 用户本轮要做完整流程,还是只做写作、排版、封面建议、导出或草稿箱推送中的一部分。
18
- 3. 排版结果使用 Markdown 还是 HTML。
18
+ 3. 开始输出正文之前,必须先和用户核对大概字数范围,例如 800-1200 字、1500-2000 字、3000 字以上;如果用户没有给出范围,先询问,不要直接写完整正文。
19
+ 4. 是否需要正文插图、插图数量、插图来源和大概插入位置。
20
+ - 用户提供图片时:先梳理每张图适合插入的段落位置,再进入排版、上传和草稿箱链路。
21
+ - 用户要求生成正文插图时:先使用 YuanFlow 内置生图技能生成图片,再把生成图片作为正文插图素材进入上传链路。
22
+ - 用户没有要求正文插图时:不要擅自生成插图;只处理文字正文和可选封面。
23
+ 5. 排版结果使用 Markdown 还是 HTML。
19
24
  - 如果选择 HTML,会把 Markdown 转成微信公众号编辑器可粘贴的内联样式 HTML。
20
- 4. 是否需要推送到微信公众号草稿箱。草稿箱只负责生成草稿,正式发布必须由用户自行在公众号后台确认。
25
+ 6. 是否需要推送到微信公众号草稿箱。草稿箱只负责生成草稿,正式发布必须由用户自行在公众号后台确认。
21
26
 
22
27
  ## 内容创作流程
23
28
 
@@ -39,11 +44,57 @@ description: 当用户需要公众号文章创作、文章改写、Markdown 排
39
44
  Agent 在排版前应做结构化增强,但不能改变事实:
40
45
 
41
46
  - 连续对话内容转成 `dialogue` 容器。
42
- - 连续图片转成 `gallery` 容器。
47
+ - 连续图片转成 `gallery` 容器;第一版如果脚本没有专门 gallery 渲染器,应退化为多张 Markdown 图片连续插入,不要丢图。
43
48
  - 金句、关键引用、核心观点转成 `callout` 容器。
44
49
  - 步骤、时间线、对比、清单转成对应结构容器。
45
50
  - 外部链接转脚注,避免微信编辑器粘贴后结构混乱。
46
51
 
52
+ ## 正文插图与草稿箱链路
53
+
54
+ 当用户要求正文插图或草稿箱推送时,必须区分三类图片:
55
+
56
+ 1. 正文插图:插入文章内容里的图片。进入草稿箱前必须上传到微信“图文消息图片”接口,拿到微信返回的 `url` 后替换 Markdown/HTML 中的图片地址。
57
+ 2. 封面图:草稿箱图文的 `thumb_media_id`。需要上传为永久图片素材,拿到 `media_id` 后写入草稿箱 payload。
58
+ 3. 本地预览图:只用于 YuanFlow 对话或工作台展示,不能直接当作微信公众号正文图片地址使用。
59
+
60
+ 正文插图来源规则:
61
+
62
+ - 用户提供图片:读取用户给出的本地路径或已上传文件路径,按语义决定插入段落,并在 Markdown 中写成 `![说明](本地路径)`。
63
+ - 用户要求生成插图:调用 `生图技能` 生成图片,使用工具返回的本地缓存路径插入 Markdown。
64
+ - 用户只要求封面:只生成或使用封面,不在正文里额外插图。
65
+
66
+ 推送草稿箱时使用 `scripts/wechat_draft.py`:
67
+
68
+ ```bash
69
+ python scripts/wechat_draft.py \
70
+ --credentials-file "D:\公众号token\新建文本文档.txt" \
71
+ --input article.md \
72
+ --theme newspaper \
73
+ --cover-image cover.png \
74
+ --output-dir dist \
75
+ --push-draft \
76
+ --json
77
+ ```
78
+
79
+ 执行顺序:
80
+
81
+ 1. 读取或生成 Markdown 正文,确保正文里的本地图片路径真实存在。
82
+ 2. 先用 `wechat_format.py` 完成主题排版和本地预览。
83
+ 3. 如果要推送草稿箱,再用 `wechat_draft.py`:
84
+ - 获取 `access_token`。
85
+ - 上传正文图片到 `/cgi-bin/media/uploadimg`,替换正文图片地址。
86
+ - 上传封面图到 `/cgi-bin/material/add_material`,取得 `thumb_media_id`。
87
+ - 调用 `/cgi-bin/draft/add` 创建草稿箱草稿。
88
+ 4. 返回草稿 `media_id`、本地 `draft_payload.json`、`wechat.html`、`preview.html` 和图片上传映射。
89
+
90
+ 接口细节需要确认时,读取 `references/wechat-official-api.md`,不要凭记忆改接口路径。
91
+
92
+ 安全要求:
93
+
94
+ - 真实创建草稿必须显式传入 `--push-draft`,否则只生成本地 HTML 和草稿 payload。
95
+ - 不要在对话、工作台或日志里输出 AppSecret、access_token。
96
+ - 如果微信接口返回 IP 白名单、权限不足、未认证、额度不足等错误,要把错误原因原样归类说明,不要假装成功。
97
+
47
98
  ## 确定性排版脚本
48
99
 
49
100
  当前 Skill 内置 `scripts/wechat_format.py`,用于把 Markdown 转成微信可粘贴的内联样式 HTML。
@@ -54,20 +105,21 @@ Agent 在排版前应做结构化增强,但不能改变事实:
54
105
 
55
106
  除非用户在本轮已明确指定某个主题 ID,否则不得直接执行 `wechat_format.py --theme ... --output ...`。正确顺序是:
56
107
 
57
- 1. 文章正文确认或生成后,先写入 Markdown 源文件。
58
- 2. 执行主题画廊命令,基于正文生成 20 个核心主题预览:
108
+ 1. 文章正文确认或生成后,先准备 Markdown 正文。
109
+ 2. 如果当前 YuanFlow 主程序工具列表里存在 `wechat_theme_gallery_update`,必须优先调用这个受控工具,传入已确认的 `markdown` 正文或受控 `markdown_path`。该工具会直接运行主题画廊脚本并写入右侧内容创作工作台,避免 Agent 手动复制主题预览 HTML 时丢字段。
110
+ 3. 如果没有 `wechat_theme_gallery_update`,再执行主题画廊命令,基于正文生成 20 个核心主题预览:
59
111
 
60
112
  ```bash
61
113
  python scripts/wechat_format.py --input article.md --theme-gallery --gallery-limit 20 --json
62
114
  ```
63
115
 
64
- 3. 使用 `content_workbench_update` 写入右侧 `内容创作` 工作台:
116
+ 4. 只有在没有受控工具时,才手动使用 `content_workbench_update` 写入右侧 `内容创作` 工作台:
65
117
  - `status`: `collecting`
66
118
  - `activeStep`: `theme_select`
67
119
  - `steps`: 必须包含 `主题选择`
68
- - `views`: 至少包含脚本返回的 `option_grid` 主题卡片;可以同时附带 `editor_panel` 显示 Markdown 摘要。
69
- 4. 明确告诉用户:请在右侧工作台选择一个主题,然后发送输入框上方出现的待执行任务。
70
- 5. 只有拿到用户选择的主题后,才执行:
120
+ - `views`: 至少包含脚本返回的 `option_grid` 主题卡片;必须保留 `previewHtml`,不要省略主题预览;可以同时附带 `editor_panel` 显示 Markdown 摘要。
121
+ 5. 明确告诉用户:请在右侧工作台选择一个主题,然后发送输入框上方出现的待执行任务。
122
+ 6. 只有拿到用户选择的主题后,才执行:
71
123
 
72
124
  ```bash
73
125
  python scripts/wechat_format.py --input article.md --theme <selected_theme> --output dist/wechat.html --preview dist/preview.html --json
@@ -117,6 +169,8 @@ python scripts/wechat_format.py --input article.md --theme newspaper --output di
117
169
  - 官方入口:https://developers.weixin.qq.com/platform
118
170
  - 必需信息:AppID、AppSecret
119
171
  - 凭证由 YuanFlow Runtime 保存到用户数据目录的本地 secrets/config 中,Skill 不自行写入凭证文件,也不把密钥打印到对话里。
172
+ - 官方迁移说明:https://developers.weixin.qq.com/doc/subscription/guide/dev/migration.html
173
+ - 获取 `access_token` 前,必须确认已经在“微信开发者平台”官网配置 API IP 白名单,并把当前机器出口 IP 加入白名单;具体路径在微信开发者平台:我的业务 - 公众号/服务号 - 基础信息 - 开发密钥 - API IP 白名单。未配置时,接口通常返回 `40164 invalid ip ... not in whitelist`。
120
174
 
121
175
  如果当前环境没有可用的受控凭证保存工具,应提示用户先在程序设置或 Runtime 受控入口配置凭证,再继续草稿箱流程。
122
176
 
@@ -0,0 +1,68 @@
1
+ # 微信公众号草稿箱接口参考
2
+
3
+ 本文只记录本 Skill 使用到的官方接口,便于 Agent 在执行公众号生成与发布时判断链路边界。
4
+
5
+ 官方迁移说明:
6
+
7
+ - https://developers.weixin.qq.com/doc/subscription/guide/dev/migration.html
8
+
9
+ ## 开发者信息
10
+
11
+ AppID、AppSecret 和 API IP 白名单已迁移到微信开发者平台管理:
12
+
13
+ - 入口:https://developers.weixin.qq.com/platform
14
+ - 路径:我的业务 - 公众号/服务号 - 基础信息 - 开发密钥
15
+ - 如果当前机器出口 IP 不在 API IP 白名单内,接口会返回 `40164 invalid ip ... not in whitelist`。
16
+
17
+ ## 接口链路
18
+
19
+ 1. 获取 access_token
20
+
21
+ ```text
22
+ GET https://api.weixin.qq.com/cgi-bin/token
23
+ 参数:grant_type=client_credential, appid, secret
24
+ ```
25
+
26
+ 2. 上传正文图片
27
+
28
+ ```text
29
+ POST https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=ACCESS_TOKEN
30
+ multipart 字段:media
31
+ 返回:url
32
+ ```
33
+
34
+ 这个接口用于图文消息正文里的图片。草稿箱正文 HTML 中的 `<img src="">` 应使用该接口返回的 `url`。
35
+
36
+ 3. 上传封面素材
37
+
38
+ ```text
39
+ POST https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=ACCESS_TOKEN&type=image
40
+ multipart 字段:media
41
+ 返回:media_id
42
+ ```
43
+
44
+ 草稿箱图文需要 `thumb_media_id`,本 Skill 默认先按 `type=image` 上传;如果微信账号侧要求 `thumb`,脚本会回退尝试 `type=thumb`。
45
+
46
+ 4. 新增草稿
47
+
48
+ ```text
49
+ POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN
50
+ JSON:{"articles":[...]}
51
+ ```
52
+
53
+ 核心字段:
54
+
55
+ - `title`
56
+ - `author`
57
+ - `digest`
58
+ - `content`
59
+ - `content_source_url`
60
+ - `thumb_media_id`
61
+ - `need_open_comment`
62
+ - `only_fans_can_comment`
63
+
64
+ ## 执行约束
65
+
66
+ - 真实创建草稿必须显式使用 `--push-draft`。
67
+ - 不要输出 AppSecret 或 access_token。
68
+ - 微信返回错误时保留 `errcode/errmsg`,并把错误归因到白名单、凭证、素材、草稿权限或额度。
@@ -0,0 +1,346 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import html
5
+ import json
6
+ import mimetypes
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import requests
13
+
14
+ from wechat_format import build_preview, markdown_to_wechat_html, _load_theme
15
+
16
+
17
+ WECHAT_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"
18
+ WECHAT_UPLOAD_ARTICLE_IMAGE_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg"
19
+ WECHAT_ADD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"
20
+ WECHAT_ADD_DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add"
21
+
22
+
23
+ class WechatApiError(RuntimeError):
24
+ def __init__(self, code: str, message: str, *, payload: dict[str, Any] | None = None) -> None:
25
+ super().__init__(message)
26
+ self.code = code
27
+ self.payload = payload or {}
28
+
29
+
30
+ def _redact_secret(value: str) -> str:
31
+ if not value:
32
+ return ""
33
+ if len(value) <= 8:
34
+ return "***"
35
+ return f"{value[:4]}***{value[-4:]}"
36
+
37
+
38
+ def _load_credentials(path: Path | None, app_id: str, app_secret: str) -> dict[str, str]:
39
+ raw_app_id = app_id.strip()
40
+ raw_app_secret = app_secret.strip()
41
+ if path:
42
+ text = path.read_text(encoding="utf-8-sig")
43
+ id_match = re.search(r"AppID[::\s]+([A-Za-z0-9_-]+)", text)
44
+ secret_match = re.search(r"AppSecret[::\s]+([A-Za-z0-9_-]+)", text)
45
+ raw_app_id = raw_app_id or (id_match.group(1).strip() if id_match else "")
46
+ raw_app_secret = raw_app_secret or (secret_match.group(1).strip() if secret_match else "")
47
+ if not raw_app_id or not raw_app_secret:
48
+ raise WechatApiError("credentials-missing", "缺少 AppID 或 AppSecret。")
49
+ return {"app_id": raw_app_id, "app_secret": raw_app_secret}
50
+
51
+
52
+ def _request_json(method: str, url: str, **kwargs: Any) -> dict[str, Any]:
53
+ response = requests.request(method, url, timeout=30, **kwargs)
54
+ try:
55
+ payload = response.json()
56
+ except ValueError as exc:
57
+ raise WechatApiError("wechat-non-json-response", response.text[:500]) from exc
58
+ if response.status_code >= 400:
59
+ raise WechatApiError("wechat-http-error", f"HTTP {response.status_code}", payload=payload)
60
+ errcode = payload.get("errcode")
61
+ if errcode not in (None, 0):
62
+ raise WechatApiError(str(errcode), str(payload.get("errmsg") or "wechat api error"), payload=payload)
63
+ return payload
64
+
65
+
66
+ def get_access_token(app_id: str, app_secret: str) -> str:
67
+ payload = _request_json(
68
+ "GET",
69
+ WECHAT_TOKEN_URL,
70
+ params={
71
+ "grant_type": "client_credential",
72
+ "appid": app_id,
73
+ "secret": app_secret,
74
+ },
75
+ )
76
+ token = str(payload.get("access_token") or "")
77
+ if not token:
78
+ raise WechatApiError("access-token-missing", "微信接口没有返回 access_token。", payload=payload)
79
+ return token
80
+
81
+
82
+ def _guess_content_type(path: Path) -> str:
83
+ return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
84
+
85
+
86
+ def upload_article_image(access_token: str, image_path: Path) -> str:
87
+ with image_path.open("rb") as image_file:
88
+ payload = _request_json(
89
+ "POST",
90
+ WECHAT_UPLOAD_ARTICLE_IMAGE_URL,
91
+ params={"access_token": access_token},
92
+ files={"media": (image_path.name, image_file, _guess_content_type(image_path))},
93
+ )
94
+ url = str(payload.get("url") or "")
95
+ if not url:
96
+ raise WechatApiError("article-image-url-missing", "正文图片上传成功但没有返回 url。", payload=payload)
97
+ return url
98
+
99
+
100
+ def upload_cover_material(access_token: str, image_path: Path, material_type: str = "image") -> str:
101
+ with image_path.open("rb") as image_file:
102
+ files: dict[str, Any] = {"media": (image_path.name, image_file, _guess_content_type(image_path))}
103
+ data = None
104
+ if material_type == "video":
105
+ data = {"description": json.dumps({"title": image_path.stem, "introduction": ""}, ensure_ascii=False)}
106
+ payload = _request_json(
107
+ "POST",
108
+ WECHAT_ADD_MATERIAL_URL,
109
+ params={"access_token": access_token, "type": material_type},
110
+ files=files,
111
+ data=data,
112
+ )
113
+ media_id = str(payload.get("media_id") or "")
114
+ if not media_id:
115
+ raise WechatApiError("cover-media-id-missing", "封面素材上传成功但没有返回 media_id。", payload=payload)
116
+ return media_id
117
+
118
+
119
+ def _is_remote_url(value: str) -> bool:
120
+ return re.match(r"^https?://", value, re.IGNORECASE) is not None
121
+
122
+
123
+ def _resolve_markdown_image_path(src: str, markdown_dir: Path) -> Path:
124
+ normalized = src.strip().strip("\"'")
125
+ path = Path(normalized)
126
+ if not path.is_absolute():
127
+ path = markdown_dir / path
128
+ return path.resolve()
129
+
130
+
131
+ def upload_and_replace_markdown_images(markdown: str, markdown_dir: Path, access_token: str) -> tuple[str, list[dict[str, str]]]:
132
+ uploads: list[dict[str, str]] = []
133
+
134
+ def replace(match: re.Match[str]) -> str:
135
+ alt = match.group(1)
136
+ src = match.group(2).strip()
137
+ if _is_remote_url(src):
138
+ return match.group(0)
139
+ image_path = _resolve_markdown_image_path(src, markdown_dir)
140
+ if not image_path.exists() or not image_path.is_file():
141
+ raise WechatApiError("article-image-not-found", f"正文图片不存在:{image_path}")
142
+ uploaded_url = upload_article_image(access_token, image_path)
143
+ uploads.append({"alt": alt, "source": str(image_path), "url": uploaded_url})
144
+ return f"![{alt}]({uploaded_url})"
145
+
146
+ updated = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", replace, markdown)
147
+ return updated, uploads
148
+
149
+
150
+ def _extract_title(markdown: str, fallback: str) -> str:
151
+ for line in markdown.splitlines():
152
+ match = re.match(r"^#\s+(.+)$", line.strip())
153
+ if match:
154
+ return match.group(1).strip()[:64]
155
+ return fallback.strip()[:64] or "YuanFlow 公众号草稿"
156
+
157
+
158
+ def _plain_text_digest(markdown: str) -> str:
159
+ text = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", markdown)
160
+ text = re.sub(r"`{1,3}[^`]*`{1,3}", "", text)
161
+ text = re.sub(r"[#>*_\-\[\]()`]", "", text)
162
+ text = re.sub(r"\s+", " ", text).strip()
163
+ return _truncate_utf8(text, 54)
164
+
165
+
166
+ def _truncate_utf8(value: str, max_bytes: int) -> str:
167
+ value = value.strip()
168
+ encoded = value.encode("utf-8")
169
+ if len(encoded) <= max_bytes:
170
+ return value
171
+ return encoded[:max_bytes].decode("utf-8", errors="ignore").rstrip()
172
+
173
+
174
+ def build_draft_payload(
175
+ *,
176
+ title: str,
177
+ author: str,
178
+ digest: str,
179
+ content: str,
180
+ thumb_media_id: str,
181
+ content_source_url: str = "",
182
+ ) -> dict[str, Any]:
183
+ return {
184
+ "articles": [
185
+ {
186
+ "title": _truncate_utf8(title, 64),
187
+ "author": author,
188
+ "digest": _truncate_utf8(digest, 54),
189
+ "content": content,
190
+ "content_source_url": content_source_url,
191
+ "thumb_media_id": thumb_media_id,
192
+ "need_open_comment": 0,
193
+ "only_fans_can_comment": 0,
194
+ }
195
+ ]
196
+ }
197
+
198
+
199
+ def add_draft(access_token: str, payload: dict[str, Any]) -> dict[str, Any]:
200
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
201
+ return _request_json(
202
+ "POST",
203
+ WECHAT_ADD_DRAFT_URL,
204
+ params={"access_token": access_token},
205
+ data=body,
206
+ headers={"Content-Type": "application/json; charset=utf-8"},
207
+ )
208
+
209
+
210
+ def run(args: argparse.Namespace) -> dict[str, Any]:
211
+ input_path = Path(args.input).resolve()
212
+ output_dir = Path(args.output_dir).resolve()
213
+ output_dir.mkdir(parents=True, exist_ok=True)
214
+
215
+ markdown = input_path.read_text(encoding="utf-8")
216
+ title = args.title.strip() or _extract_title(markdown, "YuanFlow 公众号草稿")
217
+ author = args.author.strip() or "YuanFlow"
218
+ theme = _load_theme(args.theme)
219
+ image_uploads: list[dict[str, str]] = []
220
+ cover_upload: dict[str, str] | None = None
221
+ draft_result: dict[str, Any] | None = None
222
+ access_token_status = "skipped"
223
+
224
+ credentials = {"app_id": "", "app_secret": ""}
225
+ if args.push_draft or args.credentials_file or args.app_id or args.app_secret:
226
+ credentials = _load_credentials(
227
+ Path(args.credentials_file).resolve() if args.credentials_file else None,
228
+ args.app_id,
229
+ args.app_secret,
230
+ )
231
+
232
+ if args.push_draft:
233
+ access_token = get_access_token(credentials["app_id"], credentials["app_secret"])
234
+ access_token_status = "ok"
235
+ markdown, image_uploads = upload_and_replace_markdown_images(markdown, input_path.parent, access_token)
236
+ else:
237
+ access_token = ""
238
+
239
+ html_body = markdown_to_wechat_html(markdown, theme)
240
+ html_path = output_dir / "wechat.html"
241
+ preview_path = output_dir / "preview.html"
242
+ uploaded_markdown_path = output_dir / "article.uploaded.md"
243
+ html_path.write_text(html_body, encoding="utf-8")
244
+ preview_path.write_text(build_preview(html_body, title), encoding="utf-8")
245
+ uploaded_markdown_path.write_text(markdown, encoding="utf-8")
246
+
247
+ cover_media_id = args.thumb_media_id.strip()
248
+ if args.push_draft and not cover_media_id:
249
+ cover_image = Path(args.cover_image).resolve() if args.cover_image else None
250
+ if not cover_image:
251
+ raise WechatApiError("cover-image-required", "推送草稿箱必须提供 --cover-image 或 --thumb-media-id。")
252
+ if not cover_image.exists() or not cover_image.is_file():
253
+ raise WechatApiError("cover-image-not-found", f"封面图片不存在:{cover_image}")
254
+ try:
255
+ cover_media_id = upload_cover_material(access_token, cover_image, args.cover_material_type)
256
+ except WechatApiError:
257
+ if args.cover_material_type == "image":
258
+ cover_media_id = upload_cover_material(access_token, cover_image, "thumb")
259
+ else:
260
+ raise
261
+ cover_upload = {"source": str(cover_image), "media_id": cover_media_id}
262
+
263
+ draft_payload: dict[str, Any] | None = None
264
+ if cover_media_id:
265
+ draft_payload = build_draft_payload(
266
+ title=title,
267
+ author=author,
268
+ digest=args.digest.strip() or _plain_text_digest(markdown),
269
+ content=html_body,
270
+ content_source_url=args.content_source_url.strip(),
271
+ thumb_media_id=cover_media_id,
272
+ )
273
+ draft_payload_path = output_dir / "draft_payload.json"
274
+ draft_payload_path.write_text(json.dumps(draft_payload, ensure_ascii=False, indent=2), encoding="utf-8")
275
+ else:
276
+ draft_payload_path = None
277
+
278
+ if args.push_draft:
279
+ if not draft_payload:
280
+ raise WechatApiError("draft-payload-missing", "草稿 payload 未生成。")
281
+ draft_result = add_draft(access_token, draft_payload)
282
+
283
+ result = {
284
+ "ok": True,
285
+ "pushed": bool(args.push_draft),
286
+ "title": title,
287
+ "theme": args.theme,
288
+ "account": {
289
+ "app_id": credentials["app_id"],
290
+ "app_secret": _redact_secret(credentials["app_secret"]),
291
+ },
292
+ "access_token_status": access_token_status,
293
+ "files": {
294
+ "html": str(html_path),
295
+ "preview": str(preview_path),
296
+ "uploaded_markdown": str(uploaded_markdown_path),
297
+ "draft_payload": str(draft_payload_path) if draft_payload_path else "",
298
+ },
299
+ "article_images": image_uploads,
300
+ "cover": cover_upload,
301
+ "draft": draft_result,
302
+ }
303
+ return result
304
+
305
+
306
+ def main() -> int:
307
+ if hasattr(sys.stdout, "reconfigure"):
308
+ sys.stdout.reconfigure(encoding="utf-8")
309
+ if hasattr(sys.stderr, "reconfigure"):
310
+ sys.stderr.reconfigure(encoding="utf-8")
311
+ parser = argparse.ArgumentParser(description="YuanFlow 微信公众号草稿箱推送工具")
312
+ parser.add_argument("--input", required=True, help="Markdown 输入文件")
313
+ parser.add_argument("--theme", default="wechat-native", help="排版主题")
314
+ parser.add_argument("--title", default="", help="文章标题,默认从一级标题提取")
315
+ parser.add_argument("--author", default="YuanFlow", help="作者")
316
+ parser.add_argument("--digest", default="", help="摘要,默认从正文提取")
317
+ parser.add_argument("--content-source-url", default="", help="原文链接,可选")
318
+ parser.add_argument("--cover-image", default="", help="封面图片路径")
319
+ parser.add_argument("--thumb-media-id", default="", help="已上传封面素材 media_id")
320
+ parser.add_argument("--cover-material-type", default="image", choices=["image", "thumb"], help="封面上传素材类型")
321
+ parser.add_argument("--output-dir", default="dist", help="输出目录")
322
+ parser.add_argument("--credentials-file", default="", help="包含 AppID/AppSecret 的本地文件")
323
+ parser.add_argument("--app-id", default="", help="微信公众号 AppID")
324
+ parser.add_argument("--app-secret", default="", help="微信公众号 AppSecret")
325
+ parser.add_argument("--push-draft", action="store_true", help="显式创建草稿箱草稿;不传则只生成本地文件和 payload")
326
+ parser.add_argument("--json", action="store_true", help="输出 JSON")
327
+ args = parser.parse_args()
328
+
329
+ try:
330
+ result = run(args)
331
+ except WechatApiError as exc:
332
+ payload = {"ok": False, "error": {"code": exc.code, "message": str(exc), "payload": exc.payload}}
333
+ print(json.dumps(payload, ensure_ascii=False), file=sys.stderr)
334
+ return 2
335
+
336
+ if args.json:
337
+ print(json.dumps(result, ensure_ascii=False))
338
+ else:
339
+ print(f"HTML: {result['files']['html']}")
340
+ if result.get("draft"):
341
+ print(f"Draft: {result['draft']}")
342
+ return 0
343
+
344
+
345
+ if __name__ == "__main__":
346
+ raise SystemExit(main())