aiconductor-cli 1.2.3 → 1.3.1

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/src/api.js CHANGED
@@ -12,8 +12,10 @@ const DOUYIN_USER_VIDEOS_PATH = "/api/douyin_user_videos";
12
12
  const DOUYIN_USER_DETAIL_PATH = "/api/douyin_user_detail";
13
13
  const DOUYIN_USER_SEARCH_PATH = "/api/douyin_user_search";
14
14
  const DOUYIN_VIDEO_SEARCH_PATH = "/api/douyin_video_search";
15
- const DOUYIN_LINK_TO_TEXT_PATH = "/api/douyin_link_to_text_v2";
16
- const XHS_NOTE_PUBLISH_PATH = "/api/xhs_note_publish";
15
+ const DOUYIN_LINK_TO_TEXT_PATH = "/api/douyin_link_to_text_v2";
16
+ const XHS_NOTE_PUBLISH_PATH = "/api/xhs_note_publish";
17
+ const R2_UPLOADER_PATH = "/api/r2_uploader";
18
+ const MAX_R2_FILE_SIZE = 100 * 1024 * 1024;
17
19
 
18
20
  /**
19
21
  * 解析 API Key,三级优先级:命令行参数 > 系统环境变量 > .env 文件
@@ -73,21 +75,20 @@ function validateInt(value, name) {
73
75
  * @param {object} body
74
76
  * @returns {Promise<object>}
75
77
  */
76
- function httpPost(host, path, body, headers = {}) {
77
- return new Promise((resolve, reject) => {
78
- const data = JSON.stringify(body);
79
-
80
- const options = {
81
- hostname: host,
82
- port: 443,
83
- path: path,
84
- method: "POST",
85
- headers: {
86
- "Content-Type": "application/json",
87
- "Content-Length": Buffer.byteLength(data),
88
- ...headers,
89
- },
90
- };
78
+ function httpPost(host, requestPath, body, headers = {}) {
79
+ return new Promise((resolve, reject) => {
80
+ const data = JSON.stringify(body);
81
+ const options = {
82
+ hostname: host,
83
+ port: 443,
84
+ path: requestPath,
85
+ method: "POST",
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ "Content-Length": Buffer.byteLength(data),
89
+ ...headers,
90
+ },
91
+ };
91
92
 
92
93
  const req = https.request(options, (res) => {
93
94
  let chunks = [];
@@ -118,10 +119,122 @@ function httpPost(host, path, body, headers = {}) {
118
119
  reject({ type: "network_error", message: err.message });
119
120
  });
120
121
 
121
- req.write(data);
122
- req.end();
123
- });
124
- }
122
+ req.write(data);
123
+ req.end();
124
+ });
125
+ }
126
+
127
+ function contentTypeForFilename(filename) {
128
+ const types = {
129
+ ".avif": "image/avif",
130
+ ".bmp": "image/bmp",
131
+ ".gif": "image/gif",
132
+ ".heic": "image/heic",
133
+ ".heif": "image/heif",
134
+ ".jpeg": "image/jpeg",
135
+ ".jpg": "image/jpeg",
136
+ ".png": "image/png",
137
+ ".webp": "image/webp",
138
+ ".avi": "video/x-msvideo",
139
+ ".m4v": "video/x-m4v",
140
+ ".mkv": "video/x-matroska",
141
+ ".mov": "video/quicktime",
142
+ ".mp4": "video/mp4",
143
+ ".webm": "video/webm",
144
+ };
145
+ return types[path.extname(filename).toLowerCase()] || "application/octet-stream";
146
+ }
147
+
148
+ function contentTypeForBase64(value, fallback) {
149
+ const prefix = Buffer.from(value.replace(/\s/g, "").slice(0, 64), "base64");
150
+ if (prefix.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
151
+ return "image/png";
152
+ }
153
+ if (prefix[0] === 0xff && prefix[1] === 0xd8 && prefix[2] === 0xff) return "image/jpeg";
154
+ if (prefix.toString("ascii", 0, 2) === "BM") return "image/bmp";
155
+ if (prefix.toString("ascii", 0, 3) === "GIF") return "image/gif";
156
+ if (prefix.toString("ascii", 0, 4) === "RIFF" && prefix.toString("ascii", 8, 12) === "WEBP") {
157
+ return "image/webp";
158
+ }
159
+ if (prefix.toString("ascii", 0, 4) === "RIFF" && prefix.toString("ascii", 8, 11) === "AVI") {
160
+ return "video/x-msvideo";
161
+ }
162
+ if (prefix.toString("ascii", 4, 8) === "ftyp") {
163
+ const brand = prefix.toString("ascii", 8, 12);
164
+ if (["heic", "heix", "hevc", "hevx", "mif1", "msf1"].includes(brand)) return "image/heic";
165
+ if (["avif", "avis"].includes(brand)) return "image/avif";
166
+ return brand === "qt " ? "video/quicktime" : "video/mp4";
167
+ }
168
+ if (prefix.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) return "video/webm";
169
+ return fallback || "application/octet-stream";
170
+ }
171
+
172
+ /**
173
+ * 上传远程文件、本地文件或 Base64 内容到临时 R2。
174
+ * @param {string} apiKey
175
+ * @param {{ filePath?: string, fileUrl?: string, fileBase64?: string, filename?: string, defaultContentType?: string, validDays?: number }} params
176
+ * @returns {Promise<object>}
177
+ */
178
+ async function r2Upload(apiKey, params) {
179
+ const sourceCount = [params.filePath, params.fileUrl, params.fileBase64]
180
+ .filter((value) => value !== undefined && value !== null).length;
181
+ if (sourceCount !== 1) {
182
+ throw Object.assign(new Error("filePath、fileUrl、fileBase64 必须且只能提供一个"), {
183
+ type: "validation_error",
184
+ });
185
+ }
186
+
187
+ const validDays = params.validDays === undefined ? 1 : Number(params.validDays);
188
+ if (validDays !== 1 && validDays !== 7) {
189
+ throw Object.assign(new Error(`validDays 仅支持 1 或 7,收到: ${params.validDays}`), {
190
+ type: "validation_error",
191
+ });
192
+ }
193
+
194
+ let fileBase64 = params.fileBase64;
195
+ let filename = params.filename;
196
+ if (params.filePath) {
197
+ const absolutePath = path.resolve(params.filePath);
198
+ let stat;
199
+ try {
200
+ stat = await fs.promises.stat(absolutePath);
201
+ } catch (err) {
202
+ throw Object.assign(new Error(`无法读取本地文件 ${absolutePath}: ${err.message}`), {
203
+ type: "validation_error",
204
+ });
205
+ }
206
+ if (!stat.isFile()) {
207
+ throw Object.assign(new Error(`本地路径不是文件: ${absolutePath}`), {
208
+ type: "validation_error",
209
+ });
210
+ }
211
+ if (stat.size > MAX_R2_FILE_SIZE) {
212
+ throw Object.assign(new Error(`本地文件超过 100MB: ${absolutePath}`), {
213
+ type: "validation_error",
214
+ });
215
+ }
216
+
217
+ const file = await fs.promises.readFile(absolutePath);
218
+ filename = params.filename || path.basename(absolutePath);
219
+ fileBase64 = `data:${contentTypeForFilename(filename)};base64,${file.toString("base64")}`;
220
+ } else if (fileBase64 && !/^data:/i.test(fileBase64)) {
221
+ const compactBase64 = fileBase64.replace(/\s/g, "");
222
+ fileBase64 = `data:${contentTypeForBase64(compactBase64, params.defaultContentType)};base64,${compactBase64}`;
223
+ }
224
+
225
+ const body = { valid_days: validDays };
226
+ if (params.fileUrl) body.file_url = params.fileUrl;
227
+ if (fileBase64) body.file_base64 = fileBase64;
228
+ if (filename) body.filename = filename;
229
+ const resp = await httpPost(API_HOST, R2_UPLOADER_PATH, body, {
230
+ Authorization: `Bearer ${apiKey}`,
231
+ });
232
+
233
+ if (!resp || resp.success === false || !resp.file_url) {
234
+ throw new Error(`R2 上传失败: ${resp?.message || "接口未返回 file_url"}`);
235
+ }
236
+ return stripBilling(resp);
237
+ }
125
238
 
126
239
  /**
127
240
  * 发布 HTML 到线上
@@ -304,17 +417,16 @@ async function douyinLinkToText(apiKey, videoUrl) {
304
417
  /**
305
418
  * 小红书笔记发布 — 生成扫码发布 H5 页面与二维码
306
419
  * @param {string} apiKey
307
- * @param {{ type: string, title?: string, content?: string, images?: string[], video?: string, cover?: string, transferToOss?: boolean }} params
420
+ * @param {{ type: string, title?: string, content?: string, images?: string[], video?: string, cover?: string }} params
308
421
  * @returns {Promise<object>}
309
422
  */
310
423
  async function xhsNotePublish(apiKey, params) {
311
424
  const body = { type: params.type };
312
425
  if (params.title) body.title = params.title;
313
426
  if (params.content) body.content = params.content;
314
- if (params.images && params.images.length > 0) body.images = params.images;
315
- if (params.video) body.video = params.video;
316
- if (params.cover) body.cover = params.cover;
317
- if (params.transferToOss !== undefined) body.transfer_to_oss = params.transferToOss;
427
+ if (params.images && params.images.length > 0) body.images = params.images;
428
+ if (params.video) body.video = params.video;
429
+ if (params.cover) body.cover = params.cover;
318
430
 
319
431
  const resp = await httpPost(API_HOST, XHS_NOTE_PUBLISH_PATH, body, {
320
432
  Authorization: `Bearer ${apiKey}`,
@@ -332,7 +444,8 @@ module.exports = {
332
444
  douyinUserVideos,
333
445
  douyinUserDetail,
334
446
  douyinUserSearch,
335
- douyinVideoSearch,
336
- douyinLinkToText,
337
- xhsNotePublish,
338
- };
447
+ douyinVideoSearch,
448
+ douyinLinkToText,
449
+ r2Upload,
450
+ xhsNotePublish,
451
+ };
@@ -1,6 +1,82 @@
1
- const { xhsNotePublish } = require("../api");
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { r2Upload, xhsNotePublish } = require("../api");
2
4
  const { outputResult, resolveKeyOrExit, handleApiError } = require("../lib/cli-utils");
3
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
+
4
80
  /**
5
81
  * text 格式输出:用 ✓ 开头展示关键信息
6
82
  */
@@ -12,7 +88,6 @@ function textFormatter(resp) {
12
88
  lines.push(` H5 地址: ${d.url}`);
13
89
  if (resp.message) lines.push(` 消息: ${resp.message}`);
14
90
  if (d.qrcode) lines.push(` 二维码: ${d.qrcode.substring(0, 50)}...`);
15
- if (d.transfer_to_oss) lines.push(` OSS转存: 是`);
16
91
  if (d.media) {
17
92
  if (d.media.images) lines.push(` 图片: ${d.media.images.length} 张`);
18
93
  if (d.media.video) lines.push(` 视频: ${d.media.video}`);
@@ -25,24 +100,119 @@ async function notePublishCommand(type, options = {}) {
25
100
  const format = options.format || "json";
26
101
  const apiKey = resolveKeyOrExit(options.apiKey, format);
27
102
 
28
- // 解析 --images(逗号分隔的 URL 列表)
29
- let images;
30
- if (options.images) {
31
- images = options.images.split(",").map((s) => s.trim()).filter(Boolean);
32
- }
103
+ try {
104
+ if (type !== "normal" && type !== "video") {
105
+ throw validationError(`笔记类型仅支持 normal 或 video,收到: ${type}`);
106
+ }
33
107
 
34
- // 解析 --transfer-to-oss(布尔标志)
35
- const transferToOss = options.transferToOss !== undefined;
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
+ }
36
208
 
37
- try {
38
209
  const result = await xhsNotePublish(apiKey, {
39
210
  type,
40
211
  title: options.title,
41
212
  content: options.content,
42
213
  images,
43
- video: options.video,
44
- cover: options.cover,
45
- transferToOss,
214
+ video,
215
+ cover,
46
216
  });
47
217
  outputResult(result, format, textFormatter);
48
218
  process.exit(0);
@@ -60,6 +60,10 @@ function handleApiError(err, format) {
60
60
  );
61
61
  } else if (err.type === "network_error") {
62
62
  outputError({ message: `网络错误: ${err.message}` }, format);
63
+ } else if (err.type === "validation_error") {
64
+ outputError({ message: `参数错误: ${err.message}` }, format);
65
+ } else if (err.type === "api_error") {
66
+ outputError({ message: err.message }, format);
63
67
  } else {
64
68
  outputError({ message: `未知错误: ${err.message || err}` }, format);
65
69
  }
File without changes