baiduwangpan-cli 1.0.1 → 1.1.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/README.md CHANGED
@@ -229,18 +229,25 @@ bdp groups bdp gshares <gid>
229
229
  │ │
230
230
  ├── /mbox/group/list ├── /mbox/group/listshare
231
231
  │ → 获取全部群组 (gid) │ → 获取分享库 (msg_id + from_uk + fs_id)
232
- │ │
232
+ │ │ └ has_more=1 时按 last_msg_time 游标翻页
233
233
  └───────────────────────────────────┘
234
234
  ↓
235
- bdp gls <gid> <fs_id>
236
- │
237
- ├── /mbox/msg/shareinfo
238
- │ → 浏览文件/子目录 (分页)
239
- │ → 递归遍历目录树
240
- ↓
241
- 文件列表 (名称/大小/类型/fs_id)
235
+ bdp gls <gid> <fs_id> bdp gsearch <gid> "关键词"
236
+ │ │
237
+ ├── /mbox/msg/shareinfo ├── 全量遍历: shareinfo 自动翻页 (每页上限 100)
238
+ │ → 浏览文件/子目录 │ → 目录级并发扫描 + 磁盘缓存
239
+ ↓ ↓
240
+ 文件列表 (名称/大小/类型/fs_id) 匹配结果
242
241
  ```
243
242
 
243
+ **逆向实测结论(2026-08)**:
244
+
245
+ - 群聊文件**没有服务端搜索接口**(`listshare`/`shareinfo` 的 `key`/`keyword` 参数被忽略,`/api/search` 不支持群维度)——网页端搜索同样是客户端遍历,`gsearch` 走的是同一条路。
246
+ - `shareinfo` 每页**硬上限 100 条**(`num>100` 不返回更多),`page` 翻页可用 → `gsearch`/`gtree` 自动全页遍历,不再漏数据。
247
+ - 高频调用会触发限流,表现为 `errno=-3`、**self-echo**(`errno=0` 但返回目录自身)、或空列表;工具自动退避重试、连续限流停止扫描,重跑命令即续扫。
248
+ - 群文件路径可能含**非法百分号编码**(裸 `%`),直接 `decodeURIComponent` 会抛 `URIError` 报废整个目录 → 已改为安全解码。
249
+ - `gsearch`/`gtree`/`gshares` 默认启用**磁盘会话缓存**(目录 30min / 分享 5min,`~/.bdp/cache/`,`--no-cache` 关闭、`bdp cache clear` 清理)——重复搜索秒回,深扫续扫收敛。
250
+
244
251
  ## 📁 项目结构
245
252
 
246
253
  ```
package/bin/bdp.js CHANGED
@@ -52,12 +52,38 @@ function output(data, jsonFlag, formatter) {
52
52
  }
53
53
  }
54
54
 
55
+ function emitJson(data, args) {
56
+ const json = JSON.stringify(data, null, 2);
57
+ if (args.flags.jsonFile) {
58
+ require("fs").writeFileSync(args.flags.jsonFile, json, "utf-8");
59
+ console.log(args.flags.jsonFile);
60
+ } else {
61
+ console.log(json);
62
+ }
63
+ }
64
+
65
+ function verboseLog(args, msg) {
66
+ if (args.flags.verbose) process.stderr.write(`[verbose] ${msg}\n`);
67
+ }
68
+
55
69
  function parseArgs(argv) {
56
70
  const opts = { _: [], flags: {} };
57
71
  for (let i = 0; i < argv.length; i++) {
58
72
  const arg = argv[i];
59
73
  if (arg === "--json") {
60
74
  opts.flags.json = true;
75
+ } else if (arg === "--legacy-json") {
76
+ opts.flags.legacyJson = true;
77
+ } else if (arg === "--json-file") {
78
+ opts.flags.jsonFile = argv[++i];
79
+ } else if (arg === "--verbose") {
80
+ opts.flags.verbose = true;
81
+ } else if (arg === "--unique") {
82
+ opts.flags.unique = true;
83
+ } else if (arg === "--no-unique") {
84
+ opts.flags.unique = false;
85
+ } else if (arg === "--all") {
86
+ opts.flags.all = true;
61
87
  } else if (arg === "-n" || arg === "--lines") {
62
88
  opts.flags.n = parseInt(argv[++i], 10) || 20;
63
89
  } else if (arg === "-p" || arg === "--path") {
@@ -76,6 +102,28 @@ function parseArgs(argv) {
76
102
  opts.flags.stoken = argv[++i];
77
103
  } else if (arg === "--page") {
78
104
  opts.flags.page = parseInt(argv[++i], 10) || 1;
105
+ } else if (arg === "--page-size") {
106
+ opts.flags.pageSize = parseInt(argv[++i], 10) || 50;
107
+ } else if (arg === "--limit") {
108
+ opts.flags.limit = parseInt(argv[++i], 10) || 50;
109
+ } else if (arg === "--concurrency") {
110
+ opts.flags.concurrency = parseInt(argv[++i], 10) || 4;
111
+ } else if (arg === "--depth") {
112
+ opts.flags.depth = parseInt(argv[++i], 10) || 2;
113
+ } else if (arg === "--max-nodes") {
114
+ opts.flags.maxNodes = parseInt(argv[++i], 10) || 2000;
115
+ } else if (arg === "--max-pages") {
116
+ opts.flags.maxPages = parseInt(argv[++i], 10) || 50;
117
+ } else if (arg === "--max-requests") {
118
+ opts.flags.maxRequests = parseInt(argv[++i], 10) || 400;
119
+ } else if (arg === "--no-cache") {
120
+ opts.flags.noCache = true;
121
+ } else if (arg === "--from-uk") {
122
+ opts.flags.fromUk = argv[++i];
123
+ } else if (arg === "--msg-id") {
124
+ opts.flags.msgId = argv[++i];
125
+ } else if (arg === "--parent-fs-id") {
126
+ opts.flags.parentFsId = argv[++i];
79
127
  } else if (arg.startsWith("-")) {
80
128
  opts.flags[arg.replace(/^-+/, "")] = true;
81
129
  } else {
@@ -337,7 +385,7 @@ cmds.groups = async (args, json) => {
337
385
  cmds.gshares = async (args, json) => {
338
386
  const gid = args._[0];
339
387
  if (!gid) { console.error("Usage: bdp gshares <gid>"); process.exit(1); }
340
- const shares = await group.listShares(gid);
388
+ const shares = await group.listShares(gid, { cache: args.flags.noCache !== true });
341
389
  output(shares, json, (list) => {
342
390
  console.log(`${list.length} shares in group ${gid}:\n`);
343
391
  list.forEach((s, i) => {
@@ -350,31 +398,169 @@ cmds.gshares = async (args, json) => {
350
398
  cmds.gls = async (args, json) => {
351
399
  const gid = args._[0];
352
400
  const fsId = args._[1];
353
- if (!gid || !fsId) { console.error("Usage: bdp gls <gid> <fs_id>"); process.exit(1); }
354
- const result = await group.listFiles(gid, fsId, { page: args.flags.page || 1 });
355
- output(result, json, (d) => {
356
- console.log(`${d.files.length} items${d.hasMore ? " (has more — use --page)" : ""}:\n`);
357
- d.files.forEach((f) => {
401
+ if (!gid || !fsId) { console.error("Usage: bdp gls <gid> <fs_id> [--page N] [--page-size N]"); process.exit(1); }
402
+
403
+ const opts = {
404
+ page: args.flags.page || 1,
405
+ pageSize: args.flags.pageSize || 50,
406
+ cache: args.flags.noCache !== true,
407
+ };
408
+ if (args.flags.fromUk) opts.fromUk = args.flags.fromUk;
409
+ if (args.flags.msgId) opts.msgId = args.flags.msgId;
410
+ if (args.flags.parentFsId) opts.parentFsId = args.flags.parentFsId;
411
+
412
+ verboseLog(args, `gls gid=${gid} fsId=${fsId} page=${opts.page} pageSize=${opts.pageSize}`);
413
+ const result = await group.listFiles(gid, fsId, opts);
414
+
415
+ if (args.flags.legacyJson) {
416
+ emitJson(result.files, args);
417
+ return;
418
+ }
419
+
420
+ if (result.autoResolved) {
421
+ console.error(`[auto] 传入的 fromUk/msgId 与群 ${gid} 不匹配(API errno=2131),已自动改用 gshares 解析的参数 (fromUk=${result.fromUk}, msgId=${result.msgId})`);
422
+ }
423
+
424
+ if (result.fallback) {
425
+ verboseLog(args, `fallback: ${result.fallback.reason} -> ${result.fallback.resolvedFsId} (${result.fallback.level})`);
426
+ }
427
+
428
+ if (args.flags.jsonFile || json) {
429
+ emitJson(result, args);
430
+ } else {
431
+ if (result.fallback) {
432
+ console.log(`⚠️ fallback: ${result.fallback.reason} — 已回退到 ${result.fallback.resolvedFsId} (${result.fallback.level})`);
433
+ }
434
+ console.log(`${result.files.length} items${result.hasMore ? " (has more — use --page)" : ""}:\n`);
435
+ result.files.forEach((f) => {
358
436
  const dir = f.isDir ? "[DIR] " : " ";
359
437
  console.log(` ${dir}${f.name} ${formatSize(f.size)} fs_id:${f.fsId}`);
360
438
  });
361
- });
439
+ }
440
+ };
441
+
442
+ cmds.gtree = async (args, json) => {
443
+ const gid = args._[0];
444
+ if (!gid) { console.error("Usage: bdp gtree <gid> [--depth N] [--concurrency N] [--max-nodes N]"); process.exit(1); }
445
+
446
+ const opts = {
447
+ depth: args.flags.depth || 2,
448
+ concurrency: args.flags.concurrency || 4,
449
+ maxNodes: args.flags.maxNodes || 2000,
450
+ maxPages: args.flags.maxPages || 50,
451
+ maxRequests: args.flags.maxRequests || 400,
452
+ cache: args.flags.noCache !== true,
453
+ };
454
+ verboseLog(args, `gtree gid=${gid} depth=${opts.depth} concurrency=${opts.concurrency} maxNodes=${opts.maxNodes} maxPages=${opts.maxPages} maxRequests=${opts.maxRequests} cache=${opts.cache}`);
455
+ const result = await group.treeFiles(gid, opts);
456
+
457
+ if (args.flags.jsonFile || json) {
458
+ emitJson(result, args);
459
+ } else {
460
+ console.log(`${result.tree.length} nodes (depth ${result.depth}, ${result.failed.length} failed${result.truncated ? ", truncated" : ""}):\n`);
461
+ result.tree.forEach((n) => {
462
+ const dir = n.isDir ? "[DIR] " : " ";
463
+ console.log(` ${dir}${n.path || n.name} ${formatSize(n.size)} fs_id:${n.fsId}`);
464
+ });
465
+ }
362
466
  };
363
467
 
364
468
  cmds.gsearch = async (args, json) => {
365
469
  const gid = args._[0];
366
470
  const keyword = args._[1];
367
- if (!gid || !keyword) { console.error("Usage: bdp gsearch <gid> <keyword>"); process.exit(1); }
368
- const results = await group.searchFiles(gid, keyword);
369
- output(results, json, (list) => {
370
- console.log(`${list.length} matches for "${keyword}":\n`);
371
- list.forEach((r) => {
471
+ if (!gid || !keyword) { console.error("Usage: bdp gsearch <gid> <keyword> [--page N] [--limit N]"); process.exit(1); }
472
+
473
+ const opts = {
474
+ page: args.flags.page || 1,
475
+ limit: args.flags.limit || 50,
476
+ concurrency: args.flags.concurrency || 4,
477
+ unique: args.flags.unique !== false,
478
+ all: args.flags.all === true,
479
+ depth: args.flags.depth || 1,
480
+ maxPages: args.flags.maxPages || 50,
481
+ maxRequests: args.flags.maxRequests || 400,
482
+ cache: args.flags.noCache !== true,
483
+ };
484
+
485
+ verboseLog(args, `gsearch gid=${gid} keyword="${keyword}" page=${opts.page} limit=${opts.limit} concurrency=${opts.concurrency} unique=${opts.unique} all=${opts.all} depth=${opts.depth} maxPages=${opts.maxPages} maxRequests=${opts.maxRequests} cache=${opts.cache}`);
486
+ const result = await group.searchFiles(gid, keyword, opts);
487
+
488
+ if (result.partial) {
489
+ verboseLog(args, `partial: ${result.failedShares}/${result.totalShares} shares failed`);
490
+ if (result.failedDirs && result.failedDirs.length > 0) {
491
+ verboseLog(args, `failed dirs: ${result.failedDirs.map((f) => `${f.name || f.fsId}(${f.errno})`).slice(0, 10).join(", ")}`);
492
+ }
493
+ }
494
+ if (result.throttled) {
495
+ verboseLog(args, `throttled: API 限流中断,重跑命令可续扫(磁盘缓存只补缺失目录)`);
496
+ }
497
+
498
+ if (args.flags.legacyJson) {
499
+ emitJson(result.results, args);
500
+ return;
501
+ }
502
+
503
+ if (args.flags.jsonFile || json) {
504
+ emitJson(result, args);
505
+ } else {
506
+ console.log(`${result.returned} matches for "${keyword}"${result.hasMore ? " (has more — use --page " + (result.nextPage || "?") + ")" : ""}:\n`);
507
+ result.results.forEach((r) => {
372
508
  const dir = r.isDir ? "[DIR] " : " ";
373
509
  console.log(` ${dir}${r.path || r.name} ${formatSize(r.size)} fs_id:${r.fsId}`);
374
510
  });
511
+ if (result.throttled) {
512
+ console.error(`⚠️ API 限流中断(已扫描 ${result.scannedShares}/${result.totalShares} 分享,失败 ${result.failedShares})。重跑命令可续扫(磁盘缓存加速)`);
513
+ } else if (result.partial) {
514
+ console.error(`[verbose] ${result.failedShares} shares failed (partial result)`);
515
+ }
516
+ }
517
+ };
518
+
519
+ cmds.cache = async (args, json) => {
520
+ const action = args._[0] || "info";
521
+ const { clearGroupCache } = group;
522
+ if (action === "clear") {
523
+ clearGroupCache();
524
+ output({ cleared: true }, json, () => console.log("✅ 群聊会话缓存已清除"));
525
+ return;
526
+ }
527
+ // info: 显示缓存目录位置
528
+ const path = require("path");
529
+ const os = require("os");
530
+ const dir = process.env.BDP_CACHE_DIR || path.join(os.homedir(), ".bdp", "cache");
531
+ output({ cacheDir: dir, ttl: { dir: "30min", shares: "5min" } }, json, () => {
532
+ console.log(`Cache dir: ${dir}`);
533
+ console.log(`Dir TTL: 30 min (gsearch/gtree 目录列表)`);
534
+ console.log(`Shares TTL: 5 min (gshares 分享列表)`);
535
+ console.log(`Clear with: bdp cache clear`);
375
536
  });
376
537
  };
377
538
 
539
+ cmds.error = async (args, json) => {
540
+ const code = args._[0] || "-3";
541
+ const ERRORS = {
542
+ "-3": "群分享 API 拒绝请求。已观察到大目录、错误的 msgId/fromUk、过期分享、请求过于频繁(限流)都可能触发。\n" +
543
+ "处理:减小 --page-size(自动重试 50→20→10)或 --concurrency;\n" +
544
+ " 限流表现为 errno=0 但只返回目录自身(self-echo),工具会自动退避重试(1s/2s);\n" +
545
+ " 大目录已支持自动全页遍历(每页上限 100,--max-pages 控制页数);\n" +
546
+ " 若回退到父目录(fallback level=parent),Agent 应从父目录换路径继续遍历。",
547
+ "-6": "接口鉴权失败。通常为 access_token/cookie 失效,请重新执行 bdp login。",
548
+ "2131": "群分享 msg_id 不属于该群(shareinfo 以 msg_id 为查找键,from_uk 不参与校验)。\n" +
549
+ "常见原因:gid 与 --from-uk/--msg-id/--parent-fs-id 来自不同群/分享库(参数错配)。\n" +
550
+ "处理:用 bdp gshares <gid> 查看本群分享(或 bdp gsearch <gid> <关键词>),\n" +
551
+ " 取其中一行的 fsId/fromUk/msgId 重试,保证 gid/fsId/fromUk/msgId 同源;\n" +
552
+ " fsId 为子目录时保持其父分享行的 fromUk/msgId 不变;\n" +
553
+ " 或去掉 --from-uk/--msg-id 让 CLI 自动解析(仅顶层分享)。\n" +
554
+ " 工具已支持:显式参数错配时自动按 fsId 纠正(结果含 autoResolved:true)。",
555
+ };
556
+ const msg = ERRORS[code] || `未知错误码 ${code},请提交 Issue: https://github.com/NkAntony777/baiduwangpan-cli/issues`;
557
+ if (args.flags.jsonFile || json) {
558
+ emitJson({ code, description: msg }, args);
559
+ } else {
560
+ console.log(msg);
561
+ }
562
+ };
563
+
378
564
  // ── Main ───────────────────────────────────────────────
379
565
 
380
566
  const HELP = `bdp — 百度网盘 CLI
@@ -398,8 +584,15 @@ PAN FILE OPERATIONS (全盘)
398
584
  GROUP CHAT OPERATIONS (群聊)
399
585
  groups List all groups
400
586
  gshares <gid> List share libraries in group
587
+ gtree <gid> Build directory tree (BFS)
588
+ [--depth N] [--concurrency N] [--max-nodes N]
401
589
  gls <gid> <fs_id> Browse files in a share
402
- gsearch <gid> <keyword> Search group file names
590
+ [--page N] [--page-size N] [--from-uk X] [--msg-id Y] [--parent-fs-id Z]
591
+ gsearch <gid> <keyword> Search group file names (全量遍历目录, 缓存命中秒回)
592
+ [--page N] [--limit N] [--depth N] [--concurrency N]
593
+ [--max-pages N] [--max-requests N] [--no-unique] [--all] [--no-cache]
594
+ cache [clear] Show cache info or clear session cache
595
+ error <code> Explain an error code (e.g. -3)
403
596
 
404
597
  CONFIGURATION
405
598
  login Browser QR login (auto-detect cookies)
@@ -409,12 +602,25 @@ CONFIGURATION
409
602
 
410
603
  OPTIONS
411
604
  --json Output JSON (for Agent consumption)
605
+ --json-file <path> Write JSON to file (UTF-8, bypasses console encoding)
606
+ --legacy-json Output bare array (gls/gsearch legacy format)
607
+ --verbose Write progress/log to stderr only
412
608
  -n <N> Number of lines (head/tail)
413
609
  -p <path> Search path (search)
414
610
  -i Ignore case (grep)
415
611
  -N Show line numbers (cat/grep)
416
612
  -o <dir> Output directory (get)
417
- --page <N> Page number (gls)
613
+ --page <N> Page number (gls/gsearch)
614
+ --page-size <N> Page size for gls (default 50, max 100)
615
+ --limit <N> Page size for gsearch (default 50)
616
+ --depth <N> Recursive search/tree depth (gsearch default 1, gtree default 2)
617
+ --max-nodes <N> Tree node cap (gtree default 2000)
618
+ --max-pages <N> Max pages per directory scan (gsearch/gtree default 50, 100 items/page)
619
+ --max-requests <N> Request budget per command (gsearch/gtree default 400, prevents throttle)
620
+ --concurrency <N> Parallel share scans for gsearch/gtree (default 4)
621
+ --no-cache Disable in-process session cache (gsearch/gtree/gshares/gls)
622
+ --no-unique Keep duplicates from different msgId (gsearch)
623
+ --all Fetch all results, no paging (gsearch, not Agent default)
418
624
 
419
625
  EXAMPLES
420
626
  bdp login --bduss abc123 --stoken def456
@@ -424,7 +630,10 @@ EXAMPLES
424
630
  bdp groups --json
425
631
  bdp gshares 539478953581833690
426
632
  bdp gls 539478953581833690 742474845517885 --json
427
- bdp gsearch 539478953581833690 "倪海厦"`;
633
+ bdp gls 539478953581833690 292608024165826 --from-uk 2642611875 --msg-id 5069931974377329661 --json
634
+ bdp gsearch 539478953581833690 "倪海厦" --limit 20 --json
635
+ bdp gsearch 539478953581833690 "古籍" --page 2 --limit 20 --json-file result.json
636
+ bdp error -3`;
428
637
 
429
638
  async function main() {
430
639
  const argv = process.argv.slice(2);
@@ -439,20 +648,22 @@ async function main() {
439
648
  process.exit(0);
440
649
  }
441
650
 
651
+ let exitCode = 0;
442
652
  try {
443
653
  await handler(args, json);
444
654
  } catch (e) {
445
655
  if (json) {
446
- console.log(JSON.stringify({ error: e.message }));
656
+ console.log(JSON.stringify({ error: e.message, ...(e.errno !== undefined ? { errno: e.errno } : {}) }));
447
657
  } else {
448
658
  console.error(`[ERROR] ${e.message}`);
449
659
  }
450
- process.exitCode = 1;
660
+ exitCode = 1;
451
661
  } finally {
452
662
  try {
453
663
  require("../lib/browser-login").disconnectBrowserSession();
454
664
  } catch {}
455
665
  }
666
+ process.exit(exitCode);
456
667
  }
457
668
 
458
669
  main();
@@ -233,8 +233,13 @@ async function openPersistentSession({ browser, profileDir, port, minimized }) {
233
233
  proc = launchBrowser(browser, profileDir, port, { minimized });
234
234
  await waitForDevTools(port, 15000);
235
235
  }
236
- const { cdp, tab } = await connectPanTab(port);
237
- return { browser, profileDir, port, proc, cdp, tab };
236
+ try {
237
+ const { cdp, tab } = await connectPanTab(port);
238
+ return { browser, profileDir, port, proc, cdp, tab };
239
+ } catch (e) {
240
+ if (proc) killBrowser(proc);
241
+ throw e;
242
+ }
238
243
  }
239
244
 
240
245
  async function getBrowserSession(settings = {}) {
@@ -276,15 +281,10 @@ async function getBrowserSession(settings = {}) {
276
281
 
277
282
  async function browserJson(url, options = {}, settings = {}) {
278
283
  const session = await getBrowserSession(settings);
279
- let response;
280
- try {
281
- response = await evaluateFetch(session.cdp, url, options);
282
- } catch (error) {
283
- session.cdp.close();
284
- activeSession = null;
285
- throw error;
286
- }
284
+ const response = await evaluateFetch(session.cdp, url, options);
287
285
 
286
+ // 注意:fetch 错误(网络/API 断连)不关闭共享会话——并发批处理中一个请求失败会
287
+ // 连带杀死同批其他在途请求(ws 关闭)。会话死活由下次调用前的 ping 自动检测重连。
288
288
  try {
289
289
  return JSON.parse(response.text.replace(/^\s+/, ""));
290
290
  } catch {
@@ -379,7 +379,8 @@ async function browserLogin({
379
379
 
380
380
  function scheduleDisconnect() {
381
381
  if (disconnectTimer) clearTimeout(disconnectTimer);
382
- disconnectTimer = setTimeout(disconnectBrowserSession, 500);
382
+ // 空闲 90 秒后断开浏览器会话(并发批处理扫描中请求间隔可能较长,短超时会中途断连)
383
+ disconnectTimer = setTimeout(disconnectBrowserSession, 90000);
383
384
  disconnectTimer.unref();
384
385
  }
385
386
 
package/lib/group.js CHANGED
@@ -1,5 +1,109 @@
1
1
  const http = require("./http");
2
2
  const { getBdstoken } = http;
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const crypto = require("crypto");
7
+
8
+ // ── 常量(逆向实测结论)────────────────────────────────
9
+ const MAX_PAGE_SIZE = 100; // shareinfo 服务端每页硬上限(num>100 不返回更多)
10
+ const MAX_LIST_SHARE_PAGES = 10; // listshare 分页上限(10 页 × 50 条消息)
11
+ const DIR_CACHE_TTL = 1800000; // 目录列表缓存 30 分钟(群文件树很少变动)
12
+ const EMPTY_DIR_CACHE_TTL = 60000; // 空目录缓存 1 分钟(限流时 API 可能返回空列表,短 TTL 防缓存投毒)
13
+ const SHARE_CACHE_TTL = 300000; // 群分享列表缓存 5 分钟(新分享消息较频繁)
14
+ const DEFAULT_MAX_REQUESTS = 400; // 单条命令 shareinfo 请求预算(防深扫失控触发限流)
15
+
16
+ // ── 会话级缓存(内存 L1 + 磁盘 L2,跨命令复用)──────────
17
+ const cacheStore = new Map();
18
+
19
+ function cacheDir() {
20
+ return process.env.BDP_CACHE_DIR || path.join(os.homedir(), ".bdp", "cache");
21
+ }
22
+
23
+ function cachePath(key) {
24
+ return path.join(cacheDir(), crypto.createHash("sha1").update(key).digest("hex") + ".json");
25
+ }
26
+
27
+ function cacheGet(key) {
28
+ const entry = cacheStore.get(key); // L1
29
+ if (entry) {
30
+ if (Date.now() - entry.at <= entry.ttl) return entry.value;
31
+ cacheStore.delete(key);
32
+ }
33
+ try {
34
+ // L2: 磁盘缓存(跨 CLI 进程复用,避免重复扫描触发限流)
35
+ const disk = JSON.parse(fs.readFileSync(cachePath(key), "utf-8"));
36
+ if (Date.now() - disk.at <= disk.ttl) {
37
+ cacheStore.set(key, { at: disk.at, ttl: disk.ttl, value: disk.value });
38
+ return disk.value;
39
+ }
40
+ fs.unlinkSync(cachePath(key));
41
+ } catch {}
42
+ return null;
43
+ }
44
+
45
+ function cacheSet(key, value, ttl) {
46
+ cacheStore.set(key, { at: Date.now(), ttl, value });
47
+ try {
48
+ fs.mkdirSync(cacheDir(), { recursive: true });
49
+ fs.writeFileSync(cachePath(key), JSON.stringify({ at: Date.now(), ttl, value }), "utf-8");
50
+ } catch {}
51
+ }
52
+
53
+ function clearGroupCache() {
54
+ cacheStore.clear();
55
+ try {
56
+ fs.rmSync(cacheDir(), { recursive: true, force: true });
57
+ } catch {}
58
+ }
59
+
60
+ // ── 工具 ──────────────────────────────────────────────
61
+
62
+ function toStr(v) {
63
+ if (v === null || v === undefined) return "";
64
+ return String(v);
65
+ }
66
+
67
+ // 安全解码:部分群文件路径含非法百分号编码(裸 % 等),decodeURIComponent 会抛 URIError
68
+ // 导致整个目录扫描失败(实测 30/349 目录因此报废)。失败时回退原始字符串。
69
+ function safeDecode(s) {
70
+ if (!s) return "";
71
+ try {
72
+ return decodeURIComponent(s);
73
+ } catch {
74
+ return s;
75
+ }
76
+ }
77
+
78
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
79
+
80
+ class ShareApiError extends Error {
81
+ constructor(errno, message) {
82
+ super(message);
83
+ this.errno = errno;
84
+ this.name = "ShareApiError";
85
+ }
86
+ }
87
+
88
+ // errno=2131 的详细说明(bdp error 2131 与 gls 报错共用)
89
+ function describeErrno2131(gidS, msgIdS) {
90
+ return (
91
+ `shareinfo rejected: msg_id ${msgIdS} 不属于群 ${gidS} 的任何分享 (errno=2131)。` +
92
+ `群分享参数必须同源:gid 与 --from-uk/--msg-id/--parent-fs-id 应来自同一次 gshares/gsearch 结果。` +
93
+ `处理:运行 bdp gshares ${gidS} 查看本群分享(或 bdp gsearch ${gidS} <关键词>),` +
94
+ `取其中一行的 fsId/fromUk/msgId 重试;fsId 为子目录时保持同一行的 fromUk/msgId。`
95
+ );
96
+ }
97
+
98
+ // 限流退化检测:shareinfo 被限流时 errno=0 但返回"目录自身"而非子项。
99
+ // 目录不可能包含自身,故返回列表里出现请求 fs_id 即可判定为退化响应。
100
+ function isSelfEcho(data, fsIdS) {
101
+ const records = data.records;
102
+ if (!Array.isArray(records) || records.length === 0) return false;
103
+ return records.some((r) => toStr(r.fs_id) === toStr(fsIdS));
104
+ }
105
+
106
+ // ── 群组列表 ──────────────────────────────────────────
3
107
 
4
108
  async function listGroups() {
5
109
  const bdstoken = await getBdstoken();
@@ -12,112 +116,557 @@ async function listGroups() {
12
116
  if (data.errno !== 0) throw new Error(`API error: errno=${data.errno}`);
13
117
 
14
118
  return (data.records || []).map((g) => ({
15
- gid: g.gid,
119
+ gid: toStr(g.gid),
16
120
  name: g.name,
17
- gnum: g.gnum,
121
+ gnum: toStr(g.gnum),
18
122
  description: g.gdesc || "",
19
- type: g.type,
123
+ type: toStr(g.type),
20
124
  created: g.ctime,
21
125
  }));
22
126
  }
23
127
 
24
- async function listShares(gid) {
25
- const bdstoken = await getBdstoken();
26
- const data = await http.webJson(
27
- `${http.PAN_BASE}/mbox/group/listshare?clienttype=0&app_id=${http.APP_ID}&web=1&type=2&gid=${gid}&limit=50&desc=1&bdstoken=${bdstoken}`,
28
- { method: "POST", body: "" }
29
- );
128
+ // ── 群内分享库 ────────────────────────────────────────
30
129
 
31
- if (data.errno !== 0) throw new Error(`API error: errno=${data.errno}`);
130
+ async function listShares(gid, options = {}) {
131
+ const gidS = toStr(gid);
132
+ const cache = options.cache === true;
133
+ const ttlMs = options.ttlMs || SHARE_CACHE_TTL;
134
+ const key = `shares:${gidS}`;
135
+
136
+ if (cache) {
137
+ const hit = cacheGet(key);
138
+ if (hit) return hit;
139
+ }
32
140
 
141
+ const bdstoken = await getBdstoken();
33
142
  const shares = [];
34
- for (const msg of data.records?.msg_list || []) {
35
- for (const f of msg.file_list || []) {
36
- shares.push({
37
- msgId: msg.msg_id,
38
- fromUk: msg.uk,
39
- fromUser: msg.uname,
40
- name: decodeURIComponent(f.path || f.server_filename || ""),
41
- fsId: f.fs_id,
42
- isDir: f.isdir === "1",
43
- size: parseInt(f.size || "0"),
44
- });
143
+ const seenMsg = new Set();
144
+ let cursor = "";
145
+ let stop = false;
146
+
147
+ // has_more=1 时用 last_msg_time 游标翻页(desc 序);无新消息/无游标则停止
148
+ for (let page = 0; page < MAX_LIST_SHARE_PAGES && !stop; page++) {
149
+ const url =
150
+ `${http.PAN_BASE}/mbox/group/listshare?clienttype=0&app_id=${http.APP_ID}&web=1&type=2&gid=${gidS}` +
151
+ `&limit=50&desc=1${cursor ? `&last_msg_time=${cursor}` : ""}&bdstoken=${bdstoken}`;
152
+ const data = await http.webJson(url, { method: "POST", body: "" });
153
+
154
+ if (data.errno !== 0) throw new Error(`API error: errno=${data.errno}`);
155
+
156
+ let added = 0;
157
+ for (const msg of data.records?.msg_list || []) {
158
+ const mid = toStr(msg.msg_id);
159
+ if (seenMsg.has(mid)) continue; // 防重复页
160
+ seenMsg.add(mid);
161
+ added++;
162
+ for (const f of msg.file_list || []) {
163
+ shares.push({
164
+ msgId: mid,
165
+ fromUk: toStr(msg.uk),
166
+ fromUser: msg.uname,
167
+ name: safeDecode(f.path || f.server_filename || ""),
168
+ fsId: toStr(f.fs_id),
169
+ isDir: f.isdir === "1",
170
+ size: parseInt(f.size || "0", 10),
171
+ });
172
+ }
45
173
  }
174
+
175
+ if (data.has_more !== 1 || added === 0) stop = true;
176
+ else cursor = toStr(data.last_msg_time || "");
177
+ if (!cursor) stop = true;
46
178
  }
179
+
180
+ if (cache) cacheSet(key, shares, ttlMs);
47
181
  return shares;
48
182
  }
49
183
 
184
+ // ── 底层 shareinfo 请求 ───────────────────────────────
185
+
186
+ function mapShareInfoFile(f, parentFsId, fromUkS, msgIdS) {
187
+ return {
188
+ name: f.server_filename || "",
189
+ path: safeDecode(f.path || ""),
190
+ isDir: f.isdir === 1 || f.isdir === "1",
191
+ size: parseInt(f.size || "0", 10),
192
+ fsId: toStr(f.fs_id),
193
+ md5: f.md5 || "",
194
+ category: parseInt(f.category || "0", 10),
195
+ ctime: toStr(f.server_ctime || ""),
196
+ mtime: toStr(f.server_mtime || ""),
197
+ parentFsId,
198
+ fromUk: fromUkS,
199
+ msgId: msgIdS,
200
+ };
201
+ }
202
+
203
+ // 单页 shareinfo;errno!=0 抛 ShareApiError;限流退化(self-echo)标记 throttled
204
+ async function shareInfoRaw(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
205
+ const bdstoken = await getBdstoken();
206
+ const url =
207
+ `${http.PAN_BASE}/mbox/msg/shareinfo?type=2&from_uk=${fromUkS}&msg_id=${msgIdS}` +
208
+ `&to_uk=0&num=${pageSize}&page=${page}&fs_id=${fsIdS}&gid=${gidS}` +
209
+ `&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`;
210
+
211
+ const data = await http.webJson(url, { method: "POST", body: "" });
212
+
213
+ if (data.errno !== 0) {
214
+ throw new ShareApiError(data.errno, `API error: errno=${data.errno}`);
215
+ }
216
+ if (isSelfEcho(data, fsIdS)) {
217
+ const e = new ShareApiError(-3, `shareinfo throttled (self-echo) for fs_id ${fsIdS}`);
218
+ e.throttled = true;
219
+ throw e;
220
+ }
221
+ return data;
222
+ }
223
+
224
+ // 限流/瞬时错误退避重试:2s → 4s → 2 次后放弃。
225
+ // - 网络/连接错误(非 ShareApiError):本地瞬时故障,重试(实测并发扫描中 API 会随机断连)
226
+ // - self-echo 限流:重试,但受 throttleState 上限约束(consecutive≥3 停止扫描 / total≥5 不再重试)
227
+ // - 硬 errno(-3/2131 等):不重试(listFiles 有 page-size 降级链处理 -3)
228
+ // budget 为整条命令的请求预算,耗尽后拒绝新请求(防深扫失控)。
229
+ async function shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, pageSize, budget, throttleState) {
230
+ if (budget) {
231
+ if (budget.remaining <= 0) throw new ShareApiError(-3, "shareinfo request budget exhausted");
232
+ budget.remaining--;
233
+ }
234
+ let attempts = 0;
235
+ for (;;) {
236
+ try {
237
+ const data = await shareInfoRaw(gidS, fsIdS, fromUkS, msgIdS, page, pageSize);
238
+ if (throttleState) throttleState.consecutive = 0;
239
+ return data;
240
+ } catch (e) {
241
+ const isThrottled = e instanceof ShareApiError && e.throttled;
242
+ const isNetwork = !(e instanceof ShareApiError);
243
+ if (!isThrottled && !isNetwork) throw e;
244
+ if (isThrottled && throttleState) {
245
+ throttleState.consecutive = (throttleState.consecutive || 0) + 1;
246
+ throttleState.total = (throttleState.total || 0) + 1;
247
+ }
248
+ const maxRetries = isThrottled && throttleState && (throttleState.consecutive >= 3 || throttleState.total >= 5) ? 0 : 2;
249
+ if (attempts >= maxRetries) throw e;
250
+ attempts++;
251
+ await delay(2000 * attempts);
252
+ }
253
+ }
254
+ }
255
+
256
+ // 单页 shareinfo(gls 用),带限流退避
257
+ async function shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
258
+ const data = await shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, pageSize);
259
+ const files = (Array.isArray(data.records) ? data.records : []).map((f) =>
260
+ mapShareInfoFile(f, fsIdS, fromUkS, msgIdS)
261
+ );
262
+ return { files, hasMore: data.has_more === 1, page, pageSize };
263
+ }
264
+
265
+ // 全量拉取目录内容(gsearch/gtree 用):自动翻页 + 去重 + 缓存。
266
+ // 逆向实测:shareinfo 每页硬上限 100,has_more=1 时 page=N 可继续翻页;
267
+ // 旧实现只取 page=1,超过 100 子项的目录会漏数据。
268
+ async function fetchShareDir({ gidS, fsIdS, fromUkS, msgIdS, maxPages = 50, cache = false, ttlMs = DIR_CACHE_TTL, budget, throttleState }) {
269
+ const key = `dir:${gidS}:${fsIdS}:${fromUkS}:${msgIdS}`;
270
+ if (cache) {
271
+ const hit = cacheGet(key);
272
+ if (hit) return { ...hit, cached: true };
273
+ }
274
+
275
+ const files = [];
276
+ const seenFs = new Set();
277
+ let hasMore = false;
278
+ let pages = 0;
279
+
280
+ for (let page = 1; page <= maxPages; page++) {
281
+ const data = await shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, MAX_PAGE_SIZE, budget, throttleState);
282
+ const records = Array.isArray(data.records) ? data.records : [];
283
+ for (const f of records) {
284
+ const fsId = toStr(f.fs_id);
285
+ if (seenFs.has(fsId)) continue;
286
+ seenFs.add(fsId);
287
+ files.push(mapShareInfoFile(f, fsIdS, fromUkS, msgIdS));
288
+ }
289
+ hasMore = data.has_more === 1;
290
+ pages = page;
291
+ if (!hasMore || records.length === 0) break;
292
+ }
293
+
294
+ const result = { files, hasMore, pages, truncated: pages >= maxPages };
295
+ // 空目录用短 TTL 缓存:限流时 API 可能返回空列表(缓存投毒会导致静默丢数据)
296
+ if (cache) cacheSet(key, result, files.length === 0 ? EMPTY_DIR_CACHE_TTL : ttlMs);
297
+ return { ...result, cached: false };
298
+ }
299
+
300
+ // ── 列出分享库内容 (gls) ──────────────────────────────
301
+
50
302
  async function listFiles(gid, fsId, options = {}) {
303
+ const gidS = toStr(gid);
304
+ const fsIdS = toStr(fsId);
305
+ const parentFsId = toStr(options.parentFsId || "");
51
306
  const page = options.page || 1;
52
- const num = options.num || 100;
307
+ let pageSize = options.pageSize || 50;
308
+ pageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE);
309
+ const cache = options.cache === true;
53
310
 
54
- // First get the fromUk and msgId for this fsId
55
- let { fromUk, msgId } = options;
56
- if (!fromUk || !msgId) {
57
- const shares = await listShares(gid);
58
- const match = shares.find((s) => s.fsId === fsId);
59
- if (match) {
60
- fromUk = match.fromUk;
61
- msgId = match.msgId;
62
- } else if (shares.length > 0) {
63
- fromUk = shares[0].fromUk;
64
- msgId = shares[0].msgId;
65
- } else {
66
- throw new Error("Cannot find share info for this fsId");
311
+ // 来源解析:显式传入优先;否则仅在顶层分享中精确匹配(禁止"用第一个分享"兜底)
312
+ let fromUkS = toStr(options.fromUk || "");
313
+ let msgIdS = toStr(options.msgId || "");
314
+
315
+ if (!fromUkS || !msgIdS) {
316
+ const shares = await listShares(gidS, { cache });
317
+ const match = shares.find((s) => s.fsId === fsIdS);
318
+ if (!match) {
319
+ throw new Error(
320
+ `Cannot determine fromUk/msgId for fsId ${fsIdS} (not a top-level share). ` +
321
+ `Pass --from-uk and --msg-id explicitly.`
322
+ );
67
323
  }
324
+ fromUkS = match.fromUk;
325
+ msgIdS = match.msgId;
68
326
  }
69
327
 
70
- const bdstoken = await getBdstoken();
71
- const data = await http.webJson(
72
- `${http.PAN_BASE}/mbox/msg/shareinfo?type=2&from_uk=${fromUk}&msg_id=${msgId}&to_uk=0&num=${num}&page=${page}&fs_id=${fsId}&gid=${gid}&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`,
73
- { method: "POST", body: "" }
328
+ // errno=-3 时依次降 page-size: 50 → 20 → 10
329
+ const attempts = [...new Set([pageSize, 50, 20, 10].filter((n) => n <= pageSize))];
330
+ let lastErrno = null;
331
+
332
+ for (const size of attempts) {
333
+ try {
334
+ const result = await shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, size);
335
+ return {
336
+ files: result.files,
337
+ hasMore: result.hasMore,
338
+ page,
339
+ pageSize: size,
340
+ fromUk: fromUkS,
341
+ msgId: msgIdS,
342
+ fallback: null,
343
+ };
344
+ } catch (e) {
345
+ if (e instanceof ShareApiError && e.errno === -3) {
346
+ lastErrno = -3;
347
+ continue; // 尝试更小的 page-size
348
+ }
349
+ if (e instanceof ShareApiError && e.errno === 2131) {
350
+ // msg_id 不属于该群(常见:gid 与参数来自不同群/分享库)。
351
+ // 尝试按 fsId 从本群 gshares 自动解析纠正一次;失败则给出明确指引。
352
+ try {
353
+ const shares = await listShares(gidS, { cache });
354
+ const match = shares.find((s) => s.fsId === fsIdS);
355
+ if (match) {
356
+ const result = await shareInfoRequest(gidS, fsIdS, match.fromUk, match.msgId, page, size);
357
+ return {
358
+ files: result.files,
359
+ hasMore: result.hasMore,
360
+ page,
361
+ pageSize: size,
362
+ fromUk: match.fromUk,
363
+ msgId: match.msgId,
364
+ fallback: null,
365
+ autoResolved: true,
366
+ };
367
+ }
368
+ } catch {}
369
+ throw new ShareApiError(2131, describeErrno2131(gidS, msgIdS));
370
+ }
371
+ throw e;
372
+ }
373
+ }
374
+
375
+ // 全部 page-size 都失败(errno=-3)→ 回退
376
+ if (parentFsId) {
377
+ // 回退到父目录,展示兄弟内容帮助 Agent 换路径
378
+ try {
379
+ const parent = await shareInfoRequest(gidS, toStr(parentFsId), fromUkS, msgIdS, 1, 50);
380
+ return {
381
+ files: parent.files,
382
+ hasMore: parent.hasMore,
383
+ page: 1,
384
+ pageSize: 50,
385
+ fromUk: fromUkS,
386
+ msgId: msgIdS,
387
+ fallback: {
388
+ reason: "errno=-3",
389
+ requestedFsId: fsIdS,
390
+ resolvedFsId: toStr(parentFsId),
391
+ level: "parent",
392
+ },
393
+ };
394
+ } catch (e) {
395
+ throw new ShareApiError(
396
+ -3,
397
+ `shareinfo rejected for fsId ${fsIdS} (errno=-3), and parent fallback also failed. ` +
398
+ `The share may be expired or the directory may be too large for the API.`
399
+ );
400
+ }
401
+ }
402
+
403
+ // 目标本身是顶层分享 → 退回 gshares 结果
404
+ try {
405
+ const shares = await listShares(gidS, { cache });
406
+ const match = shares.find((s) => s.fsId === fsIdS);
407
+ if (match) {
408
+ return {
409
+ files: [{
410
+ name: match.name,
411
+ path: match.name,
412
+ isDir: match.isDir,
413
+ size: match.size,
414
+ fsId: match.fsId,
415
+ md5: "",
416
+ category: 0,
417
+ ctime: "",
418
+ mtime: "",
419
+ parentFsId: "",
420
+ fromUk: match.fromUk,
421
+ msgId: match.msgId,
422
+ }],
423
+ hasMore: false,
424
+ page: 1,
425
+ pageSize: 50,
426
+ fromUk: fromUkS,
427
+ msgId: msgIdS,
428
+ fallback: {
429
+ reason: "errno=-3",
430
+ requestedFsId: fsIdS,
431
+ resolvedFsId: fsIdS,
432
+ level: "group-shares",
433
+ },
434
+ };
435
+ }
436
+ } catch {}
437
+
438
+ throw new ShareApiError(
439
+ -3,
440
+ `shareinfo rejected for fsId ${fsIdS} (errno=-3). ` +
441
+ `群分享 API 拒绝请求:已观察到大目录、错误的 msgId/fromUk、过期分享、请求过于频繁都可能触发。` +
442
+ `请减小 --page-size,稍后重试,或携带 gsearch 返回的 fromUk/msgId/parentFsId 信息重试。`
74
443
  );
444
+ }
75
445
 
76
- if (data.errno !== 0) throw new Error(`API error: errno=${data.errno}`);
446
+ // ── 目录树 (gtree) ────────────────────────────────────
77
447
 
78
- const files = (Array.isArray(data.records) ? data.records : []).map((f) => ({
79
- name: f.server_filename || "",
80
- path: decodeURIComponent(f.path || ""),
81
- isDir: f.isdir === 1 || f.isdir === "1",
82
- size: parseInt(f.size || "0"),
83
- fsId: f.fs_id,
84
- md5: f.md5 || "",
85
- category: parseInt(f.category || "0"),
86
- ctime: f.server_ctime || "",
87
- mtime: f.server_mtime || "",
88
- }));
448
+ async function treeFiles(gid, options = {}) {
449
+ const gidS = toStr(gid);
450
+ const maxDepth = Math.min(Math.max(1, options.depth || 2), 5);
451
+ const concurrency = Math.min(Math.max(1, options.concurrency || 4), 8);
452
+ const maxNodes = options.maxNodes || 2000;
453
+ const maxPages = Math.min(Math.max(1, options.maxPages || 50), 200);
454
+ const cache = options.cache === true;
455
+ const budget = { remaining: Math.min(Math.max(1, options.maxRequests || DEFAULT_MAX_REQUESTS), 2000) };
456
+ const throttleState = { consecutive: 0 };
457
+
458
+ const shares = await listShares(gidS, { cache });
459
+ const tree = [];
460
+ const failed = [];
461
+
462
+ // BFS: queue 元素 { node, depth, share }
463
+ const queue = shares.map((s) => ({ node: { ...s, parentFsId: "" }, depth: 1, share: s }));
464
+ let scanned = 0;
465
+
466
+ // 逐层 BFS,层内并发
467
+ for (let depth = 1; depth <= maxDepth; depth++) {
468
+ const level = queue.filter((q) => q.depth === depth);
469
+ if (level.length === 0) break;
470
+
471
+ const results = await Promise.all(
472
+ level.map(async (entry) => {
473
+ const { node, share } = entry;
474
+ if (!node.isDir || node.size > 0) return null; // 非目录不进队列
475
+ scanned++;
476
+ try {
477
+ const { files } = await fetchShareDir({
478
+ gidS, fsIdS: node.fsId, fromUkS: share.fromUk, msgIdS: share.msgId,
479
+ maxPages, cache, budget, throttleState,
480
+ });
481
+ return files.map((f) => ({
482
+ node: { ...f, fromUser: share.fromUser },
483
+ depth: depth + 1,
484
+ share,
485
+ }));
486
+ } catch {
487
+ failed.push({ fsId: node.fsId, name: node.name });
488
+ return null;
489
+ }
490
+ })
491
+ );
89
492
 
90
- return { files, hasMore: data.has_more === 1, page };
493
+ for (const list of results) {
494
+ if (!list) continue;
495
+ for (const item of list) {
496
+ tree.push(item.node);
497
+ if (tree.length >= maxNodes) break;
498
+ if (item.depth <= maxDepth && item.node.isDir) queue.push({ node: item.node, depth: item.depth, share: item.share });
499
+ }
500
+ if (tree.length >= maxNodes) break;
501
+ }
502
+ if (tree.length >= maxNodes) break;
503
+
504
+ // 检测到限流后自动放慢下一层
505
+ if (throttleState.consecutive > 0) await delay(300);
506
+ // 连续限流 ≥3 次:停止扫描,结果可续扫(磁盘缓存加速重跑)
507
+ if (throttleState.consecutive >= 3) break;
508
+ }
509
+
510
+ return { tree, failed, scanned, maxDepth, truncated: tree.length >= maxNodes, throttled: throttleState.consecutive >= 3 };
91
511
  }
92
512
 
513
+ // ── 群文件搜索 (gsearch) ──────────────────────────────
514
+
93
515
  async function searchFiles(gid, keyword, options = {}) {
94
- const maxDepth = options.maxDepth || 1;
95
- const results = [];
516
+ const gidS = toStr(gid);
96
517
  const kw = keyword.toLowerCase();
97
518
 
98
- const shares = await listShares(gid);
519
+ const limit = options.limit || 50;
520
+ const page = options.page || 1;
521
+ const concurrency = Math.min(Math.max(1, options.concurrency || 4), 8);
522
+ const unique = options.unique !== false;
523
+ const fetchAll = options.all === true;
524
+ const depth = Math.min(Math.max(1, options.depth || 1), 5); // 默认 1 层(兼容旧行为)
525
+ const maxPages = Math.min(Math.max(1, options.maxPages || 50), 200); // 单目录最多拉取页数(100/页)
526
+ const cache = options.cache === true;
527
+ const maxRequests = Math.min(Math.max(1, options.maxRequests || DEFAULT_MAX_REQUESTS), 2000);
528
+ const budget = { remaining: maxRequests };
529
+ const throttleState = { consecutive: 0 };
530
+ const startIdx = (page - 1) * limit;
531
+ const stopAt = fetchAll ? Infinity : startIdx + limit + 1; // +1 判断 hasMore
532
+
533
+ const shares = await listShares(gidS, { cache });
534
+ const totalShares = shares.length;
535
+
536
+ const seen = new Set();
537
+ const results = [];
538
+ const failedDirs = [];
539
+ let scannedShares = 0;
540
+ let failedShares = 0;
541
+ let throttledShares = 0;
542
+ let cachedDirs = 0;
543
+ let finished = false;
544
+
545
+ const pushIfMatch = (item) => {
546
+ if (finished) return;
547
+ const key = unique ? toStr(item.fsId) : `${toStr(item.fsId)}|${toStr(item.msgId)}`;
548
+ if (unique && seen.has(key)) return;
549
+ seen.add(key);
550
+ results.push(item);
551
+ if (results.length >= stopAt) finished = true;
552
+ };
99
553
 
554
+ // 阶段 1:顶层分享名称匹配(非目录分享在此完成扫描)
100
555
  for (const share of shares) {
556
+ if (finished) break;
557
+ if (!share.isDir) scannedShares++;
101
558
  if (share.name.toLowerCase().includes(kw)) {
102
- results.push({ ...share, group: gid });
559
+ pushIfMatch({
560
+ name: share.name,
561
+ path: share.name,
562
+ isDir: share.isDir,
563
+ size: share.size,
564
+ fsId: share.fsId,
565
+ md5: "",
566
+ category: 0,
567
+ ctime: "",
568
+ mtime: "",
569
+ parentFsId: "",
570
+ fromUk: share.fromUk,
571
+ msgId: share.msgId,
572
+ fromUser: share.fromUser,
573
+ group: gidS,
574
+ });
103
575
  }
576
+ }
104
577
 
105
- if (share.isDir && maxDepth > 0) {
106
- try {
107
- const { files } = await listFiles(gid, share.fsId, {
108
- fromUk: share.fromUk,
109
- msgId: share.msgId,
110
- });
111
- for (const f of files) {
112
- if (f.name.toLowerCase().includes(kw)) {
113
- results.push({ ...f, group: gid });
578
+ // 阶段 2:BFS 递归扫描目录(每层并发,默认 depth=1 保持旧行为)
579
+ const dirShares = shares.filter((s) => s.isDir);
580
+ let queue = dirShares.map((s) => ({ fsId: s.fsId, fromUk: s.fromUk, msgId: s.msgId, depth: 1 }));
581
+
582
+ for (let level = 1; level <= depth && queue.length > 0 && !finished; level++) {
583
+ const current = queue;
584
+ queue = [];
585
+
586
+ for (let i = 0; i < current.length && !finished; i += concurrency) {
587
+ const batch = current.slice(i, i + concurrency);
588
+ const batchResults = await Promise.all(
589
+ batch.map(async (share) => {
590
+ scannedShares++;
591
+ if (budget.remaining <= 0) {
592
+ // 请求预算耗尽:剩余目录快速失败,避免失控触发限流
593
+ failedShares++;
594
+ return { matches: [], subDirs: [] };
595
+ }
596
+ try {
597
+ const { files, cached } = await fetchShareDir({
598
+ gidS, fsIdS: share.fsId, fromUkS: share.fromUk, msgIdS: share.msgId,
599
+ maxPages, cache, budget, throttleState,
600
+ });
601
+ if (cached) cachedDirs++;
602
+ const matches = files.filter((f) => f.name.toLowerCase().includes(kw));
603
+ const subDirs = files.filter((f) => f.isDir);
604
+ return { matches, subDirs };
605
+ } catch (e) {
606
+ failedShares++;
607
+ const errno = e instanceof ShareApiError ? e.errno : "?";
608
+ if (e instanceof ShareApiError && e.throttled) throttledShares++;
609
+ failedDirs.push({ fsId: share.fsId, name: share.name || "", errno, message: e.message });
610
+ return { matches: [], subDirs: [] };
114
611
  }
612
+ })
613
+ );
614
+
615
+ for (const { matches, subDirs } of batchResults) {
616
+ if (finished) break;
617
+ for (const f of matches) {
618
+ pushIfMatch({ ...f, group: gidS });
619
+ if (finished) break;
115
620
  }
116
- } catch {}
621
+ // 收集下一层待扫描目录
622
+ if (level < depth && !finished) {
623
+ for (const d of subDirs) {
624
+ queue.push({ fsId: d.fsId, name: d.name, fromUk: d.fromUk, msgId: d.msgId, depth: level + 1 });
625
+ }
626
+ }
627
+ }
628
+
629
+ // 检测到限流后自动放慢(批间降速),缓解服务端压力
630
+ if (throttleState.consecutive > 0) await delay(300);
631
+ // 连续限流 ≥3 次:服务端已饱和,停止扫描快速失败。
632
+ // 已成功扫描的目录已写入磁盘缓存,重跑命令只补扫缺失部分(续扫收敛)。
633
+ if (throttleState.consecutive >= 3) {
634
+ if (throttledShares < 3) throttledShares = 3;
635
+ finished = true;
636
+ break;
637
+ }
117
638
  }
118
639
  }
119
640
 
120
- return results;
641
+ const complete = scannedShares >= totalShares;
642
+ const hasMore = !fetchAll && results.length > startIdx + limit;
643
+ const pageSlice = fetchAll ? results : results.slice(startIdx, startIdx + limit);
644
+
645
+ return {
646
+ results: pageSlice,
647
+ page,
648
+ pageSize: limit,
649
+ returned: pageSlice.length,
650
+ total: null,
651
+ hasMore,
652
+ nextPage: hasMore ? page + 1 : null,
653
+ unique,
654
+ complete,
655
+ partial: failedShares > 0,
656
+ throttled: throttleState.consecutive >= 3,
657
+ failedDirs: failedShares > 0 ? failedDirs.slice(0, 50) : [],
658
+ scannedShares,
659
+ totalShares,
660
+ failedShares,
661
+ throttledShares,
662
+ cachedDirs,
663
+ budgetUsed: maxRequests - budget.remaining,
664
+ depth,
665
+ maxPages,
666
+ };
121
667
  }
122
668
 
123
- module.exports = { listGroups, listShares, listFiles, searchFiles };
669
+ module.exports = {
670
+ listGroups, listShares, listFiles, treeFiles, searchFiles,
671
+ ShareApiError, describeErrno2131, clearGroupCache,
672
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baiduwangpan-cli",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "百度网盘 CLI — 全盘读写 · 免下载读取文件内容 · 群聊文件浏览 (Agent 友好)",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/bdp.js",
11
- "test": "node --test",
11
+ "test": "node --test test/*.test.js",
12
12
  "postinstall": "node scripts/postinstall.js"
13
13
  },
14
14
  "engines": {
@@ -38,7 +38,7 @@
38
38
  ],
39
39
  "repository": {
40
40
  "type": "git",
41
- "url": "https://github.com/NkAntony777/baiduwangpan-cli.git"
41
+ "url": "git+https://github.com/NkAntony777/baiduwangpan-cli.git"
42
42
  },
43
43
  "homepage": "https://github.com/NkAntony777/baiduwangpan-cli",
44
44
  "bugs": {