aiconductor-cli 1.3.5 → 1.5.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.
@@ -1,225 +1,268 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
- const { r2Upload, xhsNotePublish } = require("../api");
4
- const { outputResult, resolveKeyOrExit, handleApiError } = require("../lib/cli-utils");
5
-
6
- const MAX_IMAGE_SIZE = 32 * 1024 * 1024;
7
- const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
8
-
9
- function validationError(message) {
10
- return Object.assign(new Error(message), { type: "validation_error" });
11
- }
12
-
13
- function resolveBase64Input(value, label) {
14
- if (!value.startsWith("@")) return value;
15
- const filePath = path.resolve(value.slice(1));
16
- let content;
17
- try {
18
- content = fs.readFileSync(filePath, "utf-8").trim();
19
- } catch (err) {
20
- throw validationError(`${label} Base64 文件读取失败 ${filePath}: ${err.message}`);
21
- }
22
- if (!content) throw validationError(`${label} Base64 文件内容为空: ${filePath}`);
23
- return content;
24
- }
25
-
26
- function validateLocalFile(filePath, label, maxSize) {
27
- const absolutePath = path.resolve(filePath);
28
- let stat;
29
- try {
30
- stat = fs.statSync(absolutePath);
31
- } catch (err) {
32
- throw validationError(`${label}文件读取失败 ${absolutePath}: ${err.message}`);
33
- }
34
- if (!stat.isFile()) throw validationError(`${label}路径不是文件: ${absolutePath}`);
35
- if (stat.size > maxSize) {
36
- throw validationError(`${label}文件超过 ${maxSize / 1024 / 1024}MB: ${absolutePath}`);
37
- }
38
- }
39
-
40
- function validateUrl(value, label) {
41
- let parsed;
42
- try {
43
- parsed = new URL(value);
44
- } catch (_) {
45
- throw validationError(`${label}不是有效 URL: ${value}`);
46
- }
47
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
48
- throw validationError(`${label}仅支持 HTTP/HTTPS URL: ${value}`);
49
- }
50
- }
51
-
52
- function validateBase64Size(value, label, maxSize) {
53
- const dataUrlPrefix = value.match(/^data:[^,]*,/i)?.[0];
54
- const payload = (dataUrlPrefix ? value.slice(dataUrlPrefix.length) : value).replace(/\s/g, "");
55
- const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
56
- const size = Math.max(0, Math.floor(payload.length * 3 / 4) - padding);
57
- if (size > maxSize) {
58
- throw validationError(`${label} Base64 内容超过 ${maxSize / 1024 / 1024}MB`);
59
- }
60
- }
61
-
62
- async function uploadMedia(apiKey, params, label) {
63
- try {
64
- const result = await r2Upload(apiKey, params);
65
- return result.file_url;
66
- } catch (err) {
67
- const detail = err.type === "http_error"
68
- ? `HTTP ${err.statusCode}: ${err.body?.message || JSON.stringify(err.body)}`
69
- : err.message || err;
70
- const wrapped = new Error(`${label}上传失败: ${detail}`);
71
- wrapped.type = err.type === "validation_error" || err.type === "network_error"
72
- ? err.type
73
- : "api_error";
74
- wrapped.statusCode = err.statusCode;
75
- wrapped.body = err.body;
76
- throw wrapped;
77
- }
78
- }
79
-
80
- /**
81
- * text 格式输出:用 ✓ 开头展示关键信息
82
- */
83
- function textFormatter(resp) {
84
- const d = resp.data || {};
85
- const lines = [];
86
- lines.push("✓ 小红书笔记发布页已生成");
87
- lines.push(` ID: ${d.id}`);
88
- lines.push(` H5 地址: ${d.url}`);
89
- if (resp.message) lines.push(` 消息: ${resp.message}`);
90
- if (d.qrcode) lines.push(` 二维码: ${d.qrcode.substring(0, 50)}...`);
91
- if (d.media) {
92
- if (d.media.images) lines.push(` 图片: ${d.media.images.length} 张`);
93
- if (d.media.video) lines.push(` 视频: ${d.media.video}`);
94
- if (d.media.cover) lines.push(` 封面: ${d.media.cover}`);
95
- }
96
- return lines.join("\n");
97
- }
98
-
99
- async function notePublishCommand(type, options = {}) {
100
- const format = options.format || "json";
101
- const apiKey = resolveKeyOrExit(options.apiKey, format);
102
-
103
- try {
104
- if (type !== "normal" && type !== "video") {
105
- throw validationError(`笔记类型仅支持 normal 或 video,收到: ${type}`);
106
- }
107
-
108
- const imageUrls = options.images
109
- ? options.images.split(",").map((value) => value.trim()).filter(Boolean)
110
- : [];
111
- const imageFiles = options.imageFile || [];
112
- const imageBase64 = (options.imageBase64 || []).map((value, index) =>
113
- resolveBase64Input(value, `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`)
114
- );
115
- const videoBase64 = options.videoBase64
116
- ? resolveBase64Input(options.videoBase64, "视频")
117
- : undefined;
118
- const coverBase64 = options.coverBase64
119
- ? resolveBase64Input(options.coverBase64, "视频封面")
120
- : undefined;
121
- const imageCount = imageUrls.length + imageFiles.length + imageBase64.length;
122
- const videoSources = [options.video, options.videoFile, videoBase64].filter(Boolean);
123
- const coverSources = [options.cover, options.coverFile, coverBase64].filter(Boolean);
124
- const validDays = Number(options.uploadValidDays || 7);
125
-
126
- if (validDays !== 1 && validDays !== 7) {
127
- throw validationError(`--upload-valid-days 仅支持 1 或 7,收到: ${options.uploadValidDays}`);
128
- }
129
- if (type === "normal") {
130
- if (videoSources.length > 0 || coverSources.length > 0) {
131
- throw validationError("normal 类型不能使用视频或封面参数");
132
- }
133
- if (imageCount === 0) {
134
- throw validationError("normal 类型至少需要一张图片,请使用 --images、--image-file 或 --image-base64");
135
- }
136
- if (imageCount > 18) {
137
- throw validationError(`图片最多 18 张,当前共 ${imageCount} 张`);
138
- }
139
- imageUrls.forEach((url, index) => validateUrl(url, `第 ${index + 1} 张图片`));
140
- imageFiles.forEach((filePath, index) => validateLocalFile(
141
- filePath,
142
- `第 ${imageUrls.length + index + 1} 张图片`,
143
- MAX_IMAGE_SIZE
144
- ));
145
- imageBase64.forEach((value, index) => validateBase64Size(
146
- value,
147
- `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`,
148
- MAX_IMAGE_SIZE
149
- ));
150
- } else {
151
- if (imageCount > 0) {
152
- throw validationError("video 类型不能使用图片参数");
153
- }
154
- if (videoSources.length !== 1) {
155
- throw validationError("video 类型必须且只能使用 --video、--video-file、--video-base64 之一");
156
- }
157
- if (coverSources.length > 1) {
158
- throw validationError("视频封面只能使用 --cover、--cover-file、--cover-base64 之一");
159
- }
160
- if (options.video) validateUrl(options.video, "视频");
161
- if (options.videoFile) validateLocalFile(options.videoFile, "视频", MAX_UPLOAD_SIZE);
162
- if (videoBase64) validateBase64Size(videoBase64, "视频", MAX_UPLOAD_SIZE);
163
- if (options.cover) validateUrl(options.cover, "视频封面");
164
- if (options.coverFile) validateLocalFile(options.coverFile, "视频封面", MAX_IMAGE_SIZE);
165
- if (coverBase64) validateBase64Size(coverBase64, "视频封面", MAX_IMAGE_SIZE);
166
- }
167
-
168
- let images;
169
- let video;
170
- let cover;
171
- if (type === "normal") {
172
- const uploadedImages = await Promise.all([
173
- ...imageFiles.map((filePath, index) => uploadMedia(
174
- apiKey,
175
- { filePath, validDays },
176
- `第 ${imageUrls.length + index + 1} 张图片`
177
- )),
178
- ...imageBase64.map((fileBase64, index) => uploadMedia(
179
- apiKey,
180
- { fileBase64, defaultContentType: "image/jpeg", validDays },
181
- `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`
182
- )),
183
- ]);
184
- images = [...imageUrls, ...uploadedImages];
185
- } else {
186
- video = options.video;
187
- if (options.videoFile) {
188
- video = await uploadMedia(apiKey, { filePath: options.videoFile, validDays }, "视频");
189
- } else if (videoBase64) {
190
- video = await uploadMedia(
191
- apiKey,
192
- { fileBase64: videoBase64, defaultContentType: "video/mp4", validDays },
193
- "视频"
194
- );
195
- }
196
-
197
- cover = options.cover;
198
- if (options.coverFile) {
199
- cover = await uploadMedia(apiKey, { filePath: options.coverFile, validDays }, "视频封面");
200
- } else if (coverBase64) {
201
- cover = await uploadMedia(
202
- apiKey,
203
- { fileBase64: coverBase64, defaultContentType: "image/jpeg", validDays },
204
- "视频封面"
205
- );
206
- }
207
- }
208
-
209
- const result = await xhsNotePublish(apiKey, {
210
- type,
211
- title: options.title,
212
- content: options.content,
213
- images,
214
- video,
215
- cover,
216
- });
217
- outputResult(result, format, textFormatter);
218
- process.exit(0);
219
- } catch (err) {
220
- handleApiError(err, format);
221
- process.exit(1);
222
- }
223
- }
224
-
225
- module.exports = { notePublishCommand };
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { r2Upload, xhsHotList, xhsNotePublish } = require("../api");
4
+ const { outputResult, resolveKeyOrExit, handleApiError } = require("../lib/cli-utils");
5
+
6
+ const MAX_IMAGE_SIZE = 32 * 1024 * 1024;
7
+ const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
8
+
9
+ function validationError(message) {
10
+ return Object.assign(new Error(message), { type: "validation_error" });
11
+ }
12
+
13
+ function resolveBase64Input(value, label) {
14
+ if (!value.startsWith("@")) return value;
15
+ const filePath = path.resolve(value.slice(1));
16
+ let content;
17
+ try {
18
+ content = fs.readFileSync(filePath, "utf-8").trim();
19
+ } catch (err) {
20
+ throw validationError(`${label} Base64 文件读取失败 ${filePath}: ${err.message}`);
21
+ }
22
+ if (!content) throw validationError(`${label} Base64 文件内容为空: ${filePath}`);
23
+ return content;
24
+ }
25
+
26
+ function validateLocalFile(filePath, label, maxSize) {
27
+ const absolutePath = path.resolve(filePath);
28
+ let stat;
29
+ try {
30
+ stat = fs.statSync(absolutePath);
31
+ } catch (err) {
32
+ throw validationError(`${label}文件读取失败 ${absolutePath}: ${err.message}`);
33
+ }
34
+ if (!stat.isFile()) throw validationError(`${label}路径不是文件: ${absolutePath}`);
35
+ if (stat.size > maxSize) {
36
+ throw validationError(`${label}文件超过 ${maxSize / 1024 / 1024}MB: ${absolutePath}`);
37
+ }
38
+ }
39
+
40
+ function validateUrl(value, label) {
41
+ let parsed;
42
+ try {
43
+ parsed = new URL(value);
44
+ } catch (_) {
45
+ throw validationError(`${label}不是有效 URL: ${value}`);
46
+ }
47
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
48
+ throw validationError(`${label}仅支持 HTTP/HTTPS URL: ${value}`);
49
+ }
50
+ }
51
+
52
+ function validateBase64Size(value, label, maxSize) {
53
+ const dataUrlPrefix = value.match(/^data:[^,]*,/i)?.[0];
54
+ const payload = (dataUrlPrefix ? value.slice(dataUrlPrefix.length) : value).replace(/\s/g, "");
55
+ const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
56
+ const size = Math.max(0, Math.floor(payload.length * 3 / 4) - padding);
57
+ if (size > maxSize) {
58
+ throw validationError(`${label} Base64 内容超过 ${maxSize / 1024 / 1024}MB`);
59
+ }
60
+ }
61
+
62
+ async function uploadMedia(apiKey, params, label) {
63
+ try {
64
+ const result = await r2Upload(apiKey, params);
65
+ return result.file_url;
66
+ } catch (err) {
67
+ const detail = err.type === "http_error"
68
+ ? `HTTP ${err.statusCode}: ${err.body?.message || JSON.stringify(err.body)}`
69
+ : err.message || err;
70
+ const wrapped = new Error(`${label}上传失败: ${detail}`);
71
+ wrapped.type = err.type === "validation_error" || err.type === "network_error"
72
+ ? err.type
73
+ : "api_error";
74
+ wrapped.statusCode = err.statusCode;
75
+ wrapped.body = err.body;
76
+ throw wrapped;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * text 格式输出:用 ✓ 开头展示关键信息
82
+ * @param {object} resp
83
+ * @returns {string}
84
+ */
85
+ function notePublishTextFormatter(resp) {
86
+ const d = resp.data || {};
87
+ const lines = [];
88
+ lines.push("✓ 小红书笔记发布页已生成");
89
+ lines.push(` ID: ${d.id}`);
90
+ lines.push(` H5 地址: ${d.url}`);
91
+ if (resp.message) lines.push(` 消息: ${resp.message}`);
92
+ if (d.qrcode_url) lines.push(` 二维码图: ${d.qrcode_url}`);
93
+ if (d.qrcode) lines.push(` 二维码: ${d.qrcode.substring(0, 50)}...`);
94
+ if (d.media) {
95
+ if (d.media.images) lines.push(` 图片: ${d.media.images.length} 张`);
96
+ if (d.media.video) lines.push(` 视频: ${d.media.video}`);
97
+ if (d.media.cover) lines.push(` 封面: ${d.media.cover}`);
98
+ }
99
+ return lines.join("\n");
100
+ }
101
+
102
+ /**
103
+ * text 格式输出:展示小红书实时热榜。
104
+ * @param {object} resp
105
+ * @returns {string}
106
+ */
107
+ function hotListTextFormatter(resp = {}) {
108
+ const data = resp.data || {};
109
+ const lines = ["小红书热榜"];
110
+ if (data.date) lines.push(`日期: ${data.date}`);
111
+
112
+ (data.items || []).forEach((item) => {
113
+ const rank = item.rank === undefined ? "-" : item.rank;
114
+ const trend = item.trend ? ` ${item.trend}` : "";
115
+ lines.push(`\n[${rank}] ${item.title || "未命名话题"}${trend}`);
116
+ if (item.hot || item.hot_value !== undefined) lines.push(` 热度: ${item.hot || item.hot_value}`);
117
+ if (item.url) lines.push(` URL: ${item.url}`);
118
+ });
119
+
120
+ return lines.join("\n");
121
+ }
122
+
123
+ /**
124
+ * 获取小红书实时热榜。
125
+ * @param {{ apiKey?: string, format?: "json"|"text" }} options
126
+ * @returns {Promise<void>}
127
+ */
128
+ async function hotListCommand(options = {}) {
129
+ const format = options.format || "json";
130
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
131
+
132
+ try {
133
+ const result = await xhsHotList(apiKey);
134
+ outputResult(result, format, hotListTextFormatter);
135
+ process.exit(0);
136
+ } catch (err) {
137
+ handleApiError(err, format);
138
+ process.exit(1);
139
+ }
140
+ }
141
+
142
+ async function notePublishCommand(type, options = {}) {
143
+ const format = options.format || "json";
144
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
145
+
146
+ try {
147
+ if (type !== "normal" && type !== "video") {
148
+ throw validationError(`笔记类型仅支持 normal 或 video,收到: ${type}`);
149
+ }
150
+
151
+ const imageUrls = options.images
152
+ ? options.images.split(",").map((value) => value.trim()).filter(Boolean)
153
+ : [];
154
+ const imageFiles = options.imageFile || [];
155
+ const imageBase64 = (options.imageBase64 || []).map((value, index) =>
156
+ resolveBase64Input(value, `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`)
157
+ );
158
+ const videoBase64 = options.videoBase64
159
+ ? resolveBase64Input(options.videoBase64, "视频")
160
+ : undefined;
161
+ const coverBase64 = options.coverBase64
162
+ ? resolveBase64Input(options.coverBase64, "视频封面")
163
+ : undefined;
164
+ const imageCount = imageUrls.length + imageFiles.length + imageBase64.length;
165
+ const videoSources = [options.video, options.videoFile, videoBase64].filter(Boolean);
166
+ const coverSources = [options.cover, options.coverFile, coverBase64].filter(Boolean);
167
+ const validDays = Number(options.uploadValidDays || 7);
168
+
169
+ if (validDays !== 1 && validDays !== 7) {
170
+ throw validationError(`--upload-valid-days 仅支持 1 或 7,收到: ${options.uploadValidDays}`);
171
+ }
172
+ if (type === "normal") {
173
+ if (videoSources.length > 0 || coverSources.length > 0) {
174
+ throw validationError("normal 类型不能使用视频或封面参数");
175
+ }
176
+ if (imageCount === 0) {
177
+ throw validationError("normal 类型至少需要一张图片,请使用 --images、--image-file 或 --image-base64");
178
+ }
179
+ if (imageCount > 18) {
180
+ throw validationError(`图片最多 18 张,当前共 ${imageCount} 张`);
181
+ }
182
+ imageUrls.forEach((url, index) => validateUrl(url, `第 ${index + 1} 张图片`));
183
+ imageFiles.forEach((filePath, index) => validateLocalFile(
184
+ filePath,
185
+ `第 ${imageUrls.length + index + 1} 张图片`,
186
+ MAX_IMAGE_SIZE
187
+ ));
188
+ imageBase64.forEach((value, index) => validateBase64Size(
189
+ value,
190
+ `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`,
191
+ MAX_IMAGE_SIZE
192
+ ));
193
+ } else {
194
+ if (imageCount > 0) {
195
+ throw validationError("video 类型不能使用图片参数");
196
+ }
197
+ if (videoSources.length !== 1) {
198
+ throw validationError("video 类型必须且只能使用 --video、--video-file、--video-base64 之一");
199
+ }
200
+ if (coverSources.length > 1) {
201
+ throw validationError("视频封面只能使用 --cover、--cover-file、--cover-base64 之一");
202
+ }
203
+ if (options.video) validateUrl(options.video, "视频");
204
+ if (options.videoFile) validateLocalFile(options.videoFile, "视频", MAX_UPLOAD_SIZE);
205
+ if (videoBase64) validateBase64Size(videoBase64, "视频", MAX_UPLOAD_SIZE);
206
+ if (options.cover) validateUrl(options.cover, "视频封面");
207
+ if (options.coverFile) validateLocalFile(options.coverFile, "视频封面", MAX_IMAGE_SIZE);
208
+ if (coverBase64) validateBase64Size(coverBase64, "视频封面", MAX_IMAGE_SIZE);
209
+ }
210
+
211
+ let images;
212
+ let video;
213
+ let cover;
214
+ if (type === "normal") {
215
+ const uploadedImages = await Promise.all([
216
+ ...imageFiles.map((filePath, index) => uploadMedia(
217
+ apiKey,
218
+ { filePath, validDays },
219
+ `第 ${imageUrls.length + index + 1} 张图片`
220
+ )),
221
+ ...imageBase64.map((fileBase64, index) => uploadMedia(
222
+ apiKey,
223
+ { fileBase64, defaultContentType: "image/jpeg", validDays },
224
+ `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`
225
+ )),
226
+ ]);
227
+ images = [...imageUrls, ...uploadedImages];
228
+ } else {
229
+ video = options.video;
230
+ if (options.videoFile) {
231
+ video = await uploadMedia(apiKey, { filePath: options.videoFile, validDays }, "视频");
232
+ } else if (videoBase64) {
233
+ video = await uploadMedia(
234
+ apiKey,
235
+ { fileBase64: videoBase64, defaultContentType: "video/mp4", validDays },
236
+ "视频"
237
+ );
238
+ }
239
+
240
+ cover = options.cover;
241
+ if (options.coverFile) {
242
+ cover = await uploadMedia(apiKey, { filePath: options.coverFile, validDays }, "视频封面");
243
+ } else if (coverBase64) {
244
+ cover = await uploadMedia(
245
+ apiKey,
246
+ { fileBase64: coverBase64, defaultContentType: "image/jpeg", validDays },
247
+ "视频封面"
248
+ );
249
+ }
250
+ }
251
+
252
+ const result = await xhsNotePublish(apiKey, {
253
+ type,
254
+ title: options.title,
255
+ content: options.content,
256
+ images,
257
+ video,
258
+ cover,
259
+ });
260
+ outputResult(result, format, notePublishTextFormatter);
261
+ process.exit(0);
262
+ } catch (err) {
263
+ handleApiError(err, format);
264
+ process.exit(1);
265
+ }
266
+ }
267
+
268
+ module.exports = { hotListCommand, hotListTextFormatter, notePublishCommand, notePublishTextFormatter };