aiconductor-cli 1.2.3 → 1.3.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/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 到线上
@@ -332,7 +445,8 @@ module.exports = {
332
445
  douyinUserVideos,
333
446
  douyinUserDetail,
334
447
  douyinUserSearch,
335
- douyinVideoSearch,
336
- douyinLinkToText,
337
- xhsNotePublish,
338
- };
448
+ douyinVideoSearch,
449
+ douyinLinkToText,
450
+ r2Upload,
451
+ xhsNotePublish,
452
+ };
@@ -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
  */
@@ -25,24 +101,120 @@ async function notePublishCommand(type, options = {}) {
25
101
  const format = options.format || "json";
26
102
  const apiKey = resolveKeyOrExit(options.apiKey, format);
27
103
 
28
- // 解析 --images(逗号分隔的 URL 列表)
29
- let images;
30
- if (options.images) {
31
- images = options.images.split(",").map((s) => s.trim()).filter(Boolean);
32
- }
104
+ try {
105
+ if (type !== "normal" && type !== "video") {
106
+ throw validationError(`笔记类型仅支持 normal 或 video,收到: ${type}`);
107
+ }
33
108
 
34
- // 解析 --transfer-to-oss(布尔标志)
35
- const transferToOss = options.transferToOss !== undefined;
109
+ const imageUrls = options.images
110
+ ? options.images.split(",").map((value) => value.trim()).filter(Boolean)
111
+ : [];
112
+ const imageFiles = options.imageFile || [];
113
+ const imageBase64 = (options.imageBase64 || []).map((value, index) =>
114
+ resolveBase64Input(value, `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`)
115
+ );
116
+ const videoBase64 = options.videoBase64
117
+ ? resolveBase64Input(options.videoBase64, "视频")
118
+ : undefined;
119
+ const coverBase64 = options.coverBase64
120
+ ? resolveBase64Input(options.coverBase64, "视频封面")
121
+ : undefined;
122
+ const imageCount = imageUrls.length + imageFiles.length + imageBase64.length;
123
+ const videoSources = [options.video, options.videoFile, videoBase64].filter(Boolean);
124
+ const coverSources = [options.cover, options.coverFile, coverBase64].filter(Boolean);
125
+ const validDays = Number(options.uploadValidDays || 7);
126
+
127
+ if (validDays !== 1 && validDays !== 7) {
128
+ throw validationError(`--upload-valid-days 仅支持 1 或 7,收到: ${options.uploadValidDays}`);
129
+ }
130
+ if (type === "normal") {
131
+ if (videoSources.length > 0 || coverSources.length > 0) {
132
+ throw validationError("normal 类型不能使用视频或封面参数");
133
+ }
134
+ if (imageCount === 0) {
135
+ throw validationError("normal 类型至少需要一张图片,请使用 --images、--image-file 或 --image-base64");
136
+ }
137
+ if (imageCount > 18) {
138
+ throw validationError(`图片最多 18 张,当前共 ${imageCount} 张`);
139
+ }
140
+ imageUrls.forEach((url, index) => validateUrl(url, `第 ${index + 1} 张图片`));
141
+ imageFiles.forEach((filePath, index) => validateLocalFile(
142
+ filePath,
143
+ `第 ${imageUrls.length + index + 1} 张图片`,
144
+ MAX_IMAGE_SIZE
145
+ ));
146
+ imageBase64.forEach((value, index) => validateBase64Size(
147
+ value,
148
+ `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`,
149
+ MAX_IMAGE_SIZE
150
+ ));
151
+ } else {
152
+ if (imageCount > 0) {
153
+ throw validationError("video 类型不能使用图片参数");
154
+ }
155
+ if (videoSources.length !== 1) {
156
+ throw validationError("video 类型必须且只能使用 --video、--video-file、--video-base64 之一");
157
+ }
158
+ if (coverSources.length > 1) {
159
+ throw validationError("视频封面只能使用 --cover、--cover-file、--cover-base64 之一");
160
+ }
161
+ if (options.video) validateUrl(options.video, "视频");
162
+ if (options.videoFile) validateLocalFile(options.videoFile, "视频", MAX_UPLOAD_SIZE);
163
+ if (videoBase64) validateBase64Size(videoBase64, "视频", MAX_UPLOAD_SIZE);
164
+ if (options.cover) validateUrl(options.cover, "视频封面");
165
+ if (options.coverFile) validateLocalFile(options.coverFile, "视频封面", MAX_IMAGE_SIZE);
166
+ if (coverBase64) validateBase64Size(coverBase64, "视频封面", MAX_IMAGE_SIZE);
167
+ }
168
+
169
+ let images;
170
+ let video;
171
+ let cover;
172
+ if (type === "normal") {
173
+ const uploadedImages = await Promise.all([
174
+ ...imageFiles.map((filePath, index) => uploadMedia(
175
+ apiKey,
176
+ { filePath, validDays },
177
+ `第 ${imageUrls.length + index + 1} 张图片`
178
+ )),
179
+ ...imageBase64.map((fileBase64, index) => uploadMedia(
180
+ apiKey,
181
+ { fileBase64, defaultContentType: "image/jpeg", validDays },
182
+ `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`
183
+ )),
184
+ ]);
185
+ images = [...imageUrls, ...uploadedImages];
186
+ } else {
187
+ video = options.video;
188
+ if (options.videoFile) {
189
+ video = await uploadMedia(apiKey, { filePath: options.videoFile, validDays }, "视频");
190
+ } else if (videoBase64) {
191
+ video = await uploadMedia(
192
+ apiKey,
193
+ { fileBase64: videoBase64, defaultContentType: "video/mp4", validDays },
194
+ "视频"
195
+ );
196
+ }
197
+
198
+ cover = options.cover;
199
+ if (options.coverFile) {
200
+ cover = await uploadMedia(apiKey, { filePath: options.coverFile, validDays }, "视频封面");
201
+ } else if (coverBase64) {
202
+ cover = await uploadMedia(
203
+ apiKey,
204
+ { fileBase64: coverBase64, defaultContentType: "image/jpeg", validDays },
205
+ "视频封面"
206
+ );
207
+ }
208
+ }
36
209
 
37
- try {
38
210
  const result = await xhsNotePublish(apiKey, {
39
211
  type,
40
212
  title: options.title,
41
213
  content: options.content,
42
214
  images,
43
- video: options.video,
44
- cover: options.cover,
45
- transferToOss,
215
+ video,
216
+ cover,
217
+ transferToOss: Boolean(options.transferToOss),
46
218
  });
47
219
  outputResult(result, format, textFormatter);
48
220
  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