@sovovs/bycli 2.1.47 → 2.1.48
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/cli-manifest.json
CHANGED
|
@@ -29044,6 +29044,62 @@
|
|
|
29044
29044
|
"sourceFile": "weixin/create-draft.js",
|
|
29045
29045
|
"navigateBefore": false
|
|
29046
29046
|
},
|
|
29047
|
+
{
|
|
29048
|
+
"site": "weixin",
|
|
29049
|
+
"name": "create-newspic",
|
|
29050
|
+
"description": "通过微信公众号官方 API 创建贴图草稿(不会公开发布)",
|
|
29051
|
+
"access": "write",
|
|
29052
|
+
"example": "bycli weixin create-newspic --title \"相册标题\" --images \"./01.jpg,http://example.com/02.png\" --content \"说明文字\" --appid wx123 --appsecret secret",
|
|
29053
|
+
"strategy": "local",
|
|
29054
|
+
"browser": false,
|
|
29055
|
+
"args": [
|
|
29056
|
+
{
|
|
29057
|
+
"name": "title",
|
|
29058
|
+
"type": "str",
|
|
29059
|
+
"required": true,
|
|
29060
|
+
"help": "贴图标题(最长 32 字)"
|
|
29061
|
+
},
|
|
29062
|
+
{
|
|
29063
|
+
"name": "images",
|
|
29064
|
+
"type": "str",
|
|
29065
|
+
"required": true,
|
|
29066
|
+
"help": "1–20 张本地或 HTTP(S) 图片,使用逗号分隔"
|
|
29067
|
+
},
|
|
29068
|
+
{
|
|
29069
|
+
"name": "content",
|
|
29070
|
+
"type": "str",
|
|
29071
|
+
"required": false,
|
|
29072
|
+
"valueRequired": true,
|
|
29073
|
+
"help": "可选的纯文本贴图说明"
|
|
29074
|
+
},
|
|
29075
|
+
{
|
|
29076
|
+
"name": "appid",
|
|
29077
|
+
"type": "str",
|
|
29078
|
+
"required": true,
|
|
29079
|
+
"help": "公众号 AppID"
|
|
29080
|
+
},
|
|
29081
|
+
{
|
|
29082
|
+
"name": "appsecret",
|
|
29083
|
+
"type": "str",
|
|
29084
|
+
"required": true,
|
|
29085
|
+
"help": "公众号 AppSecret;请勿提交到 shell 历史或日志"
|
|
29086
|
+
},
|
|
29087
|
+
{
|
|
29088
|
+
"name": "allow-private-image-hosts",
|
|
29089
|
+
"type": "boolean",
|
|
29090
|
+
"default": false,
|
|
29091
|
+
"required": false,
|
|
29092
|
+
"help": "允许下载 localhost/内网 HTTP(S) 图片;云元数据地址始终禁止"
|
|
29093
|
+
}
|
|
29094
|
+
],
|
|
29095
|
+
"columns": [
|
|
29096
|
+
"status",
|
|
29097
|
+
"detail"
|
|
29098
|
+
],
|
|
29099
|
+
"type": "js",
|
|
29100
|
+
"modulePath": "weixin/create-newspic.js",
|
|
29101
|
+
"sourceFile": "weixin/create-newspic.js"
|
|
29102
|
+
},
|
|
29047
29103
|
{
|
|
29048
29104
|
"site": "weixin",
|
|
29049
29105
|
"name": "download",
|
|
@@ -12,7 +12,7 @@ function apiError(context, payload) {
|
|
|
12
12
|
return new CommandExecutionError(`${context} failed (${code}): ${message}`);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
async function readJsonResponse(response, context) {
|
|
15
|
+
export async function readJsonResponse(response, context) {
|
|
16
16
|
let payload;
|
|
17
17
|
try {
|
|
18
18
|
payload = await response.json();
|
|
@@ -24,7 +24,7 @@ async function readJsonResponse(response, context) {
|
|
|
24
24
|
return payload;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
async function getAccessToken(appid, appsecret, fetchImpl) {
|
|
27
|
+
export async function getAccessToken(appid, appsecret, fetchImpl) {
|
|
28
28
|
const url = new URL(`${API_BASE}/token`);
|
|
29
29
|
url.searchParams.set('grant_type', 'client_credential');
|
|
30
30
|
url.searchParams.set('appid', appid);
|
|
@@ -43,7 +43,7 @@ function mimeType(filePath) {
|
|
|
43
43
|
return 'image/jpeg';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
async function uploadImage(filePath, token, fetchImpl) {
|
|
46
|
+
export async function uploadImage(filePath, token, fetchImpl) {
|
|
47
47
|
const data = await nodeFs.readFile(filePath);
|
|
48
48
|
const form = new FormData();
|
|
49
49
|
form.append('media', new Blob([data], { type: mimeType(filePath) }), nodePath.basename(filePath));
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import * as nodePath from 'node:path';
|
|
2
|
+
import { access, stat } from 'node:fs/promises';
|
|
3
|
+
import { constants as fsConstants } from 'node:fs';
|
|
4
|
+
import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
|
|
5
|
+
import { downloadRemoteImage } from './remote-image.js';
|
|
6
|
+
import { getAccessToken, readJsonResponse, uploadImage } from './api-draft.js';
|
|
7
|
+
|
|
8
|
+
const API_BASE = 'https://api.weixin.qq.com/cgi-bin';
|
|
9
|
+
const MIN_IMAGES = 1;
|
|
10
|
+
const MAX_IMAGES = 20;
|
|
11
|
+
const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
12
|
+
|
|
13
|
+
function isRemoteImageSource(source) {
|
|
14
|
+
return /^https?:\/\//iu.test(String(source || ''));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function validateImageCount(images) {
|
|
18
|
+
if (!Array.isArray(images) || images.length < MIN_IMAGES || images.length > MAX_IMAGES) {
|
|
19
|
+
throw new ArgumentError('newspic requires 1–20 images');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resolveLocalImage(source, baseDir) {
|
|
24
|
+
const imagePath = nodePath.resolve(baseDir, source);
|
|
25
|
+
if (!SUPPORTED_IMAGE_EXTENSIONS.has(nodePath.extname(imagePath).toLowerCase())) {
|
|
26
|
+
throw new ArgumentError(`newspic image must be a jpg, jpeg, png, gif, or webp file: ${source}`);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
await access(imagePath, fsConstants.R_OK);
|
|
30
|
+
const info = await stat(imagePath);
|
|
31
|
+
if (!info.isFile() || info.size <= 0) throw new Error('not a non-empty file');
|
|
32
|
+
} catch {
|
|
33
|
+
throw new ArgumentError(`newspic image must be a readable non-empty file: ${imagePath}`);
|
|
34
|
+
}
|
|
35
|
+
return imagePath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function stageNewspicImages(images, {
|
|
39
|
+
baseDir = process.cwd(),
|
|
40
|
+
allowPrivateHosts = false,
|
|
41
|
+
fetchImpl = globalThis.fetch,
|
|
42
|
+
lookupImpl,
|
|
43
|
+
downloadImpl = downloadRemoteImage,
|
|
44
|
+
} = {}) {
|
|
45
|
+
validateImageCount(images);
|
|
46
|
+
const downloads = [];
|
|
47
|
+
const paths = [];
|
|
48
|
+
let cleaned = false;
|
|
49
|
+
const cleanup = async () => {
|
|
50
|
+
if (cleaned) return;
|
|
51
|
+
cleaned = true;
|
|
52
|
+
const results = await Promise.allSettled(downloads.map(download => download.cleanup()));
|
|
53
|
+
const failure = results.find(result => result.status === 'rejected');
|
|
54
|
+
if (failure) {
|
|
55
|
+
throw new CommandExecutionError(`Failed to clean up a temporary Weixin image: ${failure.reason?.message ?? failure.reason}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
for (const rawSource of images) {
|
|
60
|
+
const source = String(rawSource ?? '').trim();
|
|
61
|
+
if (!source) throw new ArgumentError('newspic image source must not be empty');
|
|
62
|
+
if (isRemoteImageSource(source)) {
|
|
63
|
+
const downloaded = await downloadImpl(source, {
|
|
64
|
+
allowPrivateHosts,
|
|
65
|
+
fetchImpl,
|
|
66
|
+
...(lookupImpl ? { lookupImpl } : {}),
|
|
67
|
+
});
|
|
68
|
+
downloads.push(downloaded);
|
|
69
|
+
paths.push(downloaded.path);
|
|
70
|
+
} else {
|
|
71
|
+
paths.push(await resolveLocalImage(source, baseDir));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { paths, cleanup };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
try {
|
|
77
|
+
await cleanup();
|
|
78
|
+
} catch (cleanupError) {
|
|
79
|
+
throw new CommandExecutionError(`${error?.message ?? error}; ${cleanupError.message}`);
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function deleteMaterial(mediaId, token, fetchImpl) {
|
|
86
|
+
const url = new URL(`${API_BASE}/material/del_material`);
|
|
87
|
+
url.searchParams.set('access_token', token);
|
|
88
|
+
const response = await fetchImpl(url.toString(), {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
91
|
+
body: JSON.stringify({ media_id: mediaId }),
|
|
92
|
+
});
|
|
93
|
+
await readJsonResponse(response, '删除图片素材');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function createNewspicDraftViaApi({
|
|
97
|
+
appid,
|
|
98
|
+
appsecret,
|
|
99
|
+
title,
|
|
100
|
+
content = '',
|
|
101
|
+
images,
|
|
102
|
+
baseDir = process.cwd(),
|
|
103
|
+
fetchImpl = globalThis.fetch,
|
|
104
|
+
imageFetchImpl = globalThis.fetch,
|
|
105
|
+
lookupImpl,
|
|
106
|
+
downloadImpl = downloadRemoteImage,
|
|
107
|
+
allowPrivateImageHosts = false,
|
|
108
|
+
} = {}) {
|
|
109
|
+
if (!String(appid ?? '').trim() || !String(appsecret ?? '').trim()) {
|
|
110
|
+
throw new CommandExecutionError('newspic API requires both appid and appsecret');
|
|
111
|
+
}
|
|
112
|
+
if (typeof fetchImpl !== 'function') throw new CommandExecutionError('newspic API requires fetch support');
|
|
113
|
+
validateImageCount(images);
|
|
114
|
+
|
|
115
|
+
const staged = await stageNewspicImages(images, {
|
|
116
|
+
baseDir,
|
|
117
|
+
allowPrivateHosts: allowPrivateImageHosts,
|
|
118
|
+
fetchImpl: imageFetchImpl,
|
|
119
|
+
downloadImpl,
|
|
120
|
+
...(lookupImpl ? { lookupImpl } : {}),
|
|
121
|
+
});
|
|
122
|
+
let token;
|
|
123
|
+
const imageMediaIds = [];
|
|
124
|
+
let draftResult;
|
|
125
|
+
try {
|
|
126
|
+
token = await getAccessToken(String(appid).trim(), String(appsecret).trim(), fetchImpl);
|
|
127
|
+
for (const imagePath of staged.paths) {
|
|
128
|
+
const uploaded = await uploadImage(imagePath, token, fetchImpl);
|
|
129
|
+
imageMediaIds.push(uploaded.media_id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const url = new URL(`${API_BASE}/draft/add`);
|
|
133
|
+
url.searchParams.set('access_token', token);
|
|
134
|
+
const body = {
|
|
135
|
+
articles: [{
|
|
136
|
+
article_type: 'newspic',
|
|
137
|
+
title: String(title ?? ''),
|
|
138
|
+
content: String(content ?? ''),
|
|
139
|
+
need_open_comment: 0,
|
|
140
|
+
only_fans_can_comment: 0,
|
|
141
|
+
image_info: {
|
|
142
|
+
image_list: imageMediaIds.map(imageMediaId => ({ image_media_id: imageMediaId })),
|
|
143
|
+
},
|
|
144
|
+
}],
|
|
145
|
+
};
|
|
146
|
+
const response = await fetchImpl(url.toString(), {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
149
|
+
body: JSON.stringify(body),
|
|
150
|
+
});
|
|
151
|
+
const payload = await readJsonResponse(response, '创建贴图草稿');
|
|
152
|
+
if (!payload.media_id) throw new CommandExecutionError('创建贴图草稿 failed: response did not contain media_id');
|
|
153
|
+
draftResult = { mediaId: payload.media_id, imageMediaIds };
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const cleanupProblems = [];
|
|
156
|
+
if (token && imageMediaIds.length > 0) {
|
|
157
|
+
const results = await Promise.allSettled(imageMediaIds.map(mediaId => deleteMaterial(mediaId, token, fetchImpl)));
|
|
158
|
+
const failedMediaIds = imageMediaIds.filter((_mediaId, index) => results[index].status === 'rejected');
|
|
159
|
+
if (failedMediaIds.length > 0) {
|
|
160
|
+
cleanupProblems.push(`failed to delete permanent materials: ${failedMediaIds.join(', ')}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await staged.cleanup();
|
|
165
|
+
} catch (cleanupError) {
|
|
166
|
+
cleanupProblems.push(cleanupError.message);
|
|
167
|
+
}
|
|
168
|
+
if (cleanupProblems.length > 0) {
|
|
169
|
+
throw new CommandExecutionError(`${error?.message ?? error}; cleanup incomplete: ${cleanupProblems.join('; ')}`);
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await staged.cleanup();
|
|
176
|
+
} catch (cleanupError) {
|
|
177
|
+
return { ...draftResult, cleanupWarning: cleanupError.message };
|
|
178
|
+
}
|
|
179
|
+
return draftResult;
|
|
180
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
2
|
+
import { ArgumentError } from '@sovovs/bycli/errors';
|
|
3
|
+
import { createNewspicDraftViaApi } from './_wechat/api-newspic.js';
|
|
4
|
+
|
|
5
|
+
const MAX_TITLE_LENGTH = 32;
|
|
6
|
+
|
|
7
|
+
function codePointLength(value) {
|
|
8
|
+
return [...value].length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requiredText(value, name) {
|
|
12
|
+
const text = String(value ?? '').trim();
|
|
13
|
+
if (!text) throw new ArgumentError(`${name} must not be empty`);
|
|
14
|
+
return text;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseImages(value) {
|
|
18
|
+
const images = requiredText(value, 'images').split(',').map(item => item.trim());
|
|
19
|
+
if (images.some(image => !image)) throw new ArgumentError('images must not contain empty entries');
|
|
20
|
+
if (images.length < 1 || images.length > 20) throw new ArgumentError('newspic requires 1–20 images');
|
|
21
|
+
return images;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const createNewspicCommand = cli({
|
|
25
|
+
site: 'weixin',
|
|
26
|
+
name: 'create-newspic',
|
|
27
|
+
access: 'write',
|
|
28
|
+
description: '通过微信公众号官方 API 创建贴图草稿(不会公开发布)',
|
|
29
|
+
example: 'bycli weixin create-newspic --title "相册标题" --images "./01.jpg,http://example.com/02.png" --content "说明文字" --appid wx123 --appsecret secret',
|
|
30
|
+
strategy: Strategy.LOCAL,
|
|
31
|
+
browser: false,
|
|
32
|
+
args: [
|
|
33
|
+
{ name: 'title', required: true, help: '贴图标题(最长 32 字)' },
|
|
34
|
+
{ name: 'images', required: true, help: '1–20 张本地或 HTTP(S) 图片,使用逗号分隔' },
|
|
35
|
+
{ name: 'content', valueRequired: true, help: '可选的纯文本贴图说明' },
|
|
36
|
+
{ name: 'appid', required: true, help: '公众号 AppID' },
|
|
37
|
+
{ name: 'appsecret', required: true, help: '公众号 AppSecret;请勿提交到 shell 历史或日志' },
|
|
38
|
+
{ name: 'allow-private-image-hosts', type: 'boolean', default: false, help: '允许下载 localhost/内网 HTTP(S) 图片;云元数据地址始终禁止' },
|
|
39
|
+
],
|
|
40
|
+
columns: ['status', 'detail'],
|
|
41
|
+
|
|
42
|
+
func: async (kwargs) => {
|
|
43
|
+
const title = requiredText(kwargs.title, 'title');
|
|
44
|
+
if (codePointLength(title) > MAX_TITLE_LENGTH) {
|
|
45
|
+
throw new ArgumentError(`title must be at most ${MAX_TITLE_LENGTH} characters`);
|
|
46
|
+
}
|
|
47
|
+
const images = parseImages(kwargs.images);
|
|
48
|
+
const result = await createNewspicDraftViaApi({
|
|
49
|
+
appid: requiredText(kwargs.appid, 'appid'),
|
|
50
|
+
appsecret: requiredText(kwargs.appsecret, 'appsecret'),
|
|
51
|
+
title,
|
|
52
|
+
content: kwargs.content == null ? '' : String(kwargs.content),
|
|
53
|
+
images,
|
|
54
|
+
allowPrivateImageHosts: kwargs['allow-private-image-hosts'] === true,
|
|
55
|
+
});
|
|
56
|
+
return [{
|
|
57
|
+
status: 'newspic draft created',
|
|
58
|
+
detail: `"${title}" (media_id: ${result.mediaId}, images: ${images.length})${result.cleanupWarning ? `; warning: ${result.cleanupWarning}` : ''}`,
|
|
59
|
+
}];
|
|
60
|
+
},
|
|
61
|
+
});
|