baiduwangpan-cli 1.1.5 → 1.7.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/lib/partial.js CHANGED
@@ -1,54 +1,54 @@
1
- /**
2
- * lib/partial.js — gsearch --save-partial 的纯工具函数
3
- *
4
- * 用途:搜索未完整(超时/限流/预算耗尽/失败)时,自动把已搜到的部分结果
5
- * 落盘到 JSON 文件,避免"跑完了才发现只有 partial:true、结果却没保存"。
6
- */
7
-
8
- const path = require("path");
9
-
10
- // 非法文件名字符(Windows/Linux 通用)
11
- const ILLEGAL = /[\\/:*?"<>|]/g;
12
-
13
- /**
14
- * --save-partial 未指定 --json-file 时的默认保存路径:
15
- * <cwd>/bdp-gsearch-<gid>-<keyword>-partial-<timestamp>.json
16
- */
17
- function defaultPartialFile(gid, keyword, options = {}) {
18
- const cwd = options.cwd || process.cwd();
19
- const date = options.date || new Date();
20
- const safeKw = String(keyword == null ? "" : keyword).replace(ILLEGAL, "_").slice(0, 40) || "search";
21
- const ts = date.toISOString().replace(/[:.]/g, "-");
22
- return path.join(cwd, `bdp-gsearch-${String(gid)}-${safeKw}-partial-${ts}.json`);
23
- }
24
-
25
- /**
26
- * 部分结果落盘对象:results 为已搜到的全部匹配(不翻页切片),
27
- * 附带扫描诊断(scannedShares/totalShares/failedShares/stoppedReason 等),
28
- * 供 Agent / 用户超时后直接读取。
29
- */
30
- function buildPartialPayload(result, meta = {}) {
31
- return {
32
- saved: "partial",
33
- partial: true,
34
- complete: false,
35
- timedOut: !!result.timedOut,
36
- stoppedReason: result.stoppedReason || null,
37
- gid: meta.gid,
38
- keyword: meta.keyword,
39
- page: result.page,
40
- pageSize: result.pageSize,
41
- total: result.results.length,
42
- results: result.results,
43
- scannedShares: result.scannedShares,
44
- totalShares: result.totalShares,
45
- failedShares: result.failedShares,
46
- throttledShares: result.throttledShares,
47
- cachedDirs: result.cachedDirs,
48
- budgetUsed: result.budgetUsed,
49
- depth: result.depth,
50
- maxPages: result.maxPages,
51
- };
52
- }
53
-
1
+ /**
2
+ * lib/partial.js — gsearch --save-partial 的纯工具函数
3
+ *
4
+ * 用途:搜索未完整(超时/限流/预算耗尽/失败)时,自动把已搜到的部分结果
5
+ * 落盘到 JSON 文件,避免"跑完了才发现只有 partial:true、结果却没保存"。
6
+ */
7
+
8
+ const path = require("path");
9
+
10
+ // 非法文件名字符(Windows/Linux 通用)
11
+ const ILLEGAL = /[\\/:*?"<>|]/g;
12
+
13
+ /**
14
+ * --save-partial 未指定 --json-file 时的默认保存路径:
15
+ * <cwd>/bdp-gsearch-<gid>-<keyword>-partial-<timestamp>.json
16
+ */
17
+ function defaultPartialFile(gid, keyword, options = {}) {
18
+ const cwd = options.cwd || process.cwd();
19
+ const date = options.date || new Date();
20
+ const safeKw = String(keyword == null ? "" : keyword).replace(ILLEGAL, "_").slice(0, 40) || "search";
21
+ const ts = date.toISOString().replace(/[:.]/g, "-");
22
+ return path.join(cwd, `bdp-gsearch-${String(gid)}-${safeKw}-partial-${ts}.json`);
23
+ }
24
+
25
+ /**
26
+ * 部分结果落盘对象:results 为已搜到的全部匹配(不翻页切片),
27
+ * 附带扫描诊断(scannedShares/totalShares/failedShares/stoppedReason 等),
28
+ * 供 Agent / 用户超时后直接读取。
29
+ */
30
+ function buildPartialPayload(result, meta = {}) {
31
+ return {
32
+ saved: "partial",
33
+ partial: true,
34
+ complete: false,
35
+ timedOut: !!result.timedOut,
36
+ stoppedReason: result.stoppedReason || null,
37
+ gid: meta.gid,
38
+ keyword: meta.keyword,
39
+ page: result.page,
40
+ pageSize: result.pageSize,
41
+ total: result.results.length,
42
+ results: result.results,
43
+ scannedShares: result.scannedShares,
44
+ totalShares: result.totalShares,
45
+ failedShares: result.failedShares,
46
+ throttledShares: result.throttledShares,
47
+ cachedDirs: result.cachedDirs,
48
+ budgetUsed: result.budgetUsed,
49
+ depth: result.depth,
50
+ maxPages: result.maxPages,
51
+ };
52
+ }
53
+
54
54
  module.exports = { defaultPartialFile, buildPartialPayload };
@@ -0,0 +1,327 @@
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
+ const http = require("./http");
14
+ // 表格解析(CJK 显示宽对齐)与尺寸换算统一到 lib/table.js,与 pan.js 共用
15
+ const { parseSizeText, dispWidth, sliceByDisplay, parseTable } = require("./table");
16
+
17
+ // child_process 延迟加载:测试可整体替换 require.cache["child_process"] 注入 mock
18
+ function childProc() {
19
+ return require("child_process");
20
+ }
21
+
22
+
23
+ // 离线下载任务状态(BaiduPCS-Go baidupcs.CloudDlTaskInfo.Status 源码确认)
24
+ const OFFLINE_STATUS = {
25
+ 0: "下载成功",
26
+ 1: "下载进行中",
27
+ 2: "系统错误",
28
+ 3: "资源不存在",
29
+ 4: "下载超时",
30
+ 5: "资源存在但下载失败",
31
+ 6: "存储空间不足",
32
+ 7: "任务取消",
33
+ };
34
+ const OFFLINE_STATUS_TO_CODE = {};
35
+ for (const [code, text] of Object.entries(OFFLINE_STATUS)) OFFLINE_STATUS_TO_CODE[text] = Number(code);
36
+
37
+ // 解析 ConvertFileSize 输出("1.234567GB" / "100MB" / "0B")→ 字节数;无法解析返回 null
38
+
39
+ function runPCS(args) {
40
+ const cfg = config.get();
41
+ const { spawnSync } = childProc();
42
+ const result = spawnSync(cfg.pcsPath, args, {
43
+ encoding: "utf-8",
44
+ maxBuffer: 20 * 1024 * 1024,
45
+ });
46
+ if (result.error) {
47
+ throw new Error(`BaiduPCS-Go not found at: ${cfg.pcsPath}. Set pcsPath in config or BAIDUPCS_CMD env.`);
48
+ }
49
+ if (result.status != null && result.status !== 0) {
50
+ throw new Error(`BaiduPCS-Go ${args[0] || ""} 退出码 ${result.status}: ${(result.stdout || "").trim() || "(无输出)"}`);
51
+ }
52
+ return result.stdout || "";
53
+ }
54
+
55
+ // ── mv / cp ─────────────────────────────────────────────
56
+
57
+ // 移动/重命名。dst 为已存在目录 → 移动;否则按重命名处理(引擎行为)。
58
+ // 成功: { src, dst, op: "mv"|"rename"|"cp", renamed, raw }
59
+ function mv(srcPath, dstPath) {
60
+ return cpMvOp("mv", "移动", srcPath, dstPath);
61
+ }
62
+
63
+ // 拷贝。成功: { src, dst, op: "cp", renamed: false, raw }
64
+ function cp(srcPath, dstPath) {
65
+ return cpMvOp("cp", "拷贝", srcPath, dstPath);
66
+ }
67
+
68
+ function cpMvOp(cmd, opName, srcPath, dstPath) {
69
+ const output = runPCS([cmd, srcPath, dstPath]);
70
+ if (!output.trim()) throw new Error(`${opName}失败: BaiduPCS-Go 无输出`);
71
+ // 成功输出含 "重命名成功"/"操作成功"/"拷贝成功",失败输出含
72
+ // "操作失败"/"移动失败"/"拷贝失败"/"path error"/"不是一个目录"/"遇到错误, ..., 代码: N"
73
+ if (/(操作失败|拷贝失败|移动失败|重命名失败|path error|不是一个目录|不存在|遇到错误)/.test(output)) {
74
+ throw new Error(`${opName}失败: ${output.trim()}`);
75
+ }
76
+ const renamed = /重命名成功/.test(output);
77
+ const op = cmd === "mv" ? (renamed ? "rename" : "move") : "cp";
78
+ return { src: srcPath, dst: dstPath, op, renamed, raw: output };
79
+ }
80
+
81
+ // ── quota ───────────────────────────────────────────────
82
+
83
+ // 获取网盘配额。实测输出(单行):
84
+ // 用户名: , 总空间: 10.004883TB, 已用空间: 9.982749TB, 比率: 99.778767%
85
+ // 成功: { total, used, free, ratio, username, raw }(字节数 + 百分比)
86
+ function quota() {
87
+ const output = runPCS(["quota"]);
88
+ const m = output.match(
89
+ /用户名:\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
90
+ );
91
+ if (!m) throw new Error(`获取网盘配额失败: 无法解析输出\n${output.trim()}`);
92
+ const total = parseSizeText(m[2]);
93
+ const used = parseSizeText(m[3]);
94
+ if (total === null || used === null) throw new Error(`获取网盘配额失败: 无法解析大小\n${output.trim()}`);
95
+ return { total, used, free: total - used, ratio: parseFloat(m[4]), username: m[1].trim(), raw: output };
96
+ }
97
+
98
+ // ── offlinedl ───────────────────────────────────────────
99
+
100
+ // 添加离线下载任务(单 URL)。savePath 为目标目录(不传则用引擎工作目录)。
101
+ // 成功: { taskId, url, savePath, raw }
102
+ function offlineAdd(url, savePath) {
103
+ const args = ["offlinedl", "add"];
104
+ if (savePath) args.push(`-path=${savePath}`);
105
+ args.push(url);
106
+ const output = runPCS(args);
107
+ // 成功: "[1] 添加离线任务成功, 任务ID(task_id): 8833, 源地址: ..., 保存路径: ..."
108
+ const ok = output.match(/添加离线任务成功, 任务ID\(task_id\):\s*(\d+)/);
109
+ if (ok) return { taskId: Number(ok[1]), url, savePath: savePath || null, raw: output };
110
+ // 失败(引擎仍返回 0): "[1] 添加离线下载任务: 遇到错误, ..., 代码: 31045, 消息: ..., 地址: ..."
111
+ const fail = output.match(/\[\d+\]\s*(.+?),\s*地址:\s*\S+/);
112
+ throw new Error(`添加离线下载任务失败: ${fail ? fail[1].trim() : (output.trim() || "(无输出)")}`);
113
+ }
114
+
115
+ // 查询离线下载任务列表。输出为 pcstable 表格:
116
+ // # 任务ID 任务名称 文件大小 创建日期 保存路径 资源地址 状态
117
+ // 成功: { tasks: [{ index, taskId, name, size, createTime, savePath, sourceUrl, status, statusCode }], count, raw }
118
+ function offlineList() {
119
+ const output = runPCS(["offlinedl", "list"]);
120
+ const rows = parseTable(output, ["#", "任务ID", "任务名称", "文件大小", "创建日期", "保存路径", "资源地址", "状态"]);
121
+ if (rows === null) throw new Error(`查询离线下载任务列表失败: ${output.trim() || "(无输出)"}`);
122
+ const tasks = rows.map((r) => ({
123
+ index: parseInt(r[0], 10),
124
+ taskId: parseInt(r[1], 10),
125
+ name: r[2],
126
+ size: parseSizeText(r[3]),
127
+ createTime: r[4],
128
+ savePath: r[5],
129
+ sourceUrl: r[6],
130
+ status: r[7],
131
+ statusCode: OFFLINE_STATUS_TO_CODE[r[7]] != null ? OFFLINE_STATUS_TO_CODE[r[7]] : null,
132
+ }));
133
+ return { tasks, count: tasks.length, raw: output };
134
+ }
135
+
136
+ // ── share ───────────────────────────────────────────────
137
+
138
+ // 创建分享。password 可选(缺省为公开分享);options.combined=true 时加 -f 输出
139
+ // "链接?pwd=密码" 合并格式。成功: { shareId, link, pwd, combined, raw }
140
+ function shareSet(remotePath, password, options = {}) {
141
+ const args = ["share", "set"];
142
+ if (password) args.push("-p", password);
143
+ if (options.combined) args.push("-f");
144
+ args.push(remotePath);
145
+ const output = runPCS(args);
146
+ // -f 合并格式: "shareID: 5, 链接: https://pan.baidu.com/s/x?pwd=abcd"
147
+ let m = output.match(/shareID:\s*(\d+),\s*链接:\s*([^\s,]+?)\?pwd=([^\s,]*)/);
148
+ if (m) return { shareId: Number(m[1]), link: m[2], pwd: m[3], combined: true, raw: output };
149
+ // 普通格式: "shareID: 5, 链接: https://pan.baidu.com/s/x, 密码: abcd"(无密码时 密码 为空)
150
+ m = output.match(/shareID:\s*(\d+),\s*链接:\s*([^\s,]+),\s*密码:\s*([^\s,]*)/);
151
+ if (m) return { shareId: Number(m[1]), link: m[2], pwd: m[3], combined: false, raw: output };
152
+ if (/创建分享链接失败/.test(output)) throw new Error(`创建分享失败: ${output.trim()}`);
153
+ throw new Error(`创建分享失败: 无法解析输出\n${output.trim()}`);
154
+ }
155
+
156
+ // 取消分享。成功: { shareId, ok: true, raw }
157
+ function shareCancel(shareId) {
158
+ const output = runPCS(["share", "cancel", String(shareId)]);
159
+ if (/取消分享成功/.test(output)) return { shareId, ok: true, raw: output };
160
+ const m = output.match(/取消分享失败:\s*(.*)/);
161
+ throw new Error(`取消分享失败: ${m ? m[1].trim() : (output.trim() || "(无输出)")}`);
162
+ }
163
+
164
+ // ── share list:网页 API(首选)────────────────────────────────
165
+ //
166
+ // BaiduPCS-Go v4.0.1 的 `share list` 存在引擎 bug(nil pointer panic,实测复现),
167
+ // 但网页 API /share/record 完全可用(errno=0,返回 list + count + nextpage)。
168
+ // 因此列举"我的分享"优先走网页 API,失败才回退引擎。
169
+ //
170
+ // 成功: { shares: [{ index, shareId, link, pwd, typicalPath, ctime, expireTime,
171
+ // expireText, viewCount, fileCount, dirCount, fsIds }],
172
+ // count, total, page, hasMore, source, raw }
173
+ async function shareListWeb(page = 1) {
174
+ const bdstoken = await http.getBdstoken();
175
+ const url =
176
+ `${http.PAN_BASE}/share/record?page=${page}&desc=1&order=time&has_hd=1&web=1` +
177
+ `&app_id=${http.APP_ID}&bdstoken=${encodeURIComponent(bdstoken)}&channel=chunlei`;
178
+ const data = await http.webJson(url);
179
+ if (!data || data.errno !== 0 || !Array.isArray(data.list)) {
180
+ const msg = (data && (data.show_msg || `errno=${data && data.errno}`)) || "无响应";
181
+ throw new Error(`网页 API 列出分享失败: ${msg}`);
182
+ }
183
+ const shares = data.list.map((s, i) => ({
184
+ index: i + 1,
185
+ shareId: s.shareId,
186
+ link: s.shortlink || (s.shorturl ? `https://pan.baidu.com/s/${s.shorturl}` : ""),
187
+ pwd: s.passwd || "",
188
+ typicalDir: s.typicalCategory != null ? String(s.typicalCategory) : "",
189
+ typicalPath: s.typicalPath || s.name || "",
190
+ ctime: s.ctime || null,
191
+ expireTime: s.expiredTime || 0,
192
+ expireText: s.expiredTime
193
+ ? new Date(s.expiredTime * 1000).toISOString().replace("T", " ").slice(0, 19)
194
+ : "永久",
195
+ viewCount: s.vCnt || 0,
196
+ fileCount: s.tCnt || 0,
197
+ dirCount: s.dCnt || 0,
198
+ fsIds: Array.isArray(s.fsIds) ? s.fsIds : [],
199
+ status: s.status,
200
+ }));
201
+ return {
202
+ shares,
203
+ count: shares.length,
204
+ total: typeof data.count === "number" ? data.count : shares.length,
205
+ page,
206
+ hasMore: !!data.nextpage,
207
+ source: "web-api",
208
+ raw: null,
209
+ };
210
+ }
211
+
212
+ // 列出已分享文件/目录(引擎版,保留兼容;网页 API 不可用时回退路径)。
213
+ // 输出为 pcstable 表格:
214
+ // # ShareID 分享链接 提取密码 特征目录 特征路径 过期时间 浏览次数
215
+ // 成功: { shares: [{ index, shareId, link, pwd, typicalDir, typicalPath, expireText, viewCount }], count, raw }
216
+ function shareList(page = 1) {
217
+ const args = ["share", "list"];
218
+ if (page && page !== 1) args.push(`--page=${page}`);
219
+ let output;
220
+ try {
221
+ output = runPCS(args);
222
+ } catch (e) {
223
+ // BaiduPCS-Go v4.0.1 的 share list 存在引擎 bug(nil pointer panic,实测复现),
224
+ // 无法从引擎侧修复,只列出友好的替代说明
225
+ if (/panic|退出码/.test(e.message)) {
226
+ throw new Error(
227
+ `列出分享失败: BaiduPCS-Go v4.0.1 的 share list 存在引擎 bug(panic,实测复现)。\n` +
228
+ ` 处理: 用 bdp share cancel <shareId> 管理已知分享;或到 https://pan.baidu.com 网页端查看分享列表。`
229
+ );
230
+ }
231
+ throw e;
232
+ }
233
+ const rows = parseTable(output, ["#", "ShareID", "分享链接", "提取密码", "特征目录", "特征路径", "过期时间", "浏览次数"]);
234
+ if (rows === null) throw new Error(`列出分享失败: ${output.trim() || "(无输出)"}`);
235
+ const shares = rows.map((r) => ({
236
+ index: parseInt(r[0], 10),
237
+ shareId: parseInt(r[1], 10),
238
+ link: r[2],
239
+ pwd: r[3] || "",
240
+ typicalDir: r[4],
241
+ typicalPath: r[5],
242
+ expireText: r[6],
243
+ viewCount: parseInt(r[7], 10) || 0,
244
+ }));
245
+ return { shares, count: shares.length, raw: output };
246
+ }
247
+
248
+ // 列举我的分享的统一入口:网页 API 优先,引擎回退。
249
+ // 两个来源都失败时给出可执行的替代方案(而不是裸栈)。
250
+ async function shareListSafe(page = 1) {
251
+ let webError = null;
252
+ try {
253
+ return await shareListWeb(page);
254
+ } catch (e) {
255
+ webError = e.message;
256
+ }
257
+ try {
258
+ const r = shareList(page);
259
+ return { ...r, source: "engine", webError };
260
+ } catch (engineErr) {
261
+ const isPanic = /panic|退出码/.test(engineErr.message);
262
+ throw new Error(
263
+ `列出分享失败: 网页 API 与 BaiduPCS-Go v4.0.1 引擎均不可用。\n` +
264
+ ` 网页 API: ${webError}\n` +
265
+ (isPanic
266
+ ? ` 引擎: BaiduPCS-Go v4.0.1 的 share list 存在 panic bug(实测复现)。\n`
267
+ : ` 引擎: ${engineErr.message}\n`) +
268
+ ` 处理: 用 bdp share cancel <shareId> 管理已知分享;或到 https://pan.baidu.com 网页端查看分享列表。`
269
+ );
270
+ }
271
+ }
272
+
273
+ // ── 工具 ────────────────────────────────────────────────
274
+
275
+ // dispWidth / sliceByDisplay / parseTable 已移至 lib/table.js(与 pan.js 共用)
276
+
277
+ // ── 回收站 ─────────────────────────────────────────────
278
+
279
+ // 列出回收站。成功: { items: [{ index, fsId, size(字节|null), createTime, modifyTime, remainDays, path }], count, raw }
280
+ // 注:FS ID 列(15 位数字)比表头宽,显示宽度切分会错位,改用正则解析
281
+ function recycleList(page = 1) {
282
+ const args = ["recycle", "list"];
283
+ if (page && page !== 1) args.push(`--page=${page}`);
284
+ const output = runPCS(args);
285
+ const items = [];
286
+ for (const line of output.split("\n")) {
287
+ 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+(.+)$/);
288
+ if (!m) continue;
289
+ const sizeText = m[3];
290
+ items.push({
291
+ index: parseInt(m[1], 10),
292
+ fsId: parseInt(m[2], 10),
293
+ size: sizeText && sizeText !== "-" ? parseSizeText(sizeText) : null,
294
+ createTime: m[4],
295
+ modifyTime: m[6],
296
+ md5: m[8] || "",
297
+ remainDays: m[9] !== "-" ? parseInt(m[9], 10) : null,
298
+ path: m[10].trim(),
299
+ });
300
+ }
301
+ if (items.length === 0 && !/^\s*#/.test(output) && output.trim() === "") {
302
+ throw new Error(`列出回收站失败: ${output.trim() || "(无输出)"}`);
303
+ }
304
+ return { items, count: items.length, raw: output };
305
+ }
306
+
307
+ // 还原回收站文件/目录。成功: { fsIds, ok: true, raw }
308
+ function recycleRestore(fsIds) {
309
+ const output = runPCS(["recycle", "restore", ...fsIds.map(String)]);
310
+ if (/还原成功|还原完成|成功/.test(output)) return { fsIds, ok: true, raw: output };
311
+ throw new Error(`还原失败: ${output.trim() || "(无输出)"}`);
312
+ }
313
+
314
+ // 清空回收站(⚠️ 不可恢复)。成功: { ok: true, raw }
315
+ function recycleClean() {
316
+ const output = runPCS(["recycle", "delete", "-all"]);
317
+ if (/清空成功|成功|完成/.test(output)) return { ok: true, raw: output };
318
+ throw new Error(`清空回收站失败: ${output.trim() || "(无输出)"}`);
319
+ }
320
+
321
+ module.exports = {
322
+ mv, cp, quota,
323
+ offlineAdd, offlineList,
324
+ shareSet, shareCancel, shareList, shareListWeb, shareListSafe,
325
+ recycleList, recycleRestore, recycleClean,
326
+ parseSizeText, parseTable, OFFLINE_STATUS,
327
+ };
package/lib/table.js ADDED
@@ -0,0 +1,173 @@
1
+ // lib/table.js — BaiduPCS-Go pcstable 表格解析(共享模块)
2
+ //
3
+ // BaiduPCS-Go 用 tablewriter 渲染表格:无边框、无列分隔符、列按**显示宽度**对齐。
4
+ // 含 CJK 时各行的码元偏移并不一致(一个汉字占 2 列),必须先换算成显示宽度再切片。
5
+ //
6
+ // 本模块被 pan.js(ls/search 结构化)与 pcs-extra.js(share list 等)共用。
7
+
8
+ const SIZE_UNITS = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, PB: 1024 ** 5 };
9
+
10
+ // 解析 "1.234567GB" / "129B" / "100 MB" → 字节数;无法解析返回 null
11
+ function parseSizeText(text) {
12
+ const m = String(text || "").trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|PB)?$/i);
13
+ if (!m) return null;
14
+ const unit = (m[2] || "B").toUpperCase();
15
+ return Math.round(parseFloat(m[1]) * SIZE_UNITS[unit]);
16
+ }
17
+
18
+ // 东亚宽字符(CJK/全角等)显示宽度近似,对应 go-runewidth 的 East Asian Width W/F:
19
+ // 计 2 列,其余字符计 1 列。表头/数据行均按此规则渲染,切分即自洽。
20
+ const WIDE_CHAR_RE = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/;
21
+
22
+ function dispWidth(str) {
23
+ let w = 0;
24
+ for (const ch of String(str)) w += WIDE_CHAR_RE.test(ch) ? 2 : 1;
25
+ return w;
26
+ }
27
+
28
+ // 在 line 中取显示宽度 [startDisp, endDisp) 覆盖的码元区间
29
+ function sliceByDisplay(line, startDisp, endDisp) {
30
+ const len = line.length;
31
+ const dispAt = new Array(len + 1);
32
+ let d = 0;
33
+ for (let i = 0; i < len; i++) {
34
+ dispAt[i] = d;
35
+ d += dispWidth(line[i]);
36
+ }
37
+ dispAt[len] = d;
38
+ let s = len;
39
+ let e = len;
40
+ for (let i = 0; i <= len; i++) {
41
+ if (s === len && dispAt[i] >= startDisp) s = i;
42
+ if (dispAt[i] >= endDisp) {
43
+ e = i;
44
+ break;
45
+ }
46
+ }
47
+ return line.slice(s, e);
48
+ }
49
+
50
+ // 解析 BaiduPCS-Go 的 pcstable 表格。
51
+ // 原理: 表头与数据行在同一表格渲染下列起始显示偏移一致 → 用表头标题的显示位置切分每行。
52
+ // 表头找不到返回 null。
53
+ function parseTable(output, titles) {
54
+ const lines = String(output).split(/\r?\n/).filter((l) => l.trim() !== "");
55
+ const headerIdx = lines.findIndex((l) => titles.every((t) => l.includes(t)));
56
+ if (headerIdx === -1) return null;
57
+ const header = lines[headerIdx];
58
+ const offsets = [];
59
+ let pos = 0;
60
+ for (const t of titles) {
61
+ const idx = header.indexOf(t, pos);
62
+ if (idx === -1) return null;
63
+ offsets.push(dispWidth(header.slice(0, idx)));
64
+ pos = idx + t.length;
65
+ }
66
+ const rows = [];
67
+ for (let i = headerIdx + 1; i < lines.length; i++) {
68
+ const cells = [];
69
+ for (let c = 0; c < titles.length; c++) {
70
+ const start = offsets[c];
71
+ const end = c + 1 < titles.length ? offsets[c + 1] : dispWidth(lines[i]);
72
+ cells.push(sliceByDisplay(lines[i], start, end).trim());
73
+ }
74
+ rows.push(cells);
75
+ }
76
+ return rows;
77
+ }
78
+
79
+ // "2026-01-07 14:15:59" → 本地时区 epoch 毫秒;无法解析返回 null
80
+ function parseMtime(text) {
81
+ const s = String(text || "").trim();
82
+ if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return null;
83
+ const t = new Date(s.replace(" ", "T")).getTime();
84
+ return Number.isNaN(t) ? null : t;
85
+ }
86
+
87
+ // 引擎数据行的结构:<序号> <大小> <日期时间> <名称/路径>
88
+ //
89
+ // 关键:不能用"表头标题的显示偏移"来切列 —— go-olekukonko/tablewriter 的
90
+ // 表头是**居中**的,数据单元格按类型对齐(数字右对齐、字符串左对齐),
91
+ // 因此表头与数据的列偏移并不一致(实测 ls 表头"修改日期"在 disp 25,
92
+ // 数据日期在 disp 19),按偏移切会整体错位。
93
+ //
94
+ // 可靠锚点是日期列:"YYYY-MM-DD HH:MM:SS" 格式唯一且固定宽度。
95
+ const ROW_RE = /^\s*(\d+)\s+(.*?)\s*(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})\s+(.*?)\s*$/;
96
+
97
+ // 解析一行 → { index, sizeText, mtimeText, rest };不是数据行返回 null
98
+ function parseEngineRow(line) {
99
+ const m = String(line).match(ROW_RE);
100
+ if (!m) return null;
101
+ let sizeText = m[2].trim();
102
+ if (sizeText === "-" || sizeText === "") sizeText = null;
103
+ return {
104
+ index: parseInt(m[1], 10),
105
+ sizeText,
106
+ mtimeText: m[3],
107
+ rest: m[4],
108
+ };
109
+ }
110
+
111
+ // ls / search 共用的行 → 结构化条目
112
+ function rowToEntry(row, { pathPrefix, isSearch }) {
113
+ if (!row) return null;
114
+ const isDir = row.rest.endsWith("/") || row.sizeText === null;
115
+ const name = isSearch
116
+ ? (row.rest.split("/").pop() || row.rest)
117
+ : row.rest.replace(/\/+$/, "");
118
+ const fullPath = isSearch ? row.rest : joinPath(pathPrefix, row.rest);
119
+ return {
120
+ index: row.index,
121
+ name,
122
+ path: fullPath,
123
+ isDir,
124
+ sizeText: row.sizeText,
125
+ size: row.sizeText ? parseSizeText(row.sizeText) : null,
126
+ mtimeText: row.mtimeText,
127
+ mtime: parseMtime(row.mtimeText),
128
+ };
129
+ }
130
+
131
+ function joinPath(prefix, name) {
132
+ if (!prefix || prefix === "/") return "/" + name;
133
+ return prefix.replace(/\/+$/, "") + "/" + name;
134
+ }
135
+
136
+ // 表尾汇总行: "总: 239.34MB 文件总数: 5, 目录总数: 19"
137
+ function parseFooter(output) {
138
+ const m = String(output).match(/总:\s*([^\s]+)\s+文件总数:\s*(\d+),\s*目录总数:\s*(\d+)/);
139
+ if (!m) return { totalText: null, fileCount: null, dirCount: null };
140
+ return {
141
+ totalText: m[1],
142
+ fileCount: parseInt(m[2], 10),
143
+ dirCount: parseInt(m[3], 10),
144
+ };
145
+ }
146
+
147
+ // bdp ls 输出 → { entries, totalText, fileCount, dirCount }
148
+ function parseLsTable(output, pathPrefix = "/") {
149
+ const lines = String(output).split(/\r?\n/);
150
+ const entries = [];
151
+ for (const line of lines) {
152
+ const e = rowToEntry(parseEngineRow(line), { pathPrefix, isSearch: false });
153
+ if (e) entries.push(e);
154
+ }
155
+ return { entries, ...parseFooter(output) };
156
+ }
157
+
158
+ // bdp search 输出 → { entries, totalText, fileCount, dirCount }
159
+ function parseSearchTable(output) {
160
+ const lines = String(output).split(/\r?\n/);
161
+ const entries = [];
162
+ for (const line of lines) {
163
+ const e = rowToEntry(parseEngineRow(line), { pathPrefix: null, isSearch: true });
164
+ if (e) entries.push(e);
165
+ }
166
+ return { entries, ...parseFooter(output) };
167
+ }
168
+
169
+ module.exports = {
170
+ dispWidth, sliceByDisplay, parseTable,
171
+ parseSizeText, parseMtime, parseEngineRow,
172
+ parseLsTable, parseSearchTable,
173
+ };