@wecode-ai/weibo-openclaw-plugin 2.0.0 → 2.0.1-beta.2

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.
@@ -0,0 +1,1032 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 微博视频上传 API 封装脚本
5
+ *
6
+ * 使用方法:
7
+ * node weibo-video.js <command> [options]
8
+ *
9
+ * 命令:
10
+ * login 登录并获取 Token(整合原 token 命令功能)
11
+ * refresh 刷新 Token
12
+ * upload 上传本地视频文件
13
+ *
14
+ * 配置优先级:
15
+ * 1. 本地配置文件 ~/.weibo-video/config.json
16
+ * 2. OpenClaw 配置文件 ~/.openclaw/openclaw.json
17
+ * 3. 环境变量 WEIBO_APP_ID、WEIBO_APP_SECRET
18
+ *
19
+ * 示例:
20
+ * # 登录(首次使用会引导配置)
21
+ * node weibo-video.js login
22
+ *
23
+ * # 上传视频(自动使用缓存的 Token)
24
+ * node weibo-video.js upload --file="/path/to/video.mp4"
25
+ */
26
+
27
+ import https from 'https';
28
+ import http from 'http';
29
+ import { URL } from 'url';
30
+ import fs from 'fs/promises';
31
+ import { createReadStream, statSync } from 'fs';
32
+ import path from 'path';
33
+ import os from 'os';
34
+ import crypto from 'crypto';
35
+ import readline from 'readline';
36
+ import { fileURLToPath } from 'url';
37
+
38
+ // 获取 __dirname 等效值(ES 模块中不可用)
39
+ const __filename = fileURLToPath(import.meta.url);
40
+ const __dirname = path.dirname(__filename);
41
+
42
+ // ============================================================================
43
+ // 配置常量
44
+ // ============================================================================
45
+
46
+ const BASE_URL = 'https://open-im.api.weibo.com';
47
+
48
+ // 默认分片大小:10MB(与参考代码一致)
49
+ const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024;
50
+
51
+ // 单位长度:1KB(用于将服务端返回的分片大小从 KB 转换为 Byte)
52
+ const DEFAULT_UNIT_LEN = 1024;
53
+
54
+ const CONFIG_PATHS = {
55
+ openclaw: path.join(os.homedir(), '.openclaw', 'openclaw.json'),
56
+ local: path.join(os.homedir(), '.weibo-video', 'config.json'),
57
+ tokenCache: path.join(os.homedir(), '.weibo-video', 'token-cache.json')
58
+ };
59
+
60
+ // ============================================================================
61
+ // 错误类定义
62
+ // ============================================================================
63
+
64
+ class ConfigError extends Error {
65
+ constructor(message) {
66
+ super(message);
67
+ this.name = 'ConfigError';
68
+ }
69
+ }
70
+
71
+ class TokenError extends Error {
72
+ constructor(message, retryable = false) {
73
+ super(message);
74
+ this.name = 'TokenError';
75
+ this.retryable = retryable;
76
+ }
77
+ }
78
+
79
+ class APIError extends Error {
80
+ constructor(message, code, retryable = false) {
81
+ super(message);
82
+ this.name = 'APIError';
83
+ this.code = code;
84
+ this.retryable = retryable;
85
+ }
86
+ }
87
+
88
+ // 错误码映射
89
+ const ERROR_MESSAGES = {
90
+ 40001: '参数缺失:token、check、name 或 length',
91
+ 40002: '参数缺失或超限',
92
+ 40100: 'Token 无效或已过期,请重新登录',
93
+ 42900: '频率限制:超过每日调用次数上限,请明天再试',
94
+ 50000: '服务器内部错误,请稍后重试',
95
+ 50001: '操作失败,请检查参数后重试'
96
+ };
97
+
98
+ // 可重试的错误码
99
+ const RETRYABLE_ERRORS = new Set([50000, 50001]);
100
+
101
+ // ============================================================================
102
+ // 日志工具
103
+ // ============================================================================
104
+
105
+ const Logger = {
106
+ info: (msg) => console.log(`[INFO] ${msg}`),
107
+ success: (msg) => console.log(`[SUCCESS] ✓ ${msg}`),
108
+ warn: (msg) => console.warn(`[WARN] ⚠ ${msg}`),
109
+ error: (msg) => console.error(`[ERROR] ✗ ${msg}`),
110
+ debug: (msg) => process.env.DEBUG && console.log(`[DEBUG] ${msg}`),
111
+ progress: (current, total, msg) => {
112
+ const percent = Math.round((current / total) * 100);
113
+ const bar = '█'.repeat(Math.floor(percent / 5)) + '░'.repeat(20 - Math.floor(percent / 5));
114
+ process.stdout.write(`\r[${bar}] ${percent}% ${msg}`);
115
+ if (current === total) console.log();
116
+ }
117
+ };
118
+
119
+ // ============================================================================
120
+ // 加密模块
121
+ // ============================================================================
122
+
123
+ /**
124
+ * 生成加密密钥(基于机器特征)
125
+ * @returns {Buffer} 32 字节的加密密钥
126
+ */
127
+ function generateEncryptionKey() {
128
+ const machineId = `${os.hostname()}-${os.homedir()}`;
129
+ return crypto.createHash('sha256').update(machineId).digest();
130
+ }
131
+
132
+ /**
133
+ * 加密文本
134
+ * @param {string} text - 要加密的文本
135
+ * @returns {string} 加密后的字符串(格式: encrypted:iv:authTag:encrypted)
136
+ */
137
+ function encrypt(text) {
138
+ const key = generateEncryptionKey();
139
+ const iv = crypto.randomBytes(16);
140
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
141
+
142
+ let encrypted = cipher.update(text, 'utf8', 'hex');
143
+ encrypted += cipher.final('hex');
144
+
145
+ const authTag = cipher.getAuthTag();
146
+
147
+ // 格式: encrypted:iv:authTag:encrypted
148
+ return `encrypted:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
149
+ }
150
+
151
+ /**
152
+ * 解密文本
153
+ * @param {string} encryptedText - 加密的文本
154
+ * @returns {string} 解密后的原文
155
+ */
156
+ function decrypt(encryptedText) {
157
+ if (!encryptedText.startsWith('encrypted:')) {
158
+ // 如果没有加密前缀,返回原文(兼容旧配置)
159
+ return encryptedText;
160
+ }
161
+
162
+ const parts = encryptedText.substring(10).split(':');
163
+ if (parts.length !== 3) {
164
+ throw new Error('Invalid encrypted format');
165
+ }
166
+
167
+ const [ivHex, authTagHex, encrypted] = parts;
168
+ const key = generateEncryptionKey();
169
+ const iv = Buffer.from(ivHex, 'hex');
170
+ const authTag = Buffer.from(authTagHex, 'hex');
171
+
172
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
173
+ decipher.setAuthTag(authTag);
174
+
175
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
176
+ decrypted += decipher.final('utf8');
177
+
178
+ return decrypted;
179
+ }
180
+
181
+ // ============================================================================
182
+ // 配置管理
183
+ // ============================================================================
184
+
185
+ /**
186
+ * 加载配置(按优先级合并)
187
+ * @returns {Promise<object>} 配置对象
188
+ */
189
+ async function loadConfig() {
190
+ const config = {
191
+ appId: process.env.WEIBO_APP_ID,
192
+ appSecret: process.env.WEIBO_APP_SECRET
193
+ };
194
+
195
+ // 尝试读取 OpenClaw 配置
196
+ try {
197
+ const openclawData = await fs.readFile(CONFIG_PATHS.openclaw, 'utf8');
198
+ const openclawConfig = JSON.parse(openclawData);
199
+ const weiboConfig = openclawConfig.channels?.weibo;
200
+ if (weiboConfig) {
201
+ config.appId = config.appId || weiboConfig.appId;
202
+ config.appSecret = config.appSecret || weiboConfig.appSecret;
203
+ }
204
+ } catch (err) {
205
+ Logger.debug('OpenClaw 配置不存在或读取失败');
206
+ }
207
+
208
+ // 尝试读取本地配置(优先级最高)
209
+ try {
210
+ const localData = await fs.readFile(CONFIG_PATHS.local, 'utf8');
211
+ const localConfig = JSON.parse(localData);
212
+
213
+ // 解密敏感信息
214
+ if (localConfig.appId) {
215
+ config.appId = decrypt(localConfig.appId);
216
+ }
217
+ if (localConfig.appSecret) {
218
+ config.appSecret = decrypt(localConfig.appSecret);
219
+ }
220
+ if (localConfig.apiEndpoint) {
221
+ config.apiEndpoint = localConfig.apiEndpoint;
222
+ }
223
+ } catch (err) {
224
+ Logger.debug('本地配置不存在或读取失败');
225
+ }
226
+
227
+ return config;
228
+ }
229
+
230
+ /**
231
+ * 保存本地配置(加密敏感信息)
232
+ * @param {object} config - 配置对象
233
+ */
234
+ async function saveLocalConfig(config) {
235
+ // 加密敏感信息
236
+ const encryptedConfig = {
237
+ appId: encrypt(config.appId),
238
+ appSecret: encrypt(config.appSecret)
239
+ };
240
+
241
+ if (config.apiEndpoint) {
242
+ encryptedConfig.apiEndpoint = config.apiEndpoint;
243
+ }
244
+
245
+ await fs.mkdir(path.dirname(CONFIG_PATHS.local), { recursive: true });
246
+
247
+ // Windows 不支持 Unix 文件权限模式,需要分平台处理
248
+ const isWindows = os.platform() === 'win32';
249
+ const writeOptions = isWindows ? {} : { mode: 0o600 };
250
+
251
+ await fs.writeFile(
252
+ CONFIG_PATHS.local,
253
+ JSON.stringify(encryptedConfig, null, 2),
254
+ writeOptions
255
+ );
256
+ }
257
+
258
+ // ============================================================================
259
+ // Token 管理
260
+ // ============================================================================
261
+
262
+ /**
263
+ * Token 管理器类
264
+ */
265
+ class TokenManager {
266
+ constructor() {
267
+ this.tokenCache = null;
268
+ }
269
+
270
+ /**
271
+ * 检查 Token 是否有效(提前 60 秒过期)
272
+ * @returns {boolean}
273
+ */
274
+ isTokenValid() {
275
+ if (!this.tokenCache) return false;
276
+ const expiresAt = this.tokenCache.acquiredAt +
277
+ (this.tokenCache.expiresIn - 60) * 1000;
278
+ return Date.now() < expiresAt;
279
+ }
280
+
281
+ /**
282
+ * 获取有效 Token(自动刷新)
283
+ * @param {string} appId - 应用 ID
284
+ * @param {string} appSecret - 应用密钥
285
+ * @returns {Promise<string>} Token
286
+ */
287
+ async getValidToken(appId, appSecret) {
288
+ await this.loadTokenCache();
289
+
290
+ if (this.isTokenValid()) {
291
+ Logger.debug('使用缓存的 Token');
292
+ return this.tokenCache.token;
293
+ }
294
+
295
+ Logger.debug('Token 已过期或不存在,获取新 Token');
296
+ return await this.fetchNewToken(appId, appSecret);
297
+ }
298
+
299
+ /**
300
+ * 获取新 Token 并缓存
301
+ * @param {string} appId - 应用 ID
302
+ * @param {string} appSecret - 应用密钥
303
+ * @returns {Promise<string>} Token
304
+ */
305
+ async fetchNewToken(appId, appSecret) {
306
+ const result = await getToken(appId, appSecret);
307
+
308
+ if (result.code !== 0) {
309
+ const message = ERROR_MESSAGES[result.code] || result.message || '获取 Token 失败';
310
+ throw new TokenError(message, RETRYABLE_ERRORS.has(result.code));
311
+ }
312
+
313
+ this.tokenCache = {
314
+ token: result.data.token,
315
+ acquiredAt: Date.now(),
316
+ expiresIn: result.data.expire_in
317
+ };
318
+
319
+ await this.saveTokenCache();
320
+ return this.tokenCache.token;
321
+ }
322
+
323
+ /**
324
+ * 加载 Token 缓存
325
+ */
326
+ async loadTokenCache() {
327
+ try {
328
+ const data = await fs.readFile(CONFIG_PATHS.tokenCache, 'utf8');
329
+ this.tokenCache = JSON.parse(data);
330
+ } catch (err) {
331
+ this.tokenCache = null;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * 保存 Token 缓存
337
+ */
338
+ async saveTokenCache() {
339
+ await fs.mkdir(path.dirname(CONFIG_PATHS.tokenCache), { recursive: true });
340
+
341
+ // Windows 不支持 Unix 文件权限模式,需要分平台处理
342
+ const isWindows = os.platform() === 'win32';
343
+ const writeOptions = isWindows ? {} : { mode: 0o600 };
344
+
345
+ await fs.writeFile(
346
+ CONFIG_PATHS.tokenCache,
347
+ JSON.stringify(this.tokenCache, null, 2),
348
+ writeOptions
349
+ );
350
+ }
351
+
352
+ /**
353
+ * 清除 Token 缓存
354
+ */
355
+ async clearTokenCache() {
356
+ this.tokenCache = null;
357
+ try {
358
+ await fs.unlink(CONFIG_PATHS.tokenCache);
359
+ } catch (err) {
360
+ // 忽略文件不存在的错误
361
+ }
362
+ }
363
+ }
364
+
365
+ // 全局 TokenManager 实例
366
+ const tokenManager = new TokenManager();
367
+
368
+ // ============================================================================
369
+ // 交互式配置
370
+ // ============================================================================
371
+
372
+ /**
373
+ * 提示用户输入
374
+ * @param {string} question - 问题
375
+ * @returns {Promise<string>} 用户输入
376
+ */
377
+ function prompt(question) {
378
+ const rl = readline.createInterface({
379
+ input: process.stdin,
380
+ output: process.stdout
381
+ });
382
+
383
+ return new Promise((resolve) => {
384
+ rl.question(question, (answer) => {
385
+ rl.close();
386
+ resolve(answer.trim());
387
+ });
388
+ });
389
+ }
390
+
391
+ /**
392
+ * 交互式配置向导
393
+ * @returns {Promise<object>} 配置对象
394
+ */
395
+ async function interactiveConfig() {
396
+ console.log('\n=== 微博视频上传配置向导 ===\n');
397
+ console.log('请输入您的微博应用凭证信息。');
398
+ console.log('如果您还没有凭证,请私信 @微博龙虾助手 发送 "连接龙虾" 获取。\n');
399
+
400
+ const appId = await prompt('请输入 App ID: ');
401
+ const appSecret = await prompt('请输入 App Secret: ');
402
+
403
+ if (!appId || !appSecret) {
404
+ throw new ConfigError('App ID 和 App Secret 不能为空');
405
+ }
406
+
407
+ const config = { appId, appSecret };
408
+ await saveLocalConfig(config);
409
+
410
+ console.log('\n配置已保存到:', CONFIG_PATHS.local);
411
+ return config;
412
+ }
413
+
414
+ // ============================================================================
415
+ // HTTP 请求
416
+ // ============================================================================
417
+
418
+ /**
419
+ * 发送 HTTP 请求
420
+ * @param {string} method - HTTP 方法
421
+ * @param {string} url - 请求 URL
422
+ * @param {object} data - 请求数据(POST 时使用)
423
+ * @returns {Promise<object>} 响应数据
424
+ */
425
+ function request(method, url, data = null) {
426
+ return new Promise((resolve, reject) => {
427
+ const urlObj = new URL(url);
428
+ const isHttps = urlObj.protocol === 'https:';
429
+ const httpModule = isHttps ? https : http;
430
+
431
+ const options = {
432
+ hostname: urlObj.hostname,
433
+ port: urlObj.port || (isHttps ? 443 : 80),
434
+ path: urlObj.pathname + urlObj.search,
435
+ method: method,
436
+ headers: {
437
+ 'Content-Type': 'application/json',
438
+ 'Accept': 'application/json',
439
+ },
440
+ };
441
+
442
+ const req = httpModule.request(options, (res) => {
443
+ let body = '';
444
+ res.on('data', (chunk) => {
445
+ body += chunk;
446
+ });
447
+ res.on('end', () => {
448
+ try {
449
+ const json = JSON.parse(body);
450
+ resolve(json);
451
+ } catch (e) {
452
+ reject(new Error(`解析响应失败: ${body}`));
453
+ }
454
+ });
455
+ });
456
+
457
+ req.on('error', (e) => {
458
+ reject(e);
459
+ });
460
+
461
+ if (data) {
462
+ req.write(JSON.stringify(data));
463
+ }
464
+
465
+ req.end();
466
+ });
467
+ }
468
+
469
+ /**
470
+ * 发送带二进制数据的 HTTP 请求
471
+ * @param {string} url - 请求 URL
472
+ * @param {Buffer} data - 二进制数据
473
+ * @returns {Promise<object>} 响应数据
474
+ */
475
+ function requestWithBinary(url, data) {
476
+ return new Promise((resolve, reject) => {
477
+ const urlObj = new URL(url);
478
+ const isHttps = urlObj.protocol === 'https:';
479
+ const httpModule = isHttps ? https : http;
480
+
481
+ const options = {
482
+ hostname: urlObj.hostname,
483
+ port: urlObj.port || (isHttps ? 443 : 80),
484
+ path: urlObj.pathname + urlObj.search,
485
+ method: 'POST',
486
+ headers: {
487
+ 'Content-Type': 'application/octet-stream',
488
+ 'Content-Length': data.length,
489
+ 'Accept': 'application/json',
490
+ },
491
+ };
492
+
493
+ const req = httpModule.request(options, (res) => {
494
+ let body = '';
495
+ res.on('data', (chunk) => {
496
+ body += chunk;
497
+ });
498
+ res.on('end', () => {
499
+ try {
500
+ const json = JSON.parse(body);
501
+ resolve(json);
502
+ } catch (e) {
503
+ reject(new Error(`解析响应失败: ${body}`));
504
+ }
505
+ });
506
+ });
507
+
508
+ req.on('error', (e) => {
509
+ reject(e);
510
+ });
511
+
512
+ req.write(data);
513
+ req.end();
514
+ });
515
+ }
516
+
517
+ /**
518
+ * 处理 API 响应错误
519
+ * @param {object} result - API 响应
520
+ * @returns {object} 原始响应(如果成功)
521
+ * @throws {APIError} 如果响应包含错误
522
+ */
523
+ function handleAPIError(result) {
524
+ if (result.code === 0) return result;
525
+
526
+ const message = ERROR_MESSAGES[result.code] || result.message || '未知错误';
527
+ const retryable = RETRYABLE_ERRORS.has(result.code);
528
+
529
+ throw new APIError(message, result.code, retryable);
530
+ }
531
+
532
+ // ============================================================================
533
+ // 文件工具函数
534
+ // ============================================================================
535
+
536
+ /**
537
+ * 计算文件的 MD5 校验值
538
+ * @param {string} filePath - 文件路径
539
+ * @returns {Promise<string>} MD5 校验值(十六进制)
540
+ */
541
+ async function calculateFileMD5(filePath) {
542
+ return new Promise((resolve, reject) => {
543
+ const hash = crypto.createHash('md5');
544
+ const stream = createReadStream(filePath);
545
+
546
+ stream.on('data', (data) => hash.update(data));
547
+ stream.on('end', () => resolve(hash.digest('hex')));
548
+ stream.on('error', reject);
549
+ });
550
+ }
551
+
552
+ /**
553
+ * 计算 Buffer 的 MD5 校验值
554
+ * @param {Buffer} buffer - 数据缓冲区
555
+ * @returns {string} MD5 校验值(十六进制)
556
+ */
557
+ function calculateBufferMD5(buffer) {
558
+ return crypto.createHash('md5').update(buffer).digest('hex');
559
+ }
560
+
561
+ /**
562
+ * 读取文件分片
563
+ * @param {string} filePath - 文件路径
564
+ * @param {number} start - 起始位置
565
+ * @param {number} size - 分片大小
566
+ * @returns {Promise<Buffer>} 分片数据
567
+ */
568
+ async function readFileChunk(filePath, start, size) {
569
+ const fileHandle = await fs.open(filePath, 'r');
570
+ try {
571
+ const buffer = Buffer.alloc(size);
572
+ const { bytesRead } = await fileHandle.read(buffer, 0, size, start);
573
+ return buffer.slice(0, bytesRead);
574
+ } finally {
575
+ await fileHandle.close();
576
+ }
577
+ }
578
+
579
+ // ============================================================================
580
+ // API 函数
581
+ // ============================================================================
582
+
583
+ /**
584
+ * 获取 Token
585
+ * @param {string} appId - 开发者应用ID
586
+ * @param {string} appSecret - 开发者应用密钥
587
+ * @returns {Promise<object>} Token 信息
588
+ */
589
+ async function getToken(appId, appSecret) {
590
+ const url = `${BASE_URL}/open/auth/ws_token`;
591
+ const data = {
592
+ app_id: appId,
593
+ app_secret: appSecret,
594
+ };
595
+ return request('POST', url, data);
596
+ }
597
+
598
+ /**
599
+ * 刷新 Token
600
+ * @param {string} token - 当前 Token
601
+ * @returns {Promise<object>} 刷新结果
602
+ */
603
+ async function refreshToken(token) {
604
+ const url = `${BASE_URL}/open/auth/refresh_token`;
605
+ const data = { token };
606
+ return request('POST', url, data);
607
+ }
608
+
609
+ /**
610
+ * 初始化视频上传
611
+ * @param {string} token - 认证令牌
612
+ * @param {object} options - 上传选项
613
+ * @returns {Promise<object>} 初始化结果
614
+ */
615
+ async function initVideoUpload(token, options) {
616
+ const params = new URLSearchParams({
617
+ token,
618
+ check: options.check,
619
+ name: options.name,
620
+ length: options.length.toString(),
621
+ });
622
+
623
+ // 可选参数
624
+ if (options.type) params.append('type', options.type);
625
+ if (options.videoType) params.append('video_type', options.videoType);
626
+ if (options.uploadOnly !== undefined) params.append('upload_only', options.uploadOnly.toString());
627
+ if (options.customNameSupport !== undefined) params.append('custom_name_support', options.customNameSupport.toString());
628
+ if (options.mediaprops) params.append('mediaprops', JSON.stringify(options.mediaprops));
629
+
630
+ const url = `${BASE_URL}/open/video/init?${params.toString()}`;
631
+ return request('GET', url);
632
+ }
633
+
634
+ /**
635
+ * 上传视频分片
636
+ * @param {string} token - 认证令牌
637
+ * @param {object} options - 分片选项
638
+ * @param {Buffer} chunkData - 分片数据
639
+ * @returns {Promise<object>} 上传结果
640
+ */
641
+ async function uploadVideoChunk(token, options, chunkData) {
642
+ const params = new URLSearchParams({
643
+ token,
644
+ filetoken: options.fileToken,
645
+ filelength: options.fileLength.toString(),
646
+ filecheck: options.fileCheck,
647
+ chunksize: options.chunkSize.toString(),
648
+ startloc: options.startLoc.toString(),
649
+ chunkindex: options.chunkIndex.toString(),
650
+ chunkcount: options.chunkCount.toString(),
651
+ sectioncheck: options.sectionCheck,
652
+ });
653
+
654
+ // 可选参数
655
+ if (options.type) params.append('type', options.type);
656
+ if (options.videoType) params.append('video_type', options.videoType);
657
+
658
+ const url = `${BASE_URL}/open/video/upload?${params.toString()}`;
659
+ return requestWithBinary(url, chunkData);
660
+ }
661
+
662
+ /**
663
+ * 上传视频文件(完整流程)
664
+ * @param {string} token - 认证令牌
665
+ * @param {string} filePath - 视频文件路径
666
+ * @param {object} options - 上传选项
667
+ * @returns {Promise<object>} 上传结果
668
+ */
669
+ async function uploadVideo(token, filePath, options = {}) {
670
+ // 获取文件信息
671
+ const stats = statSync(filePath);
672
+ const fileLength = stats.size;
673
+ const fileName = path.basename(filePath);
674
+
675
+ Logger.info(`准备上传视频: ${fileName}`);
676
+ Logger.info(`文件大小: ${(fileLength / 1024 / 1024).toFixed(2)} MB`);
677
+
678
+ // 计算文件 MD5
679
+ Logger.info('计算文件校验值...');
680
+ const fileMD5 = await calculateFileMD5(filePath);
681
+ Logger.debug(`文件 MD5: ${fileMD5}`);
682
+
683
+ // 初始化上传
684
+ Logger.info('初始化上传...');
685
+ const initResult = await initVideoUpload(token, {
686
+ check: fileMD5,
687
+ name: fileName,
688
+ length: fileLength,
689
+ type: options.type || 'video',
690
+ videoType: options.videoType || 'normal',
691
+ uploadOnly: options.uploadOnly,
692
+ customNameSupport: options.customNameSupport,
693
+ mediaprops: options.mediaprops,
694
+ });
695
+
696
+ if (initResult.code !== 0) {
697
+ throw new APIError(
698
+ ERROR_MESSAGES[initResult.code] || initResult.message || '初始化上传失败',
699
+ initResult.code,
700
+ RETRYABLE_ERRORS.has(initResult.code)
701
+ );
702
+ }
703
+
704
+ const { fileToken, mediaId } = initResult.data;
705
+ Logger.debug(`fileToken: ${fileToken}`);
706
+ Logger.debug(`mediaId: ${mediaId}`);
707
+
708
+ // 计算分片大小(服务端返回的 length 单位是 KB,需要转换为 Byte)
709
+ // 如果服务端没有返回 length,使用默认分片大小
710
+ let pieceSize = initResult.data.length
711
+ ? initResult.data.length * DEFAULT_UNIT_LEN
712
+ : DEFAULT_CHUNK_SIZE;
713
+
714
+ // 如果分片大小大于等于文件大小,使用文件大小作为分片大小
715
+ if (pieceSize >= fileLength) {
716
+ pieceSize = fileLength;
717
+ }
718
+
719
+ Logger.debug(`分片大小: ${(pieceSize / 1024 / 1024).toFixed(2)} MB`);
720
+
721
+ // 计算分片数量
722
+ const chunkCount = Math.ceil(fileLength / pieceSize);
723
+ Logger.info(`分片数量: ${chunkCount}`);
724
+
725
+ // 上传分片
726
+ let uploadResult = null;
727
+ for (let i = 0; i < chunkCount; i++) {
728
+ const chunkIndex = i + 1;
729
+ const startLoc = i * pieceSize;
730
+ const currentChunkSize = Math.min(pieceSize, fileLength - startLoc);
731
+
732
+ // 读取分片数据
733
+ const chunkData = await readFileChunk(filePath, startLoc, currentChunkSize);
734
+ const sectionCheck = calculateBufferMD5(chunkData);
735
+
736
+ Logger.progress(chunkIndex, chunkCount, `上传分片 ${chunkIndex}/${chunkCount}`);
737
+
738
+ uploadResult = await uploadVideoChunk(token, {
739
+ fileToken,
740
+ fileLength,
741
+ fileCheck: fileMD5,
742
+ chunkSize: chunkData.length,
743
+ startLoc,
744
+ chunkIndex,
745
+ chunkCount,
746
+ sectionCheck,
747
+ type: options.type || 'video',
748
+ videoType: options.videoType || 'normal',
749
+ }, chunkData);
750
+
751
+ if (uploadResult.code !== 0) {
752
+ throw new APIError(
753
+ ERROR_MESSAGES[uploadResult.code] || uploadResult.message || `上传分片 ${chunkIndex} 失败`,
754
+ uploadResult.code,
755
+ RETRYABLE_ERRORS.has(uploadResult.code)
756
+ );
757
+ }
758
+ }
759
+
760
+ // 返回最终结果
761
+ if (uploadResult && uploadResult.data && uploadResult.data.complete) {
762
+ Logger.success('视频上传完成!');
763
+ return {
764
+ code: 0,
765
+ message: 'success',
766
+ data: {
767
+ mediaId,
768
+ fmid: uploadResult.data.fmid,
769
+ url: uploadResult.data.url,
770
+ }
771
+ };
772
+ }
773
+
774
+ return uploadResult;
775
+ }
776
+
777
+ // ============================================================================
778
+ // 命令处理
779
+ // ============================================================================
780
+
781
+ /**
782
+ * 处理 login 命令
783
+ */
784
+ async function handleLoginCommand() {
785
+ console.log('\n=== 微博视频上传登录 ===\n');
786
+
787
+ // 加载配置
788
+ let config = await loadConfig();
789
+
790
+ // 如果没有配置,引导用户输入
791
+ if (!config.appId || !config.appSecret) {
792
+ console.log('未找到配置信息,开始配置向导...\n');
793
+ config = await interactiveConfig();
794
+ } else {
795
+ console.log('找到现有配置:');
796
+ console.log(` App ID: ${config.appId}`);
797
+ console.log(` App Secret: ${config.appSecret.substring(0, 10)}...`);
798
+ console.log();
799
+
800
+ const useExisting = await prompt('是否使用现有配置?(y/n): ');
801
+ if (useExisting.toLowerCase() !== 'y') {
802
+ config = await interactiveConfig();
803
+ }
804
+ }
805
+
806
+ // 获取 Token
807
+ console.log('\n正在获取访问令牌...');
808
+ try {
809
+ const token = await tokenManager.fetchNewToken(config.appId, config.appSecret);
810
+ console.log('\n✓ 登录成功!');
811
+ console.log(`Token: ${token.substring(0, 20)}...`);
812
+ console.log(`有效期: ${tokenManager.tokenCache.expiresIn} 秒 (约 ${(tokenManager.tokenCache.expiresIn / 3600).toFixed(1)} 小时)`);
813
+ console.log(`过期时间: ${new Date(tokenManager.tokenCache.acquiredAt + tokenManager.tokenCache.expiresIn * 1000).toLocaleString()}`);
814
+
815
+ // 输出 JSON 格式
816
+ console.log('\n--- Token 信息(JSON 格式)---');
817
+ console.log(JSON.stringify({
818
+ code: 0,
819
+ message: 'success',
820
+ data: {
821
+ token: token,
822
+ expire_in: tokenManager.tokenCache.expiresIn
823
+ }
824
+ }, null, 2));
825
+ } catch (err) {
826
+ Logger.error(`登录失败: ${err.message}`);
827
+ process.exit(1);
828
+ }
829
+ }
830
+
831
+ /**
832
+ * 获取有效的 Token(自动从配置或环境变量获取)
833
+ * @returns {Promise<string>} Token
834
+ */
835
+ async function getValidTokenForCommand() {
836
+ // 优先使用环境变量中的 Token
837
+ const envToken = process.env.WEIBO_TOKEN;
838
+ if (envToken) {
839
+ Logger.debug('使用环境变量中的 Token');
840
+ return envToken;
841
+ }
842
+
843
+ // 尝试从配置获取 Token
844
+ const config = await loadConfig();
845
+
846
+ if (!config.appId || !config.appSecret) {
847
+ throw new ConfigError('未找到配置信息,请先运行 "node weibo-video.js login" 进行登录');
848
+ }
849
+
850
+ return await tokenManager.getValidToken(config.appId, config.appSecret);
851
+ }
852
+
853
+ /**
854
+ * 解析命令行参数
855
+ * @param {string[]} args - 命令行参数
856
+ * @returns {object} 解析后的参数对象
857
+ */
858
+ function parseArgs(args) {
859
+ const result = {};
860
+ for (let i = 0; i < args.length; i++) {
861
+ const arg = args[i];
862
+ if (arg.startsWith('--')) {
863
+ const [key, ...valueParts] = arg.slice(2).split('=');
864
+ const value = valueParts.join('=') || args[++i] || true;
865
+ result[key] = value;
866
+ }
867
+ }
868
+ return result;
869
+ }
870
+
871
+ /**
872
+ * 打印帮助信息
873
+ */
874
+ function printHelp() {
875
+ console.log(`
876
+ 微博视频上传 API 封装脚本
877
+
878
+ 使用方法:
879
+ node weibo-video.js <command> [options]
880
+
881
+ 命令:
882
+ login 登录并获取 Token(首次使用请先执行此命令)
883
+ refresh 刷新 Token
884
+ upload 上传本地视频文件
885
+ help 显示帮助信息
886
+
887
+ 配置优先级:
888
+ 1. 本地配置文件 ~/.weibo-video/config.json
889
+ 2. OpenClaw 配置文件 ~/.openclaw/openclaw.json
890
+ 3. 环境变量 WEIBO_APP_ID、WEIBO_APP_SECRET
891
+
892
+ 环境变量:
893
+ WEIBO_APP_ID 开发者应用ID
894
+ WEIBO_APP_SECRET 开发者应用密钥
895
+ WEIBO_TOKEN 认证令牌(可选,如果已有token)
896
+ DEBUG 设置为任意值启用调试日志
897
+
898
+ 选项:
899
+ --file=<path> 视频文件路径(必填)
900
+ --type=<type> 文件类型,默认 video(可选)
901
+ --video-type=<type> 视频类型,默认 normal(可选)
902
+ --upload-only 是否仅上传,默认 false(可选)
903
+ --custom-name 是否支持自定义名称,默认 false(可选)
904
+
905
+ 示例:
906
+ # 首次使用,登录并配置
907
+ node weibo-video.js login
908
+
909
+ # 上传视频(自动使用缓存的 Token)
910
+ node weibo-video.js upload --file="/path/to/video.mp4"
911
+
912
+ # 使用环境变量(兼容旧方式)
913
+ WEIBO_TOKEN=xxx node weibo-video.js upload --file="/path/to/video.mp4"
914
+ `);
915
+ }
916
+
917
+ /**
918
+ * 主函数
919
+ */
920
+ async function main() {
921
+ const args = process.argv.slice(2);
922
+ const command = args[0];
923
+ const options = parseArgs(args.slice(1));
924
+
925
+ if (!command || command === 'help') {
926
+ printHelp();
927
+ return;
928
+ }
929
+
930
+ try {
931
+ let result;
932
+
933
+ switch (command) {
934
+ case 'login':
935
+ await handleLoginCommand();
936
+ return;
937
+
938
+ case 'token':
939
+ // 兼容旧的 token 命令,重定向到 login
940
+ Logger.warn('token 命令已废弃,请使用 login 命令');
941
+ await handleLoginCommand();
942
+ return;
943
+
944
+ case 'refresh': {
945
+ const token = await getValidTokenForCommand();
946
+ result = await refreshToken(token);
947
+
948
+ // 如果刷新成功,更新缓存
949
+ if (result.code === 0 && result.data) {
950
+ tokenManager.tokenCache = {
951
+ token: result.data.token,
952
+ acquiredAt: Date.now(),
953
+ expiresIn: result.data.expire_in
954
+ };
955
+ await tokenManager.saveTokenCache();
956
+ }
957
+ break;
958
+ }
959
+
960
+ case 'upload': {
961
+ if (!options.file) {
962
+ Logger.error('需要指定 --file 参数(视频文件路径)');
963
+ process.exit(1);
964
+ }
965
+
966
+ // 检查文件是否存在
967
+ try {
968
+ await fs.access(options.file);
969
+ } catch (err) {
970
+ Logger.error(`文件不存在: ${options.file}`);
971
+ process.exit(1);
972
+ }
973
+
974
+ const token = await getValidTokenForCommand();
975
+ result = await uploadVideo(token, options.file, {
976
+ type: options.type,
977
+ videoType: options['video-type'],
978
+ uploadOnly: options['upload-only'] === 'true' || options['upload-only'] === true,
979
+ customNameSupport: options['custom-name'] === 'true' || options['custom-name'] === true,
980
+ });
981
+ break;
982
+ }
983
+
984
+ default:
985
+ Logger.error(`未知命令: ${command}`);
986
+ console.log('使用 "node weibo-video.js help" 查看帮助信息');
987
+ process.exit(1);
988
+ }
989
+
990
+ console.log(JSON.stringify(result, null, 2));
991
+
992
+ } catch (error) {
993
+ if (error instanceof ConfigError) {
994
+ Logger.error(error.message);
995
+ } else if (error instanceof TokenError) {
996
+ Logger.error(`Token 错误: ${error.message}`);
997
+ if (error.retryable) {
998
+ Logger.info('这是一个可重试的错误,请稍后再试');
999
+ }
1000
+ } else if (error instanceof APIError) {
1001
+ Logger.error(`API 错误 (${error.code}): ${error.message}`);
1002
+ if (error.retryable) {
1003
+ Logger.info('这是一个可重试的错误,请稍后再试');
1004
+ }
1005
+ } else {
1006
+ Logger.error(`请求失败: ${error.message}`);
1007
+ }
1008
+ process.exit(1);
1009
+ }
1010
+ }
1011
+
1012
+ // 导出函数供模块使用
1013
+ export {
1014
+ getToken,
1015
+ refreshToken,
1016
+ initVideoUpload,
1017
+ uploadVideoChunk,
1018
+ uploadVideo,
1019
+ loadConfig,
1020
+ saveLocalConfig,
1021
+ TokenManager,
1022
+ encrypt,
1023
+ decrypt,
1024
+ CONFIG_PATHS,
1025
+ DEFAULT_CHUNK_SIZE,
1026
+ DEFAULT_UNIT_LEN,
1027
+ calculateFileMD5,
1028
+ calculateBufferMD5,
1029
+ };
1030
+
1031
+ // 如果直接运行脚本,执行主函数
1032
+ main();