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 +1 -1
- package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/SKILL.md +63 -9
- package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/references/wechat-official-api.md +68 -0
- package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/scripts/wechat_draft.py +346 -0
package/package.json
CHANGED
|
@@ -15,9 +15,14 @@ description: 当用户需要公众号文章创作、文章改写、Markdown 排
|
|
|
15
15
|
|
|
16
16
|
1. 用户是在已有文章上处理,还是完全重新创作。
|
|
17
17
|
2. 用户本轮要做完整流程,还是只做写作、排版、封面建议、导出或草稿箱推送中的一部分。
|
|
18
|
-
3.
|
|
18
|
+
3. 开始输出正文之前,必须先和用户核对大概字数范围,例如 800-1200 字、1500-2000 字、3000 字以上;如果用户没有给出范围,先询问,不要直接写完整正文。
|
|
19
|
+
4. 是否需要正文插图、插图数量、插图来源和大概插入位置。
|
|
20
|
+
- 用户提供图片时:先梳理每张图适合插入的段落位置,再进入排版、上传和草稿箱链路。
|
|
21
|
+
- 用户要求生成正文插图时:先使用 YuanFlow 内置生图技能生成图片,再把生成图片作为正文插图素材进入上传链路。
|
|
22
|
+
- 用户没有要求正文插图时:不要擅自生成插图;只处理文字正文和可选封面。
|
|
23
|
+
5. 排版结果使用 Markdown 还是 HTML。
|
|
19
24
|
- 如果选择 HTML,会把 Markdown 转成微信公众号编辑器可粘贴的内联样式 HTML。
|
|
20
|
-
|
|
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.
|
|
58
|
-
2.
|
|
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
|
-
|
|
116
|
+
4. 只有在没有受控工具时,才手动使用 `content_workbench_update` 写入右侧 `内容创作` 工作台:
|
|
65
117
|
- `status`: `collecting`
|
|
66
118
|
- `activeStep`: `theme_select`
|
|
67
119
|
- `steps`: 必须包含 `主题选择`
|
|
68
|
-
- `views`: 至少包含脚本返回的 `option_grid`
|
|
69
|
-
|
|
70
|
-
|
|
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""
|
|
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())
|