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/README.md +47 -14
- package/bin/bdp.js +119 -11
- package/lib/browser-login.js +5 -10
- package/lib/config.js +64 -16
- package/lib/group.js +389 -64
- package/package.json +2 -2
- package/scripts/postinstall.js +110 -65
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
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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 (
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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),
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 = {
|
|
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.
|
|
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": {
|