aiconductor-cli 1.2.2 → 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,7 +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";
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;
16
19
 
17
20
  /**
18
21
  * 解析 API Key,三级优先级:命令行参数 > 系统环境变量 > .env 文件
@@ -72,21 +75,20 @@ function validateInt(value, name) {
72
75
  * @param {object} body
73
76
  * @returns {Promise<object>}
74
77
  */
75
- function httpPost(host, path, body, headers = {}) {
76
- return new Promise((resolve, reject) => {
77
- const data = JSON.stringify(body);
78
-
79
- const options = {
80
- hostname: host,
81
- port: 443,
82
- path: path,
83
- method: "POST",
84
- headers: {
85
- "Content-Type": "application/json",
86
- "Content-Length": Buffer.byteLength(data),
87
- ...headers,
88
- },
89
- };
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
+ };
90
92
 
91
93
  const req = https.request(options, (res) => {
92
94
  let chunks = [];
@@ -117,10 +119,122 @@ function httpPost(host, path, body, headers = {}) {
117
119
  reject({ type: "network_error", message: err.message });
118
120
  });
119
121
 
120
- req.write(data);
121
- req.end();
122
- });
123
- }
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
+ }
124
238
 
125
239
  /**
126
240
  * 发布 HTML 到线上
@@ -300,6 +414,27 @@ async function douyinLinkToText(apiKey, videoUrl) {
300
414
  return stripBilling(resp);
301
415
  }
302
416
 
417
+ /**
418
+ * 小红书笔记发布 — 生成扫码发布 H5 页面与二维码
419
+ * @param {string} apiKey
420
+ * @param {{ type: string, title?: string, content?: string, images?: string[], video?: string, cover?: string, transferToOss?: boolean }} params
421
+ * @returns {Promise<object>}
422
+ */
423
+ async function xhsNotePublish(apiKey, params) {
424
+ const body = { type: params.type };
425
+ if (params.title) body.title = params.title;
426
+ if (params.content) body.content = params.content;
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;
430
+ if (params.transferToOss !== undefined) body.transfer_to_oss = params.transferToOss;
431
+
432
+ const resp = await httpPost(API_HOST, XHS_NOTE_PUBLISH_PATH, body, {
433
+ Authorization: `Bearer ${apiKey}`,
434
+ });
435
+ return stripBilling(resp);
436
+ }
437
+
303
438
  module.exports = {
304
439
  resolveApiKey,
305
440
  publishHtml,
@@ -310,6 +445,8 @@ module.exports = {
310
445
  douyinUserVideos,
311
446
  douyinUserDetail,
312
447
  douyinUserSearch,
313
- douyinVideoSearch,
314
- douyinLinkToText,
315
- };
448
+ douyinVideoSearch,
449
+ douyinLinkToText,
450
+ r2Upload,
451
+ xhsNotePublish,
452
+ };
@@ -0,0 +1,227 @@
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.transfer_to_oss) lines.push(` OSS转存: 是`);
92
+ if (d.media) {
93
+ if (d.media.images) lines.push(` 图片: ${d.media.images.length} 张`);
94
+ if (d.media.video) lines.push(` 视频: ${d.media.video}`);
95
+ if (d.media.cover) lines.push(` 封面: ${d.media.cover}`);
96
+ }
97
+ return lines.join("\n");
98
+ }
99
+
100
+ async function notePublishCommand(type, options = {}) {
101
+ const format = options.format || "json";
102
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
103
+
104
+ try {
105
+ if (type !== "normal" && type !== "video") {
106
+ throw validationError(`笔记类型仅支持 normal 或 video,收到: ${type}`);
107
+ }
108
+
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
+ }
209
+
210
+ const result = await xhsNotePublish(apiKey, {
211
+ type,
212
+ title: options.title,
213
+ content: options.content,
214
+ images,
215
+ video,
216
+ cover,
217
+ transferToOss: Boolean(options.transferToOss),
218
+ });
219
+ outputResult(result, format, textFormatter);
220
+ process.exit(0);
221
+ } catch (err) {
222
+ handleApiError(err, format);
223
+ process.exit(1);
224
+ }
225
+ }
226
+
227
+ module.exports = { notePublishCommand };
@@ -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