yuanflow-cli 0.1.34 → 0.1.35

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.34",
3
+ "version": "0.1.35",
4
4
  "description": "YuanFlow 自媒体 API CLI 与 Skill 安装器。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,7 +5,7 @@ description: 当用户需要公众号文章创作、文章改写、Markdown 排
5
5
 
6
6
  # 公众号生成与发布
7
7
 
8
- 本 Skill 用于把公众号文章从“需求确认、内容生成、排版预览、导出 HTML、推送草稿箱”串成一个可控流程。它可以一次性完成全流程,也可以只做其中一段,例如只写文章、只生成封面建议、只做排版预览或只推送草稿箱。
8
+ 本 Skill 用于把公众号文章从“需求确认、内容生成、排版预览、导出 HTML、新增或更新草稿箱草稿”串成一个可控流程。它可以一次性完成全流程,也可以只做其中一段,例如只写文章、只生成封面建议、只做排版预览、只新增草稿或更新已有草稿。
9
9
 
10
10
  ## 分类
11
11
 
@@ -22,7 +22,7 @@ description: 当用户需要公众号文章创作、文章改写、Markdown 排
22
22
  - 用户没有要求正文插图时:不要擅自生成插图;只处理文字正文和可选封面。
23
23
  5. 排版结果使用 Markdown 还是 HTML。
24
24
  - 如果选择 HTML,会把 Markdown 转成微信公众号编辑器可粘贴的内联样式 HTML。
25
- 6. 是否需要推送到微信公众号草稿箱。草稿箱只负责生成草稿,正式发布必须由用户自行在公众号后台确认。
25
+ 6. 是否需要写入微信公众号草稿箱,并确认是新增草稿还是更新已有草稿。更新已有草稿时必须先拿到目标草稿的 `media_id`,多图文还要确认更新第几篇,第一篇 `index=0`。草稿箱只负责生成或更新草稿,正式发布必须由用户自行在公众号后台确认。
26
26
 
27
27
  ## 内容创作流程
28
28
 
@@ -63,7 +63,7 @@ Agent 在排版前应做结构化增强,但不能改变事实:
63
63
  - 用户要求生成插图:调用 `生图技能` 生成图片,使用工具返回的本地缓存路径插入 Markdown。
64
64
  - 用户只要求封面:只生成或使用封面,不在正文里额外插图。
65
65
 
66
- 使用生图技能生成公众号封面或正文插图时,不能只凭当前文章主题临时想象一个空泛提示词。必须先查看 `生图技能` 的提示词参考目录,至少读取 `生图技能/references/image-prompt-reference.md` 中与本次任务最接近的方向,例如知识卡、信息图、海报封面、科技商业插画、人物/场景图、产品图或编辑部视觉。读取后只借鉴其中的构图、镜头、版式、材质、灯光、信息层级、文字约束和负面提示写法,再结合当前公众号主题重写成新的高质量中文提示词;不要原样复制案例,不要带入无关品牌、人物、Logo、水印或英文占位字。若参考库没有完全匹配的方向,也要选择最接近的视觉类型作为结构参考,再生成更具体的画面规划。
66
+ 使用生图技能生成公众号封面或正文插图时,不能只凭当前文章主题临时想象一个空泛提示词。必须先查看 `生图技能` 的提示词参考目录,优先根据参考文件的标题、目录或小节名判断有没有适合当前用户需求或间接相近方向的内容,例如知识卡、信息图、海报封面、科技商业插画、人物/场景图、产品图或编辑部视觉;发现相近方向后再进入对应小节阅读参考。不要求每次完整查看所有提示词,也不要机械逐篇浏览。读取后只借鉴其中的构图、镜头、版式、材质、灯光、信息层级、文字约束和负面提示写法,再结合当前公众号主题重写成新的高质量中文提示词;不要原样复制案例,不要带入无关品牌、人物、Logo、水印或英文占位字。若参考库没有完全匹配的方向,也要选择最接近的视觉类型作为结构参考,再生成更具体的画面规划。
67
67
 
68
68
  生成公众号正文插图时必须遵守中文默认规则:
69
69
 
@@ -72,7 +72,9 @@ Agent 在排版前应做结构化增强,但不能改变事实:
72
72
  - 如果文章段落确实需要图中文字,只允许使用与文章一致的简体中文短句,并明确列出具体中文文字。
73
73
  - 除非用户明确要求英文或双语,不要在正文插图、封面图或预览图里生成英文。
74
74
 
75
- 推送草稿箱时使用 `scripts/wechat_draft.py`:
75
+ 写入草稿箱时使用 `scripts/wechat_draft.py`。
76
+
77
+ 新增草稿示例:
76
78
 
77
79
  ```bash
78
80
  python scripts/wechat_draft.py \
@@ -85,22 +87,36 @@ python scripts/wechat_draft.py \
85
87
  --json
86
88
  ```
87
89
 
90
+ 更新已有草稿示例:
91
+
92
+ ```bash
93
+ python scripts/wechat_draft.py \
94
+ --credentials-file "D:\path\to\wechat-credentials.txt" \
95
+ --input article.md \
96
+ --theme newspaper \
97
+ --draft-media-id MEDIA_ID \
98
+ --draft-index 0 \
99
+ --output-dir dist \
100
+ --update-draft \
101
+ --json
102
+ ```
103
+
88
104
  执行顺序:
89
105
 
90
106
  1. 读取或生成 Markdown 正文,确保正文里的本地图片路径真实存在。
91
107
  2. 先用 `wechat_format.py` 完成主题排版和本地预览。
92
- 3. 如果要推送草稿箱,再用 `wechat_draft.py`:
108
+ 3. 如果要新增或更新草稿箱,再用 `wechat_draft.py`:
93
109
  - 获取 `access_token`。
94
110
  - 上传正文图片到 `/cgi-bin/media/uploadimg`,替换正文图片地址。
95
- - 上传封面图到 `/cgi-bin/material/add_material`,取得 `thumb_media_id`。
96
- - 调用 `/cgi-bin/draft/add` 创建草稿箱草稿。
97
- 4. 返回草稿 `media_id`、本地 `draft_payload.json`、`wechat.html`、`preview.html` 和图片上传映射。
111
+ - 新增草稿时必须上传或提供封面图 `thumb_media_id`,再调用 `/cgi-bin/draft/add` 创建草稿箱草稿。
112
+ - 更新已有草稿时必须提供 `--draft-media-id` 和 `--draft-index`,调用 `/cgi-bin/draft/update` 更新指定图文。更新接口不是局部 patch,需要按接口要求重新提交该篇文章的完整 `articles` 对象;如果只改正文且不换封面,可以不传封面。
113
+ 4. 返回草稿 `media_id` 或更新结果、本地 `draft_payload.json`、`wechat.html`、`preview.html` 和图片上传映射。
98
114
 
99
115
  接口细节需要确认时,读取 `references/wechat-official-api.md`,不要凭记忆改接口路径。
100
116
 
101
117
  安全要求:
102
118
 
103
- - 真实创建草稿必须显式传入 `--push-draft`,否则只生成本地 HTML 和草稿 payload。
119
+ - 真实新增草稿必须显式传入 `--push-draft`;真实更新草稿必须显式传入 `--update-draft`、`--draft-media-id` 和 `--draft-index`。否则只生成本地 HTML 和草稿 payload。
104
120
  - 不要在对话、工作台或日志里输出 AppSecret、access_token。
105
121
  - 如果微信接口返回 IP 白名单、权限不足、未认证、额度不足等错误,要把错误原因原样归类说明,不要假装成功。
106
122
 
@@ -169,7 +185,7 @@ python scripts/wechat_format.py --input article.md --theme newspaper --output di
169
185
  2. `option_grid`:主题卡片和文章预览结果。主题卡片必须带 `intent: select_wechat_theme`、`payload.theme` 和 `instruction`,让用户点击后形成待执行任务。
170
186
  3. `preview_frame`:手机样式 HTML 预览。
171
187
  4. `editor_panel`:Markdown 或 HTML 源码。
172
- 5. `action_bar`:导出 HTML、推送草稿箱、继续改写等动作。
188
+ 5. `action_bar`:导出 HTML、新增草稿、更新已有草稿、继续改写等动作。
173
189
 
174
190
  ## 公众号凭证
175
191
 
@@ -187,7 +203,7 @@ python scripts/wechat_format.py --input article.md --theme newspaper --output di
187
203
 
188
204
  封面不使用第三方生成接口。需要封面时,使用 YuanFlow 内置生图技能生成。
189
205
 
190
- 生成封面前同样必须先读取 `生图技能/references/image-prompt-reference.md`,选择与文章定位最接近的封面、海报、知识卡、商业科技视觉或信息图方向作为参考。封面提示词要把文章主题、目标读者、视觉主元素、构图层级、比例、中文文字规则和负面约束写清楚,避免只写“生成一张某主题封面图”这类空泛需求。
206
+ 生成封面前同样必须先查看 `生图技能/references/image-prompt-reference.md` 的标题、目录或小节名,选择与文章定位最接近的封面、海报、知识卡、商业科技视觉或信息图方向作为参考;只需进入相近方向阅读,不需要完整扫完所有提示词。封面提示词要把文章主题、目标读者、视觉主元素、构图层级、比例、中文文字规则和负面约束写清楚,避免只写“生成一张某主题封面图”这类空泛需求。
191
207
 
192
208
  建议比例:
193
209
 
@@ -61,8 +61,24 @@ JSON:{"articles":[...]}
61
61
  - `need_open_comment`
62
62
  - `only_fans_can_comment`
63
63
 
64
+ 5. 更新草稿
65
+
66
+ ```text
67
+ POST https://api.weixin.qq.com/cgi-bin/draft/update?access_token=ACCESS_TOKEN
68
+ JSON:{"media_id":"MEDIA_ID","index":0,"articles":{...}}
69
+ ```
70
+
71
+ 核心字段:
72
+
73
+ - `media_id`:要修改的图文消息草稿 ID
74
+ - `index`:要更新的文章位置,第一篇为 `0`
75
+ - `articles`:新的图文信息对象,不是数组
76
+
77
+ 更新草稿不是局部 patch。调用时应按官方字段重新提交指定 `index` 这一篇文章的完整内容对象。正文内图片仍必须先走 `/cgi-bin/media/uploadimg`,封面如需更新则传 `thumb_media_id`;如果只更新正文且不更换封面,可以不传新的封面。
78
+
64
79
  ## 执行约束
65
80
 
66
81
  - 真实创建草稿必须显式使用 `--push-draft`。
82
+ - 真实更新草稿必须显式使用 `--update-draft`,并提供 `--draft-media-id` 和 `--draft-index`。
67
83
  - 不要输出 AppSecret 或 access_token。
68
84
  - 微信返回错误时保留 `errcode/errmsg`,并把错误归因到白名单、凭证、素材、草稿权限或额度。
@@ -18,6 +18,7 @@ WECHAT_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"
18
18
  WECHAT_UPLOAD_ARTICLE_IMAGE_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg"
19
19
  WECHAT_ADD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"
20
20
  WECHAT_ADD_DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add"
21
+ WECHAT_UPDATE_DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/update"
21
22
 
22
23
 
23
24
  class WechatApiError(RuntimeError):
@@ -171,29 +172,31 @@ def _truncate_utf8(value: str, max_bytes: int) -> str:
171
172
  return encoded[:max_bytes].decode("utf-8", errors="ignore").rstrip()
172
173
 
173
174
 
174
- def build_draft_payload(
175
+ def build_draft_article(
175
176
  *,
176
177
  title: str,
177
178
  author: str,
178
179
  digest: str,
179
180
  content: str,
180
- thumb_media_id: str,
181
+ thumb_media_id: str = "",
181
182
  content_source_url: str = "",
182
183
  ) -> 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
- ]
184
+ article = {
185
+ "title": _truncate_utf8(title, 64),
186
+ "author": author,
187
+ "digest": _truncate_utf8(digest, 54),
188
+ "content": content,
189
+ "content_source_url": content_source_url,
190
+ "need_open_comment": 0,
191
+ "only_fans_can_comment": 0,
196
192
  }
193
+ if thumb_media_id:
194
+ article["thumb_media_id"] = thumb_media_id
195
+ return article
196
+
197
+
198
+ def build_draft_payload(*, article: dict[str, Any]) -> dict[str, Any]:
199
+ return {"articles": [article]}
197
200
 
198
201
 
199
202
  def add_draft(access_token: str, payload: dict[str, Any]) -> dict[str, Any]:
@@ -207,7 +210,36 @@ def add_draft(access_token: str, payload: dict[str, Any]) -> dict[str, Any]:
207
210
  )
208
211
 
209
212
 
213
+ def update_draft(
214
+ access_token: str,
215
+ *,
216
+ media_id: str,
217
+ index: int,
218
+ article: dict[str, Any],
219
+ ) -> dict[str, Any]:
220
+ if not media_id.strip():
221
+ raise WechatApiError("draft-media-id-missing", "更新草稿必须提供 --draft-media-id。")
222
+ body = json.dumps(
223
+ {
224
+ "media_id": media_id.strip(),
225
+ "index": index,
226
+ "articles": article,
227
+ },
228
+ ensure_ascii=False,
229
+ ).encode("utf-8")
230
+ return _request_json(
231
+ "POST",
232
+ WECHAT_UPDATE_DRAFT_URL,
233
+ params={"access_token": access_token},
234
+ data=body,
235
+ headers={"Content-Type": "application/json; charset=utf-8"},
236
+ )
237
+
238
+
210
239
  def run(args: argparse.Namespace) -> dict[str, Any]:
240
+ if args.push_draft and args.update_draft:
241
+ raise WechatApiError("draft-operation-conflict", "--push-draft 和 --update-draft 不能同时使用。")
242
+
211
243
  input_path = Path(args.input).resolve()
212
244
  output_dir = Path(args.output_dir).resolve()
213
245
  output_dir.mkdir(parents=True, exist_ok=True)
@@ -222,14 +254,15 @@ def run(args: argparse.Namespace) -> dict[str, Any]:
222
254
  access_token_status = "skipped"
223
255
 
224
256
  credentials = {"app_id": "", "app_secret": ""}
225
- if args.push_draft or args.credentials_file or args.app_id or args.app_secret:
257
+ will_write_draft = bool(args.push_draft or args.update_draft)
258
+ if will_write_draft or args.credentials_file or args.app_id or args.app_secret:
226
259
  credentials = _load_credentials(
227
260
  Path(args.credentials_file).resolve() if args.credentials_file else None,
228
261
  args.app_id,
229
262
  args.app_secret,
230
263
  )
231
264
 
232
- if args.push_draft:
265
+ if will_write_draft:
233
266
  access_token = get_access_token(credentials["app_id"], credentials["app_secret"])
234
267
  access_token_status = "ok"
235
268
  markdown, image_uploads = upload_and_replace_markdown_images(markdown, input_path.parent, access_token)
@@ -245,10 +278,8 @@ def run(args: argparse.Namespace) -> dict[str, Any]:
245
278
  uploaded_markdown_path.write_text(markdown, encoding="utf-8")
246
279
 
247
280
  cover_media_id = args.thumb_media_id.strip()
248
- if args.push_draft and not cover_media_id:
281
+ if will_write_draft and not cover_media_id and args.cover_image:
249
282
  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
283
  if not cover_image.exists() or not cover_image.is_file():
253
284
  raise WechatApiError("cover-image-not-found", f"封面图片不存在:{cover_image}")
254
285
  try:
@@ -261,8 +292,11 @@ def run(args: argparse.Namespace) -> dict[str, Any]:
261
292
  cover_upload = {"source": str(cover_image), "media_id": cover_media_id}
262
293
 
263
294
  draft_payload: dict[str, Any] | None = None
264
- if cover_media_id:
265
- draft_payload = build_draft_payload(
295
+ draft_article: dict[str, Any] | None = None
296
+ if args.push_draft and not cover_media_id:
297
+ raise WechatApiError("cover-image-required", "新增草稿必须提供 --cover-image 或 --thumb-media-id。")
298
+ if cover_media_id or args.update_draft:
299
+ draft_article = build_draft_article(
266
300
  title=title,
267
301
  author=author,
268
302
  digest=args.digest.strip() or _plain_text_digest(markdown),
@@ -270,6 +304,9 @@ def run(args: argparse.Namespace) -> dict[str, Any]:
270
304
  content_source_url=args.content_source_url.strip(),
271
305
  thumb_media_id=cover_media_id,
272
306
  )
307
+ draft_payload = build_draft_payload(
308
+ article=draft_article,
309
+ )
273
310
  draft_payload_path = output_dir / "draft_payload.json"
274
311
  draft_payload_path.write_text(json.dumps(draft_payload, ensure_ascii=False, indent=2), encoding="utf-8")
275
312
  else:
@@ -279,10 +316,20 @@ def run(args: argparse.Namespace) -> dict[str, Any]:
279
316
  if not draft_payload:
280
317
  raise WechatApiError("draft-payload-missing", "草稿 payload 未生成。")
281
318
  draft_result = add_draft(access_token, draft_payload)
319
+ elif args.update_draft:
320
+ if not draft_article:
321
+ raise WechatApiError("draft-payload-missing", "草稿更新 payload 未生成。")
322
+ draft_result = update_draft(
323
+ access_token,
324
+ media_id=args.draft_media_id,
325
+ index=args.draft_index,
326
+ article=draft_article,
327
+ )
282
328
 
283
329
  result = {
284
330
  "ok": True,
285
- "pushed": bool(args.push_draft),
331
+ "pushed": will_write_draft,
332
+ "operation": "update" if args.update_draft else ("add" if args.push_draft else "preview"),
286
333
  "title": title,
287
334
  "theme": args.theme,
288
335
  "account": {
@@ -323,6 +370,9 @@ def main() -> int:
323
370
  parser.add_argument("--app-id", default="", help="微信公众号 AppID")
324
371
  parser.add_argument("--app-secret", default="", help="微信公众号 AppSecret")
325
372
  parser.add_argument("--push-draft", action="store_true", help="显式创建草稿箱草稿;不传则只生成本地文件和 payload")
373
+ parser.add_argument("--update-draft", action="store_true", help="显式更新已有草稿;需同时提供 --draft-media-id")
374
+ parser.add_argument("--draft-media-id", default="", help="要更新的已有草稿 media_id")
375
+ parser.add_argument("--draft-index", type=int, default=0, help="要更新的图文位置,第一篇为 0")
326
376
  parser.add_argument("--json", action="store_true", help="输出 JSON")
327
377
  args = parser.parse_args()
328
378