baiduwangpan-cli 1.1.3 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -21
- package/bin/bdp.js +797 -93
- package/lib/config.js +101 -11
- package/lib/console.js +99 -0
- package/lib/group.js +196 -26
- package/lib/http.js +10 -2
- package/lib/index.js +3 -1
- package/lib/name-match.js +67 -0
- package/lib/pan.js +582 -19
- package/lib/pcs-extra.js +318 -0
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -7,6 +7,9 @@ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
|
7
7
|
|
|
8
8
|
const PCS_BINARY_NAMES = ["BaiduPCS-Go.exe", "BaiduPCS-Go"];
|
|
9
9
|
|
|
10
|
+
// 认证相关字段:profile 只覆盖这些字段,pcsPath/ua/maxBytes 保持全局
|
|
11
|
+
const AUTH_KEYS = ["bduss", "stoken", "cookie", "webTransport", "browserProfile", "browserPort"];
|
|
12
|
+
|
|
10
13
|
function load() {
|
|
11
14
|
try {
|
|
12
15
|
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
@@ -66,17 +69,25 @@ function findPCS() {
|
|
|
66
69
|
|
|
67
70
|
function get() {
|
|
68
71
|
const fileCfg = load();
|
|
72
|
+
const profile = profileOf(fileCfg);
|
|
73
|
+
const src = { ...fileCfg };
|
|
74
|
+
if (profile) {
|
|
75
|
+
// profile 只覆盖认证相关字段;pcsPath/ua/maxBytes 保持全局
|
|
76
|
+
for (const key of AUTH_KEYS) {
|
|
77
|
+
if (key in profile) src[key] = profile[key];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
69
80
|
return {
|
|
70
|
-
bduss:
|
|
71
|
-
stoken:
|
|
72
|
-
cookie:
|
|
73
|
-
webTransport:
|
|
74
|
-
browserProfile:
|
|
75
|
-
browserPort: parseInt(
|
|
76
|
-
pcsPath: resolvePcsPath(
|
|
77
|
-
ua:
|
|
81
|
+
bduss: src.bduss || process.env.BDP_BDUSS || "",
|
|
82
|
+
stoken: src.stoken || process.env.BDP_STOKEN || "",
|
|
83
|
+
cookie: src.cookie || process.env.BDP_COOKIE || "",
|
|
84
|
+
webTransport: src.webTransport || "curl",
|
|
85
|
+
browserProfile: src.browserProfile || "",
|
|
86
|
+
browserPort: parseInt(src.browserPort || "0", 10) || 0,
|
|
87
|
+
pcsPath: resolvePcsPath(src),
|
|
88
|
+
ua: src.ua || process.env.NETDISK_UA ||
|
|
78
89
|
"netdisk;P2SP;3.0.0.8;netdisk;11.12.3;ANG-AN00;android-android;10.0;JSbridge4.4.0;jointBridge;1.1.0;",
|
|
79
|
-
maxBytes: parseInt(
|
|
90
|
+
maxBytes: parseInt(src.maxBytes || process.env.BDP_MAX_CAT_BYTES || "1048576", 10),
|
|
80
91
|
};
|
|
81
92
|
}
|
|
82
93
|
|
|
@@ -108,7 +119,18 @@ function update(values) {
|
|
|
108
119
|
|
|
109
120
|
function setAuth(values) {
|
|
110
121
|
const cfg = load();
|
|
111
|
-
|
|
122
|
+
const name = activeProfileName(cfg);
|
|
123
|
+
const prof = profileOf(cfg);
|
|
124
|
+
if (name && prof) {
|
|
125
|
+
// 激活 profile:只重写该 profile 的认证字段,全局字段与其他 profile 不受影响
|
|
126
|
+
const merged = { ...prof };
|
|
127
|
+
for (const key of AUTH_KEYS) delete merged[key];
|
|
128
|
+
cfg.profiles[name] = { ...merged, ...values };
|
|
129
|
+
save(cfg);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// 无激活 profile:保持原语义,认证字段写在全局
|
|
133
|
+
for (const key of AUTH_KEYS) {
|
|
112
134
|
delete cfg[key];
|
|
113
135
|
}
|
|
114
136
|
// setAuth 只替换认证相关字段,保留 pcsPath/ua/maxBytes 等自定义配置(升级不丢 pcsPath)
|
|
@@ -120,4 +142,72 @@ function isLoggedIn() {
|
|
|
120
142
|
return !!(cfg.bduss && cfg.stoken);
|
|
121
143
|
}
|
|
122
144
|
|
|
123
|
-
|
|
145
|
+
// ---------- profile(多账号)----------
|
|
146
|
+
// 存储:config.json 内 "profiles": { "<name>": { 认证字段覆盖 } } + "activeProfile": "<name>"。
|
|
147
|
+
// 无 activeProfile 时一切行为与单账号完全一致(向后兼容)。
|
|
148
|
+
|
|
149
|
+
function activeProfileName(fileCfg = null) {
|
|
150
|
+
const cfg = fileCfg || load();
|
|
151
|
+
const name = cfg.activeProfile;
|
|
152
|
+
return typeof name === "string" && name !== "" ? name : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 返回激活 profile 的对象;activeProfile 未设置或对应条目缺失/畸形时返回 null
|
|
156
|
+
function profileOf(fileCfg) {
|
|
157
|
+
const name = activeProfileName(fileCfg);
|
|
158
|
+
if (!name) return null;
|
|
159
|
+
const profiles = fileCfg.profiles;
|
|
160
|
+
if (!profiles || typeof profiles !== "object" || Array.isArray(profiles)) return null;
|
|
161
|
+
const p = profiles[name];
|
|
162
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) return null;
|
|
163
|
+
return p;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 切换当前 profile 并持久化;name 为空串/null/undefined 表示切回全局(等价 unsetProfile())
|
|
167
|
+
function useProfile(name) {
|
|
168
|
+
if (name === undefined || name === null) return unsetProfile();
|
|
169
|
+
const n = String(name).trim();
|
|
170
|
+
if (n === "") return unsetProfile();
|
|
171
|
+
const cfg = load();
|
|
172
|
+
if (!cfg.profiles || typeof cfg.profiles !== "object" || Array.isArray(cfg.profiles)) {
|
|
173
|
+
cfg.profiles = {};
|
|
174
|
+
}
|
|
175
|
+
const existing = cfg.profiles[n];
|
|
176
|
+
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
|
177
|
+
cfg.profiles[n] = {}; // name 不存在时建空 profile
|
|
178
|
+
}
|
|
179
|
+
cfg.activeProfile = n;
|
|
180
|
+
save(cfg);
|
|
181
|
+
return n;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 切回全局:删除 activeProfile;未激活时不做任何写操作
|
|
185
|
+
function unsetProfile() {
|
|
186
|
+
const cfg = load();
|
|
187
|
+
if (!("activeProfile" in cfg)) return null;
|
|
188
|
+
delete cfg.activeProfile;
|
|
189
|
+
save(cfg);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 当前 profile 名(无 → null)
|
|
194
|
+
function getProfile() {
|
|
195
|
+
return activeProfileName();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// [{ name, bduss? }],按配置文件中的存储顺序
|
|
199
|
+
function listProfiles() {
|
|
200
|
+
const cfg = load();
|
|
201
|
+
const profiles = cfg.profiles;
|
|
202
|
+
if (!profiles || typeof profiles !== "object" || Array.isArray(profiles)) return [];
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const [name, p] of Object.entries(profiles)) {
|
|
205
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) continue;
|
|
206
|
+
const entry = { name };
|
|
207
|
+
if (p.bduss) entry.bduss = p.bduss;
|
|
208
|
+
out.push(entry);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { get, set, update, setAuth, load, save, isLoggedIn, useProfile, unsetProfile, getProfile, listProfiles, findPCS, findPCSInDir, resolvePcsPath, CONFIG_DIR, CONFIG_FILE };
|
package/lib/console.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 控制台编码工具
|
|
5
|
+
*
|
|
6
|
+
* 背景: 在中文 Windows (zh-CN) 上, 控制台默认代码页为 cp936 (GBK),
|
|
7
|
+
* 而 Node 的 stdout 始终输出 UTF-8 字节, 导致 `bdp` 的中文输出在
|
|
8
|
+
* PowerShell / cmd 中显示乱码 (如 "测试中文输出" 显示为 "娴嬭瘯涓枃杈撳嚭")。
|
|
9
|
+
*
|
|
10
|
+
* 修复思路: 检测当前代码页, 若不是 65001 (UTF-8) 且标准输出是终端,
|
|
11
|
+
* 则通过 `cmd /c chcp 65001` 切换代码页, 使控制台按 UTF-8 解释输出。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { spawnSync, execSync } = require("node:child_process");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 解析 `cmd /c chcp` 的输出, 提取代码页数字。
|
|
18
|
+
*
|
|
19
|
+
* 注意: chcp 输出的语言随当前代码页变化 ——
|
|
20
|
+
* 代码页为 936 时: "活动代码页: 936" (GBK 编码, 中文标签)
|
|
21
|
+
* 代码页为 65001 时: "Active code page: 65001" (英文标签)
|
|
22
|
+
* 因此同时兼容中英文两种格式。
|
|
23
|
+
*
|
|
24
|
+
* @param {string} output `cmd /c chcp` 的原始输出
|
|
25
|
+
* @returns {number|null} 代码页数字; 空串或无法解析时返回 null
|
|
26
|
+
*/
|
|
27
|
+
function parseChcp(output) {
|
|
28
|
+
if (typeof output !== "string" || output.trim() === "") return null;
|
|
29
|
+
// 兼容 "Active code page: 936" 与 "活动代码页: 936" 两种格式
|
|
30
|
+
const m = output.match(/(?:code\s*page|代码页)\s*[::]?\s*(\d+)/i);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
const n = Number(m[1]);
|
|
33
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 从 `cmd /c chcp` 的原始输出 Buffer 中解析代码页数字。
|
|
38
|
+
* 无论输出是 UTF-8 还是 GBK 编码, 数字本身都是 ASCII, 因此兼容两种场景:
|
|
39
|
+
* 1) 按 UTF-8 解码解析;
|
|
40
|
+
* 2) 失败则按 GBK 解码 (TextDecoder, 依赖 ICU; 无 ICU 时静默跳过);
|
|
41
|
+
* 3) 兜底: 直接从原始字节中提取数字 (任何编码下数字均为 ASCII)。
|
|
42
|
+
*
|
|
43
|
+
* @param {Buffer} buf chcp 输出的原始字节
|
|
44
|
+
* @returns {number|null} 代码页数字; 解析失败返回 null
|
|
45
|
+
*/
|
|
46
|
+
function resolveChcpNumber(buf) {
|
|
47
|
+
let cp = parseChcp(buf.toString("utf8"));
|
|
48
|
+
if (cp !== null) return cp;
|
|
49
|
+
try {
|
|
50
|
+
cp = parseChcp(new TextDecoder("gbk").decode(buf));
|
|
51
|
+
} catch {
|
|
52
|
+
/* 构建无 GBK 解码器时跳过 */
|
|
53
|
+
}
|
|
54
|
+
if (cp !== null) return cp;
|
|
55
|
+
const m = buf.toString("latin1").match(/(\d{3,5})/);
|
|
56
|
+
return m ? Number(m[1]) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 确保 Windows 控制台使用 UTF-8 代码页, 避免中文乱码。
|
|
61
|
+
*
|
|
62
|
+
* 仅在同时满足以下条件时才尝试切换:
|
|
63
|
+
* - process.platform === "win32" (其他平台无此问题)
|
|
64
|
+
* - process.stdout.isTTY (标准输出是终端; 重定向/管道场景不处理)
|
|
65
|
+
* - 当前代码页不是 65001 (已是 UTF-8 则跳过)
|
|
66
|
+
*
|
|
67
|
+
* 切换失败时静默忽略, 不抛异常; 幂等, 可安全重复调用。
|
|
68
|
+
*/
|
|
69
|
+
function ensureUtf8Console() {
|
|
70
|
+
if (process.platform !== "win32" || !process.stdout.isTTY) return;
|
|
71
|
+
|
|
72
|
+
// 1. 查询当前代码页 (spawnSync 返回原始 Buffer, 不经过控制台解码)
|
|
73
|
+
let current = null;
|
|
74
|
+
try {
|
|
75
|
+
const r = spawnSync("cmd", ["/c", "chcp"], {
|
|
76
|
+
encoding: "buffer",
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
});
|
|
79
|
+
if (r.status !== 0 || !r.stdout || r.stdout.length === 0) return;
|
|
80
|
+
current = resolveChcpNumber(r.stdout);
|
|
81
|
+
} catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2. 已是最新代码页或无法解析, 无需处理
|
|
86
|
+
if (current === null || current === 65001) return;
|
|
87
|
+
|
|
88
|
+
// 3. 切换代码页。
|
|
89
|
+
// 注意: 不能用 windowsHide —— 隐藏窗口的子进程会被分配独立的隐藏控制台,
|
|
90
|
+
// 其中的 chcp 只改那个隐藏控制台的代码页, 对用户所在控制台无效 (已实测)。
|
|
91
|
+
// 失败静默 (例如控制台被其它程序独占)。
|
|
92
|
+
try {
|
|
93
|
+
execSync("cmd /c chcp 65001 >nul", { stdio: "ignore" });
|
|
94
|
+
} catch {
|
|
95
|
+
/* 静默失败 */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { parseChcp, ensureUtf8Console };
|
package/lib/group.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require("fs");
|
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const os = require("os");
|
|
6
6
|
const crypto = require("crypto");
|
|
7
|
+
const { matchesName: matchName } = require("./name-match");
|
|
7
8
|
|
|
8
9
|
// ── 常量(逆向实测结论)────────────────────────────────
|
|
9
10
|
const MAX_PAGE_SIZE = 100; // shareinfo 服务端每页硬上限(num>100 不返回更多)
|
|
@@ -88,7 +89,7 @@ class ShareApiError extends Error {
|
|
|
88
89
|
// errno=2131 的详细说明(bdp error 2131 与 gls 报错共用)
|
|
89
90
|
function describeErrno2131(gidS, msgIdS) {
|
|
90
91
|
return (
|
|
91
|
-
`shareinfo
|
|
92
|
+
`shareinfo 请求被拒: msg_id ${msgIdS} 不属于群 ${gidS} 的任何分享(errno=2131)。` +
|
|
92
93
|
`群分享参数必须同源:gid 与 --from-uk/--msg-id/--parent-fs-id 应来自同一次 gshares/gsearch 结果。` +
|
|
93
94
|
`处理:运行 bdp gshares ${gidS} 查看本群分享(或 bdp gsearch ${gidS} <关键词>),` +
|
|
94
95
|
`取其中一行的 fsId/fromUk/msgId 重试;fsId 为子目录时保持同一行的 fromUk/msgId。`
|
|
@@ -107,13 +108,13 @@ function isSelfEcho(data, fsIdS) {
|
|
|
107
108
|
|
|
108
109
|
async function listGroups() {
|
|
109
110
|
const bdstoken = await getBdstoken();
|
|
110
|
-
if (!bdstoken) throw new Error("
|
|
111
|
+
if (!bdstoken) throw new Error("无法获取 bdstoken — 请检查 BDUSS/STOKEN 配置(或重新执行 bdp login)");
|
|
111
112
|
|
|
112
113
|
const data = await http.webJson(
|
|
113
114
|
`${http.PAN_BASE}/mbox/group/list?clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`
|
|
114
115
|
);
|
|
115
116
|
|
|
116
|
-
if (data.errno !== 0) throw new Error(`API
|
|
117
|
+
if (data.errno !== 0) throw new Error(`API 返回错误: errno=${data.errno}(可运行 bdp error ${data.errno} 查看含义)`);
|
|
117
118
|
|
|
118
119
|
return (data.records || []).map((g) => ({
|
|
119
120
|
gid: toStr(g.gid),
|
|
@@ -151,7 +152,7 @@ async function listShares(gid, options = {}) {
|
|
|
151
152
|
`&limit=50&desc=1${cursor ? `&last_msg_time=${cursor}` : ""}&bdstoken=${bdstoken}`;
|
|
152
153
|
const data = await http.webJson(url, { method: "POST", body: "" });
|
|
153
154
|
|
|
154
|
-
if (data.errno !== 0) throw new Error(`API
|
|
155
|
+
if (data.errno !== 0) throw new Error(`API 返回错误: errno=${data.errno}(可运行 bdp error ${data.errno} 查看含义)`);
|
|
155
156
|
|
|
156
157
|
let added = 0;
|
|
157
158
|
for (const msg of data.records?.msg_list || []) {
|
|
@@ -211,10 +212,10 @@ async function shareInfoRaw(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
|
|
|
211
212
|
const data = await http.webJson(url, { method: "POST", body: "" });
|
|
212
213
|
|
|
213
214
|
if (data.errno !== 0) {
|
|
214
|
-
throw new ShareApiError(data.errno, `API
|
|
215
|
+
throw new ShareApiError(data.errno, `API 返回错误: errno=${data.errno}(可运行 bdp error ${data.errno} 查看含义)`);
|
|
215
216
|
}
|
|
216
217
|
if (isSelfEcho(data, fsIdS)) {
|
|
217
|
-
const e = new ShareApiError(-3, `shareinfo
|
|
218
|
+
const e = new ShareApiError(-3, `shareinfo 被限流(self-echo): fs_id ${fsIdS}`);
|
|
218
219
|
e.throttled = true;
|
|
219
220
|
throw e;
|
|
220
221
|
}
|
|
@@ -228,7 +229,7 @@ async function shareInfoRaw(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
|
|
|
228
229
|
// budget 为整条命令的请求预算,耗尽后拒绝新请求(防深扫失控)。
|
|
229
230
|
async function shareInfoRawWithRetry(gidS, fsIdS, fromUkS, msgIdS, page, pageSize, budget, throttleState) {
|
|
230
231
|
if (budget) {
|
|
231
|
-
if (budget.remaining <= 0) throw new ShareApiError(-3, "shareinfo
|
|
232
|
+
if (budget.remaining <= 0) throw new ShareApiError(-3, "shareinfo 请求预算已耗尽,请增大 --max-requests 或稍后重跑");
|
|
232
233
|
budget.remaining--;
|
|
233
234
|
}
|
|
234
235
|
let attempts = 0;
|
|
@@ -317,8 +318,8 @@ async function listFiles(gid, fsId, options = {}) {
|
|
|
317
318
|
const match = shares.find((s) => s.fsId === fsIdS);
|
|
318
319
|
if (!match) {
|
|
319
320
|
throw new Error(
|
|
320
|
-
|
|
321
|
-
|
|
321
|
+
`无法确定 fsId ${fsIdS}(不是顶层分享)。` +
|
|
322
|
+
`请显式传入 --from-uk 与 --msg-id。`
|
|
322
323
|
);
|
|
323
324
|
}
|
|
324
325
|
fromUkS = match.fromUk;
|
|
@@ -394,8 +395,8 @@ async function listFiles(gid, fsId, options = {}) {
|
|
|
394
395
|
} catch (e) {
|
|
395
396
|
throw new ShareApiError(
|
|
396
397
|
-3,
|
|
397
|
-
`shareinfo
|
|
398
|
-
|
|
398
|
+
`shareinfo 请求被拒: fsId ${fsIdS}(errno=-3),且回退父目录也失败。` +
|
|
399
|
+
`分享可能已过期,或目录过大超出接口限制。`
|
|
399
400
|
);
|
|
400
401
|
}
|
|
401
402
|
}
|
|
@@ -437,12 +438,192 @@ async function listFiles(gid, fsId, options = {}) {
|
|
|
437
438
|
|
|
438
439
|
throw new ShareApiError(
|
|
439
440
|
-3,
|
|
440
|
-
`shareinfo
|
|
441
|
+
`shareinfo 请求被拒: fsId ${fsIdS}(errno=-3)。` +
|
|
441
442
|
`群分享 API 拒绝请求:已观察到大目录、错误的 msgId/fromUk、过期分享、请求过于频繁都可能触发。` +
|
|
442
443
|
`请减小 --page-size,稍后重试,或携带 gsearch 返回的 fromUk/msgId/parentFsId 信息重试。`
|
|
443
444
|
);
|
|
444
445
|
}
|
|
445
446
|
|
|
447
|
+
// ── 群文件下载 (gdownload) ─────────────────────────────
|
|
448
|
+
//
|
|
449
|
+
// 逆向结论(2026-08-12):
|
|
450
|
+
// - /mbox/msg/transfer 转存已被百度拒绝(非空目录一律 errno=-10,UI 已移除保存按钮)
|
|
451
|
+
// - 新版 IM 前端的下载链路是 POST /api/sharedownload?sign=×tamp=
|
|
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=×tamp=&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=×tamp=&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
|
+
|
|
446
627
|
// ── 目录树 (gtree) ────────────────────────────────────
|
|
447
628
|
|
|
448
629
|
async function treeFiles(gid, options = {}) {
|
|
@@ -515,24 +696,12 @@ async function treeFiles(gid, options = {}) {
|
|
|
515
696
|
async function searchFiles(gid, keyword, options = {}) {
|
|
516
697
|
const gidS = toStr(gid);
|
|
517
698
|
|
|
518
|
-
//
|
|
699
|
+
// 关键词解析:空格分隔多关键词(委托 lib/name-match.js 实现,语义与旧内联逻辑一致):
|
|
519
700
|
// - 默认(all):每个词都要出现(AND,可换序)→ "玄空 飞星" 只命中同时含两者的名字(精确收窄)
|
|
520
701
|
// - --any-word:任一词出现即可(OR,更宽)→ "玄空 飞星" 命中含"玄空"或"飞星"的名字
|
|
521
702
|
// - --exact:整名精确相等(最严格);单关键词默认仍是子串包含(兼容旧行为)
|
|
522
|
-
const rawKw = String(keyword).toLowerCase();
|
|
523
|
-
const words = rawKw.split(/\s+/).filter(Boolean);
|
|
524
703
|
const matchMode = options.exact ? "exact" : (options.anyWord ? "any" : "all");
|
|
525
|
-
const matchesName = (name) => {
|
|
526
|
-
const n = String(name || "").toLowerCase();
|
|
527
|
-
if (matchMode === "exact") {
|
|
528
|
-
// 精确匹配按文件名(去掉路径前缀),顶层分享 name 形如 "/xxx",子目录文件 name 无前缀
|
|
529
|
-
const base = n.includes("/") ? n.slice(n.lastIndexOf("/") + 1) : n;
|
|
530
|
-
return base === rawKw;
|
|
531
|
-
}
|
|
532
|
-
if (words.length <= 1) return n.includes(rawKw);
|
|
533
|
-
if (matchMode === "any") return words.some((w) => n.includes(w));
|
|
534
|
-
return words.every((w) => n.includes(w));
|
|
535
|
-
};
|
|
704
|
+
const matchesName = (name) => matchName(keyword, name, { mode: matchMode });
|
|
536
705
|
|
|
537
706
|
const limit = options.limit || 50;
|
|
538
707
|
const page = options.page || 1;
|
|
@@ -751,5 +920,6 @@ async function searchFiles(gid, keyword, options = {}) {
|
|
|
751
920
|
|
|
752
921
|
module.exports = {
|
|
753
922
|
listGroups, listShares, listFiles, treeFiles, searchFiles,
|
|
923
|
+
getShareDlink, downloadFile, getShareZipDlink, downloadFiles,
|
|
754
924
|
ShareApiError, describeErrno2131, clearGroupCache,
|
|
755
925
|
};
|
package/lib/http.js
CHANGED
|
@@ -98,7 +98,14 @@ async function webJson(url, options = {}) {
|
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// bdstoken 短缓存:getFileMetaWeb 每次下载/peek 都要取一次 bdstoken(1 个 HTTP 往返),
|
|
102
|
+
// 而 token 实际有效期很长,缓存 10 分钟显著减少重复请求
|
|
103
|
+
let bdstokenCache = { token: null, expiresAt: 0 };
|
|
104
|
+
|
|
105
|
+
async function getBdstoken({ forceRefresh = false } = {}) {
|
|
106
|
+
if (!forceRefresh && bdstokenCache.token && Date.now() < bdstokenCache.expiresAt) {
|
|
107
|
+
return bdstokenCache.token;
|
|
108
|
+
}
|
|
102
109
|
const fields = encodeURIComponent('["bdstoken","token","uk","isdocuser","servertime"]');
|
|
103
110
|
const data = await webJson(
|
|
104
111
|
`https://pan.baidu.com/api/gettemplatevariable?clienttype=0&app_id=250528&web=1&fields=${fields}`
|
|
@@ -107,12 +114,13 @@ async function getBdstoken() {
|
|
|
107
114
|
if (!token) {
|
|
108
115
|
const cfg = require("./config").get();
|
|
109
116
|
throw new Error(
|
|
110
|
-
|
|
117
|
+
`无法获取 bdstoken: Web 会话未认证。\n` +
|
|
111
118
|
` BDUSS: ${cfg.bduss ? "set" : "MISSING"} STOKEN: ${cfg.stoken ? "set" : "MISSING"}\n` +
|
|
112
119
|
` Web API transport: ${cfg.webTransport}\n` +
|
|
113
120
|
` Run: bdp login, or bdp login --bduss <value> --stoken <value>`
|
|
114
121
|
);
|
|
115
122
|
}
|
|
123
|
+
bdstokenCache = { token, expiresAt: Date.now() + 10 * 60 * 1000 };
|
|
116
124
|
return token;
|
|
117
125
|
}
|
|
118
126
|
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const pan = require("./pan");
|
|
2
2
|
const group = require("./group");
|
|
3
3
|
const config = require("./config");
|
|
4
|
+
const pcsExtra = require("./pcs-extra");
|
|
5
|
+
const nameMatch = require("./name-match");
|
|
4
6
|
|
|
5
|
-
module.exports = { pan, group, config };
|
|
7
|
+
module.exports = { pan, group, config, pcsExtra, nameMatch };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ── 文件名匹配(纯函数,无 IO)──────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// 供群文件搜索(lib/group.js 的 gsearch)与网盘搜索(bdp search 集成建议)共用。
|
|
4
|
+
// 所有函数都是同步纯函数:同样的输入永远得到同样的输出,不做任何网络/文件操作。
|
|
5
|
+
//
|
|
6
|
+
// 匹配模式(options.mode):
|
|
7
|
+
// - "all" (默认)空格分词,每个词都必须出现(AND,顺序无关)
|
|
8
|
+
// - "any" 空格分词,任一词出现即可(OR)
|
|
9
|
+
// - "exact" 整名精确相等(大小写不敏感,按 basename 比较,忽略目录前缀)
|
|
10
|
+
// - "regex" pattern 直接作为正则表达式
|
|
11
|
+
// - "glob" pattern 按 shell glob(* 任意串、? 单字符)匹配
|
|
12
|
+
// 单关键词时 all/any 都退化为子串包含(与 group.js 旧行为兼容)。
|
|
13
|
+
|
|
14
|
+
// 正则元字符转义:普通字符串 → 可安全嵌入正则的等价字面量
|
|
15
|
+
function escapeRegExp(s) {
|
|
16
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// shell glob 转正则:* → .*(任意串,含空)、? → .(任意单字符)、
|
|
20
|
+
// 其余字符全部按字面量转义(如 "1.5版" 的 "." 在 glob 中就是普通句点)。
|
|
21
|
+
// 结果整体锚定(^...$),即 glob 必须匹配整个文件名(shell 语义)。
|
|
22
|
+
function globToRegExp(pattern) {
|
|
23
|
+
const escaped = String(pattern).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
24
|
+
const src = escaped.replace(/\\\*/g, ".*").replace(/\\\?/g, ".");
|
|
25
|
+
return new RegExp(`^(?:${src})$`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 核心匹配函数。
|
|
29
|
+
// pattern: 用户输入的关键词/模式
|
|
30
|
+
// name: 待匹配的文件名(可为完整路径,exact 模式会自动取 basename)
|
|
31
|
+
// options: { mode, ignoreCase },ignoreCase 默认 true(与 group.js 旧行为一致)
|
|
32
|
+
// 返回布尔值。
|
|
33
|
+
function matchesName(pattern, name, options = {}) {
|
|
34
|
+
const mode = options.mode || "all";
|
|
35
|
+
const ignoreCase = options.ignoreCase !== false;
|
|
36
|
+
const raw = String(pattern);
|
|
37
|
+
const k = ignoreCase ? raw.toLowerCase() : raw;
|
|
38
|
+
const n = String(name || "");
|
|
39
|
+
const nn = ignoreCase ? n.toLowerCase() : n;
|
|
40
|
+
|
|
41
|
+
// regex:pattern 直接编译为正则(非法正则向上抛 SyntaxError,由调用方决定处理)
|
|
42
|
+
if (mode === "regex") {
|
|
43
|
+
return new RegExp(raw, ignoreCase ? "i" : "").test(n);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// glob:经 globToRegExp 转换后整体匹配
|
|
47
|
+
if (mode === "glob") {
|
|
48
|
+
return globToRegExp(raw).test(n);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// exact:整名精确相等。按 basename 比较(忽略 "/dir/" 前缀),
|
|
52
|
+
// 顶层分享 name 形如 "/xxx",子目录文件 name 无前缀,两者统一处理。
|
|
53
|
+
if (mode === "exact") {
|
|
54
|
+
const base = nn.includes("/") ? nn.slice(nn.lastIndexOf("/") + 1) : nn;
|
|
55
|
+
return base === k;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// all / any:空格分词。空关键词(含纯空白)不过滤(匹配一切,兼容旧行为);
|
|
59
|
+
// 单关键词退化为子串包含(兼容旧行为)。
|
|
60
|
+
const words = k.trim().split(/\s+/).filter(Boolean);
|
|
61
|
+
if (words.length === 0) return true;
|
|
62
|
+
if (words.length === 1) return nn.includes(words[0]);
|
|
63
|
+
if (mode === "any") return words.some((w) => nn.includes(w));
|
|
64
|
+
return words.every((w) => nn.includes(w)); // 默认 all:每词都需出现(AND)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { globToRegExp, matchesName, escapeRegExp };
|