baiduwangpan-cli 1.1.5 → 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.
package/lib/pan.js CHANGED
@@ -1,15 +1,23 @@
1
- const { spawnSync } = require("child_process");
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
2
4
  const config = require("./config");
3
5
  const http = require("./http");
4
6
 
7
+ // child_process 延迟加载:测试可整体替换 require.cache["child_process"] 注入 mock
8
+ function childProc() {
9
+ return require("child_process");
10
+ }
11
+
5
12
  function runPCS(args) {
6
13
  const cfg = config.get();
14
+ const { spawnSync } = childProc();
7
15
  const result = spawnSync(cfg.pcsPath, args, {
8
16
  encoding: "utf-8",
9
17
  maxBuffer: 20 * 1024 * 1024,
10
18
  });
11
19
  if (result.error) {
12
- throw new Error(`BaiduPCS-Go not found at: ${cfg.pcsPath}. Set pcsPath in config or BAIDUPCS_CMD env.`);
20
+ throw new Error(`未找到 BaiduPCS-Go 可执行文件: ${cfg.pcsPath}。请在 config 中设置 pcsPath,或设置环境变量 BAIDUPCS_CMD。`);
13
21
  }
14
22
  return result.stdout || "";
15
23
  }
@@ -23,11 +31,47 @@ function getDlink(remotePath) {
23
31
  return null;
24
32
  }
25
33
 
34
+ // 通过 web 前端同款 /api/list 获取文件元信息(size/md5/isdir/ctime 一次拿全)。
35
+ // 实测 (2026-08): /api/filemetas 与 xpan filemetas 均返回 errno=2 不可用,
36
+ // 而 /api/list(父目录列表 + bdstoken)可用,md5 与 dlink URL 前缀一致,比
37
+ // BaiduPCS-Go meta 文本输出("md5 (可能不正确)")更可靠。
38
+ // 失败(未登录/接口异常/文件不在前 2000 条)返回 null,由调用方回退文本解析。
39
+ async function getFileMetaWeb(remotePath) {
40
+ const bdstoken = await http.getBdstoken();
41
+ const trimmed = remotePath.replace(/\/+$/, "");
42
+ const idx = trimmed.lastIndexOf("/");
43
+ const parent = idx <= 0 ? "/" : trimmed.slice(0, idx);
44
+ const base = idx === -1 ? trimmed : trimmed.slice(idx + 1);
45
+
46
+ for (let page = 1; page <= 20; page++) {
47
+ const url =
48
+ `https://pan.baidu.com/api/list?order=name&desc=0&showempty=0&web=1` +
49
+ `&page=${page}&num=100&dir=${encodeURIComponent(parent)}` +
50
+ `&channel=chunlei&app_id=${http.APP_ID}&bdstoken=${encodeURIComponent(bdstoken)}`;
51
+ const data = await http.webJson(url);
52
+ if (!data || data.errno !== 0 || !Array.isArray(data.list)) return null;
53
+ const item = data.list.find((f) => f.server_filename === base);
54
+ if (item) {
55
+ return {
56
+ size: typeof item.size === "number" ? item.size : parseInt(item.size, 10) || null,
57
+ md5: item.md5 || null,
58
+ isDir: item.isdir === 1 || item.isdir === "1",
59
+ serverCtime: item.server_ctime != null ? item.server_ctime : null,
60
+ localCtime: item.local_ctime != null ? item.local_ctime : null,
61
+ };
62
+ }
63
+ if (data.list.length < 100) return null; // 翻页结束仍未找到
64
+ }
65
+ return null;
66
+ }
67
+
26
68
  function getFileMeta(remotePath) {
27
69
  const output = runPCS(["meta", remotePath]);
28
70
  let size = null;
29
71
 
30
- let m = output.match(/大小[::]\s*([\d,]+)/);
72
+ // BaiduPCS-Go v4.0.1 实测输出: "文件大小 7297727, 6.959655MB"
73
+ // (首个数字为精确字节数;旧正则要求 "大小:" 冒号,实际是空格,永远匹配不到)
74
+ let m = output.match(/文件大小\s*[::]?\s*(\d[\d,]*)/);
31
75
  if (m) size = parseInt(m[1].replace(/,/g, ""), 10);
32
76
  if (!size) {
33
77
  m = output.match(/size[":\s]+(\d+)/i);
@@ -38,10 +82,15 @@ function getFileMeta(remotePath) {
38
82
  if (m) size = parseInt(m[1].replace(/,/g, ""), 10);
39
83
  }
40
84
 
41
- let isDir = false;
42
- if (/目录|isdir.*1|is_dir.*true/i.test(output)) isDir = true;
85
+ // md5(BaiduPCS-Go meta 输出 "md5 (可能不正确) xxxx",仅作文本回退参考)
86
+ let md5 = null;
87
+ m = output.match(/md5[^0-9a-fA-F]*([0-9a-fA-F]{32})/);
88
+ if (m) md5 = m[1].toLowerCase();
43
89
 
44
- return { size, isDir, raw: output };
90
+ // 只看 "类型" 行,避免文件名含"目录"字样时误判(旧正则 /目录/ 会误伤)
91
+ const isDir = /类型\s*[::]?\s*目录/.test(output) || /isdir.*1|is_dir.*true/i.test(output);
92
+
93
+ return { size, isDir, md5, raw: output };
45
94
  }
46
95
 
47
96
  function fetchRangeText(url, start, end) {
@@ -87,7 +136,7 @@ function cat(remotePath, maxBytes) {
87
136
  }
88
137
 
89
138
  const dlink = getDlink(remotePath);
90
- if (!dlink) throw new Error(`Cannot get dlink for: ${remotePath}`);
139
+ if (!dlink) throw new Error(`无法获取 ${remotePath} 的下载链接(dlink)。文件可能已删除/移动,或该路径不支持直链下载。`);
91
140
 
92
141
  const readBytes = Math.min(meta.size || limit, limit) - 1;
93
142
  return fetchRangeText(dlink, 0, readBytes);
@@ -97,7 +146,7 @@ function head(remotePath, n = 20, maxBytes) {
97
146
  const cfg = config.get();
98
147
  const limit = maxBytes || cfg.maxBytes;
99
148
  const dlink = getDlink(remotePath);
100
- if (!dlink) throw new Error(`Cannot get dlink for: ${remotePath}`);
149
+ if (!dlink) throw new Error(`无法获取 ${remotePath} 的下载链接(dlink)。文件可能已删除/移动,或该路径不支持直链下载。`);
101
150
 
102
151
  const chunkSize = Math.min(n * 4096 + 1024, limit);
103
152
  const text = fetchRangeText(dlink, 0, chunkSize - 1);
@@ -109,10 +158,10 @@ function tail(remotePath, n = 20, maxBytes) {
109
158
  const cfg = config.get();
110
159
  const limit = maxBytes || cfg.maxBytes;
111
160
  const meta = getFileMeta(remotePath);
112
- if (!meta.size) throw new Error(`Cannot get file size for: ${remotePath}`);
161
+ if (!meta.size) throw new Error(`无法获取 ${remotePath} 的文件大小。`);
113
162
 
114
163
  const dlink = getDlink(remotePath);
115
- if (!dlink) throw new Error(`Cannot get dlink for: ${remotePath}`);
164
+ if (!dlink) throw new Error(`无法获取 ${remotePath} 的下载链接(dlink)。文件可能已删除/移动,或该路径不支持直链下载。`);
116
165
 
117
166
  const chunkSize = Math.min(n * 4096 + 1024, limit, meta.size);
118
167
  const start = Math.max(0, meta.size - chunkSize);
@@ -124,7 +173,7 @@ function tail(remotePath, n = 20, maxBytes) {
124
173
  function grep(pattern, remotePath, options = {}) {
125
174
  const cfg = config.get();
126
175
  const dlink = getDlink(remotePath);
127
- if (!dlink) throw new Error(`Cannot get dlink for: ${remotePath}`);
176
+ if (!dlink) throw new Error(`无法获取 ${remotePath} 的下载链接(dlink)。文件可能已删除/移动,或该路径不支持直链下载。`);
128
177
 
129
178
  const text = fetchRangeText(dlink, 0, cfg.maxBytes - 1);
130
179
 
@@ -146,8 +195,13 @@ function grep(pattern, remotePath, options = {}) {
146
195
  return matches;
147
196
  }
148
197
 
149
- function peek(remotePath) {
150
- const meta = getFileMeta(remotePath);
198
+ async function peek(remotePath) {
199
+ // web 元信息优先(含权威 md5/ctime),失败回退 meta 文本解析
200
+ let webMeta = null;
201
+ try {
202
+ webMeta = await getFileMetaWeb(remotePath);
203
+ } catch {}
204
+ const meta = webMeta || getFileMeta(remotePath);
151
205
  const dlink = getDlink(remotePath);
152
206
 
153
207
  const result = {
@@ -157,6 +211,12 @@ function peek(remotePath) {
157
211
  dlink: !!dlink,
158
212
  };
159
213
 
214
+ if (webMeta) {
215
+ if (webMeta.md5) result.md5 = webMeta.md5;
216
+ if (webMeta.serverCtime) result.serverCtime = webMeta.serverCtime;
217
+ if (webMeta.localCtime) result.localCtime = webMeta.localCtime;
218
+ }
219
+
160
220
  if (dlink && !meta.isDir) {
161
221
  const cfg = config.get();
162
222
  const previewBytes = Math.min(512, meta.size || 512) - 1;
@@ -179,10 +239,387 @@ function peek(remotePath) {
179
239
  return result;
180
240
  }
181
241
 
182
- function download(remotePath, saveTo) {
183
- const args = ["d", remotePath];
184
- if (saveTo) args.push("--saveto", saveTo);
185
- return runPCS(args);
242
+ // ── 下载 ────────────────────────────────────────────────
243
+ //
244
+ // v1.2.0 起 bdp get 不再裸跑 BaiduPCS-Go(其 download 无断点续传,且自动跳过
245
+ // 重名文件导致中断后无法续)。改为:
246
+ // filemetas 元信息(/api/list)→ locate dlink → curl 直链分块下载
247
+ // 分块原因(实测 2026-08):部分 CDN 节点(*ct11.baidupcs.com)拒绝无界请求
248
+ // (HTTP 403, error_code 31326, hitcode 104),只允许 ≤~5MB 的有界 Range。
249
+ // 4MB 分块 + 每块失败换新 dlink 重试,同时天然支持断点续传与坏主机规避。
250
+ // 目录或无 dlink 的边界场景仍回退 BaiduPCS-Go 引擎(无校验/续传,保持原行为)。
251
+ //
252
+ // options: { resume, force, progress, verifySize }
253
+ // 硬校验:size 对比 + 0 字节检测(默认全开,失败保留文件供排查)
254
+ // 软校验:md5(百度 /api/list 的 md5 是混淆值,仅本地计算+标注,不阻断)
255
+
256
+ const DOWNLOAD_CHUNK = 4 * 1024 * 1024; // 4MB/块(CT11 节点 range 上限约 5MB)
257
+ const DEFAULT_CONCURRENCY = 3; // 并发分块路数(绕开单连接限速;仅大文件启用)
258
+ const CONCURRENT_THRESHOLD = 32 * 1024 * 1024; // 剩余 >32MB 才启用并发(小文件串行开销更小)
259
+
260
+ async function download(remotePath, saveTo, options = {}) {
261
+ const cfg = config.get();
262
+ const opts = { resume: false, force: false, progress: false, verifySize: true, dryRun: false, concurrency: DEFAULT_CONCURRENCY, ...options };
263
+
264
+ // 1) 元信息:web /api/list 优先(size 权威)→ 失败回退 meta 文本解析。
265
+ // 注:API 的 md5 为混淆键(非内容哈希,实测含非 hex 字符),仅作参考不参与硬校验
266
+ let webMeta = null;
267
+ try {
268
+ webMeta = await getFileMetaWeb(remotePath);
269
+ } catch {}
270
+ const meta = webMeta || getFileMeta(remotePath);
271
+ const remoteMd5 = webMeta ? webMeta.md5 || null : null;
272
+
273
+ // 2) dlink(locate 每次返回新链接;分块失败时自动换新,规避过期/坏节点)
274
+ const dlink = getDlink(remotePath);
275
+ let dlinkExpiry = null;
276
+ if (dlink) {
277
+ const sizeInDlink = dlink.match(/[?&]size=(\d+)/);
278
+ if (sizeInDlink && !meta.size) meta.size = parseInt(sizeInDlink[1], 10);
279
+ const exp = dlink.match(/[?&]expires=([^&]+)/);
280
+ if (exp) dlinkExpiry = exp[1];
281
+ }
282
+
283
+ // 3) 目录或无 dlink → 回退 BaiduPCS-Go 引擎
284
+ if ((meta && meta.isDir) || !dlink) {
285
+ const args = ["d", remotePath];
286
+ if (saveTo) args.push("--saveto", saveTo);
287
+ return {
288
+ path: remotePath,
289
+ engine: "pcs",
290
+ raw: runPCS(args),
291
+ warning: meta && meta.isDir
292
+ ? "目录下载走 BaiduPCS-Go 引擎(无校验/断点续传)"
293
+ : "获取 dlink 失败,走 BaiduPCS-Go 引擎",
294
+ };
295
+ }
296
+
297
+ // 4) 本地目标路径:-o 视为目录(自动创建);默认当前目录(与 gdownload 一致)
298
+ const baseName = path.basename(remotePath.replace(/[\\/]+$/, ""));
299
+ const localPath = saveTo ? path.join(saveTo, baseName) : path.join(process.cwd(), baseName);
300
+
301
+ // 已有文件决策
302
+ let existingSize = null;
303
+ try {
304
+ existingSize = fs.statSync(localPath).size;
305
+ } catch {}
306
+
307
+ // --dry-run:不落盘、不创建目录、不请求下载,仅报告将执行的动作
308
+ if (opts.dryRun) {
309
+ let action = "download";
310
+ if (existingSize !== null && !opts.force) {
311
+ if (meta.size != null && existingSize === meta.size) action = "skip";
312
+ else if (meta.size != null && existingSize > meta.size) action = "overwrite";
313
+ else action = "resume";
314
+ }
315
+ return {
316
+ path: remotePath,
317
+ dryRun: true,
318
+ engine: "dry-run",
319
+ action,
320
+ size: meta.size,
321
+ localPath,
322
+ existingSize,
323
+ dlinkExpiry,
324
+ };
325
+ }
326
+
327
+ if (saveTo) fs.mkdirSync(saveTo, { recursive: true });
328
+
329
+ if (!opts.force && existingSize !== null) {
330
+ if (meta.size != null && existingSize === meta.size) {
331
+ return verifyAndBuild(remotePath, localPath, existingSize, remoteMd5, dlinkExpiry, {
332
+ skipped: true, resumed: false,
333
+ });
334
+ }
335
+ if (meta.size != null && existingSize > meta.size) {
336
+ throw new Error(
337
+ `本地文件大于远端: 本地 ${existingSize} 字节 > 远端 ${meta.size} 字节\n` +
338
+ ` 已保留: ${localPath}\n 处理: --force 覆盖重下`
339
+ );
340
+ }
341
+ if (existingSize < (meta.size != null ? meta.size : Infinity)) {
342
+ if (!opts.resume) {
343
+ process.stderr.write(
344
+ `[auto-resume] 检测到部分文件 ${existingSize}/${meta.size != null ? meta.size : "?"} 字节,自动断点续传(--force 可强制重下)\n`
345
+ );
346
+ }
347
+ opts.resume = true;
348
+ }
349
+ }
350
+
351
+ // --force:分块下载是追加写,必须先清掉旧文件
352
+ if (opts.force && existingSize !== null) {
353
+ fs.unlinkSync(localPath);
354
+ existingSize = null;
355
+ }
356
+
357
+ // 6) 分块下载(有界 Range,天然续传)
358
+ const startedAt = Date.now();
359
+ if (meta.size != null && meta.size > 0) {
360
+ await curlChunkedDownload(dlink, remotePath, localPath, meta.size, existingSize || 0, opts);
361
+ } else {
362
+ // 拿不到远端大小(罕见):单请求无界下载(部分节点会拒绝,但无更好的办法)
363
+ const exitCode = await spawnCurl(
364
+ ["-s", "-L", "-C", "-", "--connect-timeout", "30", "-H", `User-Agent: ${cfg.ua}`, "-o", localPath, dlink],
365
+ opts.progress
366
+ );
367
+ if (exitCode !== 0) {
368
+ throw new Error(
369
+ `curl 下载失败 (exit=${exitCode}),已保留部分文件: ${localPath}\n` +
370
+ ` 处理: 重跑 bdp get --resume(会重新获取 dlink)`
371
+ );
372
+ }
373
+ }
374
+ const seconds = (Date.now() - startedAt) / 1000;
375
+
376
+ // 7) 校验(失败均保留文件供排查)
377
+ let stat = null;
378
+ try {
379
+ stat = fs.statSync(localPath);
380
+ } catch {}
381
+ if (!stat) {
382
+ throw new Error(
383
+ `下载失败,目标文件缺失: ${localPath}\n 处理: 重跑 bdp get --resume`
384
+ );
385
+ }
386
+ if (opts.verifySize && meta.size != null) {
387
+ if (stat.size === 0 && meta.size > 0) {
388
+ throw new Error(
389
+ `下载结果 0 字节: dlink 过期或网络中断\n` +
390
+ ` 已保留: ${localPath}\n 处理: 重跑 bdp get --resume(会重新获取 dlink)`
391
+ );
392
+ }
393
+ if (stat.size !== meta.size) {
394
+ throw new Error(
395
+ `大小校验失败: 远端 ${meta.size} 字节 vs 本地 ${stat.size} 字节\n` +
396
+ ` 已保留: ${localPath}\n 处理: --resume 续传 或 --force 重下`
397
+ );
398
+ }
399
+ }
400
+
401
+ return verifyAndBuild(remotePath, localPath, stat.size, remoteMd5, dlinkExpiry, {
402
+ skipped: false, resumed: opts.resume && existingSize > 0, seconds, avgSpeedBps: seconds > 0 ? Math.round(stat.size / seconds) : null,
403
+ });
404
+ }
405
+
406
+ // 分块下载主循环:从 existingSize 开始,每块 ≤4MB 有界 Range,
407
+ // 追加写入目标文件;块失败重试(每次换新 dlink,天然规避过期/坏节点)。
408
+ // 大文件(剩余 >32MB)默认 3 路并发分块(绕开单连接限速),--concurrency 1 可回串行
409
+ async function curlChunkedDownload(dlink, remotePath, localPath, metaSize, existingSize, opts) {
410
+ // 清理同目录残留的陈旧分块临时文件(上次硬中断留下的 .bdp-chunk-*.tmp)
411
+ cleanupStaleChunks(path.dirname(localPath));
412
+ const fd = fs.openSync(localPath, "a");
413
+ const state = { downloaded: existingSize, lastProgress: 0, startedAt: Date.now() };
414
+ try {
415
+ const concurrency = Math.min(Math.max(1, opts.concurrency || DEFAULT_CONCURRENCY), 8);
416
+ if (concurrency > 1 && metaSize - existingSize > CONCURRENT_THRESHOLD) {
417
+ await concurrentChunkLoop(dlink, remotePath, localPath, fd, metaSize, state, concurrency, opts);
418
+ } else {
419
+ // 串行(小文件 / --concurrency 1)
420
+ while (state.downloaded < metaSize) {
421
+ const end = Math.min(state.downloaded + DOWNLOAD_CHUNK - 1, metaSize - 1);
422
+ const data = await fetchRangeChunk(dlink, remotePath, localPath, state.downloaded, end, metaSize, opts);
423
+ fs.writeSync(fd, data);
424
+ state.downloaded += data.length;
425
+ emitProgressIfDue(state, metaSize, opts);
426
+ }
427
+ }
428
+ } finally {
429
+ fs.closeSync(fd);
430
+ }
431
+ if (opts.progress) process.stderr.write("\n");
432
+ return (Date.now() - state.startedAt) / 1000;
433
+ }
434
+
435
+ // 并发分块主循环:固定窗口预取(每块独立有界 Range + 独立 tmp 文件),
436
+ // 按序落盘(fd 写入保持顺序,文件永远是完整前缀);
437
+ // 任一块重试后仍失败 → 等待在途块结束、丢弃其结果,降级串行补齐剩余
438
+ async function concurrentChunkLoop(dlink, remotePath, localPath, fd, metaSize, state, concurrency, opts) {
439
+ let nextStart = state.downloaded;
440
+ let nextWrite = state.downloaded;
441
+ const active = new Map(); // start -> Promise<{start, data} | {start, error}>
442
+ while (nextWrite < metaSize) {
443
+ // 填充并发窗口
444
+ while (active.size < concurrency && nextStart < metaSize) {
445
+ const start = nextStart;
446
+ const end = Math.min(start + DOWNLOAD_CHUNK - 1, metaSize - 1);
447
+ active.set(
448
+ start,
449
+ fetchRangeChunk(dlink, remotePath, localPath, start, end, metaSize, opts)
450
+ .then((data) => ({ start, data }))
451
+ .catch((e) => ({ start, error: e }))
452
+ );
453
+ nextStart = end + 1;
454
+ }
455
+ const result = await active.get(nextWrite);
456
+ active.delete(nextWrite);
457
+ if (result.error) {
458
+ // 并发下块失败(已内部重试换 dlink)→ 丢弃在途结果,降级串行补齐剩余
459
+ await Promise.allSettled([...active.values()]);
460
+ active.clear();
461
+ while (nextWrite < metaSize) {
462
+ const end = Math.min(nextWrite + DOWNLOAD_CHUNK - 1, metaSize - 1);
463
+ const data = await fetchRangeChunk(dlink, remotePath, localPath, nextWrite, end, metaSize, opts);
464
+ fs.writeSync(fd, data);
465
+ nextWrite += data.length;
466
+ state.downloaded = nextWrite;
467
+ emitProgressIfDue(state, metaSize, opts);
468
+ }
469
+ return;
470
+ }
471
+ fs.writeSync(fd, result.data);
472
+ nextWrite += result.data.length;
473
+ state.downloaded = nextWrite;
474
+ emitProgressIfDue(state, metaSize, opts);
475
+ }
476
+ }
477
+
478
+ function emitProgressIfDue(state, metaSize, opts) {
479
+ if (!opts.progress) return;
480
+ const now = Date.now();
481
+ if (now - state.lastProgress > 200 || state.downloaded >= metaSize) {
482
+ state.lastProgress = now;
483
+ emitProgress(state.downloaded, metaSize, state.startedAt);
484
+ }
485
+ }
486
+
487
+ // 下载一个分块;失败重试 3 次,每次重新 locate 换新 dlink
488
+ async function fetchRangeChunk(dlink, remotePath, localPath, start, end, metaSize, opts, attempt = 0) {
489
+ const cfg = config.get();
490
+ const expected = end - start + 1;
491
+ const tmpPath = path.join(
492
+ path.dirname(localPath),
493
+ `.bdp-chunk-${process.pid}-${start}.tmp`
494
+ );
495
+ const args = [
496
+ "-s", "-L",
497
+ "-r", `${start}-${end}`,
498
+ "-o", tmpPath,
499
+ "-w", "%{http_code} %{size_download}",
500
+ "--connect-timeout", "30",
501
+ "--speed-limit", "10240", "--speed-time", "30",
502
+ "-H", `User-Agent: ${cfg.ua}`,
503
+ dlink,
504
+ ];
505
+ const { exitCode, stdout } = await spawnCurlCapture(args);
506
+ if (exitCode === 0) {
507
+ const m = (stdout || "").trim().match(/^(\d+) (\d+)$/);
508
+ if (m && m[1] === "206" && parseInt(m[2], 10) === expected) {
509
+ const data = fs.readFileSync(tmpPath);
510
+ try { fs.unlinkSync(tmpPath); } catch {}
511
+ return data;
512
+ }
513
+ }
514
+ try { fs.unlinkSync(tmpPath); } catch {}
515
+
516
+ if (attempt < 3) {
517
+ process.stderr.write(`[retry] 分块 ${start}-${end} 失败 (exit=${exitCode} resp=${(stdout || "").trim() || "?"}),换新 dlink 重试...\n`);
518
+ const freshDlink = getDlink(remotePath);
519
+ if (freshDlink) return fetchRangeChunk(freshDlink, remotePath, localPath, start, end, metaSize, opts, attempt + 1);
520
+ }
521
+ throw new Error(
522
+ `下载中断于 ${start}/${metaSize} 字节: dlink 过期或网络中断或 CDN 拒绝(resp=${(stdout || "").trim() || "?"})\n` +
523
+ ` 已保留部分文件: ${localPath}\n 处理: 重跑 bdp get --resume(会重新获取 dlink 续传)`
524
+ );
525
+ }
526
+
527
+ // 清理下载目录中残留的陈旧分块临时文件(硬中断(SIGKILL/断电)留下的
528
+ // .bdp-chunk-<pid>-<start>.tmp)。判定:
529
+ // 1) 文件名 pid ≠ 当前进程 → 陈旧(并发下载的占用文件在 Windows 上删除会
530
+ // EPERM 安全跳过;Unix 上 unlink 后 curl 继续写已打开 fd,仅浪费一块重试)
531
+ // 2) mtime 超 10 分钟 → 陈旧兜底
532
+ function cleanupStaleChunks(dir) {
533
+ const myPid = process.pid;
534
+ const cutoff = Date.now() - 10 * 60 * 1000;
535
+ let entries;
536
+ try {
537
+ entries = fs.readdirSync(dir);
538
+ } catch {
539
+ return;
540
+ }
541
+ for (const name of entries) {
542
+ if (!name.startsWith(".bdp-chunk-") || !name.endsWith(".tmp")) continue;
543
+ const m = name.match(/^\.bdp-chunk-(\d+)-/);
544
+ try {
545
+ const st = fs.statSync(path.join(dir, name));
546
+ const staleByPid = m && parseInt(m[1], 10) !== myPid;
547
+ const staleByAge = st.mtimeMs < cutoff;
548
+ if (staleByPid || staleByAge) fs.unlinkSync(path.join(dir, name));
549
+ } catch {}
550
+ }
551
+ }
552
+
553
+ function emitProgress(downloaded, total, startedAt) { const seconds = (Date.now() - startedAt) / 1000;
554
+ const speed = seconds > 0 ? downloaded / seconds : 0;
555
+ const pct = Math.round((downloaded / total) * 100);
556
+ const units = ["B", "KB", "MB", "GB"];
557
+ const fmt = (n) => {
558
+ if (!n) return "0B";
559
+ const i = Math.floor(Math.log(n) / Math.log(1024));
560
+ return (n / Math.pow(1024, i)).toFixed(1) + units[i];
561
+ };
562
+ process.stderr.write(
563
+ `\r[get] ${fmt(downloaded)}/${fmt(total)} (${pct}%) ${fmt(speed)}/s `
564
+ );
565
+ }
566
+
567
+ // 最终校验并组装返回信息。
568
+ // md5 为软校验:实测百度 /api/list 返回的 md5 是混淆键(含 o/t 等非 hex 字符,
569
+ // 与真实内容不符,dlink URL 里也用它做文件键),无法作为完整性依据。
570
+ // 仅当远端 md5 是标准 32 位 hex 且与本地一致时才能确认(md5Match=true);
571
+ // 混淆时标注 md5Obfuscated,从不硬失败。size 校验由调用方负责(精确)。
572
+ function verifyAndBuild(remotePath, localPath, size, remoteMd5, dlinkExpiry, extra) {
573
+ const localMd5 = md5File(localPath);
574
+ const remoteHex = typeof remoteMd5 === "string" && /^[0-9a-f]{32}$/i.test(remoteMd5);
575
+ let md5Match = null;
576
+ if (remoteMd5 && remoteHex) {
577
+ md5Match = localMd5 === remoteMd5.toLowerCase();
578
+ }
579
+ return {
580
+ path: remotePath,
581
+ localPath,
582
+ size,
583
+ md5: localMd5,
584
+ remoteMd5,
585
+ md5Match,
586
+ md5Obfuscated: !!remoteMd5 && !remoteHex,
587
+ dlinkExpiry,
588
+ verified: true,
589
+ ...extra,
590
+ };
591
+ }
592
+
593
+ function md5File(filePath) {
594
+ const hash = crypto.createHash("md5");
595
+ hash.update(fs.readFileSync(filePath));
596
+ return hash.digest("hex");
597
+ }
598
+
599
+ function spawnCurl(args, forwardStderr) {
600
+ return new Promise((resolve) => {
601
+ const { spawn } = childProc();
602
+ const child = spawn("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
603
+ child.stderr.on("data", (chunk) => {
604
+ if (forwardStderr) process.stderr.write(chunk);
605
+ });
606
+ child.on("error", (err) => resolve("SPAWN:" + err.message));
607
+ child.on("close", (code) => resolve(code === null ? "CLOSED" : code));
608
+ });
609
+ }
610
+
611
+ // 捕获 curl 的 -w 输出(stdout)+ 退出码
612
+ function spawnCurlCapture(args) {
613
+ return new Promise((resolve) => {
614
+ const { spawn } = childProc();
615
+ const child = spawn("curl", args, { stdio: ["ignore", "pipe", "pipe"] });
616
+ let stdout = "";
617
+ let stderr = "";
618
+ child.stdout.on("data", (c) => { stdout += c.toString(); });
619
+ child.stderr.on("data", (c) => { stderr += c.toString(); });
620
+ child.on("error", (err) => resolve({ exitCode: "SPAWN:" + err.message, stdout: "", stderr: "" }));
621
+ child.on("close", (code) => resolve({ exitCode: code === null ? "CLOSED" : code, stdout, stderr }));
622
+ });
186
623
  }
187
624
 
188
625
  function upload(localPath, remotePath) {
@@ -197,8 +634,134 @@ function remove(remotePath) {
197
634
  return runPCS(["rm", remotePath]);
198
635
  }
199
636
 
637
+ // ── 容量分析 (du) ──────────────────────────────────────
638
+ //
639
+ // 百度没有服务端目录大小接口,只能递归 /api/list 求和。
640
+ // 并发遍历 + 深度限制 + 失败容忍;目录过多时给出部分结果与 failed 计数。
641
+
642
+ const DU_DEFAULT_DEPTH = 3;
643
+ const DU_DEFAULT_TOP = 15;
644
+ const DU_DEFAULT_CONCURRENCY = 8;
645
+
646
+ // 递归统计目录大小。返回 { size, files, dirs, failed }
647
+ async function du(dirPath, options = {}) {
648
+ const depth = Math.min(Math.max(1, options.depth || DU_DEFAULT_DEPTH), 8);
649
+ const concurrency = Math.min(Math.max(1, options.concurrency || DU_DEFAULT_CONCURRENCY), 16);
650
+ const top = Math.max(1, options.top || DU_DEFAULT_TOP);
651
+ const root = dirPath && dirPath !== "/" ? dirPath.replace(/\/+$/, "") : "/";
652
+
653
+ const result = {
654
+ path: root,
655
+ size: 0,
656
+ files: 0,
657
+ dirs: 0,
658
+ failed: 0,
659
+ children: [], // { path, size, files }
660
+ truncated: false,
661
+ elapsedMs: 0,
662
+ };
663
+ const startedAt = Date.now();
664
+
665
+ // 目录 → { size, files, children: Map<name, child> }
666
+ const nodeFor = (p) => {
667
+ let node = tree.get(p);
668
+ if (!node) { node = { size: 0, files: 0, dirs: 0, children: new Map() }; tree.set(p, node); }
669
+ return node;
670
+ };
671
+
672
+ const tree = new Map();
673
+ const pending = [root];
674
+ let completed = 0;
675
+ const failedDirs = [];
676
+
677
+ async function worker() {
678
+ for (;;) {
679
+ const dir = pending.pop();
680
+ if (dir === undefined) return;
681
+ const node = nodeFor(dir);
682
+ let ok = false;
683
+ try {
684
+ for (let page = 1; page <= 50; page++) {
685
+ const data = await getFileListPage(dir, page);
686
+ if (!data || data.errno !== 0 || !Array.isArray(data.list)) break;
687
+ ok = true;
688
+ const list = data.list;
689
+ for (const item of list) {
690
+ const isDir = item.isdir === 1 || item.isdir === "1";
691
+ const size = Number(item.size) || 0;
692
+ node.size += size;
693
+ if (isDir) {
694
+ node.dirs++;
695
+ const childPath = (dir === "/" ? "" : dir) + "/" + item.server_filename;
696
+ const childNode = nodeFor(childPath);
697
+ node.children.set(item.server_filename, childNode); // 登记父子关系
698
+ if (dirDepth(childPath) <= depth) {
699
+ pending.push(childPath);
700
+ } else {
701
+ // 深度封顶:不再下钻,只记录该子目录为独立叶子
702
+ childNode.capped = true;
703
+ }
704
+ } else {
705
+ node.files++;
706
+ }
707
+ }
708
+ if (list.length < 100) break;
709
+ }
710
+ } catch {
711
+ // 网络异常 → 计入失败
712
+ }
713
+ completed++;
714
+ if (!ok) failedDirs.push(dir);
715
+ }
716
+ }
717
+
718
+ function dirDepth(p) {
719
+ if (p === "/") return 0;
720
+ return p.split("/").filter(Boolean).length;
721
+ }
722
+
723
+ const workers = Array.from({ length: concurrency }, worker);
724
+ await Promise.all(workers);
725
+
726
+ // 汇总:自底向上把 children 的聚合大小并入父节点(深度降序 → 子节点 total 先算好)
727
+ const sortedPaths = [...tree.keys()].sort((a, b) => dirDepth(b) - dirDepth(a));
728
+ for (const p of sortedPaths) {
729
+ const node = tree.get(p);
730
+ let childTotal = 0;
731
+ for (const child of node.children.values()) {
732
+ childTotal += child.total;
733
+ node.files += child.files; // 文件/目录数同样聚合到父节点
734
+ node.dirs += child.dirs;
735
+ }
736
+ node.total = node.size + childTotal;
737
+ node.childList = [...node.children.entries()]
738
+ .map(([name, c]) => ({ name, path: (p === "/" ? "" : p) + "/" + name, size: c.total, files: c.files, dirs: c.dirs, capped: !!c.capped }))
739
+ .sort((a, b) => b.size - a.size);
740
+ }
741
+
742
+ const rootNode = tree.get(root);
743
+ result.size = rootNode ? rootNode.total : 0;
744
+ result.files = rootNode ? rootNode.files : 0;
745
+ result.dirs = rootNode ? rootNode.dirs : 0;
746
+ result.children = rootNode ? rootNode.childList.slice(0, top) : [];
747
+ result.truncated = rootNode ? rootNode.childList.length > top : false;
748
+ result.failed = failedDirs.length;
749
+ result.elapsedMs = Date.now() - startedAt;
750
+ return result;
751
+ }
752
+
753
+ // /api/list 单页(含 bdstoken,复用 getFileMetaWeb 的链路)
754
+ async function getFileListPage(dir, page) {
755
+ const bdstoken = await http.getBdstoken();
756
+ const url =
757
+ `https://pan.baidu.com/api/list?order=name&desc=0&showempty=0&web=1` +
758
+ `&page=${page}&num=100&dir=${encodeURIComponent(dir)}` +
759
+ `&channel=chunlei&app_id=${http.APP_ID}&bdstoken=${encodeURIComponent(bdstoken)}`;
760
+ return http.webJson(url);
761
+ }
762
+
200
763
  module.exports = {
201
764
  ls, search, cat, head, tail, grep, peek,
202
- download, upload, mkdir, remove,
203
- getDlink, getFileMeta, fetchRangeText,
765
+ download, upload, mkdir, remove, du,
766
+ getDlink, getFileMeta, getFileMetaWeb, fetchRangeText,
204
767
  };