baiduwangpan-cli 1.1.5 → 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/pan.js CHANGED
@@ -1,204 +1,1034 @@
1
- const { spawnSync } = require("child_process");
2
- const config = require("./config");
3
- const http = require("./http");
4
-
5
- function runPCS(args) {
6
- const cfg = config.get();
7
- const result = spawnSync(cfg.pcsPath, args, {
8
- encoding: "utf-8",
9
- maxBuffer: 20 * 1024 * 1024,
10
- });
11
- if (result.error) {
12
- throw new Error(`BaiduPCS-Go not found at: ${cfg.pcsPath}. Set pcsPath in config or BAIDUPCS_CMD env.`);
13
- }
14
- return result.stdout || "";
15
- }
16
-
17
- function getDlink(remotePath) {
18
- const output = runPCS(["locate", remotePath]);
19
- const urls = output.match(/https?:\/\/[^\s]+/g);
20
- if (urls && urls.length > 0) {
21
- return urls[0].replace(/[,\s]+$/, "");
22
- }
23
- return null;
24
- }
25
-
26
- function getFileMeta(remotePath) {
27
- const output = runPCS(["meta", remotePath]);
28
- let size = null;
29
-
30
- let m = output.match(/大小[::]\s*([\d,]+)/);
31
- if (m) size = parseInt(m[1].replace(/,/g, ""), 10);
32
- if (!size) {
33
- m = output.match(/size[":\s]+(\d+)/i);
34
- if (m) size = parseInt(m[1], 10);
35
- }
36
- if (!size) {
37
- m = output.match(/(\d[\d,]*)\s*B\b/);
38
- if (m) size = parseInt(m[1].replace(/,/g, ""), 10);
39
- }
40
-
41
- let isDir = false;
42
- if (/目录|isdir.*1|is_dir.*true/i.test(output)) isDir = true;
43
-
44
- return { size, isDir, raw: output };
45
- }
46
-
47
- function fetchRangeText(url, start, end) {
48
- const cfg = config.get();
49
- const buf = http.curlRange(url, start, end, cfg.ua);
50
- return decodeText(buf);
51
- }
52
-
53
- function fetchRangeBuf(url, start, end) {
54
- const cfg = config.get();
55
- return http.curlRange(url, start, end, cfg.ua);
56
- }
57
-
58
- function decodeText(buf) {
59
- if (typeof buf === "string") return buf;
60
- for (const enc of ["utf-8", "gbk", "gb2312", "latin-1"]) {
61
- try {
62
- return buf.toString(enc);
63
- } catch {
64
- continue;
65
- }
66
- }
67
- return buf.toString("utf-8");
68
- }
69
-
70
- // ── Pan Operations ─────────────────────────────────────
71
-
72
- function ls(dirPath = "/") {
73
- return runPCS(["ls", dirPath]);
74
- }
75
-
76
- function search(keyword, searchPath = "/") {
77
- return runPCS(["search", `-path=${searchPath}`, "-r", keyword]);
78
- }
79
-
80
- function cat(remotePath, maxBytes) {
81
- const cfg = config.get();
82
- const limit = maxBytes || cfg.maxBytes;
83
- const meta = getFileMeta(remotePath);
84
-
85
- if (meta.size && meta.size > 50 * 1024 * 1024) {
86
- process.stderr.write(`[WARN] File is ${(meta.size / 1024 / 1024).toFixed(1)}MB, truncating to ${(limit / 1024).toFixed(0)}KB\n`);
87
- }
88
-
89
- const dlink = getDlink(remotePath);
90
- if (!dlink) throw new Error(`Cannot get dlink for: ${remotePath}`);
91
-
92
- const readBytes = Math.min(meta.size || limit, limit) - 1;
93
- return fetchRangeText(dlink, 0, readBytes);
94
- }
95
-
96
- function head(remotePath, n = 20, maxBytes) {
97
- const cfg = config.get();
98
- const limit = maxBytes || cfg.maxBytes;
99
- const dlink = getDlink(remotePath);
100
- if (!dlink) throw new Error(`Cannot get dlink for: ${remotePath}`);
101
-
102
- const chunkSize = Math.min(n * 4096 + 1024, limit);
103
- const text = fetchRangeText(dlink, 0, chunkSize - 1);
104
- const lines = text.split("\n");
105
- return lines.slice(0, n);
106
- }
107
-
108
- function tail(remotePath, n = 20, maxBytes) {
109
- const cfg = config.get();
110
- const limit = maxBytes || cfg.maxBytes;
111
- const meta = getFileMeta(remotePath);
112
- if (!meta.size) throw new Error(`Cannot get file size for: ${remotePath}`);
113
-
114
- const dlink = getDlink(remotePath);
115
- if (!dlink) throw new Error(`Cannot get dlink for: ${remotePath}`);
116
-
117
- const chunkSize = Math.min(n * 4096 + 1024, limit, meta.size);
118
- const start = Math.max(0, meta.size - chunkSize);
119
- const text = fetchRangeText(dlink, start, meta.size - 1);
120
- const lines = text.split("\n");
121
- return lines.slice(-n);
122
- }
123
-
124
- function grep(pattern, remotePath, options = {}) {
125
- const cfg = config.get();
126
- const dlink = getDlink(remotePath);
127
- if (!dlink) throw new Error(`Cannot get dlink for: ${remotePath}`);
128
-
129
- const text = fetchRangeText(dlink, 0, cfg.maxBytes - 1);
130
-
131
- let regex;
132
- try {
133
- regex = new RegExp(pattern, options.ignoreCase ? "gi" : "g");
134
- } catch {
135
- regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), options.ignoreCase ? "gi" : "g");
136
- }
137
-
138
- const matches = [];
139
- const lines = text.split("\n");
140
- lines.forEach((line, i) => {
141
- if (regex.test(line)) {
142
- matches.push({ line: i + 1, content: line });
143
- }
144
- regex.lastIndex = 0;
145
- });
146
- return matches;
147
- }
148
-
149
- function peek(remotePath) {
150
- const meta = getFileMeta(remotePath);
151
- const dlink = getDlink(remotePath);
152
-
153
- const result = {
154
- path: remotePath,
155
- size: meta.size,
156
- isDir: meta.isDir,
157
- dlink: !!dlink,
158
- };
159
-
160
- if (dlink && !meta.isDir) {
161
- const cfg = config.get();
162
- const previewBytes = Math.min(512, meta.size || 512) - 1;
163
- const buf = fetchRangeBuf(dlink, 0, previewBytes);
164
-
165
- let isBinary = false;
166
- for (const b of buf.slice(0, 200)) {
167
- if (b < 9 || (b > 13 && b < 32)) {
168
- isBinary = true;
169
- break;
170
- }
171
- }
172
-
173
- result.type = isBinary ? "binary" : "text";
174
- if (!isBinary) {
175
- result.preview = decodeText(buf).split("\n").slice(0, 10);
176
- }
177
- }
178
-
179
- return result;
180
- }
181
-
182
- function download(remotePath, saveTo) {
183
- const args = ["d", remotePath];
184
- if (saveTo) args.push("--saveto", saveTo);
185
- return runPCS(args);
186
- }
187
-
188
- function upload(localPath, remotePath) {
189
- return runPCS(["u", localPath, remotePath]);
190
- }
191
-
192
- function mkdir(remotePath) {
193
- return runPCS(["mkdir", remotePath]);
194
- }
195
-
196
- function remove(remotePath) {
197
- return runPCS(["rm", remotePath]);
198
- }
199
-
200
- module.exports = {
201
- ls, search, cat, head, tail, grep, peek,
202
- download, upload, mkdir, remove,
203
- getDlink, getFileMeta, fetchRangeText,
204
- };
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+ const config = require("./config");
5
+ const http = require("./http");
6
+ const { parseLsTable, parseSearchTable } = require("./table");
7
+ const { detectFileType } = require("./file-type");
8
+
9
+ // child_process 延迟加载:测试可整体替换 require.cache["child_process"] 注入 mock
10
+ function childProc() {
11
+ return require("child_process");
12
+ }
13
+
14
+ // BaiduPCS-Go 操作类命令出错时**退出码仍为 0**,错误只体现在输出文本里
15
+ // ("遇到错误, 远端服务器返回错误, 代码: N")。不解析输出就会把失败当成功
16
+ // —— 实测 bdp ls/get/put 失败时退出码为 0,Agent 会把失败误判为成功。
17
+ const ENGINE_ERROR_RE = /遇到错误,\s*远端服务器返回错误,\s*代码:\s*(-?\d+),\s*消息:\s*([^\r\n]*)/;
18
+
19
+ // 从引擎输出里识别失败信号 → { errno, message, raw };无失败信号返回 null
20
+ function detectEngineError(output) {
21
+ if (!output) return null;
22
+ const text = String(output);
23
+ const m = text.match(ENGINE_ERROR_RE);
24
+ if (m) {
25
+ return { errno: parseInt(m[1], 10), message: m[2].trim(), raw: text.trim() };
26
+ }
27
+ if (/未检测到上传的文件/.test(text)) {
28
+ return { errno: null, message: "引擎未检测到可上传的文件", raw: text.trim() };
29
+ }
30
+ if (/遍历错误/.test(text)) {
31
+ return { errno: null, message: "引擎遍历本地文件出错", raw: text.trim() };
32
+ }
33
+ if (/操作失败, 以下文件\/目录删除失败/.test(text)) {
34
+ return { errno: null, message: "引擎报告删除失败", raw: text.trim() };
35
+ }
36
+ if (/创建分享失败/.test(text)) {
37
+ return { errno: null, message: "引擎报告分享创建失败", raw: text.trim() };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ // options.check=true:检测到引擎失败信号就抛错(错误对象带 errno 字段)
43
+ function runPCS(args, options = {}) {
44
+ const cfg = config.get();
45
+ const { spawnSync } = childProc();
46
+ const result = spawnSync(cfg.pcsPath, args, {
47
+ encoding: "utf-8",
48
+ maxBuffer: 20 * 1024 * 1024,
49
+ });
50
+ if (result.error) {
51
+ throw new Error(`未找到 BaiduPCS-Go 可执行文件: ${cfg.pcsPath}。请在 config 中设置 pcsPath,或设置环境变量 BAIDUPCS_CMD。`);
52
+ }
53
+ const output = result.stdout || "";
54
+ if (options.check) {
55
+ const err = detectEngineError(output);
56
+ if (err) {
57
+ const e = new Error(err.message);
58
+ if (err.errno !== null) e.errno = err.errno;
59
+ e.engineRaw = err.raw;
60
+ throw e;
61
+ }
62
+ }
63
+ return output;
64
+ }
65
+
66
+ function getDlink(remotePath) {
67
+ const output = runPCS(["locate", remotePath]);
68
+ const urls = output.match(/https?:\/\/[^\s]+/g);
69
+ if (urls && urls.length > 0) {
70
+ return urls[0].replace(/[,\s]+$/, "");
71
+ }
72
+ return null;
73
+ }
74
+
75
+ // 通过 web 前端同款 /api/list 获取文件元信息(size/md5/isdir/ctime 一次拿全)。
76
+ // 实测 (2026-08): /api/filemetas 与 xpan filemetas 均返回 errno=2 不可用,
77
+ // 而 /api/list(父目录列表 + bdstoken)可用,md5 与 dlink URL 前缀一致,比
78
+ // BaiduPCS-Go meta 文本输出("md5 (可能不正确)")更可靠。
79
+ // 失败(未登录/接口异常/文件不在前 2000 条)返回 null,由调用方回退文本解析。
80
+ async function getFileMetaWeb(remotePath) {
81
+ const bdstoken = await http.getBdstoken();
82
+ const trimmed = remotePath.replace(/\/+$/, "");
83
+ const idx = trimmed.lastIndexOf("/");
84
+ const parent = idx <= 0 ? "/" : trimmed.slice(0, idx);
85
+ const base = idx === -1 ? trimmed : trimmed.slice(idx + 1);
86
+
87
+ for (let page = 1; page <= 20; page++) {
88
+ const url =
89
+ `https://pan.baidu.com/api/list?order=name&desc=0&showempty=0&web=1` +
90
+ `&page=${page}&num=100&dir=${encodeURIComponent(parent)}` +
91
+ `&channel=chunlei&app_id=${http.APP_ID}&bdstoken=${encodeURIComponent(bdstoken)}`;
92
+ const data = await http.webJson(url);
93
+ if (!data || data.errno !== 0 || !Array.isArray(data.list)) return null;
94
+ const item = data.list.find((f) => f.server_filename === base);
95
+ if (item) {
96
+ return {
97
+ size: typeof item.size === "number" ? item.size : parseInt(item.size, 10) || null,
98
+ md5: item.md5 || null,
99
+ isDir: item.isdir === 1 || item.isdir === "1",
100
+ serverCtime: item.server_ctime != null ? item.server_ctime : null,
101
+ localCtime: item.local_ctime != null ? item.local_ctime : null,
102
+ };
103
+ }
104
+ if (data.list.length < 100) return null; // 翻页结束仍未找到
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function getFileMeta(remotePath) {
110
+ const output = runPCS(["meta", remotePath]);
111
+ let size = null;
112
+
113
+ // BaiduPCS-Go v4.0.1 实测输出: "文件大小 7297727, 6.959655MB"
114
+ // (首个数字为精确字节数;旧正则要求 "大小:" 冒号,实际是空格,永远匹配不到)
115
+ let m = output.match(/文件大小\s*[::]?\s*(\d[\d,]*)/);
116
+ if (m) size = parseInt(m[1].replace(/,/g, ""), 10);
117
+ if (!size) {
118
+ m = output.match(/size[":\s]+(\d+)/i);
119
+ if (m) size = parseInt(m[1], 10);
120
+ }
121
+ if (!size) {
122
+ m = output.match(/(\d[\d,]*)\s*B\b/);
123
+ if (m) size = parseInt(m[1].replace(/,/g, ""), 10);
124
+ }
125
+
126
+ // md5(BaiduPCS-Go meta 输出 "md5 (可能不正确) xxxx",仅作文本回退参考)
127
+ let md5 = null;
128
+ m = output.match(/md5[^0-9a-fA-F]*([0-9a-fA-F]{32})/);
129
+ if (m) md5 = m[1].toLowerCase();
130
+
131
+ // 只看 "类型" 行,避免文件名含"目录"字样时误判(旧正则 /目录/ 会误伤)
132
+ const isDir = /类型\s*[::]?\s*目录/.test(output) || /isdir.*1|is_dir.*true/i.test(output);
133
+
134
+ return { size, isDir, md5, raw: output };
135
+ }
136
+
137
+ function fetchRangeText(url, start, end) {
138
+ const cfg = config.get();
139
+ const buf = http.curlRange(url, start, end, cfg.ua);
140
+ return decodeText(buf);
141
+ }
142
+
143
+ function fetchRangeBuf(url, start, end) {
144
+ const cfg = config.get();
145
+ return http.curlRange(url, start, end, cfg.ua);
146
+ }
147
+
148
+ // ── 编码检测 ──────────────────────────────────────────────
149
+ //
150
+ // 背景:用户的网盘里有大量 GB2312/GBK 编码的中文古籍 TXT。
151
+ // 旧实现用 Buffer.toString("utf-8") 逐个编码尝试 —— 但 toString("utf-8")
152
+ // 遇到非法字节**永不抛错**(替换成 U+FFFD),所以 gbk/gb2312 分支永远走不到,
153
+ // 结果是 75% 字符变乱码,且 grep 在这类文件上静默返回空(比乱码更糟)。
154
+ //
155
+ // 修正方案:
156
+ // 1) UTF-8 用 TextDecoder(fatal:true) 严格检测 —— 非法字节序列会抛错,可靠。
157
+ // 2) GBK / Big5 **不能用 fatal**:Node 的 "gbk" 实际是 gb18030,其 4 字节
158
+ // 序列规则会把合法 GBK 中文误判为非法(实测"台湾土地制度考查报告书"整篇
159
+ // 在 fatal 模式下抛错)。改用"替换字符比例"择优:分别宽松解码,取 U+FFFD
160
+ // 最少的那个。
161
+ // 3) 都没有明显更优解时,回退宽松 UTF-8。
162
+ //
163
+ // Node 13+ 官方二进制自带 full-icu,gbk/big5 标签开箱可用,无需额外依赖。
164
+
165
+ let UTF8_STRICT = null;
166
+ try {
167
+ UTF8_STRICT = new TextDecoder("utf-8", { fatal: true });
168
+ } catch {
169
+ UTF8_STRICT = null;
170
+ }
171
+
172
+ const CANDIDATE_ENCODINGS = ["gbk", "big5"]
173
+ .map((label) => {
174
+ try {
175
+ return { label, decoder: new TextDecoder(label) };
176
+ } catch {
177
+ return null;
178
+ }
179
+ })
180
+ .filter(Boolean);
181
+
182
+ // U+FFFD(替换字符)占比 —— 用来评估某种编码解码结果的"无损程度"
183
+ function replacementRatio(text) {
184
+ if (!text || !text.length) return 0;
185
+ const bad = (text.match(/\uFFFD/g) || []).length;
186
+ return bad / text.length;
187
+ }
188
+
189
+ // 解码为文本;返回 { text, encoding }
190
+ function decodeTextWithEncoding(buf) {
191
+ if (typeof buf === "string") return { text: buf, encoding: "utf-8" };
192
+ if (!buf || buf.length === 0) return { text: "", encoding: "utf-8" };
193
+
194
+ // 1) 严格 UTF-8
195
+ if (UTF8_STRICT) {
196
+ try {
197
+ return { text: UTF8_STRICT.decode(buf), encoding: "utf-8" };
198
+ } catch {
199
+ // 不是合法 UTF-8,继续试其他编码
200
+ }
201
+ }
202
+
203
+ // 2) GBK / Big5 —— 取替换字符最少的
204
+ let best = null;
205
+ for (const { label, decoder } of CANDIDATE_ENCODINGS) {
206
+ const text = decoder.decode(buf);
207
+ const ratio = replacementRatio(text);
208
+ if (!best || ratio < best.ratio) best = { label, text, ratio };
209
+ if (ratio === 0) break; // 完全无损,直接采用
210
+ }
211
+ // 阈值 5%:超过则认为该编码也不匹配,回退宽松 UTF-8
212
+ if (best && best.ratio < 0.05) return { text: best.text, encoding: best.label };
213
+
214
+ // 3) 兜底
215
+ return { text: buf.toString("utf-8"), encoding: "utf-8" };
216
+ }
217
+
218
+ // 解码为文本(只关心内容,不关心编码)
219
+ function decodeText(buf) {
220
+ return decodeTextWithEncoding(buf).text;
221
+ }
222
+
223
+ // ── Pan Operations ─────────────────────────────────────
224
+
225
+ function ls(dirPath = "/") {
226
+ return runPCS(["ls", dirPath]);
227
+ }
228
+
229
+ function search(keyword, searchPath = "/") {
230
+ return runPCS(["search", "-path=" + searchPath, "-r", keyword]);
231
+ }
232
+
233
+ // 列出目录(结构化)。
234
+ // 引擎输出是定宽中文表格(tablewriter,按显示宽对齐),解析成 entries[]。
235
+ // 若解析不到任何条目且检测到引擎失败信号 → 抛错;否则"目录不存在"会被当成
236
+ // "空目录"返回且退出码仍是 0(静默失败,Agent 会误判)。
237
+ // 返回: { path, entries[], totalText, fileCount, dirCount, raw, warning }
238
+ function lsEntries(dirPath = "/") {
239
+ const raw = ls(dirPath);
240
+ const parsed = parseLsTable(raw, dirPath) || { entries: [], totalText: null, fileCount: null, dirCount: null };
241
+ const err = detectEngineError(raw);
242
+ if (err && parsed.entries.length === 0) {
243
+ const e = new Error(err.message);
244
+ if (err.errno !== null) e.errno = err.errno;
245
+ e.engineRaw = err.raw;
246
+ throw e;
247
+ }
248
+ return {
249
+ path: dirPath,
250
+ entries: parsed.entries,
251
+ totalText: parsed.totalText,
252
+ fileCount: parsed.fileCount,
253
+ dirCount: parsed.dirCount,
254
+ raw,
255
+ warning: err ? err.message : null,
256
+ };
257
+ }
258
+
259
+ // 全盘搜索(结构化 + 客户端模式过滤)。
260
+ // mode: "substring"(默认) | "regex" | "glob" | "any" | "exact"
261
+ // engineKeyword: 模式搜索时引擎实际使用的字面片段(见 bdp.js 的 firstLiteralRun)
262
+ function searchEntries(keyword, searchPath = "/", options = {}) {
263
+ const mode = options.mode || "substring";
264
+ const engineKeyword = options.engineKeyword || keyword;
265
+ const raw = search(engineKeyword, searchPath);
266
+ const parsed = parseSearchTable(raw) || { entries: [], totalText: null, fileCount: null, dirCount: null };
267
+ const err = detectEngineError(raw);
268
+ if (err && parsed.entries.length === 0) {
269
+ const e = new Error(err.message);
270
+ if (err.errno !== null) e.errno = err.errno;
271
+ e.engineRaw = err.raw;
272
+ throw e;
273
+ }
274
+ let entries = parsed.entries;
275
+ if (mode !== "substring") {
276
+ const { matchesName } = require("./name-match");
277
+ entries = entries.filter((en) => {
278
+ if (mode === "glob") {
279
+ return matchesName(keyword, en.path, { mode }) || matchesName(keyword, en.name, { mode });
280
+ }
281
+ return matchesName(keyword, en.path, { mode });
282
+ });
283
+ }
284
+ return {
285
+ keyword,
286
+ engineKeyword,
287
+ path: searchPath,
288
+ mode,
289
+ entries,
290
+ total: parsed.entries.length,
291
+ matched: entries.length,
292
+ totalText: parsed.totalText,
293
+ fileCount: parsed.fileCount,
294
+ dirCount: parsed.dirCount,
295
+ raw,
296
+ warning: err ? err.message : null,
297
+ };
298
+ }
299
+
300
+ // 二进制文件拒绝按文本读取/搜索:
301
+ // 否则会把乱码灌进 Agent 上下文,且 grep 在二进制上匹配到的全是噪声。
302
+ function assertTextFile(buf, remotePath) {
303
+ const ft = detectFileType(buf, remotePath);
304
+ if (ft.binary) {
305
+ throw new Error(
306
+ "文件是二进制内容(" + ft.type + "," + ft.reason + "),无法按文本读取/搜索。\n" +
307
+ "处理: 用 bdp get " + remotePath + " 下载到本地后处理;或用 bdp peek " + remotePath + " 查看元信息。"
308
+ );
309
+ }
310
+ return ft;
311
+ }
312
+
313
+ function cat(remotePath, maxBytes) {
314
+ const cfg = config.get();
315
+ const limit = maxBytes || cfg.maxBytes;
316
+ const meta = getFileMeta(remotePath);
317
+
318
+ if (meta.size && meta.size > 50 * 1024 * 1024) {
319
+ process.stderr.write("[WARN] File is " + (meta.size / 1024 / 1024).toFixed(1) + "MB, truncating to " + (limit / 1024).toFixed(0) + "KB\n");
320
+ }
321
+
322
+ const dlink = getDlink(remotePath);
323
+ if (!dlink) throw new Error("无法获取 " + remotePath + " 的下载链接(dlink)。文件可能已删除/移动,或该路径不支持直链下载。");
324
+
325
+ const readBytes = Math.min(meta.size || limit, limit) - 1;
326
+ const buf = fetchRangeBuf(dlink, 0, readBytes);
327
+ assertTextFile(buf, remotePath);
328
+ return decodeText(buf);
329
+ }
330
+
331
+ function head(remotePath, n = 20, maxBytes) {
332
+ const cfg = config.get();
333
+ const limit = maxBytes || cfg.maxBytes;
334
+ const dlink = getDlink(remotePath);
335
+ if (!dlink) throw new Error("无法获取 " + remotePath + " 的下载链接(dlink)。文件可能已删除/移动,或该路径不支持直链下载。");
336
+
337
+ const chunkSize = Math.min(n * 4096 + 1024, limit);
338
+ const buf = fetchRangeBuf(dlink, 0, chunkSize - 1);
339
+ assertTextFile(buf, remotePath);
340
+ return decodeText(buf).split("\n").slice(0, n);
341
+ }
342
+
343
+ function tail(remotePath, n = 20, maxBytes) {
344
+ const cfg = config.get();
345
+ const limit = maxBytes || cfg.maxBytes;
346
+ const meta = getFileMeta(remotePath);
347
+ if (!meta.size) throw new Error("无法获取 " + remotePath + " 的文件大小。");
348
+
349
+ const dlink = getDlink(remotePath);
350
+ if (!dlink) throw new Error("无法获取 " + remotePath + " 的下载链接(dlink)。文件可能已删除/移动,或该路径不支持直链下载。");
351
+
352
+ const chunkSize = Math.min(n * 4096 + 1024, limit, meta.size);
353
+ const start = Math.max(0, meta.size - chunkSize);
354
+ const buf = fetchRangeBuf(dlink, start, meta.size - 1);
355
+ assertTextFile(buf, remotePath);
356
+ return decodeText(buf).split("\n").slice(-n);
357
+ }
358
+
359
+ function grep(pattern, remotePath, options = {}) {
360
+ const cfg = config.get();
361
+ const dlink = getDlink(remotePath);
362
+ if (!dlink) throw new Error("无法获取 " + remotePath + " 的下载链接(dlink)。文件可能已删除/移动,或该路径不支持直链下载。");
363
+
364
+ const buf = fetchRangeBuf(dlink, 0, cfg.maxBytes - 1);
365
+ assertTextFile(buf, remotePath);
366
+ const text = decodeText(buf);
367
+
368
+ let regex;
369
+ try {
370
+ regex = new RegExp(pattern, options.ignoreCase ? "gi" : "g");
371
+ } catch {
372
+ regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), options.ignoreCase ? "gi" : "g");
373
+ }
374
+
375
+ const matches = [];
376
+ const lines = text.split("\n");
377
+ lines.forEach((line, i) => {
378
+ if (regex.test(line)) {
379
+ matches.push({ line: i + 1, content: line });
380
+ }
381
+ regex.lastIndex = 0;
382
+ });
383
+ return matches;
384
+ }
385
+
386
+ async function peek(remotePath) {
387
+ // web 元信息优先(含权威 md5/ctime),失败回退 meta 文本解析
388
+ let webMeta = null;
389
+ try {
390
+ webMeta = await getFileMetaWeb(remotePath);
391
+ } catch {}
392
+ const meta = webMeta || getFileMeta(remotePath);
393
+ const dlink = getDlink(remotePath);
394
+
395
+ const result = {
396
+ path: remotePath,
397
+ size: meta.size,
398
+ isDir: meta.isDir,
399
+ dlink: !!dlink,
400
+ };
401
+
402
+ if (webMeta) {
403
+ if (webMeta.md5) result.md5 = webMeta.md5;
404
+ if (webMeta.serverCtime) result.serverCtime = webMeta.serverCtime;
405
+ if (webMeta.localCtime) result.localCtime = webMeta.localCtime;
406
+ }
407
+
408
+ if (dlink && !meta.isDir) {
409
+ const previewBytes = Math.min(512, meta.size || 512) - 1;
410
+ const buf = fetchRangeBuf(dlink, 0, previewBytes);
411
+
412
+ // 用魔数 + NUL 字节判定类型(旧的"控制字符"启发式会把 PDF 误判成 text)
413
+ const ft = detectFileType(buf, remotePath);
414
+ result.type = ft.type;
415
+ result.binary = ft.binary;
416
+ result.detectReason = ft.reason;
417
+ if (!ft.binary) {
418
+ const dec = decodeTextWithEncoding(buf);
419
+ result.encoding = dec.encoding;
420
+ result.preview = dec.text.split("\n").slice(0, 10);
421
+ }
422
+ }
423
+
424
+ return result;
425
+ }
426
+
427
+ // ── 下载 ────────────────────────────────────────────────
428
+ //
429
+ // v1.2.0 起 bdp get 不再裸跑 BaiduPCS-Go(其 download 无断点续传,且自动跳过
430
+ // 重名文件导致中断后无法续)。改为:
431
+ // filemetas 元信息(/api/list)→ locate dlink → curl 直链分块下载
432
+ // 分块原因(实测 2026-08):部分 CDN 节点(*ct11.baidupcs.com)拒绝无界请求
433
+ // (HTTP 403, error_code 31326, hitcode 104),只允许 ≤~5MB 的有界 Range。
434
+ // 4MB 分块 + 每块失败换新 dlink 重试,同时天然支持断点续传与坏主机规避。
435
+ // 目录或无 dlink 的边界场景仍回退 BaiduPCS-Go 引擎(无校验/续传,保持原行为)。
436
+ //
437
+ // options: { resume, force, progress, verifySize }
438
+ // 硬校验:size 对比 + 0 字节检测(默认全开,失败保留文件供排查)
439
+ // 软校验:md5(百度 /api/list 的 md5 是混淆值,仅本地计算+标注,不阻断)
440
+
441
+ const DOWNLOAD_CHUNK = 4 * 1024 * 1024; // 4MB/块(CT11 节点 range 上限约 5MB)
442
+ const DEFAULT_CONCURRENCY = 3; // 并发分块路数(绕开单连接限速;仅大文件启用)
443
+ const CONCURRENT_THRESHOLD = 32 * 1024 * 1024; // 剩余 >32MB 才启用并发(小文件串行开销更小)
444
+
445
+ async function download(remotePath, saveTo, options = {}) {
446
+ const cfg = config.get();
447
+ const opts = { resume: false, force: false, progress: false, verifySize: true, dryRun: false, concurrency: DEFAULT_CONCURRENCY, ...options };
448
+
449
+ // 1) 元信息:web /api/list 优先(size 权威)→ 失败回退 meta 文本解析。
450
+ // 注:API 的 md5 为混淆键(非内容哈希,实测含非 hex 字符),仅作参考不参与硬校验
451
+ let webMeta = null;
452
+ try {
453
+ webMeta = await getFileMetaWeb(remotePath);
454
+ } catch {}
455
+ const meta = webMeta || getFileMeta(remotePath);
456
+ const remoteMd5 = webMeta ? webMeta.md5 || null : null;
457
+
458
+ // 2) dlink(locate 每次返回新链接;分块失败时自动换新,规避过期/坏节点)
459
+ const dlink = getDlink(remotePath);
460
+ let dlinkExpiry = null;
461
+ if (dlink) {
462
+ const sizeInDlink = dlink.match(/[?&]size=(\d+)/);
463
+ if (sizeInDlink && !meta.size) meta.size = parseInt(sizeInDlink[1], 10);
464
+ const exp = dlink.match(/[?&]expires=([^&]+)/);
465
+ if (exp) dlinkExpiry = exp[1];
466
+ }
467
+
468
+ // 3) 目录或无 dlink → 回退 BaiduPCS-Go 引擎
469
+ if ((meta && meta.isDir) || !dlink) {
470
+ const args = ["d", remotePath];
471
+ if (saveTo) args.push("--saveto", saveTo);
472
+ const raw = runPCS(args);
473
+ const err = detectEngineError(raw);
474
+ if (err) {
475
+ const e = new Error(err.message);
476
+ if (err.errno !== null) e.errno = err.errno;
477
+ e.engineRaw = err.raw;
478
+ throw e;
479
+ }
480
+ // 引擎"下载成功"但 0 字节:远端文件其实不存在/无权限(引擎退出码仍是 0)
481
+ if (meta && meta.size > 0 && /数据总量:\s*0B/.test(raw)) {
482
+ throw new Error("下载失败: 引擎返回 0 字节,但远端文件应为 " + meta.size + " 字节(" + remotePath + " 可能不存在或无访问权限)");
483
+ }
484
+ return {
485
+ path: remotePath,
486
+ engine: "pcs",
487
+ raw,
488
+ warning: meta && meta.isDir
489
+ ? "目录下载走 BaiduPCS-Go 引擎(无校验/断点续传)"
490
+ : "获取 dlink 失败,走 BaiduPCS-Go 引擎",
491
+ };
492
+ }
493
+
494
+ // 4) 本地目标路径:-o 视为目录(自动创建);默认当前目录(与 gdownload 一致)
495
+ const baseName = path.basename(remotePath.replace(/[\\/]+$/, ""));
496
+ const localPath = saveTo ? path.join(saveTo, baseName) : path.join(process.cwd(), baseName);
497
+
498
+ // 已有文件决策
499
+ let existingSize = null;
500
+ try {
501
+ existingSize = fs.statSync(localPath).size;
502
+ } catch {}
503
+
504
+ // --dry-run:不落盘、不创建目录、不请求下载,仅报告将执行的动作
505
+ if (opts.dryRun) {
506
+ let action = "download";
507
+ if (existingSize !== null && !opts.force) {
508
+ if (meta.size != null && existingSize === meta.size) action = "skip";
509
+ else if (meta.size != null && existingSize > meta.size) action = "overwrite";
510
+ else action = "resume";
511
+ }
512
+ return {
513
+ path: remotePath,
514
+ dryRun: true,
515
+ engine: "dry-run",
516
+ action,
517
+ size: meta.size,
518
+ localPath,
519
+ existingSize,
520
+ dlinkExpiry,
521
+ };
522
+ }
523
+
524
+ if (saveTo) fs.mkdirSync(saveTo, { recursive: true });
525
+
526
+ if (!opts.force && existingSize !== null) {
527
+ if (meta.size != null && existingSize === meta.size) {
528
+ return verifyAndBuild(remotePath, localPath, existingSize, remoteMd5, dlinkExpiry, {
529
+ skipped: true, resumed: false,
530
+ });
531
+ }
532
+ if (meta.size != null && existingSize > meta.size) {
533
+ throw new Error(
534
+ `本地文件大于远端: 本地 ${existingSize} 字节 > 远端 ${meta.size} 字节\n` +
535
+ ` 已保留: ${localPath}\n 处理: --force 覆盖重下`
536
+ );
537
+ }
538
+ if (existingSize < (meta.size != null ? meta.size : Infinity)) {
539
+ if (!opts.resume) {
540
+ process.stderr.write(
541
+ `[auto-resume] 检测到部分文件 ${existingSize}/${meta.size != null ? meta.size : "?"} 字节,自动断点续传(--force 可强制重下)\n`
542
+ );
543
+ }
544
+ opts.resume = true;
545
+ }
546
+ }
547
+
548
+ // --force:分块下载是追加写,必须先清掉旧文件
549
+ if (opts.force && existingSize !== null) {
550
+ fs.unlinkSync(localPath);
551
+ existingSize = null;
552
+ }
553
+
554
+ // 6) 分块下载(有界 Range,天然续传)
555
+ const startedAt = Date.now();
556
+ if (meta.size != null && meta.size > 0) {
557
+ await curlChunkedDownload(dlink, remotePath, localPath, meta.size, existingSize || 0, opts);
558
+ } else {
559
+ // 拿不到远端大小(罕见):单请求无界下载(部分节点会拒绝,但无更好的办法)
560
+ const exitCode = await spawnCurl(
561
+ ["-s", "-L", "-C", "-", "--connect-timeout", "30", "-H", `User-Agent: ${cfg.ua}`, "-o", localPath, dlink],
562
+ opts.progress
563
+ );
564
+ if (exitCode !== 0) {
565
+ throw new Error(
566
+ `curl 下载失败 (exit=${exitCode}),已保留部分文件: ${localPath}\n` +
567
+ ` 处理: 重跑 bdp get --resume(会重新获取 dlink)`
568
+ );
569
+ }
570
+ }
571
+ const seconds = (Date.now() - startedAt) / 1000;
572
+
573
+ // 7) 校验(失败均保留文件供排查)
574
+ let stat = null;
575
+ try {
576
+ stat = fs.statSync(localPath);
577
+ } catch {}
578
+ if (!stat) {
579
+ throw new Error(
580
+ `下载失败,目标文件缺失: ${localPath}\n 处理: 重跑 bdp get --resume`
581
+ );
582
+ }
583
+ if (opts.verifySize && meta.size != null) {
584
+ if (stat.size === 0 && meta.size > 0) {
585
+ throw new Error(
586
+ `下载结果 0 字节: dlink 过期或网络中断\n` +
587
+ ` 已保留: ${localPath}\n 处理: 重跑 bdp get --resume(会重新获取 dlink)`
588
+ );
589
+ }
590
+ if (stat.size !== meta.size) {
591
+ throw new Error(
592
+ `大小校验失败: 远端 ${meta.size} 字节 vs 本地 ${stat.size} 字节\n` +
593
+ ` 已保留: ${localPath}\n 处理: --resume 续传 或 --force 重下`
594
+ );
595
+ }
596
+ }
597
+
598
+ return verifyAndBuild(remotePath, localPath, stat.size, remoteMd5, dlinkExpiry, {
599
+ skipped: false, resumed: opts.resume && existingSize > 0, seconds, avgSpeedBps: seconds > 0 ? Math.round(stat.size / seconds) : null,
600
+ });
601
+ }
602
+
603
+ // 分块下载主循环:从 existingSize 开始,每块 ≤4MB 有界 Range,
604
+ // 追加写入目标文件;块失败重试(每次换新 dlink,天然规避过期/坏节点)。
605
+ // 大文件(剩余 >32MB)默认 3 路并发分块(绕开单连接限速),--concurrency 1 可回串行
606
+ async function curlChunkedDownload(dlink, remotePath, localPath, metaSize, existingSize, opts) {
607
+ // 清理同目录残留的陈旧分块临时文件(上次硬中断留下的 .bdp-chunk-*.tmp)
608
+ cleanupStaleChunks(path.dirname(localPath));
609
+ const fd = fs.openSync(localPath, "a");
610
+ const state = { downloaded: existingSize, lastProgress: 0, startedAt: Date.now() };
611
+ try {
612
+ const concurrency = Math.min(Math.max(1, opts.concurrency || DEFAULT_CONCURRENCY), 8);
613
+ if (concurrency > 1 && metaSize - existingSize > CONCURRENT_THRESHOLD) {
614
+ await concurrentChunkLoop(dlink, remotePath, localPath, fd, metaSize, state, concurrency, opts);
615
+ } else {
616
+ // 串行(小文件 / --concurrency 1)
617
+ while (state.downloaded < metaSize) {
618
+ const end = Math.min(state.downloaded + DOWNLOAD_CHUNK - 1, metaSize - 1);
619
+ const data = await fetchRangeChunk(dlink, remotePath, localPath, state.downloaded, end, metaSize, opts);
620
+ fs.writeSync(fd, data);
621
+ state.downloaded += data.length;
622
+ emitProgressIfDue(state, metaSize, opts);
623
+ }
624
+ }
625
+ } finally {
626
+ fs.closeSync(fd);
627
+ }
628
+ if (opts.progress) process.stderr.write("\n");
629
+ return (Date.now() - state.startedAt) / 1000;
630
+ }
631
+
632
+ // 并发分块主循环:固定窗口预取(每块独立有界 Range + 独立 tmp 文件),
633
+ // 按序落盘(fd 写入保持顺序,文件永远是完整前缀);
634
+ // 任一块重试后仍失败 → 等待在途块结束、丢弃其结果,降级串行补齐剩余
635
+ async function concurrentChunkLoop(dlink, remotePath, localPath, fd, metaSize, state, concurrency, opts) {
636
+ let nextStart = state.downloaded;
637
+ let nextWrite = state.downloaded;
638
+ const active = new Map(); // start -> Promise<{start, data} | {start, error}>
639
+ while (nextWrite < metaSize) {
640
+ // 填充并发窗口
641
+ while (active.size < concurrency && nextStart < metaSize) {
642
+ const start = nextStart;
643
+ const end = Math.min(start + DOWNLOAD_CHUNK - 1, metaSize - 1);
644
+ active.set(
645
+ start,
646
+ fetchRangeChunk(dlink, remotePath, localPath, start, end, metaSize, opts)
647
+ .then((data) => ({ start, data }))
648
+ .catch((e) => ({ start, error: e }))
649
+ );
650
+ nextStart = end + 1;
651
+ }
652
+ const result = await active.get(nextWrite);
653
+ active.delete(nextWrite);
654
+ if (result.error) {
655
+ // 并发下块失败(已内部重试换 dlink)→ 丢弃在途结果,降级串行补齐剩余
656
+ await Promise.allSettled([...active.values()]);
657
+ active.clear();
658
+ while (nextWrite < metaSize) {
659
+ const end = Math.min(nextWrite + DOWNLOAD_CHUNK - 1, metaSize - 1);
660
+ const data = await fetchRangeChunk(dlink, remotePath, localPath, nextWrite, end, metaSize, opts);
661
+ fs.writeSync(fd, data);
662
+ nextWrite += data.length;
663
+ state.downloaded = nextWrite;
664
+ emitProgressIfDue(state, metaSize, opts);
665
+ }
666
+ return;
667
+ }
668
+ fs.writeSync(fd, result.data);
669
+ nextWrite += result.data.length;
670
+ state.downloaded = nextWrite;
671
+ emitProgressIfDue(state, metaSize, opts);
672
+ }
673
+ }
674
+
675
+ function emitProgressIfDue(state, metaSize, opts) {
676
+ if (!opts.progress) return;
677
+ const now = Date.now();
678
+ if (now - state.lastProgress > 200 || state.downloaded >= metaSize) {
679
+ state.lastProgress = now;
680
+ emitProgress(state.downloaded, metaSize, state.startedAt);
681
+ }
682
+ }
683
+
684
+ // 下载一个分块;失败重试 3 次,每次重新 locate 换新 dlink
685
+ async function fetchRangeChunk(dlink, remotePath, localPath, start, end, metaSize, opts, attempt = 0) {
686
+ const cfg = config.get();
687
+ const expected = end - start + 1;
688
+ const tmpPath = path.join(
689
+ path.dirname(localPath),
690
+ `.bdp-chunk-${process.pid}-${start}.tmp`
691
+ );
692
+ const args = [
693
+ "-s", "-L",
694
+ "-r", `${start}-${end}`,
695
+ "-o", tmpPath,
696
+ "-w", "%{http_code} %{size_download}",
697
+ "--connect-timeout", "30",
698
+ "--speed-limit", "10240", "--speed-time", "30",
699
+ "-H", `User-Agent: ${cfg.ua}`,
700
+ dlink,
701
+ ];
702
+ const { exitCode, stdout } = await spawnCurlCapture(args);
703
+ if (exitCode === 0) {
704
+ const m = (stdout || "").trim().match(/^(\d+) (\d+)$/);
705
+ if (m && m[1] === "206" && parseInt(m[2], 10) === expected) {
706
+ const data = fs.readFileSync(tmpPath);
707
+ try { fs.unlinkSync(tmpPath); } catch {}
708
+ return data;
709
+ }
710
+ }
711
+ try { fs.unlinkSync(tmpPath); } catch {}
712
+
713
+ if (attempt < 3) {
714
+ process.stderr.write(`[retry] 分块 ${start}-${end} 失败 (exit=${exitCode} resp=${(stdout || "").trim() || "?"}),换新 dlink 重试...\n`);
715
+ const freshDlink = getDlink(remotePath);
716
+ if (freshDlink) return fetchRangeChunk(freshDlink, remotePath, localPath, start, end, metaSize, opts, attempt + 1);
717
+ }
718
+ throw new Error(
719
+ `下载中断于 ${start}/${metaSize} 字节: dlink 过期或网络中断或 CDN 拒绝(resp=${(stdout || "").trim() || "?"})\n` +
720
+ ` 已保留部分文件: ${localPath}\n 处理: 重跑 bdp get --resume(会重新获取 dlink 续传)`
721
+ );
722
+ }
723
+
724
+ // 清理下载目录中残留的陈旧分块临时文件(硬中断(SIGKILL/断电)留下的
725
+ // .bdp-chunk-<pid>-<start>.tmp)。判定:
726
+ // 1) 文件名 pid ≠ 当前进程 → 陈旧(并发下载的占用文件在 Windows 上删除会
727
+ // EPERM 安全跳过;Unix 上 unlink 后 curl 继续写已打开 fd,仅浪费一块重试)
728
+ // 2) mtime 超 10 分钟 → 陈旧兜底
729
+ function cleanupStaleChunks(dir) {
730
+ const myPid = process.pid;
731
+ const cutoff = Date.now() - 10 * 60 * 1000;
732
+ let entries;
733
+ try {
734
+ entries = fs.readdirSync(dir);
735
+ } catch {
736
+ return;
737
+ }
738
+ for (const name of entries) {
739
+ if (!name.startsWith(".bdp-chunk-") || !name.endsWith(".tmp")) continue;
740
+ const m = name.match(/^\.bdp-chunk-(\d+)-/);
741
+ try {
742
+ const st = fs.statSync(path.join(dir, name));
743
+ const staleByPid = m && parseInt(m[1], 10) !== myPid;
744
+ const staleByAge = st.mtimeMs < cutoff;
745
+ if (staleByPid || staleByAge) fs.unlinkSync(path.join(dir, name));
746
+ } catch {}
747
+ }
748
+ }
749
+
750
+ function emitProgress(downloaded, total, startedAt) { const seconds = (Date.now() - startedAt) / 1000;
751
+ const speed = seconds > 0 ? downloaded / seconds : 0;
752
+ const pct = Math.round((downloaded / total) * 100);
753
+ const units = ["B", "KB", "MB", "GB"];
754
+ const fmt = (n) => {
755
+ if (!n) return "0B";
756
+ const i = Math.floor(Math.log(n) / Math.log(1024));
757
+ return (n / Math.pow(1024, i)).toFixed(1) + units[i];
758
+ };
759
+ process.stderr.write(
760
+ `\r[get] ${fmt(downloaded)}/${fmt(total)} (${pct}%) ${fmt(speed)}/s `
761
+ );
762
+ }
763
+
764
+ // 最终校验并组装返回信息。
765
+ // md5 为软校验:实测百度 /api/list 返回的 md5 是混淆键(含 o/t 等非 hex 字符,
766
+ // 与真实内容不符,dlink URL 里也用它做文件键),无法作为完整性依据。
767
+ // 仅当远端 md5 是标准 32 位 hex 且与本地一致时才能确认(md5Match=true);
768
+ // 混淆时标注 md5Obfuscated,从不硬失败。size 校验由调用方负责(精确)。
769
+ function verifyAndBuild(remotePath, localPath, size, remoteMd5, dlinkExpiry, extra) {
770
+ const localMd5 = md5File(localPath);
771
+ const remoteHex = typeof remoteMd5 === "string" && /^[0-9a-f]{32}$/i.test(remoteMd5);
772
+ let md5Match = null;
773
+ if (remoteMd5 && remoteHex) {
774
+ md5Match = localMd5 === remoteMd5.toLowerCase();
775
+ }
776
+ return {
777
+ path: remotePath,
778
+ localPath,
779
+ size,
780
+ md5: localMd5,
781
+ remoteMd5,
782
+ md5Match,
783
+ md5Obfuscated: !!remoteMd5 && !remoteHex,
784
+ dlinkExpiry,
785
+ verified: true,
786
+ ...extra,
787
+ };
788
+ }
789
+
790
+ function md5File(filePath) {
791
+ const hash = crypto.createHash("md5");
792
+ hash.update(fs.readFileSync(filePath));
793
+ return hash.digest("hex");
794
+ }
795
+
796
+ function spawnCurl(args, forwardStderr) {
797
+ return new Promise((resolve) => {
798
+ const { spawn } = childProc();
799
+ const child = spawn("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
800
+ child.stderr.on("data", (chunk) => {
801
+ if (forwardStderr) process.stderr.write(chunk);
802
+ });
803
+ child.on("error", (err) => resolve("SPAWN:" + err.message));
804
+ child.on("close", (code) => resolve(code === null ? "CLOSED" : code));
805
+ });
806
+ }
807
+
808
+ // 捕获 curl 的 -w 输出(stdout)+ 退出码
809
+ function spawnCurlCapture(args) {
810
+ return new Promise((resolve) => {
811
+ const { spawn } = childProc();
812
+ const child = spawn("curl", args, { stdio: ["ignore", "pipe", "pipe"] });
813
+ let stdout = "";
814
+ let stderr = "";
815
+ child.stdout.on("data", (c) => { stdout += c.toString(); });
816
+ child.stderr.on("data", (c) => { stderr += c.toString(); });
817
+ child.on("error", (err) => resolve({ exitCode: "SPAWN:" + err.message, stdout: "", stderr: "" }));
818
+ child.on("close", (code) => resolve({ exitCode: code === null ? "CLOSED" : code, stdout, stderr }));
819
+ });
820
+ }
821
+
822
+ function upload(localPath, remotePath) {
823
+ // check=true:引擎失败时退出码仍是 0(错误只在输出文本里),必须解析输出才能判定成败
824
+ return runPCS(["u", localPath, remotePath], { check: true });
825
+ }
826
+
827
+ // mkdir 幂等:目录已存在(errno=31061)不算失败,返回 exists=true。
828
+ // 返回 { path, created, exists, raw }
829
+ function mkdir(remotePath) {
830
+ const raw = runPCS(["mkdir", remotePath]);
831
+ const err = detectEngineError(raw);
832
+ if (err) {
833
+ if (err.errno === 31061) {
834
+ return { path: remotePath, created: false, exists: true, raw };
835
+ }
836
+ const e = new Error(err.message);
837
+ if (err.errno !== null) e.errno = err.errno;
838
+ e.engineRaw = err.raw;
839
+ throw e;
840
+ }
841
+ return { path: remotePath, created: true, exists: false, raw };
842
+ }
843
+
844
+ // 删除一个或多个路径。逐个执行并汇总结果(单个失败不影响其余)。
845
+ // 返回 [{ path, ok, error?, errno?, raw }]
846
+ function remove(remotePaths) {
847
+ const paths = Array.isArray(remotePaths) ? remotePaths : [remotePaths];
848
+ const results = [];
849
+ for (const p of paths) {
850
+ const raw = runPCS(["rm", p]);
851
+ const err = detectEngineError(raw);
852
+ if (err) {
853
+ results.push({ path: p, ok: false, error: err.message, errno: err.errno, raw });
854
+ continue;
855
+ }
856
+ if (/操作失败/.test(raw)) {
857
+ results.push({ path: p, ok: false, error: "引擎报告删除失败", raw });
858
+ continue;
859
+ }
860
+ results.push({ path: p, ok: true, raw });
861
+ }
862
+ return results;
863
+ }
864
+
865
+ // ── 容量分析 (du) ──────────────────────────────────────
866
+ //
867
+ // 百度没有服务端目录大小接口,只能递归 /api/list 求和。
868
+ // 并发遍历 + 深度限制 + 失败容忍;目录过多时给出部分结果与 failed 计数。
869
+
870
+ const DU_DEFAULT_DEPTH = 3;
871
+ const DU_DEFAULT_TOP = 15;
872
+ const DU_DEFAULT_CONCURRENCY = 8;
873
+ const DU_MAX_DEPTH = 8; // 深度上限(超过会被静默钳制,这里显式声明并在返回值里告知)
874
+
875
+ // 递归统计目录大小。
876
+ // 返回 { path, size, files, dirs, failed, children[], truncated, partial,
877
+ // depthRequested, depthLimit, depthCapped, elapsedMs }
878
+ //
879
+ // partial=true 表示"因深度封顶导致 size 不完整"——
880
+ // 此时 size 会明显小于 bdp quota 的已用空间(实测 depth=3 时 598GB vs 6.64TB)。
881
+ async function du(dirPath, options = {}) {
882
+ const requestedDepth = options.depth || DU_DEFAULT_DEPTH;
883
+ const depth = Math.min(Math.max(1, requestedDepth), DU_MAX_DEPTH);
884
+ const concurrency = Math.min(Math.max(1, options.concurrency || DU_DEFAULT_CONCURRENCY), 16);
885
+ const top = Math.max(1, options.top || DU_DEFAULT_TOP);
886
+ const root = dirPath && dirPath !== "/" ? dirPath.replace(/\/+$/, "") : "/";
887
+
888
+ const result = {
889
+ path: root,
890
+ size: 0,
891
+ files: 0,
892
+ dirs: 0,
893
+ failed: 0,
894
+ children: [], // { path, size, files, dirs, capped, partial }
895
+ truncated: false, // top-N 截断了 children 列表
896
+ partial: false, // 深度封顶导致 size 不完整
897
+ depthRequested: requestedDepth,
898
+ depthLimit: depth,
899
+ depthCapped: requestedDepth > depth,
900
+ elapsedMs: 0,
901
+ };
902
+ const startedAt = Date.now();
903
+
904
+ // 目录 → { size, files, children: Map<name, child> }
905
+ const nodeFor = (p) => {
906
+ let node = tree.get(p);
907
+ if (!node) {
908
+ node = { size: 0, files: 0, dirs: 0, children: new Map(), capped: false, partial: false };
909
+ tree.set(p, node);
910
+ }
911
+ return node;
912
+ };
913
+
914
+ const tree = new Map();
915
+ const pending = [root];
916
+ let completed = 0;
917
+ const failedDirs = [];
918
+ let seenFiles = 0;
919
+
920
+ async function worker() {
921
+ for (;;) {
922
+ const dir = pending.pop();
923
+ if (dir === undefined) return;
924
+ const node = nodeFor(dir);
925
+ let ok = false;
926
+ try {
927
+ for (let page = 1; page <= 50; page++) {
928
+ const data = await getFileListPage(dir, page);
929
+ if (!data || data.errno !== 0 || !Array.isArray(data.list)) break;
930
+ ok = true;
931
+ const list = data.list;
932
+ for (const item of list) {
933
+ const isDir = item.isdir === 1 || item.isdir === "1";
934
+ const size = Number(item.size) || 0;
935
+ node.size += size;
936
+ if (isDir) {
937
+ node.dirs++;
938
+ const childPath = (dir === "/" ? "" : dir) + "/" + item.server_filename;
939
+ const childNode = nodeFor(childPath);
940
+ node.children.set(item.server_filename, childNode); // 登记父子关系
941
+ if (dirDepth(childPath) <= depth) {
942
+ pending.push(childPath);
943
+ } else {
944
+ // 深度封顶:不再下钻,只记录该子目录为独立叶子
945
+ childNode.capped = true;
946
+ childNode.partial = true;
947
+ }
948
+ } else {
949
+ node.files++;
950
+ seenFiles++;
951
+ }
952
+ }
953
+ if (list.length < 100) break;
954
+ }
955
+ } catch {
956
+ // 网络异常 → 计入失败
957
+ }
958
+ completed++;
959
+ if (!ok) failedDirs.push(dir);
960
+ if (typeof options.onProgress === "function" && completed % 20 === 0) {
961
+ options.onProgress({
962
+ scannedDirs: completed,
963
+ pending: pending.length,
964
+ files: seenFiles,
965
+ elapsedMs: Date.now() - startedAt,
966
+ });
967
+ }
968
+ }
969
+ }
970
+
971
+ function dirDepth(p) {
972
+ if (p === "/") return 0;
973
+ return p.split("/").filter(Boolean).length;
974
+ }
975
+
976
+ const workers = Array.from({ length: concurrency }, worker);
977
+ await Promise.all(workers);
978
+
979
+ // 汇总:自底向上把 children 的聚合大小并入父节点(深度降序 → 子节点 total 先算好)
980
+ const sortedPaths = [...tree.keys()].sort((a, b) => dirDepth(b) - dirDepth(a));
981
+ for (const p of sortedPaths) {
982
+ const node = tree.get(p);
983
+ let childTotal = 0;
984
+ let anyPartial = false;
985
+ for (const child of node.children.values()) {
986
+ childTotal += child.total;
987
+ node.files += child.files; // 文件/目录数同样聚合到父节点
988
+ node.dirs += child.dirs;
989
+ if (child.capped || child.partial) anyPartial = true;
990
+ }
991
+ node.total = node.size + childTotal;
992
+ // partial 沿路径向上传播:只要有一个后代被封顶,父目录的 size 就不完整
993
+ node.partial = node.partial || anyPartial;
994
+ node.childList = [...node.children.entries()]
995
+ .map(([name, c]) => ({
996
+ name,
997
+ path: (p === "/" ? "" : p) + "/" + name,
998
+ size: c.total,
999
+ files: c.files,
1000
+ dirs: c.dirs,
1001
+ capped: !!c.capped, // 该节点自身未被扫描(深度封顶)
1002
+ partial: !!(c.capped || c.partial), // 其子树被封顶 → size 不完整
1003
+ }))
1004
+ .sort((a, b) => b.size - a.size);
1005
+ }
1006
+
1007
+ const rootNode = tree.get(root);
1008
+ result.size = rootNode ? rootNode.total : 0;
1009
+ result.files = rootNode ? rootNode.files : 0;
1010
+ result.dirs = rootNode ? rootNode.dirs : 0;
1011
+ result.children = rootNode ? rootNode.childList.slice(0, top) : [];
1012
+ result.truncated = rootNode ? rootNode.childList.length > top : false;
1013
+ result.partial = rootNode ? !!rootNode.partial : false;
1014
+ result.failed = failedDirs.length;
1015
+ result.elapsedMs = Date.now() - startedAt;
1016
+ return result;
1017
+ }
1018
+
1019
+ // /api/list 单页(含 bdstoken,复用 getFileMetaWeb 的链路)
1020
+ async function getFileListPage(dir, page) {
1021
+ const bdstoken = await http.getBdstoken();
1022
+ const url =
1023
+ `https://pan.baidu.com/api/list?order=name&desc=0&showempty=0&web=1` +
1024
+ `&page=${page}&num=100&dir=${encodeURIComponent(dir)}` +
1025
+ `&channel=chunlei&app_id=${http.APP_ID}&bdstoken=${encodeURIComponent(bdstoken)}`;
1026
+ return http.webJson(url);
1027
+ }
1028
+
1029
+ module.exports = {
1030
+ ls, search, lsEntries, searchEntries, cat, head, tail, grep, peek,
1031
+ download, upload, mkdir, remove, du,
1032
+ getDlink, getFileMeta, getFileMetaWeb, fetchRangeText,
1033
+ detectEngineError, decodeText, decodeTextWithEncoding,
1034
+ };