baiduwangpan-cli 1.1.3 → 1.6.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.
@@ -0,0 +1,318 @@
1
+ // lib/pcs-extra.js — BaiduPCS-Go 附加子命令封装: mv / cp / quota / offlinedl / share
2
+ //
3
+ // 风格对齐 lib/pan.js:
4
+ // - child_process 惰性 require(测试可整体替换 require.cache["child_process"] 注入 mock)
5
+ // - spawnSync 调用 config.get().pcsPath,encoding utf-8,maxBuffer 20MB
6
+ // - 成功返回解析后的对象(含 raw 原文);失败抛中文 Error
7
+ //
8
+ // 重要(BaiduPCS-Go v4.0.1 实测/源码确认):
9
+ // 操作类命令(mv/cp/share/offlinedl)出错时**退出码仍为 0**,错误只体现在
10
+ // 输出文本里("失败"/"遇到错误, ..., 代码: N"),因此每个函数都必须检查输出。
11
+
12
+ const config = require("./config");
13
+
14
+ // child_process 延迟加载:测试可整体替换 require.cache["child_process"] 注入 mock
15
+ function childProc() {
16
+ return require("child_process");
17
+ }
18
+
19
+ const SIZE_UNITS = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, PB: 1024 ** 5 };
20
+
21
+ // 离线下载任务状态(BaiduPCS-Go baidupcs.CloudDlTaskInfo.Status 源码确认)
22
+ const OFFLINE_STATUS = {
23
+ 0: "下载成功",
24
+ 1: "下载进行中",
25
+ 2: "系统错误",
26
+ 3: "资源不存在",
27
+ 4: "下载超时",
28
+ 5: "资源存在但下载失败",
29
+ 6: "存储空间不足",
30
+ 7: "任务取消",
31
+ };
32
+ const OFFLINE_STATUS_TO_CODE = {};
33
+ for (const [code, text] of Object.entries(OFFLINE_STATUS)) OFFLINE_STATUS_TO_CODE[text] = Number(code);
34
+
35
+ // 解析 ConvertFileSize 输出("1.234567GB" / "100MB" / "0B")→ 字节数;无法解析返回 null
36
+ function parseSizeText(text) {
37
+ const m = String(text || "").trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|PB)?$/i);
38
+ if (!m) return null;
39
+ const unit = (m[2] || "B").toUpperCase();
40
+ return Math.round(parseFloat(m[1]) * SIZE_UNITS[unit]);
41
+ }
42
+
43
+ function runPCS(args) {
44
+ const cfg = config.get();
45
+ const { spawnSync } = childProc();
46
+ const result = spawnSync(cfg.pcsPath, args, {
47
+ encoding: "utf-8",
48
+ maxBuffer: 20 * 1024 * 1024,
49
+ });
50
+ if (result.error) {
51
+ throw new Error(`BaiduPCS-Go not found at: ${cfg.pcsPath}. Set pcsPath in config or BAIDUPCS_CMD env.`);
52
+ }
53
+ if (result.status != null && result.status !== 0) {
54
+ throw new Error(`BaiduPCS-Go ${args[0] || ""} 退出码 ${result.status}: ${(result.stdout || "").trim() || "(无输出)"}`);
55
+ }
56
+ return result.stdout || "";
57
+ }
58
+
59
+ // ── mv / cp ─────────────────────────────────────────────
60
+
61
+ // 移动/重命名。dst 为已存在目录 → 移动;否则按重命名处理(引擎行为)。
62
+ // 成功: { src, dst, op: "mv"|"rename"|"cp", renamed, raw }
63
+ function mv(srcPath, dstPath) {
64
+ return cpMvOp("mv", "移动", srcPath, dstPath);
65
+ }
66
+
67
+ // 拷贝。成功: { src, dst, op: "cp", renamed: false, raw }
68
+ function cp(srcPath, dstPath) {
69
+ return cpMvOp("cp", "拷贝", srcPath, dstPath);
70
+ }
71
+
72
+ function cpMvOp(cmd, opName, srcPath, dstPath) {
73
+ const output = runPCS([cmd, srcPath, dstPath]);
74
+ if (!output.trim()) throw new Error(`${opName}失败: BaiduPCS-Go 无输出`);
75
+ // 成功输出含 "重命名成功"/"操作成功"/"拷贝成功",失败输出含
76
+ // "操作失败"/"移动失败"/"拷贝失败"/"path error"/"不是一个目录"/"遇到错误, ..., 代码: N"
77
+ if (/(操作失败|拷贝失败|移动失败|重命名失败|path error|不是一个目录|不存在|遇到错误)/.test(output)) {
78
+ throw new Error(`${opName}失败: ${output.trim()}`);
79
+ }
80
+ const renamed = /重命名成功/.test(output);
81
+ const op = cmd === "mv" ? (renamed ? "rename" : "move") : "cp";
82
+ return { src: srcPath, dst: dstPath, op, renamed, raw: output };
83
+ }
84
+
85
+ // ── quota ───────────────────────────────────────────────
86
+
87
+ // 获取网盘配额。实测输出(单行):
88
+ // 用户名: , 总空间: 10.004883TB, 已用空间: 9.982749TB, 比率: 99.778767%
89
+ // 成功: { total, used, free, ratio, username, raw }(字节数 + 百分比)
90
+ function quota() {
91
+ const output = runPCS(["quota"]);
92
+ const m = output.match(
93
+ /用户名:\s*([^,]*),\s*总空间:\s*(\d+(?:\.\d+)?(?:B|KB|MB|GB|TB|PB)?),\s*已用空间:\s*(\d+(?:\.\d+)?(?:B|KB|MB|GB|TB|PB)?),\s*比率:\s*([\d.]+)%/i
94
+ );
95
+ if (!m) throw new Error(`获取网盘配额失败: 无法解析输出\n${output.trim()}`);
96
+ const total = parseSizeText(m[2]);
97
+ const used = parseSizeText(m[3]);
98
+ if (total === null || used === null) throw new Error(`获取网盘配额失败: 无法解析大小\n${output.trim()}`);
99
+ return { total, used, free: total - used, ratio: parseFloat(m[4]), username: m[1].trim(), raw: output };
100
+ }
101
+
102
+ // ── offlinedl ───────────────────────────────────────────
103
+
104
+ // 添加离线下载任务(单 URL)。savePath 为目标目录(不传则用引擎工作目录)。
105
+ // 成功: { taskId, url, savePath, raw }
106
+ function offlineAdd(url, savePath) {
107
+ const args = ["offlinedl", "add"];
108
+ if (savePath) args.push(`-path=${savePath}`);
109
+ args.push(url);
110
+ const output = runPCS(args);
111
+ // 成功: "[1] 添加离线任务成功, 任务ID(task_id): 8833, 源地址: ..., 保存路径: ..."
112
+ const ok = output.match(/添加离线任务成功, 任务ID\(task_id\):\s*(\d+)/);
113
+ if (ok) return { taskId: Number(ok[1]), url, savePath: savePath || null, raw: output };
114
+ // 失败(引擎仍返回 0): "[1] 添加离线下载任务: 遇到错误, ..., 代码: 31045, 消息: ..., 地址: ..."
115
+ const fail = output.match(/\[\d+\]\s*(.+?),\s*地址:\s*\S+/);
116
+ throw new Error(`添加离线下载任务失败: ${fail ? fail[1].trim() : (output.trim() || "(无输出)")}`);
117
+ }
118
+
119
+ // 查询离线下载任务列表。输出为 pcstable 表格:
120
+ // # 任务ID 任务名称 文件大小 创建日期 保存路径 资源地址 状态
121
+ // 成功: { tasks: [{ index, taskId, name, size, createTime, savePath, sourceUrl, status, statusCode }], count, raw }
122
+ function offlineList() {
123
+ const output = runPCS(["offlinedl", "list"]);
124
+ const rows = parseTable(output, ["#", "任务ID", "任务名称", "文件大小", "创建日期", "保存路径", "资源地址", "状态"]);
125
+ if (rows === null) throw new Error(`查询离线下载任务列表失败: ${output.trim() || "(无输出)"}`);
126
+ const tasks = rows.map((r) => ({
127
+ index: parseInt(r[0], 10),
128
+ taskId: parseInt(r[1], 10),
129
+ name: r[2],
130
+ size: parseSizeText(r[3]),
131
+ createTime: r[4],
132
+ savePath: r[5],
133
+ sourceUrl: r[6],
134
+ status: r[7],
135
+ statusCode: OFFLINE_STATUS_TO_CODE[r[7]] != null ? OFFLINE_STATUS_TO_CODE[r[7]] : null,
136
+ }));
137
+ return { tasks, count: tasks.length, raw: output };
138
+ }
139
+
140
+ // ── share ───────────────────────────────────────────────
141
+
142
+ // 创建分享。password 可选(缺省为公开分享);options.combined=true 时加 -f 输出
143
+ // "链接?pwd=密码" 合并格式。成功: { shareId, link, pwd, combined, raw }
144
+ function shareSet(remotePath, password, options = {}) {
145
+ const args = ["share", "set"];
146
+ if (password) args.push("-p", password);
147
+ if (options.combined) args.push("-f");
148
+ args.push(remotePath);
149
+ const output = runPCS(args);
150
+ // -f 合并格式: "shareID: 5, 链接: https://pan.baidu.com/s/x?pwd=abcd"
151
+ let m = output.match(/shareID:\s*(\d+),\s*链接:\s*([^\s,]+?)\?pwd=([^\s,]*)/);
152
+ if (m) return { shareId: Number(m[1]), link: m[2], pwd: m[3], combined: true, raw: output };
153
+ // 普通格式: "shareID: 5, 链接: https://pan.baidu.com/s/x, 密码: abcd"(无密码时 密码 为空)
154
+ m = output.match(/shareID:\s*(\d+),\s*链接:\s*([^\s,]+),\s*密码:\s*([^\s,]*)/);
155
+ if (m) return { shareId: Number(m[1]), link: m[2], pwd: m[3], combined: false, raw: output };
156
+ if (/创建分享链接失败/.test(output)) throw new Error(`创建分享失败: ${output.trim()}`);
157
+ throw new Error(`创建分享失败: 无法解析输出\n${output.trim()}`);
158
+ }
159
+
160
+ // 取消分享。成功: { shareId, ok: true, raw }
161
+ function shareCancel(shareId) {
162
+ const output = runPCS(["share", "cancel", String(shareId)]);
163
+ if (/取消分享成功/.test(output)) return { shareId, ok: true, raw: output };
164
+ const m = output.match(/取消分享失败:\s*(.*)/);
165
+ throw new Error(`取消分享失败: ${m ? m[1].trim() : (output.trim() || "(无输出)")}`);
166
+ }
167
+
168
+ // 列出已分享文件/目录。输出为 pcstable 表格:
169
+ // # ShareID 分享链接 提取密码 特征目录 特征路径 过期时间 浏览次数
170
+ // 成功: { shares: [{ index, shareId, link, pwd, typicalDir, typicalPath, expireText, viewCount }], count, raw }
171
+ function shareList(page = 1) {
172
+ const args = ["share", "list"];
173
+ if (page && page !== 1) args.push(`--page=${page}`);
174
+ let output;
175
+ try {
176
+ output = runPCS(args);
177
+ } catch (e) {
178
+ // BaiduPCS-Go v4.0.1 的 share list 存在引擎 bug(nil pointer panic,实测复现),
179
+ // 无法从引擎侧修复,只列出友好的替代说明
180
+ if (/panic|退出码/.test(e.message)) {
181
+ throw new Error(
182
+ `列出分享失败: BaiduPCS-Go v4.0.1 的 share list 存在引擎 bug(panic,实测复现)。\n` +
183
+ ` 处理: 用 bdp share cancel <shareId> 管理已知分享;或到 https://pan.baidu.com 网页端查看分享列表。`
184
+ );
185
+ }
186
+ throw e;
187
+ }
188
+ const rows = parseTable(output, ["#", "ShareID", "分享链接", "提取密码", "特征目录", "特征路径", "过期时间", "浏览次数"]);
189
+ if (rows === null) throw new Error(`列出分享失败: ${output.trim() || "(无输出)"}`);
190
+ const shares = rows.map((r) => ({
191
+ index: parseInt(r[0], 10),
192
+ shareId: parseInt(r[1], 10),
193
+ link: r[2],
194
+ pwd: r[3] || "",
195
+ typicalDir: r[4],
196
+ typicalPath: r[5],
197
+ expireText: r[6],
198
+ viewCount: parseInt(r[7], 10) || 0,
199
+ }));
200
+ return { shares, count: shares.length, raw: output };
201
+ }
202
+
203
+ // ── 工具 ────────────────────────────────────────────────
204
+
205
+ // 东亚宽字符(CJK/全角等)显示宽度近似,对应 go-runewidth 的 East Asian Width W/F:
206
+ // 计 2 列,其余字符计 1 列。表头/数据行均按此规则渲染,切分即自洽。
207
+ const WIDE_CHAR_RE = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/;
208
+
209
+ function dispWidth(str) {
210
+ let w = 0;
211
+ for (const ch of String(str)) w += WIDE_CHAR_RE.test(ch) ? 2 : 1;
212
+ return w;
213
+ }
214
+
215
+ // 在 line 中取显示宽度 [startDisp, endDisp) 覆盖的码元区间
216
+ // (tablewriter 按显示宽对齐列,含 CJK 时各行列的码元偏移并不一致,
217
+ // 必须先换算成显示宽度再做切片)
218
+ function sliceByDisplay(line, startDisp, endDisp) {
219
+ const len = line.length;
220
+ const dispAt = new Array(len + 1);
221
+ let d = 0;
222
+ for (let i = 0; i < len; i++) {
223
+ dispAt[i] = d;
224
+ d += dispWidth(line[i]);
225
+ }
226
+ dispAt[len] = d;
227
+ let s = len;
228
+ let e = len;
229
+ for (let i = 0; i <= len; i++) {
230
+ if (s === len && dispAt[i] >= startDisp) s = i;
231
+ if (dispAt[i] >= endDisp) {
232
+ e = i;
233
+ break;
234
+ }
235
+ }
236
+ return line.slice(s, e);
237
+ }
238
+
239
+ // 解析 BaiduPCS-Go 的 pcstable 表格(tablewriter: 无边框、无列分隔符、列按显示宽对齐)。
240
+ // 原理: 表头与数据行在同一表格渲染下列起始显示偏移一致 → 用表头标题的显示位置切分每行。
241
+ // 表头找不到返回 null。
242
+ function parseTable(output, titles) {
243
+ const lines = String(output).split(/\r?\n/).filter((l) => l.trim() !== "");
244
+ const headerIdx = lines.findIndex((l) => titles.every((t) => l.includes(t)));
245
+ if (headerIdx === -1) return null;
246
+ const header = lines[headerIdx];
247
+ const offsets = []; // 各列表头的显示宽度偏移
248
+ let pos = 0;
249
+ for (const t of titles) {
250
+ const idx = header.indexOf(t, pos);
251
+ if (idx === -1) return null;
252
+ offsets.push(dispWidth(header.slice(0, idx)));
253
+ pos = idx + t.length;
254
+ }
255
+ const rows = [];
256
+ for (let i = headerIdx + 1; i < lines.length; i++) {
257
+ const cells = [];
258
+ for (let c = 0; c < titles.length; c++) {
259
+ const start = offsets[c];
260
+ const end = c + 1 < titles.length ? offsets[c + 1] : dispWidth(lines[i]);
261
+ cells.push(sliceByDisplay(lines[i], start, end).trim());
262
+ }
263
+ rows.push(cells);
264
+ }
265
+ return rows;
266
+ }
267
+
268
+ // ── 回收站 ─────────────────────────────────────────────
269
+
270
+ // 列出回收站。成功: { items: [{ index, fsId, size(字节|null), createTime, modifyTime, remainDays, path }], count, raw }
271
+ // 注:FS ID 列(15 位数字)比表头宽,显示宽度切分会错位,改用正则解析
272
+ function recycleList(page = 1) {
273
+ const args = ["recycle", "list"];
274
+ if (page && page !== 1) args.push(`--page=${page}`);
275
+ const output = runPCS(args);
276
+ const items = [];
277
+ for (const line of output.split("\n")) {
278
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(?:([0-9a-f]{32})\s+)?(\d+|-)\s+(.+)$/);
279
+ if (!m) continue;
280
+ const sizeText = m[3];
281
+ items.push({
282
+ index: parseInt(m[1], 10),
283
+ fsId: parseInt(m[2], 10),
284
+ size: sizeText && sizeText !== "-" ? parseSizeText(sizeText) : null,
285
+ createTime: m[4],
286
+ modifyTime: m[6],
287
+ md5: m[8] || "",
288
+ remainDays: m[9] !== "-" ? parseInt(m[9], 10) : null,
289
+ path: m[10].trim(),
290
+ });
291
+ }
292
+ if (items.length === 0 && !/^\s*#/.test(output) && output.trim() === "") {
293
+ throw new Error(`列出回收站失败: ${output.trim() || "(无输出)"}`);
294
+ }
295
+ return { items, count: items.length, raw: output };
296
+ }
297
+
298
+ // 还原回收站文件/目录。成功: { fsIds, ok: true, raw }
299
+ function recycleRestore(fsIds) {
300
+ const output = runPCS(["recycle", "restore", ...fsIds.map(String)]);
301
+ if (/还原成功|还原完成|成功/.test(output)) return { fsIds, ok: true, raw: output };
302
+ throw new Error(`还原失败: ${output.trim() || "(无输出)"}`);
303
+ }
304
+
305
+ // 清空回收站(⚠️ 不可恢复)。成功: { ok: true, raw }
306
+ function recycleClean() {
307
+ const output = runPCS(["recycle", "delete", "-all"]);
308
+ if (/清空成功|成功|完成/.test(output)) return { ok: true, raw: output };
309
+ throw new Error(`清空回收站失败: ${output.trim() || "(无输出)"}`);
310
+ }
311
+
312
+ module.exports = {
313
+ mv, cp, quota,
314
+ offlineAdd, offlineList,
315
+ shareSet, shareCancel, shareList,
316
+ recycleList, recycleRestore, recycleClean,
317
+ parseSizeText, parseTable, OFFLINE_STATUS,
318
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baiduwangpan-cli",
3
- "version": "1.1.3",
3
+ "version": "1.6.0",
4
4
  "description": "百度网盘 CLI — 全盘读写 · 免下载读取文件内容 · 群聊文件浏览 (Agent 友好)",
5
5
  "main": "lib/index.js",
6
6
  "bin": {