baiduwangpan-cli 1.0.2 → 1.1.1

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/group.js CHANGED
@@ -1,5 +1,61 @@
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
+ }
3
59
 
4
60
  // ── 工具 ──────────────────────────────────────────────
5
61
 
@@ -8,6 +64,19 @@ function toStr(v) {
8
64
  return String(v);
9
65
  }
10
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
+
11
80
  class ShareApiError extends Error {
12
81
  constructor(errno, message) {
13
82
  super(message);
@@ -16,6 +85,24 @@ class ShareApiError extends Error {
16
85
  }
17
86
  }
18
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
+
19
106
  // ── 群组列表 ──────────────────────────────────────────
20
107
 
21
108
  async function listGroups() {
@@ -40,35 +127,81 @@ async function listGroups() {
40
127
 
41
128
  // ── 群内分享库 ────────────────────────────────────────
42
129
 
43
- async function listShares(gid) {
44
- const bdstoken = await getBdstoken();
45
- const data = await http.webJson(
46
- `${http.PAN_BASE}/mbox/group/listshare?clienttype=0&app_id=${http.APP_ID}&web=1&type=2&gid=${toStr(gid)}&limit=50&desc=1&bdstoken=${bdstoken}`,
47
- { method: "POST", body: "" }
48
- );
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}`;
49
135
 
50
- if (data.errno !== 0) throw new Error(`API error: errno=${data.errno}`);
136
+ if (cache) {
137
+ const hit = cacheGet(key);
138
+ if (hit) return hit;
139
+ }
51
140
 
141
+ const bdstoken = await getBdstoken();
52
142
  const shares = [];
53
- for (const msg of data.records?.msg_list || []) {
54
- for (const f of msg.file_list || []) {
55
- shares.push({
56
- msgId: toStr(msg.msg_id),
57
- fromUk: toStr(msg.uk),
58
- fromUser: msg.uname,
59
- name: decodeURIComponent(f.path || f.server_filename || ""),
60
- fsId: toStr(f.fs_id),
61
- isDir: f.isdir === "1",
62
- size: parseInt(f.size || "0", 10),
63
- });
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
+ }
64
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;
65
178
  }
179
+
180
+ if (cache) cacheSet(key, shares, ttlMs);
66
181
  return shares;
67
182
  }
68
183
 
69
184
  // ── 底层 shareinfo 请求 ───────────────────────────────
70
185
 
71
- async function shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
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) {
72
205
  const bdstoken = await getBdstoken();
73
206
  const url =
74
207
  `${http.PAN_BASE}/mbox/msg/shareinfo?type=2&from_uk=${fromUkS}&msg_id=${msgIdS}` +
@@ -80,25 +213,90 @@ async function shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
80
213
  if (data.errno !== 0) {
81
214
  throw new ShareApiError(data.errno, `API error: errno=${data.errno}`);
82
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
+ }
83
223
 
84
- const files = (Array.isArray(data.records) ? data.records : []).map((f) => ({
85
- name: f.server_filename || "",
86
- path: decodeURIComponent(f.path || ""),
87
- isDir: f.isdir === 1 || f.isdir === "1",
88
- size: parseInt(f.size || "0", 10),
89
- fsId: toStr(f.fs_id),
90
- md5: f.md5 || "",
91
- category: parseInt(f.category || "0", 10),
92
- ctime: toStr(f.server_ctime || ""),
93
- mtime: toStr(f.server_mtime || ""),
94
- parentFsId: fsIdS,
95
- fromUk: fromUkS,
96
- msgId: msgIdS,
97
- }));
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
+ }
98
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
+ );
99
262
  return { files, hasMore: data.has_more === 1, page, pageSize };
100
263
  }
101
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
+
102
300
  // ── 列出分享库内容 (gls) ──────────────────────────────
103
301
 
104
302
  async function listFiles(gid, fsId, options = {}) {
@@ -107,14 +305,15 @@ async function listFiles(gid, fsId, options = {}) {
107
305
  const parentFsId = toStr(options.parentFsId || "");
108
306
  const page = options.page || 1;
109
307
  let pageSize = options.pageSize || 50;
110
- pageSize = Math.min(Math.max(1, pageSize), 100);
308
+ pageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE);
309
+ const cache = options.cache === true;
111
310
 
112
311
  // 来源解析:显式传入优先;否则仅在顶层分享中精确匹配(禁止"用第一个分享"兜底)
113
312
  let fromUkS = toStr(options.fromUk || "");
114
313
  let msgIdS = toStr(options.msgId || "");
115
314
 
116
315
  if (!fromUkS || !msgIdS) {
117
- const shares = await listShares(gidS);
316
+ const shares = await listShares(gidS, { cache });
118
317
  const match = shares.find((s) => s.fsId === fsIdS);
119
318
  if (!match) {
120
319
  throw new Error(
@@ -147,6 +346,28 @@ async function listFiles(gid, fsId, options = {}) {
147
346
  lastErrno = -3;
148
347
  continue; // 尝试更小的 page-size
149
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
+ }
150
371
  throw e;
151
372
  }
152
373
  }
@@ -181,7 +402,7 @@ async function listFiles(gid, fsId, options = {}) {
181
402
 
182
403
  // 目标本身是顶层分享 → 退回 gshares 结果
183
404
  try {
184
- const shares = await listShares(gidS);
405
+ const shares = await listShares(gidS, { cache });
185
406
  const match = shares.find((s) => s.fsId === fsIdS);
186
407
  if (match) {
187
408
  return {
@@ -217,8 +438,8 @@ async function listFiles(gid, fsId, options = {}) {
217
438
  throw new ShareApiError(
218
439
  -3,
219
440
  `shareinfo rejected for fsId ${fsIdS} (errno=-3). ` +
220
- `群分享 API 拒绝请求:已观察到大目录、错误的 msgId/fromUk、过期分享都可能触发。` +
221
- `请减小 --page-size,或携带 gsearch 返回的 fromUk/msgId/parentFsId 信息重试。`
441
+ `群分享 API 拒绝请求:已观察到大目录、错误的 msgId/fromUk、过期分享、请求过于频繁都可能触发。` +
442
+ `请减小 --page-size,稍后重试,或携带 gsearch 返回的 fromUk/msgId/parentFsId 信息重试。`
222
443
  );
223
444
  }
224
445
 
@@ -229,8 +450,12 @@ async function treeFiles(gid, options = {}) {
229
450
  const maxDepth = Math.min(Math.max(1, options.depth || 2), 5);
230
451
  const concurrency = Math.min(Math.max(1, options.concurrency || 4), 8);
231
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 };
232
457
 
233
- const shares = await listShares(gidS);
458
+ const shares = await listShares(gidS, { cache });
234
459
  const tree = [];
235
460
  const failed = [];
236
461
 
@@ -249,21 +474,12 @@ async function treeFiles(gid, options = {}) {
249
474
  if (!node.isDir || node.size > 0) return null; // 非目录不进队列
250
475
  scanned++;
251
476
  try {
252
- const { files } = await shareInfoRequest(gidS, node.fsId, share.fromUk, share.msgId, 1, 100);
477
+ const { files } = await fetchShareDir({
478
+ gidS, fsIdS: node.fsId, fromUkS: share.fromUk, msgIdS: share.msgId,
479
+ maxPages, cache, budget, throttleState,
480
+ });
253
481
  return files.map((f) => ({
254
- node: {
255
- name: f.name,
256
- path: f.path,
257
- isDir: f.isDir,
258
- size: f.size,
259
- fsId: f.fsId,
260
- md5: f.md5,
261
- category: f.category,
262
- parentFsId: f.parentFsId,
263
- fromUk: f.fromUk,
264
- msgId: f.msgId,
265
- fromUser: share.fromUser,
266
- },
482
+ node: { ...f, fromUser: share.fromUser },
267
483
  depth: depth + 1,
268
484
  share,
269
485
  }));
@@ -284,9 +500,14 @@ async function treeFiles(gid, options = {}) {
284
500
  if (tree.length >= maxNodes) break;
285
501
  }
286
502
  if (tree.length >= maxNodes) break;
503
+
504
+ // 检测到限流后自动放慢下一层
505
+ if (throttleState.consecutive > 0) await delay(300);
506
+ // 连续限流 ≥3 次:停止扫描,结果可续扫(磁盘缓存加速重跑)
507
+ if (throttleState.consecutive >= 3) break;
287
508
  }
288
509
 
289
- return { tree, failed, scanned, maxDepth, truncated: tree.length >= maxNodes };
510
+ return { tree, failed, scanned, maxDepth, truncated: tree.length >= maxNodes, throttled: throttleState.consecutive >= 3 };
290
511
  }
291
512
 
292
513
  // ── 群文件搜索 (gsearch) ──────────────────────────────
@@ -301,17 +522,37 @@ async function searchFiles(gid, keyword, options = {}) {
301
522
  const unique = options.unique !== false;
302
523
  const fetchAll = options.all === true;
303
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 timeoutMs = options.timeoutMs > 0 ? options.timeoutMs : 0; // 0 = 不限时
529
+ const budget = { remaining: maxRequests };
530
+ const throttleState = { consecutive: 0 };
304
531
  const startIdx = (page - 1) * limit;
305
532
  const stopAt = fetchAll ? Infinity : startIdx + limit + 1; // +1 判断 hasMore
306
533
 
307
- const shares = await listShares(gidS);
534
+ const shares = await listShares(gidS, { cache });
308
535
  const totalShares = shares.length;
309
536
 
310
537
  const seen = new Set();
311
538
  const results = [];
539
+ const failedDirs = [];
312
540
  let scannedShares = 0;
313
541
  let failedShares = 0;
542
+ let throttledShares = 0;
543
+ let cachedDirs = 0;
314
544
  let finished = false;
545
+ let timedOut = false;
546
+ let stoppedReason = null;
547
+
548
+ // 超时控制:不中断当前批,批结束后停下并返回已拿到的部分结果
549
+ // (complete:false, partial:true)。配合 CLI --json-file 的 onProgress 持续写盘,
550
+ // 即使被外部超时/杀掉,部分结果也已提前落盘。
551
+ let timeoutTimer = null;
552
+ if (timeoutMs > 0) {
553
+ timeoutTimer = setTimeout(() => { timedOut = true; }, timeoutMs);
554
+ if (typeof timeoutTimer.unref === "function") timeoutTimer.unref();
555
+ }
315
556
 
316
557
  const pushIfMatch = (item) => {
317
558
  if (finished) return;
@@ -319,7 +560,10 @@ async function searchFiles(gid, keyword, options = {}) {
319
560
  if (unique && seen.has(key)) return;
320
561
  seen.add(key);
321
562
  results.push(item);
322
- if (results.length >= stopAt) finished = true;
563
+ if (results.length >= stopAt) {
564
+ finished = true;
565
+ if (!stoppedReason) stoppedReason = "page-limit";
566
+ }
323
567
  };
324
568
 
325
569
  // 阶段 1:顶层分享名称匹配(非目录分享在此完成扫描)
@@ -346,26 +590,41 @@ async function searchFiles(gid, keyword, options = {}) {
346
590
  }
347
591
  }
348
592
 
349
- // 阶段 2:BFS 递归扫描目录(每层 4 个一批并发,默认 depth=1 保持旧行为)
593
+ // 阶段 2:BFS 递归扫描目录(每层并发,默认 depth=1 保持旧行为)
350
594
  const dirShares = shares.filter((s) => s.isDir);
351
595
  let queue = dirShares.map((s) => ({ fsId: s.fsId, fromUk: s.fromUk, msgId: s.msgId, depth: 1 }));
352
596
 
353
597
  for (let level = 1; level <= depth && queue.length > 0 && !finished; level++) {
598
+ if (timedOut) { finished = true; stoppedReason = stoppedReason || "timeout"; break; }
354
599
  const current = queue;
355
600
  queue = [];
356
601
 
357
602
  for (let i = 0; i < current.length && !finished; i += concurrency) {
603
+ if (timedOut) { finished = true; stoppedReason = stoppedReason || "timeout"; break; }
358
604
  const batch = current.slice(i, i + concurrency);
359
605
  const batchResults = await Promise.all(
360
606
  batch.map(async (share) => {
361
607
  scannedShares++;
608
+ if (budget.remaining <= 0) {
609
+ // 请求预算耗尽:剩余目录快速失败,避免失控触发限流
610
+ failedShares++;
611
+ if (!stoppedReason) stoppedReason = "budget";
612
+ return { matches: [], subDirs: [] };
613
+ }
362
614
  try {
363
- const { files } = await shareInfoRequest(gidS, share.fsId, share.fromUk, share.msgId, 1, 100);
615
+ const { files, cached } = await fetchShareDir({
616
+ gidS, fsIdS: share.fsId, fromUkS: share.fromUk, msgIdS: share.msgId,
617
+ maxPages, cache, budget, throttleState,
618
+ });
619
+ if (cached) cachedDirs++;
364
620
  const matches = files.filter((f) => f.name.toLowerCase().includes(kw));
365
621
  const subDirs = files.filter((f) => f.isDir);
366
622
  return { matches, subDirs };
367
- } catch {
623
+ } catch (e) {
368
624
  failedShares++;
625
+ const errno = e instanceof ShareApiError ? e.errno : "?";
626
+ if (e instanceof ShareApiError && e.throttled) throttledShares++;
627
+ failedDirs.push({ fsId: share.fsId, name: share.name || "", errno, message: e.message });
369
628
  return { matches: [], subDirs: [] };
370
629
  }
371
630
  })
@@ -380,18 +639,66 @@ async function searchFiles(gid, keyword, options = {}) {
380
639
  // 收集下一层待扫描目录
381
640
  if (level < depth && !finished) {
382
641
  for (const d of subDirs) {
383
- queue.push({ fsId: d.fsId, fromUk: d.fromUk, msgId: d.msgId, depth: level + 1 });
642
+ queue.push({ fsId: d.fsId, name: d.name, fromUk: d.fromUk, msgId: d.msgId, depth: level + 1 });
384
643
  }
385
644
  }
386
645
  }
646
+
647
+ // 检测到限流后自动放慢(批间降速),缓解服务端压力
648
+ if (throttleState.consecutive > 0) await delay(300);
649
+ if (timedOut) { finished = true; stoppedReason = stoppedReason || "timeout"; break; }
650
+ // 连续限流 ≥3 次:服务端已饱和,停止扫描快速失败。
651
+ // 已成功扫描的目录已写入磁盘缓存,重跑命令只补扫缺失部分(续扫收敛)。
652
+ if (throttleState.consecutive >= 3) {
653
+ if (throttledShares < 3) throttledShares = 3;
654
+ finished = true;
655
+ stoppedReason = stoppedReason || "throttled";
656
+ break;
657
+ }
658
+
659
+ // 每批扫描完主动上报部分结果(CLI 用于持续写 --json-file)
660
+ if (typeof options.onProgress === "function") {
661
+ options.onProgress({
662
+ results,
663
+ page,
664
+ pageSize: limit,
665
+ total: null,
666
+ complete: false,
667
+ partial: true,
668
+ running: true,
669
+ timedOut: false,
670
+ stoppedReason,
671
+ scannedShares,
672
+ totalShares,
673
+ failedShares,
674
+ throttledShares,
675
+ cachedDirs,
676
+ budgetUsed: maxRequests - budget.remaining,
677
+ depth,
678
+ maxPages,
679
+ });
680
+ }
387
681
  }
388
682
  }
389
683
 
390
- const complete = scannedShares >= totalShares;
684
+ if (timeoutTimer) clearTimeout(timeoutTimer);
685
+
686
+ if (timedOut) {
687
+ finished = true;
688
+ stoppedReason = stoppedReason || "timeout";
689
+ }
690
+
691
+ // 完整度判定:BFS 自然结束(队列清空 / 达到 depth 上限)且未被分页/限流/超时/预算中断,
692
+ // 且没有任何失败,才算 complete。之前"scannedShares >= totalShares"在 depth>1 或
693
+ // 提前截断时会误报 complete:true。
694
+ const scanDone = !finished && !timedOut;
695
+ const complete = scanDone && failedShares === 0;
391
696
  const hasMore = !fetchAll && results.length > startIdx + limit;
392
697
  const pageSlice = fetchAll ? results : results.slice(startIdx, startIdx + limit);
393
698
 
394
- return {
699
+ if (!stoppedReason) stoppedReason = complete ? "complete" : "stopped";
700
+
701
+ const out = {
395
702
  results: pageSlice,
396
703
  page,
397
704
  pageSize: limit,
@@ -401,12 +708,30 @@ async function searchFiles(gid, keyword, options = {}) {
401
708
  nextPage: hasMore ? page + 1 : null,
402
709
  unique,
403
710
  complete,
404
- partial: failedShares > 0,
711
+ partial: !complete,
712
+ timedOut,
713
+ stoppedReason,
714
+ throttled: throttleState.consecutive >= 3,
715
+ failedDirs: failedShares > 0 ? failedDirs.slice(0, 50) : [],
405
716
  scannedShares,
406
717
  totalShares,
407
718
  failedShares,
719
+ throttledShares,
720
+ cachedDirs,
721
+ budgetUsed: maxRequests - budget.remaining,
408
722
  depth,
723
+ maxPages,
409
724
  };
725
+
726
+ // 结束态上报(running:false),CLI 据此写最终 JSON
727
+ if (typeof options.onProgress === "function") {
728
+ options.onProgress({ ...out, results, running: false });
729
+ }
730
+
731
+ return out;
410
732
  }
411
733
 
412
- module.exports = { listGroups, listShares, listFiles, treeFiles, searchFiles, ShareApiError };
734
+ module.exports = {
735
+ listGroups, listShares, listFiles, treeFiles, searchFiles,
736
+ ShareApiError, describeErrno2131, clearGroupCache,
737
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baiduwangpan-cli",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
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": {