baiduwangpan-cli 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/group.js CHANGED
@@ -1,925 +1,929 @@
1
- const http = require("./http");
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
- const { matchesName: matchName } = require("./name-match");
8
-
9
- // ── 常量(逆向实测结论)────────────────────────────────
10
- const MAX_PAGE_SIZE = 100; // shareinfo 服务端每页硬上限(num>100 不返回更多)
11
- const MAX_LIST_SHARE_PAGES = 10; // listshare 分页上限(10 页 × 50 条消息)
12
- const DIR_CACHE_TTL = 1800000; // 目录列表缓存 30 分钟(群文件树很少变动)
13
- const EMPTY_DIR_CACHE_TTL = 60000; // 空目录缓存 1 分钟(限流时 API 可能返回空列表,短 TTL 防缓存投毒)
14
- const SHARE_CACHE_TTL = 300000; // 群分享列表缓存 5 分钟(新分享消息较频繁)
15
- const DEFAULT_MAX_REQUESTS = 400; // 单条命令 shareinfo 请求预算(防深扫失控触发限流)
16
-
17
- // ── 会话级缓存(内存 L1 + 磁盘 L2,跨命令复用)──────────
18
- const cacheStore = new Map();
19
-
20
- function cacheDir() {
21
- return process.env.BDP_CACHE_DIR || path.join(os.homedir(), ".bdp", "cache");
22
- }
23
-
24
- function cachePath(key) {
25
- return path.join(cacheDir(), crypto.createHash("sha1").update(key).digest("hex") + ".json");
26
- }
27
-
28
- function cacheGet(key) {
29
- const entry = cacheStore.get(key); // L1
30
- if (entry) {
31
- if (Date.now() - entry.at <= entry.ttl) return entry.value;
32
- cacheStore.delete(key);
33
- }
34
- try {
35
- // L2: 磁盘缓存(跨 CLI 进程复用,避免重复扫描触发限流)
36
- const disk = JSON.parse(fs.readFileSync(cachePath(key), "utf-8"));
37
- if (Date.now() - disk.at <= disk.ttl) {
38
- cacheStore.set(key, { at: disk.at, ttl: disk.ttl, value: disk.value });
39
- return disk.value;
40
- }
41
- fs.unlinkSync(cachePath(key));
42
- } catch {}
43
- return null;
44
- }
45
-
46
- function cacheSet(key, value, ttl) {
47
- cacheStore.set(key, { at: Date.now(), ttl, value });
48
- try {
49
- fs.mkdirSync(cacheDir(), { recursive: true });
50
- fs.writeFileSync(cachePath(key), JSON.stringify({ at: Date.now(), ttl, value }), "utf-8");
51
- } catch {}
52
- }
53
-
54
- function clearGroupCache() {
55
- cacheStore.clear();
56
- try {
57
- fs.rmSync(cacheDir(), { recursive: true, force: true });
58
- } catch {}
59
- }
60
-
61
- // ── 工具 ──────────────────────────────────────────────
62
-
63
- function toStr(v) {
64
- if (v === null || v === undefined) return "";
65
- return String(v);
66
- }
67
-
68
- // 安全解码:部分群文件路径含非法百分号编码(裸 % 等),decodeURIComponent 会抛 URIError
69
- // 导致整个目录扫描失败(实测 30/349 目录因此报废)。失败时回退原始字符串。
70
- function safeDecode(s) {
71
- if (!s) return "";
72
- try {
73
- return decodeURIComponent(s);
74
- } catch {
75
- return s;
76
- }
77
- }
78
-
79
- const delay = (ms) => new Promise((r) => setTimeout(r, ms));
80
-
81
- class ShareApiError extends Error {
82
- constructor(errno, message) {
83
- super(message);
84
- this.errno = errno;
85
- this.name = "ShareApiError";
86
- }
87
- }
88
-
89
- // errno=2131 的详细说明(bdp error 2131 与 gls 报错共用)
90
- function describeErrno2131(gidS, msgIdS) {
91
- return (
92
- `shareinfo 请求被拒: msg_id ${msgIdS} 不属于群 ${gidS} 的任何分享(errno=2131)。` +
93
- `群分享参数必须同源:gid 与 --from-uk/--msg-id/--parent-fs-id 应来自同一次 gshares/gsearch 结果。` +
94
- `处理:运行 bdp gshares ${gidS} 查看本群分享(或 bdp gsearch ${gidS} <关键词>),` +
95
- `取其中一行的 fsId/fromUk/msgId 重试;fsId 为子目录时保持同一行的 fromUk/msgId。`
96
- );
97
- }
98
-
99
- // 限流退化检测:shareinfo 被限流时 errno=0 但返回"目录自身"而非子项。
100
- // 目录不可能包含自身,故返回列表里出现请求 fs_id 即可判定为退化响应。
101
- function isSelfEcho(data, fsIdS) {
102
- const records = data.records;
103
- if (!Array.isArray(records) || records.length === 0) return false;
104
- return records.some((r) => toStr(r.fs_id) === toStr(fsIdS));
105
- }
106
-
107
- // ── 群组列表 ──────────────────────────────────────────
108
-
109
- async function listGroups() {
110
- const bdstoken = await getBdstoken();
111
- if (!bdstoken) throw new Error("无法获取 bdstoken — 请检查 BDUSS/STOKEN 配置(或重新执行 bdp login)");
112
-
113
- const data = await http.webJson(
114
- `${http.PAN_BASE}/mbox/group/list?clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`
115
- );
116
-
117
- if (data.errno !== 0) throw new Error(`API 返回错误: errno=${data.errno}(可运行 bdp error ${data.errno} 查看含义)`);
118
-
119
- return (data.records || []).map((g) => ({
120
- gid: toStr(g.gid),
121
- name: g.name,
122
- gnum: toStr(g.gnum),
123
- description: g.gdesc || "",
124
- type: toStr(g.type),
125
- created: g.ctime,
126
- }));
127
- }
128
-
129
- // ── 群内分享库 ────────────────────────────────────────
130
-
131
- async function listShares(gid, options = {}) {
132
- const gidS = toStr(gid);
133
- const cache = options.cache === true;
134
- const ttlMs = options.ttlMs || SHARE_CACHE_TTL;
135
- const key = `shares:${gidS}`;
136
-
137
- if (cache) {
138
- const hit = cacheGet(key);
139
- if (hit) return hit;
140
- }
141
-
142
- const bdstoken = await getBdstoken();
143
- const shares = [];
144
- const seenMsg = new Set();
145
- let cursor = "";
146
- let stop = false;
147
-
148
- // has_more=1 时用 last_msg_time 游标翻页(desc 序);无新消息/无游标则停止
149
- for (let page = 0; page < MAX_LIST_SHARE_PAGES && !stop; page++) {
150
- const url =
151
- `${http.PAN_BASE}/mbox/group/listshare?clienttype=0&app_id=${http.APP_ID}&web=1&type=2&gid=${gidS}` +
152
- `&limit=50&desc=1${cursor ? `&last_msg_time=${cursor}` : ""}&bdstoken=${bdstoken}`;
153
- const data = await http.webJson(url, { method: "POST", body: "" });
154
-
155
- if (data.errno !== 0) throw new Error(`API 返回错误: errno=${data.errno}(可运行 bdp error ${data.errno} 查看含义)`);
156
-
157
- let added = 0;
158
- for (const msg of data.records?.msg_list || []) {
159
- const mid = toStr(msg.msg_id);
160
- if (seenMsg.has(mid)) continue; // 防重复页
161
- seenMsg.add(mid);
162
- added++;
163
- for (const f of msg.file_list || []) {
164
- shares.push({
165
- msgId: mid,
166
- fromUk: toStr(msg.uk),
167
- fromUser: msg.uname,
168
- name: safeDecode(f.path || f.server_filename || ""),
169
- fsId: toStr(f.fs_id),
170
- isDir: f.isdir === "1",
171
- size: parseInt(f.size || "0", 10),
172
- });
173
- }
174
- }
175
-
176
- if (data.has_more !== 1 || added === 0) stop = true;
177
- else cursor = toStr(data.last_msg_time || "");
178
- if (!cursor) stop = true;
179
- }
180
-
181
- if (cache) cacheSet(key, shares, ttlMs);
182
- return shares;
183
- }
184
-
185
- // ── 底层 shareinfo 请求 ───────────────────────────────
186
-
187
- function mapShareInfoFile(f, parentFsId, fromUkS, msgIdS) {
188
- return {
189
- name: f.server_filename || "",
190
- path: safeDecode(f.path || ""),
191
- isDir: f.isdir === 1 || f.isdir === "1",
192
- size: parseInt(f.size || "0", 10),
193
- fsId: toStr(f.fs_id),
194
- md5: f.md5 || "",
195
- category: parseInt(f.category || "0", 10),
196
- ctime: toStr(f.server_ctime || ""),
197
- mtime: toStr(f.server_mtime || ""),
198
- parentFsId,
199
- fromUk: fromUkS,
200
- msgId: msgIdS,
201
- };
202
- }
203
-
204
- // 单页 shareinfo;errno!=0 抛 ShareApiError;限流退化(self-echo)标记 throttled
205
- async function shareInfoRaw(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
206
- const bdstoken = await getBdstoken();
207
- const url =
208
- `${http.PAN_BASE}/mbox/msg/shareinfo?type=2&from_uk=${fromUkS}&msg_id=${msgIdS}` +
209
- `&to_uk=0&num=${pageSize}&page=${page}&fs_id=${fsIdS}&gid=${gidS}` +
210
- `&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`;
211
-
212
- const data = await http.webJson(url, { method: "POST", body: "" });
213
-
214
- if (data.errno !== 0) {
215
- throw new ShareApiError(data.errno, `API 返回错误: errno=${data.errno}(可运行 bdp error ${data.errno} 查看含义)`);
216
- }
217
- if (isSelfEcho(data, fsIdS)) {
218
- const e = new ShareApiError(-3, `shareinfo 被限流(self-echo): fs_id ${fsIdS}`);
219
- e.throttled = true;
220
- throw e;
221
- }
222
- return data;
223
- }
224
-
225
- // 限流/瞬时错误退避重试:2s → 4s → 2 次后放弃。
226
- // - 网络/连接错误(非 ShareApiError):本地瞬时故障,重试(实测并发扫描中 API 会随机断连)
227
- // - self-echo 限流:重试,但受 throttleState 上限约束(consecutive≥3 停止扫描 / total≥5 不再重试)
228
- // - 硬 errno(-3/2131 等):不重试(listFiles 有 page-size 降级链处理 -3)
229
- // budget 为整条命令的请求预算,耗尽后拒绝新请求(防深扫失控)。
230
- async function shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, pageSize, budget, throttleState) {
231
- if (budget) {
232
- if (budget.remaining <= 0) throw new ShareApiError(-3, "shareinfo 请求预算已耗尽,请增大 --max-requests 或稍后重跑");
233
- budget.remaining--;
234
- }
235
- let attempts = 0;
236
- for (;;) {
237
- try {
238
- const data = await shareInfoRaw(gidS, fsIdS, fromUkS, msgIdS, page, pageSize);
239
- if (throttleState) throttleState.consecutive = 0;
240
- return data;
241
- } catch (e) {
242
- const isThrottled = e instanceof ShareApiError && e.throttled;
243
- const isNetwork = !(e instanceof ShareApiError);
244
- if (!isThrottled && !isNetwork) throw e;
245
- if (isThrottled && throttleState) {
246
- throttleState.consecutive = (throttleState.consecutive || 0) + 1;
247
- throttleState.total = (throttleState.total || 0) + 1;
248
- }
249
- const maxRetries = isThrottled && throttleState && (throttleState.consecutive >= 3 || throttleState.total >= 5) ? 0 : 2;
250
- if (attempts >= maxRetries) throw e;
251
- attempts++;
252
- await delay(2000 * attempts);
253
- }
254
- }
255
- }
256
-
257
- // 单页 shareinfo(gls 用),带限流退避
258
- async function shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
259
- const data = await shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, pageSize);
260
- const files = (Array.isArray(data.records) ? data.records : []).map((f) =>
261
- mapShareInfoFile(f, fsIdS, fromUkS, msgIdS)
262
- );
263
- return { files, hasMore: data.has_more === 1, page, pageSize };
264
- }
265
-
266
- // 全量拉取目录内容(gsearch/gtree 用):自动翻页 + 去重 + 缓存。
267
- // 逆向实测:shareinfo 每页硬上限 100,has_more=1 时 page=N 可继续翻页;
268
- // 旧实现只取 page=1,超过 100 子项的目录会漏数据。
269
- async function fetchShareDir({ gidS, fsIdS, fromUkS, msgIdS, maxPages = 50, cache = false, ttlMs = DIR_CACHE_TTL, budget, throttleState }) {
270
- const key = `dir:${gidS}:${fsIdS}:${fromUkS}:${msgIdS}`;
271
- if (cache) {
272
- const hit = cacheGet(key);
273
- if (hit) return { ...hit, cached: true };
274
- }
275
-
276
- const files = [];
277
- const seenFs = new Set();
278
- let hasMore = false;
279
- let pages = 0;
280
-
281
- for (let page = 1; page <= maxPages; page++) {
282
- const data = await shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, MAX_PAGE_SIZE, budget, throttleState);
283
- const records = Array.isArray(data.records) ? data.records : [];
284
- for (const f of records) {
285
- const fsId = toStr(f.fs_id);
286
- if (seenFs.has(fsId)) continue;
287
- seenFs.add(fsId);
288
- files.push(mapShareInfoFile(f, fsIdS, fromUkS, msgIdS));
289
- }
290
- hasMore = data.has_more === 1;
291
- pages = page;
292
- if (!hasMore || records.length === 0) break;
293
- }
294
-
295
- const result = { files, hasMore, pages, truncated: pages >= maxPages };
296
- // 空目录用短 TTL 缓存:限流时 API 可能返回空列表(缓存投毒会导致静默丢数据)
297
- if (cache) cacheSet(key, result, files.length === 0 ? EMPTY_DIR_CACHE_TTL : ttlMs);
298
- return { ...result, cached: false };
299
- }
300
-
301
- // ── 列出分享库内容 (gls) ──────────────────────────────
302
-
303
- async function listFiles(gid, fsId, options = {}) {
304
- const gidS = toStr(gid);
305
- const fsIdS = toStr(fsId);
306
- const parentFsId = toStr(options.parentFsId || "");
307
- const page = options.page || 1;
308
- let pageSize = options.pageSize || 50;
309
- pageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE);
310
- const cache = options.cache === true;
311
-
312
- // 来源解析:显式传入优先;否则仅在顶层分享中精确匹配(禁止"用第一个分享"兜底)
313
- let fromUkS = toStr(options.fromUk || "");
314
- let msgIdS = toStr(options.msgId || "");
315
-
316
- if (!fromUkS || !msgIdS) {
317
- const shares = await listShares(gidS, { cache });
318
- const match = shares.find((s) => s.fsId === fsIdS);
319
- if (!match) {
320
- throw new Error(
321
- `无法确定 fsId ${fsIdS}(不是顶层分享)。` +
322
- `请显式传入 --from-uk 与 --msg-id。`
323
- );
324
- }
325
- fromUkS = match.fromUk;
326
- msgIdS = match.msgId;
327
- }
328
-
329
- // errno=-3 时依次降 page-size: 50 → 20 → 10
330
- const attempts = [...new Set([pageSize, 50, 20, 10].filter((n) => n <= pageSize))];
331
- let lastErrno = null;
332
-
333
- for (const size of attempts) {
334
- try {
335
- const result = await shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, size);
336
- return {
337
- files: result.files,
338
- hasMore: result.hasMore,
339
- page,
340
- pageSize: size,
341
- fromUk: fromUkS,
342
- msgId: msgIdS,
343
- fallback: null,
344
- };
345
- } catch (e) {
346
- if (e instanceof ShareApiError && e.errno === -3) {
347
- lastErrno = -3;
348
- continue; // 尝试更小的 page-size
349
- }
350
- if (e instanceof ShareApiError && e.errno === 2131) {
351
- // msg_id 不属于该群(常见:gid 与参数来自不同群/分享库)。
352
- // 尝试按 fsId 从本群 gshares 自动解析纠正一次;失败则给出明确指引。
353
- try {
354
- const shares = await listShares(gidS, { cache });
355
- const match = shares.find((s) => s.fsId === fsIdS);
356
- if (match) {
357
- const result = await shareInfoRequest(gidS, fsIdS, match.fromUk, match.msgId, page, size);
358
- return {
359
- files: result.files,
360
- hasMore: result.hasMore,
361
- page,
362
- pageSize: size,
363
- fromUk: match.fromUk,
364
- msgId: match.msgId,
365
- fallback: null,
366
- autoResolved: true,
367
- };
368
- }
369
- } catch {}
370
- throw new ShareApiError(2131, describeErrno2131(gidS, msgIdS));
371
- }
372
- throw e;
373
- }
374
- }
375
-
376
- // 全部 page-size 都失败(errno=-3)→ 回退
377
- if (parentFsId) {
378
- // 回退到父目录,展示兄弟内容帮助 Agent 换路径
379
- try {
380
- const parent = await shareInfoRequest(gidS, toStr(parentFsId), fromUkS, msgIdS, 1, 50);
381
- return {
382
- files: parent.files,
383
- hasMore: parent.hasMore,
384
- page: 1,
385
- pageSize: 50,
386
- fromUk: fromUkS,
387
- msgId: msgIdS,
388
- fallback: {
389
- reason: "errno=-3",
390
- requestedFsId: fsIdS,
391
- resolvedFsId: toStr(parentFsId),
392
- level: "parent",
393
- },
394
- };
395
- } catch (e) {
396
- throw new ShareApiError(
397
- -3,
398
- `shareinfo 请求被拒: fsId ${fsIdS}(errno=-3),且回退父目录也失败。` +
399
- `分享可能已过期,或目录过大超出接口限制。`
400
- );
401
- }
402
- }
403
-
404
- // 目标本身是顶层分享 → 退回 gshares 结果
405
- try {
406
- const shares = await listShares(gidS, { cache });
407
- const match = shares.find((s) => s.fsId === fsIdS);
408
- if (match) {
409
- return {
410
- files: [{
411
- name: match.name,
412
- path: match.name,
413
- isDir: match.isDir,
414
- size: match.size,
415
- fsId: match.fsId,
416
- md5: "",
417
- category: 0,
418
- ctime: "",
419
- mtime: "",
420
- parentFsId: "",
421
- fromUk: match.fromUk,
422
- msgId: match.msgId,
423
- }],
424
- hasMore: false,
425
- page: 1,
426
- pageSize: 50,
427
- fromUk: fromUkS,
428
- msgId: msgIdS,
429
- fallback: {
430
- reason: "errno=-3",
431
- requestedFsId: fsIdS,
432
- resolvedFsId: fsIdS,
433
- level: "group-shares",
434
- },
435
- };
436
- }
437
- } catch {}
438
-
439
- throw new ShareApiError(
440
- -3,
441
- `shareinfo 请求被拒: fsId ${fsIdS}(errno=-3)。` +
442
- `群分享 API 拒绝请求:已观察到大目录、错误的 msgId/fromUk、过期分享、请求过于频繁都可能触发。` +
443
- `请减小 --page-size,稍后重试,或携带 gsearch 返回的 fromUk/msgId/parentFsId 信息重试。`
444
- );
445
- }
446
-
447
- // ── 群文件下载 (gdownload) ─────────────────────────────
448
- //
449
- // 逆向结论(2026-08-12):
450
- // - /mbox/msg/transfer 转存已被百度拒绝(非空目录一律 errno=-10,UI 已移除保存按钮)
451
- // - 新版 IM 前端的下载链路是 POST /api/sharedownload?sign=&timestamp=
452
- // (sign/timestamp 留空即可),product=mbox 参数族,登录态直接返回可下载的 dlink
453
- // - dlink 有 8 小时有效期;只支持单文件(目录 fs_id 返回的 dlink 为空)
454
-
455
- // 解析 fs_id 的来源(fromUk/msgId):显式优先;顶层分享自动解析;否则报错提示
456
- // 复用逻辑与 listFiles 一致,保证 gdownload 与 gls/gsearch 参数同源
457
- async function resolveShareSource(gidS, fsIdS, options = {}, { cache }) {
458
- let fromUkS = toStr(options.fromUk || "");
459
- let msgIdS = toStr(options.msgId || "");
460
- if (!fromUkS || !msgIdS) {
461
- const shares = await listShares(gidS, { cache });
462
- const match = shares.find((s) => s.fsId === fsIdS);
463
- if (!match) {
464
- throw new Error(
465
- `无法确定 fsId ${fsIdS}(不是顶层分享)。` +
466
- `请显式传入 --from-uk 与 --msg-id(可用 bdp gsearch <gid> <关键词> --json 获取)。`
467
- );
468
- }
469
- fromUkS = match.fromUk;
470
- msgIdS = match.msgId;
471
- }
472
- return { fromUk: fromUkS, msgId: msgIdS };
473
- }
474
-
475
- // 获取群文件直链。返回 { dlink, name, size, isDir, md5, path, fsId, fromUk, msgId }
476
- async function getShareDlink(gid, fsId, options = {}) {
477
- const gidS = toStr(gid);
478
- const fsIdS = toStr(fsId);
479
- const cache = options.cache === true;
480
-
481
- const { fromUk, msgId } = await resolveShareSource(gidS, fsIdS, options, { cache });
482
- const bdstoken = await getBdstoken();
483
- const url = `${http.PAN_BASE}/api/sharedownload?sign=&timestamp=&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`;
484
- const body = new URLSearchParams({
485
- uk: fromUk,
486
- product: "mbox",
487
- encrypt: "0",
488
- primaryid: msgId,
489
- fid_list: JSON.stringify([fsIdS]),
490
- extra: JSON.stringify({ type: "group", gid: gidS }),
491
- }).toString();
492
-
493
- const data = await http.webJson(url, { method: "POST", body });
494
- if (data.errno !== 0) {
495
- throw new Error(`API 返回错误: errno=${data.errno}(msg_id ${msgId} 不属于群 ${gidS},或分享已失效)`);
496
- }
497
- const item = (data.list || [])[0];
498
- if (!item) throw new Error(`未能获取 fsId ${fsIdS} 的文件信息`);
499
-
500
- const result = {
501
- dlink: item.dlink || "",
502
- name: item.server_filename || "",
503
- size: parseInt(item.size || "0", 10),
504
- isDir: item.isdir === 1 || item.isdir === "1",
505
- md5: item.md5 || "",
506
- path: safeDecode(item.path || ""),
507
- fsId: toStr(item.fs_id),
508
- fromUk,
509
- msgId,
510
- };
511
- // 目录的 dlink 为无效串(实测 "&clienttype=0"),先于 dlink 判空检查
512
- if (result.isDir) {
513
- throw new Error(
514
- `fsId ${fsIdS} 是目录(${result.name})。sharedownload 只支持单文件下载。` +
515
- `请用 bdp gls <gid> <fs_id> 或 bdp gsearch <gid> <关键词> 找到具体文件后下载。`
516
- );
517
- }
518
- if (!result.dlink) {
519
- throw new Error(`未能获取 fsId ${fsIdS} 的下载链接(分享可能已失效,或分享者禁止下载)`);
520
- }
521
- return result;
522
- }
523
-
524
- // 下载群文件到本地。options: { outDir, filename, fromUk, msgId, ua }
525
- // 返回 { path, name, size, dlink, fromUk, msgId }
526
- async function downloadFile(gid, fsId, options = {}) {
527
- const info = await getShareDlink(gid, fsId, options);
528
- const { spawnSync } = require("child_process");
529
- const fs = require("fs");
530
- const path = require("path");
531
-
532
- const outDir = options.outDir || process.cwd();
533
- const filename = options.filename || info.name || `fs_${info.fsId}`;
534
- const outPath = path.join(outDir, filename);
535
-
536
- fs.mkdirSync(outDir, { recursive: true });
537
- // dlink 校验 UA:netdisk 客户端 UA 会返回 118 字节错误页,浏览器 UA 正常(实测)
538
- const args = [
539
- "-s", "-L",
540
- "-H", `User-Agent: ${options.ua || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}`,
541
- "-o", outPath,
542
- "--max-time", String(options.timeoutSec || 3600),
543
- info.dlink,
544
- ];
545
- const result = spawnSync("curl", args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, timeout: (options.timeoutSec || 3600) * 1000 });
546
- if (result.error) throw new Error(`下载失败: ${result.error.message}`);
547
-
548
- let size = 0;
549
- try { size = fs.statSync(outPath).size; } catch {}
550
- if (info.size && size !== info.size) {
551
- throw new Error(
552
- `下载大小不匹配: 实际 ${size} 字节,预期 ${info.size} 字节。` +
553
- `dlink 可能已过期(有效期 8 小时),请重试。`
554
- );
555
- }
556
- return { path: outPath, name: filename, size, dlink: info.dlink, fromUk: info.fromUk, msgId: info.msgId };
557
- }
558
-
559
- // ── 群文件批量打包下载 (gdownload 多 fs_id → zip) ───────
560
- //
561
- // 逆向结论(2026-08-12 实测):
562
- // - sharedownload 单文件 >~20MB 时返回加密 list(无法解密,key 未破解)
563
- // - type=batch 时返回明文 zip 打包链接(method=batchdownload&zipcontent=...),
564
- // 小文件打包实测可用(10MB zip 完整下载);超大文件报 error_code=31090 "package is too large"
565
- // - zip 打包按 msg_id 归组:所有 fs_id 必须属于同一条分享消息
566
-
567
- // 获取多文件 zip 打包直链。options: { fromUk, msgId, cache }
568
- async function getShareZipDlink(gid, fsIds, options = {}) {
569
- const gidS = toStr(gid);
570
- const fsIdList = fsIds.map(toStr);
571
- if (fsIdList.length < 2) throw new Error(`zip 打包下载需要至少 2 个 fs_id(当前传入 ${fsIdList.length} 个)`);
572
-
573
- // 来源以第一个 fs_id 为准(其余视为同一分享消息内的文件)
574
- const { fromUk, msgId } = await resolveShareSource(gidS, fsIdList[0], options, { cache: options.cache === true });
575
- const bdstoken = await getBdstoken();
576
- const url = `${http.PAN_BASE}/api/sharedownload?sign=&timestamp=&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`;
577
- const body = new URLSearchParams({
578
- uk: fromUk,
579
- product: "mbox",
580
- encrypt: "0",
581
- type: "batch",
582
- primaryid: msgId,
583
- fid_list: JSON.stringify(fsIdList),
584
- extra: JSON.stringify({ type: "group", gid: gidS }),
585
- }).toString();
586
-
587
- const data = await http.webJson(url, { method: "POST", body });
588
- if (data.errno !== 0) {
589
- throw new Error(`API 返回错误: errno=${data.errno}(zip 批量打包)`);
590
- }
591
- if (!data.dlink) {
592
- throw new Error(`未能为 ${fsIdList.length} 个文件获取 zip 打包链接(分享可能已失效)`);
593
- }
594
- return { dlink: data.dlink, fromUk, msgId, count: fsIdList.length };
595
- }
596
-
597
- // 批量打包下载。options: { outDir, filename, fromUk, msgId, ua }
598
- async function downloadFiles(gid, fsIds, options = {}) {
599
- const { dlink, fromUk, msgId, count } = await getShareZipDlink(gid, fsIds, options);
600
- const { spawnSync } = require("child_process");
601
- const fs = require("fs");
602
- const path = require("path");
603
-
604
- const outDir = options.outDir || process.cwd();
605
- const filename = options.filename || `bdp-group-${count}files.zip`;
606
- const outPath = path.join(outDir, filename);
607
-
608
- fs.mkdirSync(outDir, { recursive: true });
609
- const args = [
610
- "-s", "-L",
611
- "-H", `User-Agent: ${options.ua || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}`,
612
- "-o", outPath,
613
- "--max-time", String(options.timeoutSec || 7200),
614
- dlink,
615
- ];
616
- const result = spawnSync("curl", args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, timeout: (options.timeoutSec || 7200) * 1000 });
617
- if (result.error) throw new Error(`下载失败: ${result.error.message}`);
618
-
619
- let size = 0;
620
- try { size = fs.statSync(outPath).size; } catch {}
621
- if (size === 0) {
622
- throw new Error(`zip 下载失败(0 字节)。dlink 可能已过期,或打包总大小超过上限(error_code 31090,可用 bdp error 31090 查询)`);
623
- }
624
- return { path: outPath, name: filename, size, dlink, fromUk, msgId, count };
625
- }
626
-
627
- // ── 目录树 (gtree) ────────────────────────────────────
628
-
629
- async function treeFiles(gid, options = {}) {
630
- const gidS = toStr(gid);
631
- const maxDepth = Math.min(Math.max(1, options.depth || 2), 5);
632
- const concurrency = Math.min(Math.max(1, options.concurrency || 4), 8);
633
- const maxNodes = options.maxNodes || 2000;
634
- const maxPages = Math.min(Math.max(1, options.maxPages || 50), 200);
635
- const cache = options.cache === true;
636
- const budget = { remaining: Math.min(Math.max(1, options.maxRequests || DEFAULT_MAX_REQUESTS), 2000) };
637
- const throttleState = { consecutive: 0 };
638
-
639
- const shares = await listShares(gidS, { cache });
640
- const tree = [];
641
- const failed = [];
642
-
643
- // BFS: queue 元素 { node, depth, share }
644
- const queue = shares.map((s) => ({ node: { ...s, parentFsId: "" }, depth: 1, share: s }));
645
- let scanned = 0;
646
-
647
- // 逐层 BFS,层内并发
648
- for (let depth = 1; depth <= maxDepth; depth++) {
649
- const level = queue.filter((q) => q.depth === depth);
650
- if (level.length === 0) break;
651
-
652
- const results = await Promise.all(
653
- level.map(async (entry) => {
654
- const { node, share } = entry;
655
- if (!node.isDir || node.size > 0) return null; // 非目录不进队列
656
- scanned++;
657
- try {
658
- const { files } = await fetchShareDir({
659
- gidS, fsIdS: node.fsId, fromUkS: share.fromUk, msgIdS: share.msgId,
660
- maxPages, cache, budget, throttleState,
661
- });
662
- return files.map((f) => ({
663
- node: { ...f, fromUser: share.fromUser },
664
- depth: depth + 1,
665
- share,
666
- }));
667
- } catch {
668
- failed.push({ fsId: node.fsId, name: node.name });
669
- return null;
670
- }
671
- })
672
- );
673
-
674
- for (const list of results) {
675
- if (!list) continue;
676
- for (const item of list) {
677
- tree.push(item.node);
678
- if (tree.length >= maxNodes) break;
679
- if (item.depth <= maxDepth && item.node.isDir) queue.push({ node: item.node, depth: item.depth, share: item.share });
680
- }
681
- if (tree.length >= maxNodes) break;
682
- }
683
- if (tree.length >= maxNodes) break;
684
-
685
- // 检测到限流后自动放慢下一层
686
- if (throttleState.consecutive > 0) await delay(300);
687
- // 连续限流 ≥3 次:停止扫描,结果可续扫(磁盘缓存加速重跑)
688
- if (throttleState.consecutive >= 3) break;
689
- }
690
-
691
- return { tree, failed, scanned, maxDepth, truncated: tree.length >= maxNodes, throttled: throttleState.consecutive >= 3 };
692
- }
693
-
694
- // ── 群文件搜索 (gsearch) ──────────────────────────────
695
-
696
- async function searchFiles(gid, keyword, options = {}) {
697
- const gidS = toStr(gid);
698
-
699
- // 关键词解析:空格分隔多关键词(委托 lib/name-match.js 实现,语义与旧内联逻辑一致):
700
- // - 默认(all):每个词都要出现(AND,可换序)→ "玄空 飞星" 只命中同时含两者的名字(精确收窄)
701
- // - --any-word:任一词出现即可(OR,更宽)→ "玄空 飞星" 命中含"玄空"或"飞星"的名字
702
- // - --exact:整名精确相等(最严格);单关键词默认仍是子串包含(兼容旧行为)
703
- const matchMode = options.exact ? "exact" : (options.anyWord ? "any" : "all");
704
- const matchesName = (name) => matchName(keyword, name, { mode: matchMode });
705
-
706
- const limit = options.limit || 50;
707
- const page = options.page || 1;
708
- const concurrency = Math.min(Math.max(1, options.concurrency || 4), 8);
709
- const unique = options.unique !== false;
710
- const fetchAll = options.all === true;
711
- const depth = Math.min(Math.max(1, options.depth || 1), 5); // 默认 1 层(兼容旧行为)
712
- const maxPages = Math.min(Math.max(1, options.maxPages || 50), 200); // 单目录最多拉取页数(100/页)
713
- const cache = options.cache === true;
714
- const maxRequests = Math.min(Math.max(1, options.maxRequests || DEFAULT_MAX_REQUESTS), 2000);
715
- const timeoutMs = options.timeoutMs > 0 ? options.timeoutMs : 0; // 0 = 不限时
716
- const budget = { remaining: maxRequests };
717
- const throttleState = { consecutive: 0 };
718
- const startIdx = (page - 1) * limit;
719
- const stopAt = fetchAll ? Infinity : startIdx + limit + 1; // +1 判断 hasMore
720
-
721
- const shares = await listShares(gidS, { cache });
722
- const totalShares = shares.length;
723
-
724
- const seen = new Set();
725
- const results = [];
726
- const failedDirs = [];
727
- let scannedShares = 0;
728
- let failedShares = 0;
729
- let throttledShares = 0;
730
- let cachedDirs = 0;
731
- let finished = false;
732
- let timedOut = false;
733
- let stoppedReason = null;
734
-
735
- // 超时控制:不中断当前批,批结束后停下并返回已拿到的部分结果
736
- // (complete:false, partial:true)。配合 CLI --json-file 的 onProgress 持续写盘,
737
- // 即使被外部超时/杀掉,部分结果也已提前落盘。
738
- let timeoutTimer = null;
739
- if (timeoutMs > 0) {
740
- timeoutTimer = setTimeout(() => { timedOut = true; }, timeoutMs);
741
- if (typeof timeoutTimer.unref === "function") timeoutTimer.unref();
742
- }
743
-
744
- const pushIfMatch = (item) => {
745
- if (finished) return;
746
- const key = unique ? toStr(item.fsId) : `${toStr(item.fsId)}|${toStr(item.msgId)}`;
747
- if (unique && seen.has(key)) return;
748
- seen.add(key);
749
- results.push(item);
750
- if (results.length >= stopAt) {
751
- finished = true;
752
- if (!stoppedReason) stoppedReason = "page-limit";
753
- }
754
- };
755
-
756
- // 阶段 1:顶层分享名称匹配(非目录分享在此完成扫描)
757
- for (const share of shares) {
758
- if (finished) break;
759
- if (!share.isDir) scannedShares++;
760
- if (matchesName(share.name)) {
761
- pushIfMatch({
762
- name: share.name,
763
- path: share.name,
764
- isDir: share.isDir,
765
- size: share.size,
766
- fsId: share.fsId,
767
- md5: "",
768
- category: 0,
769
- ctime: "",
770
- mtime: "",
771
- parentFsId: "",
772
- fromUk: share.fromUk,
773
- msgId: share.msgId,
774
- fromUser: share.fromUser,
775
- group: gidS,
776
- });
777
- }
778
- }
779
-
780
- // 阶段 2:BFS 递归扫描目录(每层并发,默认 depth=1 保持旧行为)
781
- const dirShares = shares.filter((s) => s.isDir);
782
- let queue = dirShares.map((s) => ({ fsId: s.fsId, fromUk: s.fromUk, msgId: s.msgId, depth: 1 }));
783
-
784
- for (let level = 1; level <= depth && queue.length > 0 && !finished; level++) {
785
- if (timedOut) { finished = true; stoppedReason = stoppedReason || "timeout"; break; }
786
- const current = queue;
787
- queue = [];
788
-
789
- for (let i = 0; i < current.length && !finished; i += concurrency) {
790
- if (timedOut) { finished = true; stoppedReason = stoppedReason || "timeout"; break; }
791
- const batch = current.slice(i, i + concurrency);
792
- const batchResults = await Promise.all(
793
- batch.map(async (share) => {
794
- scannedShares++;
795
- if (budget.remaining <= 0) {
796
- // 请求预算耗尽:剩余目录快速失败,避免失控触发限流
797
- failedShares++;
798
- if (!stoppedReason) stoppedReason = "budget";
799
- return { matches: [], subDirs: [] };
800
- }
801
- try {
802
- const { files, cached } = await fetchShareDir({
803
- gidS, fsIdS: share.fsId, fromUkS: share.fromUk, msgIdS: share.msgId,
804
- maxPages, cache, budget, throttleState,
805
- });
806
- if (cached) cachedDirs++;
807
- const matches = files.filter((f) => matchesName(f.name));
808
- const subDirs = files.filter((f) => f.isDir);
809
- return { matches, subDirs };
810
- } catch (e) {
811
- failedShares++;
812
- const errno = e instanceof ShareApiError ? e.errno : "?";
813
- if (e instanceof ShareApiError && e.throttled) throttledShares++;
814
- failedDirs.push({ fsId: share.fsId, name: share.name || "", errno, message: e.message });
815
- return { matches: [], subDirs: [] };
816
- }
817
- })
818
- );
819
-
820
- for (const { matches, subDirs } of batchResults) {
821
- if (finished) break;
822
- for (const f of matches) {
823
- pushIfMatch({ ...f, group: gidS });
824
- if (finished) break;
825
- }
826
- // 收集下一层待扫描目录
827
- if (level < depth && !finished) {
828
- for (const d of subDirs) {
829
- queue.push({ fsId: d.fsId, name: d.name, fromUk: d.fromUk, msgId: d.msgId, depth: level + 1 });
830
- }
831
- }
832
- }
833
-
834
- // 检测到限流后自动放慢(批间降速),缓解服务端压力
835
- if (throttleState.consecutive > 0) await delay(300);
836
- if (timedOut) { finished = true; stoppedReason = stoppedReason || "timeout"; break; }
837
- // 连续限流 ≥3 次:服务端已饱和,停止扫描快速失败。
838
- // 已成功扫描的目录已写入磁盘缓存,重跑命令只补扫缺失部分(续扫收敛)。
839
- if (throttleState.consecutive >= 3) {
840
- if (throttledShares < 3) throttledShares = 3;
841
- finished = true;
842
- stoppedReason = stoppedReason || "throttled";
843
- break;
844
- }
845
-
846
- // 每批扫描完主动上报部分结果(CLI 用于持续写 --json-file)
847
- if (typeof options.onProgress === "function") {
848
- options.onProgress({
849
- results,
850
- page,
851
- pageSize: limit,
852
- total: null,
853
- complete: false,
854
- partial: true,
855
- running: true,
856
- timedOut: false,
857
- stoppedReason,
858
- scannedShares,
859
- totalShares,
860
- failedShares,
861
- throttledShares,
862
- cachedDirs,
863
- budgetUsed: maxRequests - budget.remaining,
864
- depth,
865
- maxPages,
866
- });
867
- }
868
- }
869
- }
870
-
871
- if (timeoutTimer) clearTimeout(timeoutTimer);
872
-
873
- if (timedOut) {
874
- finished = true;
875
- stoppedReason = stoppedReason || "timeout";
876
- }
877
-
878
- // 完整度判定:BFS 自然结束(队列清空 / 达到 depth 上限)且未被分页/限流/超时/预算中断,
879
- // 且没有任何失败,才算 complete。之前"scannedShares >= totalShares"在 depth>1 或
880
- // 提前截断时会误报 complete:true。
881
- const scanDone = !finished && !timedOut;
882
- const complete = scanDone && failedShares === 0;
883
- const hasMore = !fetchAll && results.length > startIdx + limit;
884
- const pageSlice = fetchAll ? results : results.slice(startIdx, startIdx + limit);
885
-
886
- if (!stoppedReason) stoppedReason = complete ? "complete" : "stopped";
887
-
888
- const out = {
889
- results: pageSlice,
890
- page,
891
- pageSize: limit,
892
- returned: pageSlice.length,
893
- total: null,
894
- hasMore,
895
- nextPage: hasMore ? page + 1 : null,
896
- unique,
897
- complete,
898
- partial: !complete,
899
- timedOut,
900
- stoppedReason,
901
- throttled: throttleState.consecutive >= 3,
902
- failedDirs: failedShares > 0 ? failedDirs.slice(0, 50) : [],
903
- scannedShares,
904
- totalShares,
905
- failedShares,
906
- throttledShares,
907
- cachedDirs,
908
- budgetUsed: maxRequests - budget.remaining,
909
- depth,
910
- maxPages,
911
- };
912
-
913
- // 结束态上报(running:false),CLI 据此写最终 JSON
914
- if (typeof options.onProgress === "function") {
915
- options.onProgress({ ...out, results, running: false });
916
- }
917
-
918
- return out;
919
- }
920
-
921
- module.exports = {
922
- listGroups, listShares, listFiles, treeFiles, searchFiles,
923
- getShareDlink, downloadFile, getShareZipDlink, downloadFiles,
924
- ShareApiError, describeErrno2131, clearGroupCache,
925
- };
1
+ const http = require("./http");
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
+ const { matchesName: matchName } = require("./name-match");
8
+
9
+ // ── 常量(逆向实测结论)────────────────────────────────
10
+ const MAX_PAGE_SIZE = 100; // shareinfo 服务端每页硬上限(num>100 不返回更多)
11
+ const MAX_LIST_SHARE_PAGES = 10; // listshare 分页上限(10 页 × 50 条消息)
12
+ const DIR_CACHE_TTL = 1800000; // 目录列表缓存 30 分钟(群文件树很少变动)
13
+ const EMPTY_DIR_CACHE_TTL = 60000; // 空目录缓存 1 分钟(限流时 API 可能返回空列表,短 TTL 防缓存投毒)
14
+ const SHARE_CACHE_TTL = 300000; // 群分享列表缓存 5 分钟(新分享消息较频繁)
15
+ const DEFAULT_MAX_REQUESTS = 400; // 单条命令 shareinfo 请求预算(防深扫失控触发限流)
16
+
17
+ // ── 会话级缓存(内存 L1 + 磁盘 L2,跨命令复用)──────────
18
+ const cacheStore = new Map();
19
+
20
+ function cacheDir() {
21
+ return process.env.BDP_CACHE_DIR || path.join(os.homedir(), ".bdp", "cache");
22
+ }
23
+
24
+ function cachePath(key) {
25
+ return path.join(cacheDir(), crypto.createHash("sha1").update(key).digest("hex") + ".json");
26
+ }
27
+
28
+ function cacheGet(key) {
29
+ const entry = cacheStore.get(key); // L1
30
+ if (entry) {
31
+ if (Date.now() - entry.at <= entry.ttl) return entry.value;
32
+ cacheStore.delete(key);
33
+ }
34
+ try {
35
+ // L2: 磁盘缓存(跨 CLI 进程复用,避免重复扫描触发限流)
36
+ const disk = JSON.parse(fs.readFileSync(cachePath(key), "utf-8"));
37
+ if (Date.now() - disk.at <= disk.ttl) {
38
+ cacheStore.set(key, { at: disk.at, ttl: disk.ttl, value: disk.value });
39
+ return disk.value;
40
+ }
41
+ fs.unlinkSync(cachePath(key));
42
+ } catch {}
43
+ return null;
44
+ }
45
+
46
+ function cacheSet(key, value, ttl) {
47
+ cacheStore.set(key, { at: Date.now(), ttl, value });
48
+ try {
49
+ fs.mkdirSync(cacheDir(), { recursive: true });
50
+ fs.writeFileSync(cachePath(key), JSON.stringify({ at: Date.now(), ttl, value }), "utf-8");
51
+ } catch {}
52
+ }
53
+
54
+ function clearGroupCache() {
55
+ cacheStore.clear();
56
+ try {
57
+ fs.rmSync(cacheDir(), { recursive: true, force: true });
58
+ } catch {}
59
+ }
60
+
61
+ // ── 工具 ──────────────────────────────────────────────
62
+
63
+ function toStr(v) {
64
+ if (v === null || v === undefined) return "";
65
+ return String(v);
66
+ }
67
+
68
+ // 安全解码:部分群文件路径含非法百分号编码(裸 % 等),decodeURIComponent 会抛 URIError
69
+ // 导致整个目录扫描失败(实测 30/349 目录因此报废)。失败时回退原始字符串。
70
+ function safeDecode(s) {
71
+ if (!s) return "";
72
+ try {
73
+ return decodeURIComponent(s);
74
+ } catch {
75
+ return s;
76
+ }
77
+ }
78
+
79
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
80
+
81
+ class ShareApiError extends Error {
82
+ constructor(errno, message) {
83
+ super(message);
84
+ this.errno = errno;
85
+ this.name = "ShareApiError";
86
+ }
87
+ }
88
+
89
+ // errno=2131 的详细说明(bdp error 2131 与 gls 报错共用)
90
+ function describeErrno2131(gidS, msgIdS) {
91
+ return (
92
+ `shareinfo 请求被拒: msg_id ${msgIdS} 不属于群 ${gidS} 的任何分享(errno=2131)。` +
93
+ `群分享参数必须同源:gid 与 --from-uk/--msg-id/--parent-fs-id 应来自同一次 gshares/gsearch 结果。` +
94
+ `处理:运行 bdp gshares ${gidS} 查看本群分享(或 bdp gsearch ${gidS} <关键词>),` +
95
+ `取其中一行的 fsId/fromUk/msgId 重试;fsId 为子目录时保持同一行的 fromUk/msgId。`
96
+ );
97
+ }
98
+
99
+ // 限流退化检测:shareinfo 被限流时 errno=0 但返回"目录自身"而非子项。
100
+ // 目录不可能包含自身,故返回列表里出现请求 fs_id 即可判定为退化响应。
101
+ function isSelfEcho(data, fsIdS) {
102
+ const records = data.records;
103
+ if (!Array.isArray(records) || records.length === 0) return false;
104
+ return records.some((r) => toStr(r.fs_id) === toStr(fsIdS));
105
+ }
106
+
107
+ // ── 群组列表 ──────────────────────────────────────────
108
+
109
+ async function listGroups() {
110
+ const bdstoken = await getBdstoken();
111
+ if (!bdstoken) throw new Error("无法获取 bdstoken — 请检查 BDUSS/STOKEN 配置(或重新执行 bdp login)");
112
+
113
+ const data = await http.webJson(
114
+ `${http.PAN_BASE}/mbox/group/list?clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`
115
+ );
116
+
117
+ if (data.errno !== 0) throw new Error(`API 返回错误: errno=${data.errno}(可运行 bdp error ${data.errno} 查看含义)`);
118
+
119
+ return (data.records || []).map((g) => ({
120
+ gid: toStr(g.gid),
121
+ name: g.name,
122
+ gnum: toStr(g.gnum),
123
+ description: g.gdesc || "",
124
+ type: toStr(g.type),
125
+ created: g.ctime,
126
+ }));
127
+ }
128
+
129
+ // ── 群内分享库 ────────────────────────────────────────
130
+
131
+ async function listShares(gid, options = {}) {
132
+ const gidS = toStr(gid);
133
+ const cache = options.cache === true;
134
+ const ttlMs = options.ttlMs || SHARE_CACHE_TTL;
135
+ const key = `shares:${gidS}`;
136
+
137
+ if (cache) {
138
+ const hit = cacheGet(key);
139
+ if (hit) return hit;
140
+ }
141
+
142
+ const bdstoken = await getBdstoken();
143
+ const shares = [];
144
+ const seenMsg = new Set();
145
+ let cursor = "";
146
+ let stop = false;
147
+
148
+ // has_more=1 时用 last_msg_time 游标翻页(desc 序);无新消息/无游标则停止
149
+ for (let page = 0; page < MAX_LIST_SHARE_PAGES && !stop; page++) {
150
+ const url =
151
+ `${http.PAN_BASE}/mbox/group/listshare?clienttype=0&app_id=${http.APP_ID}&web=1&type=2&gid=${gidS}` +
152
+ `&limit=50&desc=1${cursor ? `&last_msg_time=${cursor}` : ""}&bdstoken=${bdstoken}`;
153
+ const data = await http.webJson(url, { method: "POST", body: "" });
154
+
155
+ if (data.errno !== 0) throw new Error(`API 返回错误: errno=${data.errno}(可运行 bdp error ${data.errno} 查看含义)`);
156
+
157
+ let added = 0;
158
+ for (const msg of data.records?.msg_list || []) {
159
+ const mid = toStr(msg.msg_id);
160
+ if (seenMsg.has(mid)) continue; // 防重复页
161
+ seenMsg.add(mid);
162
+ added++;
163
+ for (const f of msg.file_list || []) {
164
+ shares.push({
165
+ msgId: mid,
166
+ fromUk: toStr(msg.uk),
167
+ fromUser: msg.uname,
168
+ name: safeDecode(f.path || f.server_filename || ""),
169
+ fsId: toStr(f.fs_id),
170
+ isDir: f.isdir === "1",
171
+ size: parseInt(f.size || "0", 10),
172
+ });
173
+ }
174
+ }
175
+
176
+ if (data.has_more !== 1 || added === 0) stop = true;
177
+ else cursor = toStr(data.last_msg_time || "");
178
+ if (!cursor) stop = true;
179
+ }
180
+
181
+ if (cache) cacheSet(key, shares, ttlMs);
182
+ return shares;
183
+ }
184
+
185
+ // ── 底层 shareinfo 请求 ───────────────────────────────
186
+
187
+ function mapShareInfoFile(f, parentFsId, fromUkS, msgIdS) {
188
+ return {
189
+ name: f.server_filename || "",
190
+ path: safeDecode(f.path || ""),
191
+ isDir: f.isdir === 1 || f.isdir === "1",
192
+ size: parseInt(f.size || "0", 10),
193
+ fsId: toStr(f.fs_id),
194
+ md5: f.md5 || "",
195
+ category: parseInt(f.category || "0", 10),
196
+ ctime: toStr(f.server_ctime || ""),
197
+ mtime: toStr(f.server_mtime || ""),
198
+ parentFsId,
199
+ fromUk: fromUkS,
200
+ msgId: msgIdS,
201
+ };
202
+ }
203
+
204
+ // 单页 shareinfo;errno!=0 抛 ShareApiError;限流退化(self-echo)标记 throttled
205
+ async function shareInfoRaw(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
206
+ const bdstoken = await getBdstoken();
207
+ const url =
208
+ `${http.PAN_BASE}/mbox/msg/shareinfo?type=2&from_uk=${fromUkS}&msg_id=${msgIdS}` +
209
+ `&to_uk=0&num=${pageSize}&page=${page}&fs_id=${fsIdS}&gid=${gidS}` +
210
+ `&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`;
211
+
212
+ const data = await http.webJson(url, { method: "POST", body: "" });
213
+
214
+ if (data.errno !== 0) {
215
+ throw new ShareApiError(data.errno, `API 返回错误: errno=${data.errno}(可运行 bdp error ${data.errno} 查看含义)`);
216
+ }
217
+ if (isSelfEcho(data, fsIdS)) {
218
+ const e = new ShareApiError(-3, `shareinfo 被限流(self-echo): fs_id ${fsIdS}`);
219
+ e.throttled = true;
220
+ throw e;
221
+ }
222
+ return data;
223
+ }
224
+
225
+ // 限流/瞬时错误退避重试:2s → 4s → 2 次后放弃。
226
+ // - 网络/连接错误(非 ShareApiError):本地瞬时故障,重试(实测并发扫描中 API 会随机断连)
227
+ // - self-echo 限流:重试,但受 throttleState 上限约束(consecutive≥3 停止扫描 / total≥5 不再重试)
228
+ // - 硬 errno(-3/2131 等):不重试(listFiles 有 page-size 降级链处理 -3)
229
+ // budget 为整条命令的请求预算,耗尽后拒绝新请求(防深扫失控)。
230
+ async function shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, pageSize, budget, throttleState) {
231
+ if (budget) {
232
+ if (budget.remaining <= 0) throw new ShareApiError(-3, "shareinfo 请求预算已耗尽,请增大 --max-requests 或稍后重跑");
233
+ budget.remaining--;
234
+ }
235
+ let attempts = 0;
236
+ for (;;) {
237
+ try {
238
+ const data = await shareInfoRaw(gidS, fsIdS, fromUkS, msgIdS, page, pageSize);
239
+ if (throttleState) throttleState.consecutive = 0;
240
+ return data;
241
+ } catch (e) {
242
+ const isThrottled = e instanceof ShareApiError && e.throttled;
243
+ const isNetwork = !(e instanceof ShareApiError);
244
+ if (!isThrottled && !isNetwork) throw e;
245
+ if (isThrottled && throttleState) {
246
+ throttleState.consecutive = (throttleState.consecutive || 0) + 1;
247
+ throttleState.total = (throttleState.total || 0) + 1;
248
+ }
249
+ const maxRetries = isThrottled && throttleState && (throttleState.consecutive >= 3 || throttleState.total >= 5) ? 0 : 2;
250
+ if (attempts >= maxRetries) throw e;
251
+ attempts++;
252
+ await delay(2000 * attempts);
253
+ }
254
+ }
255
+ }
256
+
257
+ // 单页 shareinfo(gls 用),带限流退避
258
+ async function shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
259
+ const data = await shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, pageSize);
260
+ const files = (Array.isArray(data.records) ? data.records : []).map((f) =>
261
+ mapShareInfoFile(f, fsIdS, fromUkS, msgIdS)
262
+ );
263
+ return { files, hasMore: data.has_more === 1, page, pageSize };
264
+ }
265
+
266
+ // 全量拉取目录内容(gsearch/gtree 用):自动翻页 + 去重 + 缓存。
267
+ // 逆向实测:shareinfo 每页硬上限 100,has_more=1 时 page=N 可继续翻页;
268
+ // 旧实现只取 page=1,超过 100 子项的目录会漏数据。
269
+ async function fetchShareDir({ gidS, fsIdS, fromUkS, msgIdS, maxPages = 50, cache = false, ttlMs = DIR_CACHE_TTL, budget, throttleState }) {
270
+ const key = `dir:${gidS}:${fsIdS}:${fromUkS}:${msgIdS}`;
271
+ if (cache) {
272
+ const hit = cacheGet(key);
273
+ if (hit) return { ...hit, cached: true };
274
+ }
275
+
276
+ const files = [];
277
+ const seenFs = new Set();
278
+ let hasMore = false;
279
+ let pages = 0;
280
+
281
+ for (let page = 1; page <= maxPages; page++) {
282
+ const data = await shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, MAX_PAGE_SIZE, budget, throttleState);
283
+ const records = Array.isArray(data.records) ? data.records : [];
284
+ for (const f of records) {
285
+ const fsId = toStr(f.fs_id);
286
+ if (seenFs.has(fsId)) continue;
287
+ seenFs.add(fsId);
288
+ files.push(mapShareInfoFile(f, fsIdS, fromUkS, msgIdS));
289
+ }
290
+ hasMore = data.has_more === 1;
291
+ pages = page;
292
+ if (!hasMore || records.length === 0) break;
293
+ }
294
+
295
+ const result = { files, hasMore, pages, truncated: pages >= maxPages };
296
+ // 空目录用短 TTL 缓存:限流时 API 可能返回空列表(缓存投毒会导致静默丢数据)
297
+ if (cache) cacheSet(key, result, files.length === 0 ? EMPTY_DIR_CACHE_TTL : ttlMs);
298
+ return { ...result, cached: false };
299
+ }
300
+
301
+ // ── 列出分享库内容 (gls) ──────────────────────────────
302
+
303
+ async function listFiles(gid, fsId, options = {}) {
304
+ const gidS = toStr(gid);
305
+ const fsIdS = toStr(fsId);
306
+ const parentFsId = toStr(options.parentFsId || "");
307
+ const page = options.page || 1;
308
+ let pageSize = options.pageSize || 50;
309
+ pageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE);
310
+ const cache = options.cache === true;
311
+
312
+ // 来源解析:显式传入优先;否则仅在顶层分享中精确匹配(禁止"用第一个分享"兜底)
313
+ let fromUkS = toStr(options.fromUk || "");
314
+ let msgIdS = toStr(options.msgId || "");
315
+
316
+ if (!fromUkS || !msgIdS) {
317
+ const shares = await listShares(gidS, { cache });
318
+ const match = shares.find((s) => s.fsId === fsIdS);
319
+ if (!match) {
320
+ throw new Error(
321
+ `无法确定 fsId ${fsIdS}(不是顶层分享)。` +
322
+ `请显式传入 --from-uk 与 --msg-id。`
323
+ );
324
+ }
325
+ fromUkS = match.fromUk;
326
+ msgIdS = match.msgId;
327
+ }
328
+
329
+ // errno=-3 时依次降 page-size: 50 → 20 → 10
330
+ const attempts = [...new Set([pageSize, 50, 20, 10].filter((n) => n <= pageSize))];
331
+ let lastErrno = null;
332
+
333
+ for (const size of attempts) {
334
+ try {
335
+ const result = await shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, size);
336
+ return {
337
+ files: result.files,
338
+ hasMore: result.hasMore,
339
+ nextPage: result.hasMore ? page + 1 : null,
340
+ page,
341
+ pageSize: size,
342
+ fromUk: fromUkS,
343
+ msgId: msgIdS,
344
+ fallback: null,
345
+ };
346
+ } catch (e) {
347
+ if (e instanceof ShareApiError && e.errno === -3) {
348
+ lastErrno = -3;
349
+ continue; // 尝试更小的 page-size
350
+ }
351
+ if (e instanceof ShareApiError && e.errno === 2131) {
352
+ // msg_id 不属于该群(常见:gid 与参数来自不同群/分享库)。
353
+ // 尝试按 fsId 从本群 gshares 自动解析纠正一次;失败则给出明确指引。
354
+ try {
355
+ const shares = await listShares(gidS, { cache });
356
+ const match = shares.find((s) => s.fsId === fsIdS);
357
+ if (match) {
358
+ const result = await shareInfoRequest(gidS, fsIdS, match.fromUk, match.msgId, page, size);
359
+ return {
360
+ files: result.files,
361
+ hasMore: result.hasMore,
362
+ nextPage: result.hasMore ? page + 1 : null,
363
+ page,
364
+ pageSize: size,
365
+ fromUk: match.fromUk,
366
+ msgId: match.msgId,
367
+ fallback: null,
368
+ autoResolved: true,
369
+ };
370
+ }
371
+ } catch {}
372
+ throw new ShareApiError(2131, describeErrno2131(gidS, msgIdS));
373
+ }
374
+ throw e;
375
+ }
376
+ }
377
+
378
+ // 全部 page-size 都失败(errno=-3)→ 回退
379
+ if (parentFsId) {
380
+ // 回退到父目录,展示兄弟内容帮助 Agent 换路径
381
+ try {
382
+ const parent = await shareInfoRequest(gidS, toStr(parentFsId), fromUkS, msgIdS, 1, 50);
383
+ return {
384
+ files: parent.files,
385
+ hasMore: parent.hasMore,
386
+ nextPage: parent.hasMore ? 2 : null,
387
+ page: 1,
388
+ pageSize: 50,
389
+ fromUk: fromUkS,
390
+ msgId: msgIdS,
391
+ fallback: {
392
+ reason: "errno=-3",
393
+ requestedFsId: fsIdS,
394
+ resolvedFsId: toStr(parentFsId),
395
+ level: "parent",
396
+ },
397
+ };
398
+ } catch (e) {
399
+ throw new ShareApiError(
400
+ -3,
401
+ `shareinfo 请求被拒: fsId ${fsIdS}(errno=-3),且回退父目录也失败。` +
402
+ `分享可能已过期,或目录过大超出接口限制。`
403
+ );
404
+ }
405
+ }
406
+
407
+ // 目标本身是顶层分享 → 退回 gshares 结果
408
+ try {
409
+ const shares = await listShares(gidS, { cache });
410
+ const match = shares.find((s) => s.fsId === fsIdS);
411
+ if (match) {
412
+ return {
413
+ files: [{
414
+ name: match.name,
415
+ path: match.name,
416
+ isDir: match.isDir,
417
+ size: match.size,
418
+ fsId: match.fsId,
419
+ md5: "",
420
+ category: 0,
421
+ ctime: "",
422
+ mtime: "",
423
+ parentFsId: "",
424
+ fromUk: match.fromUk,
425
+ msgId: match.msgId,
426
+ }],
427
+ hasMore: false,
428
+ nextPage: null,
429
+ page: 1,
430
+ pageSize: 50,
431
+ fromUk: fromUkS,
432
+ msgId: msgIdS,
433
+ fallback: {
434
+ reason: "errno=-3",
435
+ requestedFsId: fsIdS,
436
+ resolvedFsId: fsIdS,
437
+ level: "group-shares",
438
+ },
439
+ };
440
+ }
441
+ } catch {}
442
+
443
+ throw new ShareApiError(
444
+ -3,
445
+ `shareinfo 请求被拒: fsId ${fsIdS}(errno=-3)。` +
446
+ `群分享 API 拒绝请求:已观察到大目录、错误的 msgId/fromUk、过期分享、请求过于频繁都可能触发。` +
447
+ `请减小 --page-size,稍后重试,或携带 gsearch 返回的 fromUk/msgId/parentFsId 信息重试。`
448
+ );
449
+ }
450
+
451
+ // ── 群文件下载 (gdownload) ─────────────────────────────
452
+ //
453
+ // 逆向结论(2026-08-12):
454
+ // - /mbox/msg/transfer 转存已被百度拒绝(非空目录一律 errno=-10,UI 已移除保存按钮)
455
+ // - 新版 IM 前端的下载链路是 POST /api/sharedownload?sign=&timestamp=
456
+ // (sign/timestamp 留空即可),product=mbox 参数族,登录态直接返回可下载的 dlink
457
+ // - dlink 有 8 小时有效期;只支持单文件(目录 fs_id 返回的 dlink 为空)
458
+
459
+ // 解析 fs_id 的来源(fromUk/msgId):显式优先;顶层分享自动解析;否则报错提示
460
+ // 复用逻辑与 listFiles 一致,保证 gdownload 与 gls/gsearch 参数同源
461
+ async function resolveShareSource(gidS, fsIdS, options = {}, { cache }) {
462
+ let fromUkS = toStr(options.fromUk || "");
463
+ let msgIdS = toStr(options.msgId || "");
464
+ if (!fromUkS || !msgIdS) {
465
+ const shares = await listShares(gidS, { cache });
466
+ const match = shares.find((s) => s.fsId === fsIdS);
467
+ if (!match) {
468
+ throw new Error(
469
+ `无法确定 fsId ${fsIdS}(不是顶层分享)。` +
470
+ `请显式传入 --from-uk 与 --msg-id(可用 bdp gsearch <gid> <关键词> --json 获取)。`
471
+ );
472
+ }
473
+ fromUkS = match.fromUk;
474
+ msgIdS = match.msgId;
475
+ }
476
+ return { fromUk: fromUkS, msgId: msgIdS };
477
+ }
478
+
479
+ // 获取群文件直链。返回 { dlink, name, size, isDir, md5, path, fsId, fromUk, msgId }
480
+ async function getShareDlink(gid, fsId, options = {}) {
481
+ const gidS = toStr(gid);
482
+ const fsIdS = toStr(fsId);
483
+ const cache = options.cache === true;
484
+
485
+ const { fromUk, msgId } = await resolveShareSource(gidS, fsIdS, options, { cache });
486
+ const bdstoken = await getBdstoken();
487
+ const url = `${http.PAN_BASE}/api/sharedownload?sign=&timestamp=&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`;
488
+ const body = new URLSearchParams({
489
+ uk: fromUk,
490
+ product: "mbox",
491
+ encrypt: "0",
492
+ primaryid: msgId,
493
+ fid_list: JSON.stringify([fsIdS]),
494
+ extra: JSON.stringify({ type: "group", gid: gidS }),
495
+ }).toString();
496
+
497
+ const data = await http.webJson(url, { method: "POST", body });
498
+ if (data.errno !== 0) {
499
+ throw new Error(`API 返回错误: errno=${data.errno}(msg_id ${msgId} 不属于群 ${gidS},或分享已失效)`);
500
+ }
501
+ const item = (data.list || [])[0];
502
+ if (!item) throw new Error(`未能获取 fsId ${fsIdS} 的文件信息`);
503
+
504
+ const result = {
505
+ dlink: item.dlink || "",
506
+ name: item.server_filename || "",
507
+ size: parseInt(item.size || "0", 10),
508
+ isDir: item.isdir === 1 || item.isdir === "1",
509
+ md5: item.md5 || "",
510
+ path: safeDecode(item.path || ""),
511
+ fsId: toStr(item.fs_id),
512
+ fromUk,
513
+ msgId,
514
+ };
515
+ // 目录的 dlink 为无效串(实测 "&clienttype=0"),先于 dlink 判空检查
516
+ if (result.isDir) {
517
+ throw new Error(
518
+ `fsId ${fsIdS} 是目录(${result.name})。sharedownload 只支持单文件下载。` +
519
+ `请用 bdp gls <gid> <fs_id> 或 bdp gsearch <gid> <关键词> 找到具体文件后下载。`
520
+ );
521
+ }
522
+ if (!result.dlink) {
523
+ throw new Error(`未能获取 fsId ${fsIdS} 的下载链接(分享可能已失效,或分享者禁止下载)`);
524
+ }
525
+ return result;
526
+ }
527
+
528
+ // 下载群文件到本地。options: { outDir, filename, fromUk, msgId, ua }
529
+ // 返回 { path, name, size, dlink, fromUk, msgId }
530
+ async function downloadFile(gid, fsId, options = {}) {
531
+ const info = await getShareDlink(gid, fsId, options);
532
+ const { spawnSync } = require("child_process");
533
+ const fs = require("fs");
534
+ const path = require("path");
535
+
536
+ const outDir = options.outDir || process.cwd();
537
+ const filename = options.filename || info.name || `fs_${info.fsId}`;
538
+ const outPath = path.join(outDir, filename);
539
+
540
+ fs.mkdirSync(outDir, { recursive: true });
541
+ // dlink 校验 UA:netdisk 客户端 UA 会返回 118 字节错误页,浏览器 UA 正常(实测)
542
+ const args = [
543
+ "-s", "-L",
544
+ "-H", `User-Agent: ${options.ua || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}`,
545
+ "-o", outPath,
546
+ "--max-time", String(options.timeoutSec || 3600),
547
+ info.dlink,
548
+ ];
549
+ const result = spawnSync("curl", args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, timeout: (options.timeoutSec || 3600) * 1000 });
550
+ if (result.error) throw new Error(`下载失败: ${result.error.message}`);
551
+
552
+ let size = 0;
553
+ try { size = fs.statSync(outPath).size; } catch {}
554
+ if (info.size && size !== info.size) {
555
+ throw new Error(
556
+ `下载大小不匹配: 实际 ${size} 字节,预期 ${info.size} 字节。` +
557
+ `dlink 可能已过期(有效期 8 小时),请重试。`
558
+ );
559
+ }
560
+ return { path: outPath, name: filename, size, dlink: info.dlink, fromUk: info.fromUk, msgId: info.msgId };
561
+ }
562
+
563
+ // ── 群文件批量打包下载 (gdownload 多 fs_id → zip) ───────
564
+ //
565
+ // 逆向结论(2026-08-12 实测):
566
+ // - sharedownload 单文件 >~20MB 时返回加密 list(无法解密,key 未破解)
567
+ // - type=batch 时返回明文 zip 打包链接(method=batchdownload&zipcontent=...),
568
+ // 小文件打包实测可用(10MB zip 完整下载);超大文件报 error_code=31090 "package is too large"
569
+ // - zip 打包按 msg_id 归组:所有 fs_id 必须属于同一条分享消息
570
+
571
+ // 获取多文件 zip 打包直链。options: { fromUk, msgId, cache }
572
+ async function getShareZipDlink(gid, fsIds, options = {}) {
573
+ const gidS = toStr(gid);
574
+ const fsIdList = fsIds.map(toStr);
575
+ if (fsIdList.length < 2) throw new Error(`zip 打包下载需要至少 2 个 fs_id(当前传入 ${fsIdList.length} 个)`);
576
+
577
+ // 来源以第一个 fs_id 为准(其余视为同一分享消息内的文件)
578
+ const { fromUk, msgId } = await resolveShareSource(gidS, fsIdList[0], options, { cache: options.cache === true });
579
+ const bdstoken = await getBdstoken();
580
+ const url = `${http.PAN_BASE}/api/sharedownload?sign=&timestamp=&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`;
581
+ const body = new URLSearchParams({
582
+ uk: fromUk,
583
+ product: "mbox",
584
+ encrypt: "0",
585
+ type: "batch",
586
+ primaryid: msgId,
587
+ fid_list: JSON.stringify(fsIdList),
588
+ extra: JSON.stringify({ type: "group", gid: gidS }),
589
+ }).toString();
590
+
591
+ const data = await http.webJson(url, { method: "POST", body });
592
+ if (data.errno !== 0) {
593
+ throw new Error(`API 返回错误: errno=${data.errno}(zip 批量打包)`);
594
+ }
595
+ if (!data.dlink) {
596
+ throw new Error(`未能为 ${fsIdList.length} 个文件获取 zip 打包链接(分享可能已失效)`);
597
+ }
598
+ return { dlink: data.dlink, fromUk, msgId, count: fsIdList.length };
599
+ }
600
+
601
+ // 批量打包下载。options: { outDir, filename, fromUk, msgId, ua }
602
+ async function downloadFiles(gid, fsIds, options = {}) {
603
+ const { dlink, fromUk, msgId, count } = await getShareZipDlink(gid, fsIds, options);
604
+ const { spawnSync } = require("child_process");
605
+ const fs = require("fs");
606
+ const path = require("path");
607
+
608
+ const outDir = options.outDir || process.cwd();
609
+ const filename = options.filename || `bdp-group-${count}files.zip`;
610
+ const outPath = path.join(outDir, filename);
611
+
612
+ fs.mkdirSync(outDir, { recursive: true });
613
+ const args = [
614
+ "-s", "-L",
615
+ "-H", `User-Agent: ${options.ua || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}`,
616
+ "-o", outPath,
617
+ "--max-time", String(options.timeoutSec || 7200),
618
+ dlink,
619
+ ];
620
+ const result = spawnSync("curl", args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, timeout: (options.timeoutSec || 7200) * 1000 });
621
+ if (result.error) throw new Error(`下载失败: ${result.error.message}`);
622
+
623
+ let size = 0;
624
+ try { size = fs.statSync(outPath).size; } catch {}
625
+ if (size === 0) {
626
+ throw new Error(`zip 下载失败(0 字节)。dlink 可能已过期,或打包总大小超过上限(error_code 31090,可用 bdp error 31090 查询)`);
627
+ }
628
+ return { path: outPath, name: filename, size, dlink, fromUk, msgId, count };
629
+ }
630
+
631
+ // ── 目录树 (gtree) ────────────────────────────────────
632
+
633
+ async function treeFiles(gid, options = {}) {
634
+ const gidS = toStr(gid);
635
+ const maxDepth = Math.min(Math.max(1, options.depth || 2), 5);
636
+ const concurrency = Math.min(Math.max(1, options.concurrency || 4), 8);
637
+ const maxNodes = options.maxNodes || 2000;
638
+ const maxPages = Math.min(Math.max(1, options.maxPages || 50), 200);
639
+ const cache = options.cache === true;
640
+ const budget = { remaining: Math.min(Math.max(1, options.maxRequests || DEFAULT_MAX_REQUESTS), 2000) };
641
+ const throttleState = { consecutive: 0 };
642
+
643
+ const shares = await listShares(gidS, { cache });
644
+ const tree = [];
645
+ const failed = [];
646
+
647
+ // BFS: queue 元素 { node, depth, share }
648
+ const queue = shares.map((s) => ({ node: { ...s, parentFsId: "" }, depth: 1, share: s }));
649
+ let scanned = 0;
650
+
651
+ // 逐层 BFS,层内并发
652
+ for (let depth = 1; depth <= maxDepth; depth++) {
653
+ const level = queue.filter((q) => q.depth === depth);
654
+ if (level.length === 0) break;
655
+
656
+ const results = await Promise.all(
657
+ level.map(async (entry) => {
658
+ const { node, share } = entry;
659
+ if (!node.isDir || node.size > 0) return null; // 非目录不进队列
660
+ scanned++;
661
+ try {
662
+ const { files } = await fetchShareDir({
663
+ gidS, fsIdS: node.fsId, fromUkS: share.fromUk, msgIdS: share.msgId,
664
+ maxPages, cache, budget, throttleState,
665
+ });
666
+ return files.map((f) => ({
667
+ node: { ...f, fromUser: share.fromUser },
668
+ depth: depth + 1,
669
+ share,
670
+ }));
671
+ } catch {
672
+ failed.push({ fsId: node.fsId, name: node.name });
673
+ return null;
674
+ }
675
+ })
676
+ );
677
+
678
+ for (const list of results) {
679
+ if (!list) continue;
680
+ for (const item of list) {
681
+ tree.push(item.node);
682
+ if (tree.length >= maxNodes) break;
683
+ if (item.depth <= maxDepth && item.node.isDir) queue.push({ node: item.node, depth: item.depth, share: item.share });
684
+ }
685
+ if (tree.length >= maxNodes) break;
686
+ }
687
+ if (tree.length >= maxNodes) break;
688
+
689
+ // 检测到限流后自动放慢下一层
690
+ if (throttleState.consecutive > 0) await delay(300);
691
+ // 连续限流 ≥3 次:停止扫描,结果可续扫(磁盘缓存加速重跑)
692
+ if (throttleState.consecutive >= 3) break;
693
+ }
694
+
695
+ return { tree, failed, scanned, maxDepth, truncated: tree.length >= maxNodes, throttled: throttleState.consecutive >= 3 };
696
+ }
697
+
698
+ // ── 群文件搜索 (gsearch) ──────────────────────────────
699
+
700
+ async function searchFiles(gid, keyword, options = {}) {
701
+ const gidS = toStr(gid);
702
+
703
+ // 关键词解析:空格分隔多关键词(委托 lib/name-match.js 实现,语义与旧内联逻辑一致):
704
+ // - 默认(all):每个词都要出现(AND,可换序)→ "玄空 飞星" 只命中同时含两者的名字(精确收窄)
705
+ // - --any-word:任一词出现即可(OR,更宽)→ "玄空 飞星" 命中含"玄空"或"飞星"的名字
706
+ // - --exact:整名精确相等(最严格);单关键词默认仍是子串包含(兼容旧行为)
707
+ const matchMode = options.exact ? "exact" : (options.anyWord ? "any" : "all");
708
+ const matchesName = (name) => matchName(keyword, name, { mode: matchMode });
709
+
710
+ const limit = options.limit || 50;
711
+ const page = options.page || 1;
712
+ const concurrency = Math.min(Math.max(1, options.concurrency || 4), 8);
713
+ const unique = options.unique !== false;
714
+ const fetchAll = options.all === true;
715
+ const depth = Math.min(Math.max(1, options.depth || 1), 5); // 默认 1 层(兼容旧行为)
716
+ const maxPages = Math.min(Math.max(1, options.maxPages || 50), 200); // 单目录最多拉取页数(100/页)
717
+ const cache = options.cache === true;
718
+ const maxRequests = Math.min(Math.max(1, options.maxRequests || DEFAULT_MAX_REQUESTS), 2000);
719
+ const timeoutMs = options.timeoutMs > 0 ? options.timeoutMs : 0; // 0 = 不限时
720
+ const budget = { remaining: maxRequests };
721
+ const throttleState = { consecutive: 0 };
722
+ const startIdx = (page - 1) * limit;
723
+ const stopAt = fetchAll ? Infinity : startIdx + limit + 1; // +1 判断 hasMore
724
+
725
+ const shares = await listShares(gidS, { cache });
726
+ const totalShares = shares.length;
727
+
728
+ const seen = new Set();
729
+ const results = [];
730
+ const failedDirs = [];
731
+ let scannedShares = 0;
732
+ let failedShares = 0;
733
+ let throttledShares = 0;
734
+ let cachedDirs = 0;
735
+ let finished = false;
736
+ let timedOut = false;
737
+ let stoppedReason = null;
738
+
739
+ // 超时控制:不中断当前批,批结束后停下并返回已拿到的部分结果
740
+ // (complete:false, partial:true)。配合 CLI --json-file 的 onProgress 持续写盘,
741
+ // 即使被外部超时/杀掉,部分结果也已提前落盘。
742
+ let timeoutTimer = null;
743
+ if (timeoutMs > 0) {
744
+ timeoutTimer = setTimeout(() => { timedOut = true; }, timeoutMs);
745
+ if (typeof timeoutTimer.unref === "function") timeoutTimer.unref();
746
+ }
747
+
748
+ const pushIfMatch = (item) => {
749
+ if (finished) return;
750
+ const key = unique ? toStr(item.fsId) : `${toStr(item.fsId)}|${toStr(item.msgId)}`;
751
+ if (unique && seen.has(key)) return;
752
+ seen.add(key);
753
+ results.push(item);
754
+ if (results.length >= stopAt) {
755
+ finished = true;
756
+ if (!stoppedReason) stoppedReason = "page-limit";
757
+ }
758
+ };
759
+
760
+ // 阶段 1:顶层分享名称匹配(非目录分享在此完成扫描)
761
+ for (const share of shares) {
762
+ if (finished) break;
763
+ if (!share.isDir) scannedShares++;
764
+ if (matchesName(share.name)) {
765
+ pushIfMatch({
766
+ name: share.name,
767
+ path: share.name,
768
+ isDir: share.isDir,
769
+ size: share.size,
770
+ fsId: share.fsId,
771
+ md5: "",
772
+ category: 0,
773
+ ctime: "",
774
+ mtime: "",
775
+ parentFsId: "",
776
+ fromUk: share.fromUk,
777
+ msgId: share.msgId,
778
+ fromUser: share.fromUser,
779
+ group: gidS,
780
+ });
781
+ }
782
+ }
783
+
784
+ // 阶段 2:BFS 递归扫描目录(每层并发,默认 depth=1 保持旧行为)
785
+ const dirShares = shares.filter((s) => s.isDir);
786
+ let queue = dirShares.map((s) => ({ fsId: s.fsId, fromUk: s.fromUk, msgId: s.msgId, depth: 1 }));
787
+
788
+ for (let level = 1; level <= depth && queue.length > 0 && !finished; level++) {
789
+ if (timedOut) { finished = true; stoppedReason = stoppedReason || "timeout"; break; }
790
+ const current = queue;
791
+ queue = [];
792
+
793
+ for (let i = 0; i < current.length && !finished; i += concurrency) {
794
+ if (timedOut) { finished = true; stoppedReason = stoppedReason || "timeout"; break; }
795
+ const batch = current.slice(i, i + concurrency);
796
+ const batchResults = await Promise.all(
797
+ batch.map(async (share) => {
798
+ scannedShares++;
799
+ if (budget.remaining <= 0) {
800
+ // 请求预算耗尽:剩余目录快速失败,避免失控触发限流
801
+ failedShares++;
802
+ if (!stoppedReason) stoppedReason = "budget";
803
+ return { matches: [], subDirs: [] };
804
+ }
805
+ try {
806
+ const { files, cached } = await fetchShareDir({
807
+ gidS, fsIdS: share.fsId, fromUkS: share.fromUk, msgIdS: share.msgId,
808
+ maxPages, cache, budget, throttleState,
809
+ });
810
+ if (cached) cachedDirs++;
811
+ const matches = files.filter((f) => matchesName(f.name));
812
+ const subDirs = files.filter((f) => f.isDir);
813
+ return { matches, subDirs };
814
+ } catch (e) {
815
+ failedShares++;
816
+ const errno = e instanceof ShareApiError ? e.errno : "?";
817
+ if (e instanceof ShareApiError && e.throttled) throttledShares++;
818
+ failedDirs.push({ fsId: share.fsId, name: share.name || "", errno, message: e.message });
819
+ return { matches: [], subDirs: [] };
820
+ }
821
+ })
822
+ );
823
+
824
+ for (const { matches, subDirs } of batchResults) {
825
+ if (finished) break;
826
+ for (const f of matches) {
827
+ pushIfMatch({ ...f, group: gidS });
828
+ if (finished) break;
829
+ }
830
+ // 收集下一层待扫描目录
831
+ if (level < depth && !finished) {
832
+ for (const d of subDirs) {
833
+ queue.push({ fsId: d.fsId, name: d.name, fromUk: d.fromUk, msgId: d.msgId, depth: level + 1 });
834
+ }
835
+ }
836
+ }
837
+
838
+ // 检测到限流后自动放慢(批间降速),缓解服务端压力
839
+ if (throttleState.consecutive > 0) await delay(300);
840
+ if (timedOut) { finished = true; stoppedReason = stoppedReason || "timeout"; break; }
841
+ // 连续限流 ≥3 次:服务端已饱和,停止扫描快速失败。
842
+ // 已成功扫描的目录已写入磁盘缓存,重跑命令只补扫缺失部分(续扫收敛)。
843
+ if (throttleState.consecutive >= 3) {
844
+ if (throttledShares < 3) throttledShares = 3;
845
+ finished = true;
846
+ stoppedReason = stoppedReason || "throttled";
847
+ break;
848
+ }
849
+
850
+ // 每批扫描完主动上报部分结果(CLI 用于持续写 --json-file)
851
+ if (typeof options.onProgress === "function") {
852
+ options.onProgress({
853
+ results,
854
+ page,
855
+ pageSize: limit,
856
+ total: null,
857
+ complete: false,
858
+ partial: true,
859
+ running: true,
860
+ timedOut: false,
861
+ stoppedReason,
862
+ scannedShares,
863
+ totalShares,
864
+ failedShares,
865
+ throttledShares,
866
+ cachedDirs,
867
+ budgetUsed: maxRequests - budget.remaining,
868
+ depth,
869
+ maxPages,
870
+ });
871
+ }
872
+ }
873
+ }
874
+
875
+ if (timeoutTimer) clearTimeout(timeoutTimer);
876
+
877
+ if (timedOut) {
878
+ finished = true;
879
+ stoppedReason = stoppedReason || "timeout";
880
+ }
881
+
882
+ // 完整度判定:BFS 自然结束(队列清空 / 达到 depth 上限)且未被分页/限流/超时/预算中断,
883
+ // 且没有任何失败,才算 complete。之前"scannedShares >= totalShares"在 depth>1 或
884
+ // 提前截断时会误报 complete:true。
885
+ const scanDone = !finished && !timedOut;
886
+ const complete = scanDone && failedShares === 0;
887
+ const hasMore = !fetchAll && results.length > startIdx + limit;
888
+ const pageSlice = fetchAll ? results : results.slice(startIdx, startIdx + limit);
889
+
890
+ if (!stoppedReason) stoppedReason = complete ? "complete" : "stopped";
891
+
892
+ const out = {
893
+ results: pageSlice,
894
+ page,
895
+ pageSize: limit,
896
+ returned: pageSlice.length,
897
+ total: null,
898
+ hasMore,
899
+ nextPage: hasMore ? page + 1 : null,
900
+ unique,
901
+ complete,
902
+ partial: !complete,
903
+ timedOut,
904
+ stoppedReason,
905
+ throttled: throttleState.consecutive >= 3,
906
+ failedDirs: failedShares > 0 ? failedDirs.slice(0, 50) : [],
907
+ scannedShares,
908
+ totalShares,
909
+ failedShares,
910
+ throttledShares,
911
+ cachedDirs,
912
+ budgetUsed: maxRequests - budget.remaining,
913
+ depth,
914
+ maxPages,
915
+ };
916
+
917
+ // 结束态上报(running:false),CLI 据此写最终 JSON
918
+ if (typeof options.onProgress === "function") {
919
+ options.onProgress({ ...out, results, running: false });
920
+ }
921
+
922
+ return out;
923
+ }
924
+
925
+ module.exports = {
926
+ listGroups, listShares, listFiles, treeFiles, searchFiles,
927
+ getShareDlink, downloadFile, getShareZipDlink, downloadFiles,
928
+ ShareApiError, describeErrno2131, clearGroupCache,
929
+ };