@rongyan/wxpost-cli 0.1.0

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/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # @rongyan/wxpost-cli
2
+
3
+ 微信公众号草稿发布命令行客户端。将本地 Markdown 文件转换为微信富文本,自动上传图片,创建/查看/发布草稿。
4
+
5
+ ## 要求
6
+
7
+ - Node.js >= 18
8
+ - 运行中的 [wxpost-server](https://github.com/rongyan6/wxpost-server) 实例
9
+
10
+ ## 安装
11
+
12
+ ```bash
13
+ npm install -g @rongyan/wxpost-cli
14
+ ```
15
+
16
+ 或通过 npx 直接运行(无需安装):
17
+
18
+ ```bash
19
+ npx @rongyan/wxpost-cli add_draft article.md
20
+ ```
21
+
22
+ ## 配置
23
+
24
+ 首次运行时自动在 `~/.@rongyan/env-cli.json` 创建配置模板并退出。编辑后重新运行即可。
25
+
26
+ ```json
27
+ {
28
+ "server_url": "http://localhost:3000",
29
+ "api_key": "your-api-key",
30
+ "need_open_comment": 1,
31
+ "only_fans_can_comment": 0,
32
+ "mdflow": {
33
+ "primary_color": null,
34
+ "heading_2": null,
35
+ "asset_dir": null
36
+ }
37
+ }
38
+ ```
39
+
40
+ | 字段 | 说明 | 默认值 |
41
+ |------|------|--------|
42
+ | `server_url` | wxpost-server 地址 | `http://localhost:3000` |
43
+ | `api_key` | 与 wxpost-server 一致的鉴权密钥 | — |
44
+ | `need_open_comment` | 草稿是否开启评论(0/1) | `1` |
45
+ | `only_fans_can_comment` | 是否仅粉丝可评论(0/1) | `0` |
46
+ | `mdflow.primary_color` | 文章主题色,`null` 则每次随机 | `null` |
47
+ | `mdflow.heading_2` | 二级标题样式,`null` 则每次随机 | `null` |
48
+ | `mdflow.asset_dir` | Mermaid 图表 PNG 输出目录(可选) | — |
49
+
50
+ ### mdflow 可选值
51
+
52
+ **primary_color**:`blue` `green` `orange` `yellow` `purple` `sky` `rosegold` `olive` `black` `gray` `pink`
53
+
54
+ **heading_2**:`default` `color` `bottom` `left`
55
+
56
+ ## 命令
57
+
58
+ ### add_draft — 创建草稿
59
+
60
+ ```bash
61
+ wxpost add_draft <file.md> [--account <appid>]
62
+ ```
63
+
64
+ 从 Markdown 文件创建草稿。文章类型由 front matter 中的 `article_type` 字段决定,默认为 `news`(图文消息)。
65
+
66
+ #### 图文消息(news)
67
+
68
+ ```markdown
69
+ ---
70
+ title: 文章标题(最多 32 字,必填)
71
+ author: 作者名(可选)
72
+ digest: 自定义摘要(可选,不填则自动截取正文前 100 字)
73
+ cover: ./images/cover.jpg
74
+ ---
75
+
76
+ 正文内容,支持标准 Markdown 语法...
77
+ ```
78
+
79
+ | front matter 字段 | 说明 |
80
+ |---|---|
81
+ | `title` | 文章标题,必填,最多 32 字 |
82
+ | `cover` | 封面图本地路径(相对于 .md 文件),必填 |
83
+ | `author` | 作者,可选 |
84
+ | `digest` | 摘要,可选,超出 128 字由服务端截断 |
85
+
86
+ 封面图会自动上传为永久素材;正文中引用的本地图片会自动上传为内容图并替换 URL。
87
+
88
+ #### 图片消息(newspic)
89
+
90
+ ```markdown
91
+ ---
92
+ title: 相册标题(必填)
93
+ article_type: newspic
94
+ image_list:
95
+ - ./photos/01.jpg
96
+ - ./photos/02.jpg
97
+ - ./photos/03.png
98
+ ---
99
+
100
+ 可选的图片说明文字(仅支持纯文本,不支持 Markdown 语法)
101
+ ```
102
+
103
+ | front matter 字段 | 说明 |
104
+ |---|---|
105
+ | `title` | 标题,必填,最多 32 字 |
106
+ | `article_type` | 固定填 `newspic` |
107
+ | `image_list` | 本地图片路径列表(相对于 .md 文件),1-20 张,必填 |
108
+
109
+ `image_list` 中的图片全部上传为永久素材;第一张自动作为封面。
110
+
111
+ #### 图片处理
112
+
113
+ 所有图片均通过文件头(magic bytes)校验格式,扩展名与内容不符会报错。
114
+
115
+ | 用途 | 支持格式 | 大小限制 | 超限处理 |
116
+ |---|---|---|---|
117
+ | 封面图(news cover) | JPG / PNG / BMP / GIF | 10MB | JPG/PNG 自动无损压缩;BMP/GIF 超限直接报错 |
118
+ | 图片消息图片(newspic image_list) | JPG / PNG / BMP / GIF | 10MB | 同上 |
119
+ | 正文内联图(news 正文 img 标签) | JPG / PNG | 1MB | 自动无损压缩;压缩后仍超则报错 |
120
+
121
+ 路径均相对于 Markdown 文件所在目录解析。
122
+
123
+ ### list_draft — 草稿列表
124
+
125
+ ```bash
126
+ wxpost list_draft [--offset <n>] [--count <n>] [--account <appid>]
127
+ ```
128
+
129
+ | 选项 | 说明 | 默认 |
130
+ |---|---|---|
131
+ | `--offset` | 起始偏移 | `0` |
132
+ | `--count` | 返回数量,最大 20 | `20` |
133
+ | `--account` | 指定公众号 AppID | 配置中的默认账号 |
134
+
135
+ 输出示例:
136
+
137
+ ```
138
+ 草稿总数: 12 本次返回: 3
139
+
140
+ [1] 我的第一篇文章
141
+ media_id: xxx
142
+ 更新时间: 2026/3/28 10:30:00
143
+
144
+ [2] 相册:春日出行
145
+ media_id: yyy
146
+ 更新时间: 2026/3/27 09:00:00
147
+ ```
148
+
149
+ ### publish — 发布草稿
150
+
151
+ ```bash
152
+ wxpost publish <media_id> [--account <appid>]
153
+ ```
154
+
155
+ 将草稿正式发布为群发消息。
156
+
157
+ ## 多账号
158
+
159
+ 所有命令均支持 `--account <appid>` 指定公众号账号,不传则使用 wxpost-server 配置中的 `defaultAccount`。
160
+
161
+ ## 完整示例
162
+
163
+ ```bash
164
+ # 创建图文草稿
165
+ wxpost add_draft article.md
166
+
167
+ # 创建并指定账号
168
+ wxpost add_draft article.md --account wx84671d15576a9880
169
+
170
+ # 查看草稿列表
171
+ wxpost list_draft
172
+
173
+ # 翻页
174
+ wxpost list_draft --offset 20 --count 10
175
+
176
+ # 发布
177
+ wxpost publish <media_id>
178
+ ```
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const cmdAddDraft = require('../src/commands/add-draft');
6
+ const cmdListDraft = require('../src/commands/list-draft');
7
+ const cmdPublish = require('../src/commands/publish');
8
+
9
+ const HELP = `
10
+ 熔岩微信公众号发布客户端
11
+
12
+ 用法:
13
+ wxpost <命令> [参数] [选项]
14
+
15
+ 命令:
16
+ add_draft <file.md> 从 Markdown 文件创建草稿
17
+ list_draft 获取草稿列表
18
+ publish <media_id> 发布草稿
19
+
20
+ add_draft 选项:
21
+ --account <appid> 指定公众号账号(默认使用配置中的 defaultAccount)
22
+
23
+ Markdown 文件需包含 front matter,图文消息(news)示例:
24
+
25
+ ---
26
+ title: 文章标题
27
+ author: 作者名
28
+ digest: 自定义摘要(可选,不填则自动截取正文前 100 字)
29
+ cover: ./images/cover.jpg
30
+ ---
31
+ 正文内容...
32
+
33
+ 图片消息(newspic)示例:
34
+
35
+ ---
36
+ title: 相册标题
37
+ article_type: newspic
38
+ image_list:
39
+ - ./photos/01.jpg
40
+ - ./photos/02.jpg
41
+ ---
42
+ 图片说明文字...
43
+
44
+ list_draft 选项:
45
+ --offset <n> 起始偏移,默认 0
46
+ --count <n> 返回数量 1-20,默认 20
47
+ --account <appid> 指定公众号账号
48
+
49
+ publish 选项:
50
+ --account <appid> 指定公众号账号
51
+
52
+ 配置文件: ~/.@rongyan/env-cli.json
53
+ server_url wxpost-server 地址,默认 http://localhost:3000
54
+ api_key API Key(与 wxpost-server 配置一致)
55
+ need_open_comment 是否开启评论,默认 1
56
+ only_fans_can_comment 仅粉丝可评论,默认 0
57
+ mdflow.primary_color 主题色(留 null 每次随机)
58
+ mdflow.heading_2 二级标题样式(留 null 每次随机)
59
+ mdflow.asset_dir Mermaid PNG 输出目录(可选)
60
+
61
+ 示例:
62
+ wxpost add_draft article.md
63
+ wxpost add_draft article.md --account wx84671d15576a9880
64
+ wxpost list_draft
65
+ wxpost list_draft --offset 20 --count 10
66
+ wxpost publish <media_id>
67
+ `;
68
+
69
+ function parseArgs(argv) {
70
+ const args = [];
71
+ const opts = {};
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const a = argv[i];
74
+ if (a.startsWith('--')) {
75
+ const key = a.slice(2);
76
+ const next = argv[i + 1];
77
+ if (next && !next.startsWith('--')) {
78
+ // camelCase 转换:--thumb-media-id => thumbMediaId
79
+ const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
80
+ opts[camel] = next;
81
+ i++;
82
+ } else {
83
+ opts[key] = true;
84
+ }
85
+ } else {
86
+ args.push(a);
87
+ }
88
+ }
89
+ return { args, opts };
90
+ }
91
+
92
+ async function main() {
93
+ const { args, opts } = parseArgs(process.argv.slice(2));
94
+ const cmd = args[0];
95
+
96
+ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
97
+ console.log(HELP);
98
+ process.exit(0);
99
+ }
100
+
101
+ if (cmd === 'add_draft') {
102
+ const file = args[1];
103
+ if (!file) {
104
+ console.error('用法: wxpost add_draft <file.md>');
105
+ process.exit(1);
106
+ }
107
+ await cmdAddDraft(file, opts);
108
+ } else if (cmd === 'list_draft') {
109
+ await cmdListDraft(opts);
110
+ } else if (cmd === 'publish') {
111
+ const mediaId = args[1];
112
+ await cmdPublish(mediaId, opts);
113
+ } else {
114
+ console.error(`未知命令: ${cmd}\n运行 wxpost --help 查看帮助`);
115
+ process.exit(1);
116
+ }
117
+ }
118
+
119
+ main().catch((e) => {
120
+ console.error(`意外错误: ${e.message}`);
121
+ process.exit(1);
122
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@rongyan/wxpost-cli",
3
+ "version": "0.1.0",
4
+ "description": "熔岩微信公众号发布客户端",
5
+ "bin": {
6
+ "wxpost": "./bin/wxpost-cli.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "start": "node bin/wxpost-cli.js"
14
+ },
15
+ "keywords": [
16
+ "wechat",
17
+ "wxpost",
18
+ "cli",
19
+ "weixin",
20
+ "markdown",
21
+ "publish"
22
+ ],
23
+ "author": "rongyan",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/rongyan6/wxpost-cli.git"
28
+ },
29
+ "homepage": "https://github.com/rongyan6/wxpost-cli#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/rongyan6/wxpost-cli/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "dependencies": {
40
+ "@rongyan/mdflow-cli": "^0.1.3",
41
+ "imagemin": "^9.0.1",
42
+ "imagemin-jpegtran": "^8.0.0",
43
+ "imagemin-optipng": "^8.0.0"
44
+ }
45
+ }
package/src/api.js ADDED
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+ const { getCliConfig } = require('./config');
6
+
7
+ function request(pathname, body) {
8
+ const { serverUrl, apiKey } = getCliConfig();
9
+ const url = new URL(pathname, serverUrl);
10
+ const isHttps = url.protocol === 'https:';
11
+ const driver = isHttps ? https : http;
12
+
13
+ const payload = JSON.stringify(body);
14
+
15
+ return new Promise((resolve, reject) => {
16
+ const req = driver.request(
17
+ url,
18
+ {
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/json; charset=utf-8',
22
+ 'Content-Length': Buffer.byteLength(payload),
23
+ 'Authorization': `Bearer ${apiKey}`,
24
+ },
25
+ },
26
+ (res) => {
27
+ let raw = '';
28
+ res.on('data', (chunk) => { raw += chunk; });
29
+ res.on('end', () => {
30
+ let data;
31
+ try {
32
+ data = JSON.parse(raw);
33
+ } catch {
34
+ return reject(new Error(`服务器响应解析失败: ${raw}`));
35
+ }
36
+ if (!data.ok) {
37
+ return reject(new Error(data.error || '请求失败'));
38
+ }
39
+ resolve(data);
40
+ });
41
+ }
42
+ );
43
+ req.on('error', (e) => reject(new Error(`连接服务器失败: ${e.message}`)));
44
+ req.write(payload);
45
+ req.end();
46
+ });
47
+ }
48
+
49
+ /**
50
+ * 上传本地图片为永久素材,返回 { media_id, url }。
51
+ * @param {Buffer} buffer
52
+ * @param {string} filename
53
+ * @param {string|null} appid
54
+ */
55
+ function uploadMaterial(buffer, filename, appid) {
56
+ const { serverUrl, apiKey } = getCliConfig();
57
+ const qs = appid ? `?appid=${appid}` : '';
58
+ const url = new URL(`/upload-material${qs}`, serverUrl);
59
+ const isHttps = url.protocol === 'https:';
60
+ const driver = isHttps ? https : http;
61
+
62
+ const boundary = `----CliUploadBoundary${Date.now()}`;
63
+ const ext = filename.split('.').pop().toLowerCase();
64
+ const mimeTypes = { png: 'image/png', gif: 'image/gif', bmp: 'image/bmp', jpg: 'image/jpeg', jpeg: 'image/jpeg' };
65
+ const mimeType = mimeTypes[ext] || 'image/jpeg';
66
+ const safeFilename = filename.split(/[\\/]/).pop() || 'image';
67
+
68
+ const head = Buffer.from(
69
+ `--${boundary}\r\n` +
70
+ `Content-Disposition: form-data; name="media"; filename="${safeFilename}"\r\n` +
71
+ `Content-Type: ${mimeType}\r\n\r\n`
72
+ );
73
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
74
+ const body = Buffer.concat([head, buffer, tail]);
75
+
76
+ return new Promise((resolve, reject) => {
77
+ const req = driver.request(
78
+ url,
79
+ {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
83
+ 'Content-Length': body.length,
84
+ 'Authorization': `Bearer ${apiKey}`,
85
+ },
86
+ },
87
+ (res) => {
88
+ let raw = '';
89
+ res.on('data', (chunk) => { raw += chunk; });
90
+ res.on('end', () => {
91
+ let data;
92
+ try {
93
+ data = JSON.parse(raw);
94
+ } catch {
95
+ return reject(new Error(`服务器响应解析失败: ${raw}`));
96
+ }
97
+ if (!data.ok) return reject(new Error(data.error || '上传失败'));
98
+ resolve(data);
99
+ });
100
+ }
101
+ );
102
+ req.on('error', (e) => reject(new Error(`连接服务器失败: ${e.message}`)));
103
+ req.write(body);
104
+ req.end();
105
+ });
106
+ }
107
+
108
+ /**
109
+ * 上传内容图(用于图文正文),返回微信 CDN url。
110
+ * @param {Buffer} buffer
111
+ * @param {string} filename
112
+ * @param {string|null} appid
113
+ */
114
+ function uploadContentImage(buffer, filename, appid) {
115
+ const { serverUrl, apiKey } = getCliConfig();
116
+ const qs = appid ? `?appid=${appid}` : '';
117
+ const url = new URL(`/upload-image${qs}`, serverUrl);
118
+ const isHttps = url.protocol === 'https:';
119
+ const driver = isHttps ? https : http;
120
+
121
+ const boundary = `----CliUploadBoundary${Date.now()}`;
122
+ const ext = filename.split('.').pop().toLowerCase();
123
+ const mimeType = ext === 'png' ? 'image/png' : 'image/jpeg';
124
+ const safeFilename = filename.split(/[\\/]/).pop() || 'image';
125
+
126
+ const head = Buffer.from(
127
+ `--${boundary}\r\n` +
128
+ `Content-Disposition: form-data; name="media"; filename="${safeFilename}"\r\n` +
129
+ `Content-Type: ${mimeType}\r\n\r\n`
130
+ );
131
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
132
+ const body = Buffer.concat([head, buffer, tail]);
133
+
134
+ return new Promise((resolve, reject) => {
135
+ const req = driver.request(
136
+ url,
137
+ {
138
+ method: 'POST',
139
+ headers: {
140
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
141
+ 'Content-Length': body.length,
142
+ 'Authorization': `Bearer ${apiKey}`,
143
+ },
144
+ },
145
+ (res) => {
146
+ let raw = '';
147
+ res.on('data', (chunk) => { raw += chunk; });
148
+ res.on('end', () => {
149
+ let data;
150
+ try {
151
+ data = JSON.parse(raw);
152
+ } catch {
153
+ return reject(new Error(`服务器响应解析失败: ${raw}`));
154
+ }
155
+ if (!data.ok) return reject(new Error(data.error || '上传失败'));
156
+ resolve(data.url);
157
+ });
158
+ }
159
+ );
160
+ req.on('error', (e) => reject(new Error(`连接服务器失败: ${e.message}`)));
161
+ req.write(body);
162
+ req.end();
163
+ });
164
+ }
165
+
166
+ function addDraft(articles, appid) {
167
+ const path = appid ? `/draft/add?appid=${appid}` : '/draft/add';
168
+ return request(path, { articles });
169
+ }
170
+
171
+ function listDraft({ offset = 0, count = 20, no_content = 0 } = {}, appid) {
172
+ const path = appid ? `/draft/list?appid=${appid}` : '/draft/list';
173
+ return request(path, { offset, count, no_content });
174
+ }
175
+
176
+ function publishDraft(mediaId, appid) {
177
+ const path = appid ? `/draft/publish?appid=${appid}` : '/draft/publish';
178
+ return request(path, { media_id: mediaId });
179
+ }
180
+
181
+ module.exports = { uploadMaterial, uploadContentImage, addDraft, listDraft, publishDraft };
@@ -0,0 +1,393 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { uploadMaterial, uploadContentImage, addDraft } = require('../api');
6
+ const { getMdflowConfig, loadConfig } = require('../config');
7
+
8
+ // ── mdflow 配置 ───────────────────────────────────────────────────────────────
9
+
10
+ const PRIMARY_COLORS = ['blue', 'green', 'orange', 'yellow', 'purple', 'sky', 'rosegold', 'olive', 'black', 'gray', 'pink'];
11
+ const HEADING_STYLES = ['default', 'color', 'bottom', 'left'];
12
+
13
+ function pickRandom(arr) {
14
+ return arr[Math.floor(Math.random() * arr.length)];
15
+ }
16
+
17
+ function buildMdflowOptions(cfg) {
18
+ const opts = {
19
+ theme: 'default',
20
+ fontFamily: 'serif',
21
+ fontSize: '推荐',
22
+ primaryColor: cfg.primary_color || pickRandom(PRIMARY_COLORS),
23
+ heading1: 'default',
24
+ heading2: cfg.heading_2 || pickRandom(HEADING_STYLES),
25
+ heading3: 'default',
26
+ codeTheme: 'github-dark',
27
+ legend: 'none',
28
+ macCodeBlock: true,
29
+ codeLineNumbers: false,
30
+ citeStatus: false,
31
+ useIndent: false,
32
+ useJustify: false,
33
+ };
34
+ if (cfg.asset_dir) opts.assetDir = cfg.asset_dir;
35
+ return opts;
36
+ }
37
+
38
+ // ── 图片校验 & 压缩 ──────────────────────────────────────────────────────────
39
+
40
+ // 内容图(正文内联):仅支持 JPG/PNG,上限 1MB
41
+ const CONTENT_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png']);
42
+ const CONTENT_IMAGE_LIMIT = 1 * 1024 * 1024;
43
+
44
+ // 永久素材(封面图 / newspic 图片列表):支持 JPG/PNG/BMP/GIF,上限 10MB
45
+ const MATERIAL_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.bmp', '.gif']);
46
+ const MATERIAL_IMAGE_LIMIT = 10 * 1024 * 1024;
47
+
48
+ // BMP/GIF 没有可用的无损压缩器,超限时直接报错
49
+ const COMPRESSIBLE_EXTS = new Set(['.jpg', '.jpeg', '.png']);
50
+
51
+ function validateImageFormat(buffer, filename, allowedExts) {
52
+ const ext = path.extname(filename).toLowerCase();
53
+ if (!allowedExts.has(ext)) {
54
+ const list = [...allowedExts].map((e) => e.slice(1).toUpperCase()).join(' / ');
55
+ throw new Error(`不支持的图片格式 "${ext}",仅支持 ${list}`);
56
+ }
57
+ if (buffer.length < 4) {
58
+ throw new Error(`"${filename}" 文件内容为空或过短,不是有效的图片`);
59
+ }
60
+ if (ext === '.png') {
61
+ if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47) {
62
+ throw new Error(`"${filename}" 扩展名为 .png 但文件头不是有效的 PNG`);
63
+ }
64
+ } else if (ext === '.bmp') {
65
+ if (buffer[0] !== 0x42 || buffer[1] !== 0x4D) {
66
+ throw new Error(`"${filename}" 扩展名为 .bmp 但文件头不是有效的 BMP`);
67
+ }
68
+ } else if (ext === '.gif') {
69
+ if (buffer[0] !== 0x47 || buffer[1] !== 0x49 || buffer[2] !== 0x46 || buffer[3] !== 0x38) {
70
+ throw new Error(`"${filename}" 扩展名为 .gif 但文件头不是有效的 GIF`);
71
+ }
72
+ } else {
73
+ if (buffer[0] !== 0xFF || buffer[1] !== 0xD8 || buffer[2] !== 0xFF) {
74
+ throw new Error(`"${filename}" 扩展名为 ${ext} 但文件头不是有效的 JPEG`);
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 校验图片格式,并在超过 sizeLimit 时尝试无损压缩(仅 JPG/PNG 支持)。
81
+ * BMP/GIF 不可压缩,超限直接报错。
82
+ * 压缩后若仍超过 hardLimit 则报错。
83
+ */
84
+ async function maybeCompress(buffer, filename, sizeLimit, hardLimit, allowedExts) {
85
+ validateImageFormat(buffer, filename, allowedExts);
86
+ if (buffer.length <= sizeLimit) return buffer;
87
+
88
+ const ext = path.extname(filename).toLowerCase();
89
+
90
+ if (!COMPRESSIBLE_EXTS.has(ext)) {
91
+ throw new Error(
92
+ `"${filename}" 超过限制(${(buffer.length / 1024 / 1024).toFixed(1)}MB / 限 ${(hardLimit / 1024 / 1024).toFixed(0)}MB),${ext.slice(1).toUpperCase()} 格式不支持自动压缩,请手动处理`
93
+ );
94
+ }
95
+
96
+ const { default: imagemin } = await import('imagemin');
97
+ let plugin;
98
+ if (ext === '.png') {
99
+ const { default: imageminOptipng } = await import('imagemin-optipng');
100
+ plugin = imageminOptipng();
101
+ } else {
102
+ const { default: imageminJpegtran } = await import('imagemin-jpegtran');
103
+ plugin = imageminJpegtran();
104
+ }
105
+ const compressed = Buffer.from(await imagemin.buffer(buffer, { plugins: [plugin] }));
106
+
107
+ if (compressed.length >= hardLimit) {
108
+ throw new Error(
109
+ `"${filename}" 压缩后仍超过限制(${(compressed.length / 1024 / 1024).toFixed(1)}MB / 限 ${(hardLimit / 1024 / 1024).toFixed(0)}MB)`
110
+ );
111
+ }
112
+ return compressed;
113
+ }
114
+
115
+ /**
116
+ * 扫描 wxhtml 中所有本地 <img src> 路径,压缩后上传为内容图,并替换 URL。
117
+ * @param {string} wxhtml
118
+ * @param {string} mdDir Markdown 文件所在目录,用于解析相对路径
119
+ * @param {string|undefined} appid
120
+ * @returns {Promise<string>} 替换后的 HTML
121
+ */
122
+ async function uploadContentImages(wxhtml, mdDir, appid) {
123
+ const IMG_RE = /(<img\b[^>]*?\ssrc=")([^"]+)(")/gi;
124
+
125
+ // 收集所有唯一的本地 src
126
+ const localSrcs = new Set();
127
+ for (const m of wxhtml.matchAll(IMG_RE)) {
128
+ const src = m[2];
129
+ if (!src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:')) {
130
+ localSrcs.add(src);
131
+ }
132
+ }
133
+ if (localSrcs.size === 0) return wxhtml;
134
+
135
+ // 逐一上传,建立 src → wx_url 映射
136
+ const urlMap = new Map();
137
+ for (const src of localSrcs) {
138
+ const absPath = path.isAbsolute(src) ? src : path.resolve(mdDir, src);
139
+ if (!fs.existsSync(absPath)) {
140
+ throw new Error(`内容图不存在: ${absPath}`);
141
+ }
142
+ const raw = fs.readFileSync(absPath);
143
+ const filename = path.basename(absPath);
144
+
145
+ let buffer;
146
+ try {
147
+ buffer = await maybeCompress(raw, filename, CONTENT_IMAGE_LIMIT, CONTENT_IMAGE_LIMIT, CONTENT_IMAGE_EXTS);
148
+ } catch (e) {
149
+ throw new Error(`内容图处理失败 (${filename}): ${e.message}`);
150
+ }
151
+ if (buffer.length < raw.length) {
152
+ process.stdout.write(` 压缩内容图: ${filename} ${(raw.length / 1024).toFixed(0)}KB → ${(buffer.length / 1024).toFixed(0)}KB\n`);
153
+ }
154
+
155
+ process.stdout.write(` 上传内容图: ${filename} ...`);
156
+ const wxUrl = await uploadContentImage(buffer, filename, appid);
157
+ process.stdout.write(` ${wxUrl}\n`);
158
+ urlMap.set(src, wxUrl);
159
+ }
160
+
161
+ // 替换 HTML 中的 src
162
+ return wxhtml.replace(IMG_RE, (_, pre, src, post) => {
163
+ return pre + (urlMap.get(src) ?? src) + post;
164
+ });
165
+ }
166
+
167
+ // ── Front matter 解析 ────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * 解析 Markdown 文件头部的 YAML front matter。
171
+ * 支持字符串、数字值;image_list 支持多行列表(- 路径)。
172
+ * @returns {{ meta: object, body: string }}
173
+ */
174
+ function parseFrontMatter(content) {
175
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
176
+ if (!match) return { meta: {}, body: content };
177
+
178
+ const rawMeta = match[1];
179
+ const body = match[2];
180
+ const meta = {};
181
+ const lines = rawMeta.split(/\r?\n/);
182
+
183
+ // 已知文件扩展名:结尾匹配则视为文件路径,整段原样保留(仅剥离两端引号)
184
+ const FILE_EXT_RE = /\.(jpe?g|png|gif|bmp|webp|tiff?|avif|heic|mp4|mp3|wav|amr|wma|m4a)$/i;
185
+
186
+ const parseVal = (raw) => {
187
+ const s = raw.trim();
188
+ if (!s) return null;
189
+ // 剥离两端匹配的单引号或双引号
190
+ const unquoted = s.replace(/^(['"])(.*)\1$/, '$2').trim();
191
+ if (!unquoted) return null;
192
+ // 以已知文件扩展名结尾 → 文件路径,原样返回(含空格、特殊字符)
193
+ if (FILE_EXT_RE.test(unquoted)) return unquoted;
194
+ // 普通值
195
+ return unquoted;
196
+ };
197
+
198
+ let currentKey = null;
199
+ for (const line of lines) {
200
+ // 列表项: - value(尽可能抓取整行,兼容路径中的空格与特殊字符)
201
+ const listMatch = line.match(/^\s+-\s+([\s\S]+)$/);
202
+ if (listMatch && currentKey) {
203
+ if (!Array.isArray(meta[currentKey])) meta[currentKey] = [];
204
+ const v = parseVal(listMatch[1]);
205
+ if (v !== null) meta[currentKey].push(v);
206
+ continue;
207
+ }
208
+ // key: value(value 部分贪婪匹配整行剩余,确保含空格的路径不被截断)
209
+ const kvMatch = line.match(/^(\w+)\s*:\s*([\s\S]*)$/);
210
+ if (kvMatch) {
211
+ currentKey = kvMatch[1];
212
+ meta[currentKey] = parseVal(kvMatch[2]);
213
+ }
214
+ }
215
+
216
+ return { meta, body };
217
+ }
218
+
219
+ // ── 上传封面图,返回 media_id ─────────────────────────────────────────────────
220
+
221
+ /**
222
+ * 读取本地图片 → 压缩(如需)→ 上传永久素材,返回 media_id。
223
+ * @param {string} imagePath 本地路径(绝对路径直接使用;相对路径相对 baseDir 解析)
224
+ * @param {string} baseDir Markdown 文件所在目录,用于解析相对路径
225
+ * @param {string} label 日志前缀,如 "封面图" / "图片[1/3]"
226
+ * @param {string|undefined} appid
227
+ */
228
+ async function uploadLocalImageAsMaterial(imagePath, baseDir, label, appid) {
229
+ const clean = imagePath.trim();
230
+ const absPath = path.isAbsolute(clean) ? clean : path.resolve(baseDir, clean);
231
+ if (!fs.existsSync(absPath)) {
232
+ throw new Error(`${label}不存在: ${absPath}`);
233
+ }
234
+ const raw = fs.readFileSync(absPath);
235
+ const filename = path.basename(absPath);
236
+
237
+ let buffer;
238
+ try {
239
+ buffer = await maybeCompress(raw, filename, MATERIAL_IMAGE_LIMIT, MATERIAL_IMAGE_LIMIT, MATERIAL_IMAGE_EXTS);
240
+ } catch (e) {
241
+ throw new Error(`${label}处理失败: ${e.message}`);
242
+ }
243
+ if (buffer.length < raw.length) {
244
+ process.stdout.write(` 压缩${label}: ${filename} ${(raw.length / 1024).toFixed(0)}KB → ${(buffer.length / 1024).toFixed(0)}KB\n`);
245
+ }
246
+
247
+ process.stdout.write(` 上传${label}: ${filename} ...`);
248
+ const result = await uploadMaterial(buffer, filename, appid);
249
+ process.stdout.write(` media_id=${result.media_id}\n`);
250
+ return result.media_id;
251
+ }
252
+
253
+ async function renderToWxhtml(body) {
254
+ const mdflowOpts = buildMdflowOptions(getMdflowConfig());
255
+ process.stdout.write(` 转换 Markdown → 微信富文本(主题色=${mdflowOpts.primaryColor} 二级标题=${mdflowOpts.heading2})...`);
256
+ try {
257
+ const { renderMarkdown } = await import('@rongyan/mdflow-cli');
258
+ const rendered = await renderMarkdown(body, mdflowOpts);
259
+ process.stdout.write(' 完成\n');
260
+ return rendered.wxhtml;
261
+ } catch (e) {
262
+ process.stdout.write('\n');
263
+ throw new Error(`Markdown 转换失败: ${e.message}`);
264
+ }
265
+ }
266
+
267
+ // ── 主命令 ───────────────────────────────────────────────────────────────────
268
+
269
+ async function cmdAddDraft(filePath, opts = {}) {
270
+ const absPath = path.resolve(filePath);
271
+ if (!fs.existsSync(absPath)) {
272
+ console.error(`文件不存在: ${absPath}`);
273
+ process.exit(1);
274
+ }
275
+
276
+ const content = fs.readFileSync(absPath, 'utf-8');
277
+ const { meta, body } = parseFrontMatter(content);
278
+
279
+ // ── 校验公共必填字段
280
+ const title = meta.title;
281
+ if (!title) {
282
+ console.error('front matter 缺少 title');
283
+ process.exit(1);
284
+ }
285
+ if (title.length > 64) title = title.slice(0, 64);
286
+
287
+ const articleType = meta.article_type || 'news';
288
+ const cfg = loadConfig();
289
+ let article;
290
+
291
+ if (articleType === 'newspic') {
292
+ // ── 图片消息
293
+ const imageList = Array.isArray(meta.image_list) ? meta.image_list : [];
294
+ if (imageList.length === 0) {
295
+ console.error('newspic 类型 front matter 缺少 image_list');
296
+ process.exit(1);
297
+ }
298
+ if (imageList.length > 20) {
299
+ console.error(`image_list 最多 20 张,当前 ${imageList.length} 张`);
300
+ process.exit(1);
301
+ }
302
+
303
+ // 上传每张图片为永久素材 → 收集 media_id
304
+ const mdDir = path.dirname(absPath);
305
+ const imageMediaIds = [];
306
+ for (let i = 0; i < imageList.length; i++) {
307
+ try {
308
+ const mediaId = await uploadLocalImageAsMaterial(
309
+ imageList[i], mdDir, `图片[${i + 1}/${imageList.length}]`, opts.account
310
+ );
311
+ imageMediaIds.push(mediaId);
312
+ } catch (e) {
313
+ console.error(`图片上传失败: ${e.message}`);
314
+ process.exit(1);
315
+ }
316
+ }
317
+
318
+ article = {
319
+ article_type: 'newspic',
320
+ title,
321
+ content: body.trim(),
322
+ need_open_comment: cfg.need_open_comment ?? 1,
323
+ only_fans_can_comment: cfg.only_fans_can_comment ?? 0,
324
+ image_info: {
325
+ image_list: imageMediaIds.map((id) => ({ image_media_id: id })),
326
+ },
327
+ };
328
+ } else {
329
+ // ── 图文消息
330
+ if (!meta.cover) {
331
+ console.error('front matter 缺少 cover(封面图路径)');
332
+ process.exit(1);
333
+ }
334
+ if (!body.trim()) {
335
+ console.error('正文内容不能为空');
336
+ process.exit(1);
337
+ }
338
+
339
+ // 上传封面图 → thumb_media_id
340
+ let thumbMediaId;
341
+ try {
342
+ thumbMediaId = await uploadLocalImageAsMaterial(
343
+ meta.cover, path.dirname(absPath), '封面图', opts.account
344
+ );
345
+ } catch (e) {
346
+ console.error(`封面图上传失败: ${e.message}`);
347
+ process.exit(1);
348
+ }
349
+
350
+ // 正文 Markdown → 微信富文本
351
+ let wxhtml;
352
+ try {
353
+ wxhtml = await renderToWxhtml(body);
354
+ } catch (e) {
355
+ console.error(e.message);
356
+ process.exit(1);
357
+ }
358
+
359
+ // 上传正文内联图片
360
+ try {
361
+ wxhtml = await uploadContentImages(wxhtml, path.dirname(absPath), opts.account);
362
+ } catch (e) {
363
+ console.error(`内容图上传失败: ${e.message}`);
364
+ process.exit(1);
365
+ }
366
+
367
+ const digest = meta.digest
368
+ || wxhtml.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 100);
369
+
370
+ article = {
371
+ article_type: 'news',
372
+ title,
373
+ ...(meta.author && { author: meta.author }),
374
+ digest,
375
+ content: wxhtml,
376
+ thumb_media_id: thumbMediaId,
377
+ need_open_comment: cfg.need_open_comment ?? 1,
378
+ only_fans_can_comment: cfg.only_fans_can_comment ?? 0,
379
+ };
380
+ }
381
+
382
+ // ── 创建草稿
383
+ try {
384
+ const result = await addDraft([article], opts.account);
385
+ console.log(`草稿创建成功`);
386
+ console.log(`media_id: ${result.media_id}`);
387
+ } catch (e) {
388
+ console.error(`创建草稿失败: ${e.message}`);
389
+ process.exit(1);
390
+ }
391
+ }
392
+
393
+ module.exports = cmdAddDraft;
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const { listDraft } = require('../api');
4
+
5
+ async function cmdListDraft(opts = {}) {
6
+ const offset = parseInt(opts.offset ?? 0, 10);
7
+ const count = parseInt(opts.count ?? 20, 10);
8
+
9
+ try {
10
+ const result = await listDraft({ offset, count, no_content: 1 }, opts.account);
11
+ const items = result.item || [];
12
+
13
+ console.log(`草稿总数: ${result.total_count} 本次返回: ${result.item_count}`);
14
+ console.log('');
15
+
16
+ if (items.length === 0) {
17
+ console.log('暂无草稿');
18
+ return;
19
+ }
20
+
21
+ items.forEach((item, i) => {
22
+ const articles = item.content?.news_item || [];
23
+ const first = articles[0] || {};
24
+ const updateTime = item.update_time
25
+ ? new Date(item.update_time * 1000).toLocaleString('zh-CN')
26
+ : '-';
27
+ const extra = articles.length > 1 ? ` 共 ${articles.length} 篇` : '';
28
+
29
+ console.log(`[${offset + i + 1}] ${first.title || '(无标题)'}${extra}`);
30
+ console.log(` media_id: ${item.media_id}`);
31
+ console.log(` 更新时间: ${updateTime}`);
32
+ if (first.author) console.log(` 作者: ${first.author}`);
33
+ console.log('');
34
+ });
35
+ } catch (e) {
36
+ console.error(`获取草稿列表失败: ${e.message}`);
37
+ process.exit(1);
38
+ }
39
+ }
40
+
41
+ module.exports = cmdListDraft;
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ const { publishDraft } = require('../api');
4
+
5
+ async function cmdPublish(mediaId, opts = {}) {
6
+ if (!mediaId) {
7
+ console.error('请提供草稿的 media_id');
8
+ process.exit(1);
9
+ }
10
+
11
+ try {
12
+ const result = await publishDraft(mediaId, opts.account);
13
+ console.log(`发布成功`);
14
+ console.log(`publish_id: ${result.publish_id}`);
15
+ if (result.msg_data_id) {
16
+ console.log(`msg_data_id: ${result.msg_data_id}`);
17
+ }
18
+ } catch (e) {
19
+ console.error(`发布失败: ${e.message}`);
20
+ process.exit(1);
21
+ }
22
+ }
23
+
24
+ module.exports = cmdPublish;
package/src/config.js ADDED
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.@rongyan');
8
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'env-cli.json');
9
+
10
+ const TEMPLATE = {
11
+ server_url: 'http://localhost:3000',
12
+ api_key: '__YOUR_API_KEY__',
13
+ need_open_comment: 1,
14
+ only_fans_can_comment: 0,
15
+ mdflow: {
16
+ primary_color: null,
17
+ heading_2: null,
18
+ asset_dir: null,
19
+ },
20
+ };
21
+
22
+ function ensureConfig() {
23
+ if (fs.existsSync(CONFIG_PATH)) return false;
24
+
25
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
26
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(TEMPLATE, null, 2) + '\n', 'utf-8');
27
+ return true;
28
+ }
29
+
30
+ let _cache = null;
31
+
32
+ function loadConfig() {
33
+ if (_cache) return _cache;
34
+
35
+ const created = ensureConfig();
36
+ if (created) {
37
+ console.error([
38
+ '',
39
+ ' 配置文件已创建:' + CONFIG_PATH,
40
+ ' 请编辑该文件,填入 wxpost-server 地址和 API Key,然后重新运行。',
41
+ '',
42
+ ' 格式说明:',
43
+ ' server_url — wxpost-server 地址,例如 http://localhost:3000',
44
+ ' api_key — 与 wxpost-server 配置中一致的 API Key',
45
+ ' mdflow.primary_color — 主题色(可选,留 null 则每次随机)',
46
+ ' mdflow.heading_2 — 二级标题样式(可选,留 null 则每次随机)',
47
+ ' mdflow.asset_dir — Mermaid PNG 输出目录(可选)',
48
+ '',
49
+ ].join('\n'));
50
+ process.exit(1);
51
+ }
52
+
53
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
54
+ try {
55
+ _cache = JSON.parse(raw);
56
+ } catch (e) {
57
+ console.error(`配置文件 JSON 格式错误 (${CONFIG_PATH}): ${e.message}`);
58
+ process.exit(1);
59
+ }
60
+
61
+ // 补全旧版配置文件缺失的字段
62
+ let patched = false;
63
+ if (_cache.need_open_comment === undefined) { _cache.need_open_comment = TEMPLATE.need_open_comment; patched = true; }
64
+ if (_cache.only_fans_can_comment === undefined) { _cache.only_fans_can_comment = TEMPLATE.only_fans_can_comment; patched = true; }
65
+ if (!_cache.mdflow) { _cache.mdflow = { ...TEMPLATE.mdflow }; patched = true; }
66
+ if (patched) fs.writeFileSync(CONFIG_PATH, JSON.stringify(_cache, null, 2) + '\n', 'utf-8');
67
+
68
+ return _cache;
69
+ }
70
+
71
+ function getCliConfig() {
72
+ const config = loadConfig();
73
+ const serverUrl = (config.server_url || 'http://localhost:3000').replace(/\/$/, '');
74
+ const apiKey = config.api_key || '';
75
+
76
+ if (!apiKey || apiKey === '__YOUR_API_KEY__') {
77
+ console.error([
78
+ '',
79
+ ' API Key 未配置。',
80
+ ` 请编辑 ${CONFIG_PATH},将 api_key 替换为真实值。`,
81
+ '',
82
+ ].join('\n'));
83
+ process.exit(1);
84
+ }
85
+
86
+ return { serverUrl, apiKey };
87
+ }
88
+
89
+ function getMdflowConfig() {
90
+ return loadConfig().mdflow || {};
91
+ }
92
+
93
+ module.exports = { loadConfig, getCliConfig, getMdflowConfig, CONFIG_PATH };