baiduwangpan-cli 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/group.js CHANGED
@@ -1,30 +1,49 @@
1
1
  const http = require("./http");
2
2
  const { getBdstoken } = http;
3
3
 
4
+ // ── 工具 ──────────────────────────────────────────────
5
+
6
+ function toStr(v) {
7
+ if (v === null || v === undefined) return "";
8
+ return String(v);
9
+ }
10
+
11
+ class ShareApiError extends Error {
12
+ constructor(errno, message) {
13
+ super(message);
14
+ this.errno = errno;
15
+ this.name = "ShareApiError";
16
+ }
17
+ }
18
+
19
+ // ── 群组列表 ──────────────────────────────────────────
20
+
4
21
  async function listGroups() {
5
22
  const bdstoken = await getBdstoken();
6
23
  if (!bdstoken) throw new Error("Cannot get bdstoken — check BDUSS/STOKEN config");
7
24
 
8
- const data = http.curlJson(
25
+ const data = await http.webJson(
9
26
  `${http.PAN_BASE}/mbox/group/list?clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`
10
27
  );
11
28
 
12
29
  if (data.errno !== 0) throw new Error(`API error: errno=${data.errno}`);
13
30
 
14
31
  return (data.records || []).map((g) => ({
15
- gid: g.gid,
32
+ gid: toStr(g.gid),
16
33
  name: g.name,
17
- gnum: g.gnum,
34
+ gnum: toStr(g.gnum),
18
35
  description: g.gdesc || "",
19
- type: g.type,
36
+ type: toStr(g.type),
20
37
  created: g.ctime,
21
38
  }));
22
39
  }
23
40
 
41
+ // ── 群内分享库 ────────────────────────────────────────
42
+
24
43
  async function listShares(gid) {
25
44
  const bdstoken = await getBdstoken();
26
- const data = http.curlJson(
27
- `${http.PAN_BASE}/mbox/group/listshare?clienttype=0&app_id=${http.APP_ID}&web=1&type=2&gid=${gid}&limit=50&desc=1&bdstoken=${bdstoken}`,
45
+ const data = await http.webJson(
46
+ `${http.PAN_BASE}/mbox/group/listshare?clienttype=0&app_id=${http.APP_ID}&web=1&type=2&gid=${toStr(gid)}&limit=50&desc=1&bdstoken=${bdstoken}`,
28
47
  { method: "POST", body: "" }
29
48
  );
30
49
 
@@ -34,90 +53,360 @@ async function listShares(gid) {
34
53
  for (const msg of data.records?.msg_list || []) {
35
54
  for (const f of msg.file_list || []) {
36
55
  shares.push({
37
- msgId: msg.msg_id,
38
- fromUk: msg.uk,
56
+ msgId: toStr(msg.msg_id),
57
+ fromUk: toStr(msg.uk),
39
58
  fromUser: msg.uname,
40
59
  name: decodeURIComponent(f.path || f.server_filename || ""),
41
- fsId: f.fs_id,
60
+ fsId: toStr(f.fs_id),
42
61
  isDir: f.isdir === "1",
43
- size: parseInt(f.size || "0"),
62
+ size: parseInt(f.size || "0", 10),
44
63
  });
45
64
  }
46
65
  }
47
66
  return shares;
48
67
  }
49
68
 
50
- async function listFiles(gid, fsId, options = {}) {
51
- const page = options.page || 1;
52
- const num = options.num || 100;
53
-
54
- // First get the fromUk and msgId for this fsId
55
- let { fromUk, msgId } = options;
56
- if (!fromUk || !msgId) {
57
- const shares = await listShares(gid);
58
- const match = shares.find((s) => s.fsId === fsId);
59
- if (match) {
60
- fromUk = match.fromUk;
61
- msgId = match.msgId;
62
- } else if (shares.length > 0) {
63
- fromUk = shares[0].fromUk;
64
- msgId = shares[0].msgId;
65
- } else {
66
- throw new Error("Cannot find share info for this fsId");
67
- }
68
- }
69
+ // ── 底层 shareinfo 请求 ───────────────────────────────
69
70
 
71
+ async function shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, pageSize) {
70
72
  const bdstoken = await getBdstoken();
71
- const data = http.curlJson(
72
- `${http.PAN_BASE}/mbox/msg/shareinfo?type=2&from_uk=${fromUk}&msg_id=${msgId}&to_uk=0&num=${num}&page=${page}&fs_id=${fsId}&gid=${gid}&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`,
73
- { method: "POST", body: "" }
74
- );
73
+ const url =
74
+ `${http.PAN_BASE}/mbox/msg/shareinfo?type=2&from_uk=${fromUkS}&msg_id=${msgIdS}` +
75
+ `&to_uk=0&num=${pageSize}&page=${page}&fs_id=${fsIdS}&gid=${gidS}` +
76
+ `&clienttype=0&app_id=${http.APP_ID}&web=1&bdstoken=${bdstoken}`;
75
77
 
76
- if (data.errno !== 0) throw new Error(`API error: errno=${data.errno}`);
78
+ const data = await http.webJson(url, { method: "POST", body: "" });
79
+
80
+ if (data.errno !== 0) {
81
+ throw new ShareApiError(data.errno, `API error: errno=${data.errno}`);
82
+ }
77
83
 
78
84
  const files = (Array.isArray(data.records) ? data.records : []).map((f) => ({
79
85
  name: f.server_filename || "",
80
86
  path: decodeURIComponent(f.path || ""),
81
87
  isDir: f.isdir === 1 || f.isdir === "1",
82
- size: parseInt(f.size || "0"),
83
- fsId: f.fs_id,
88
+ size: parseInt(f.size || "0", 10),
89
+ fsId: toStr(f.fs_id),
84
90
  md5: f.md5 || "",
85
- category: parseInt(f.category || "0"),
86
- ctime: f.server_ctime || "",
87
- mtime: f.server_mtime || "",
91
+ category: parseInt(f.category || "0", 10),
92
+ ctime: toStr(f.server_ctime || ""),
93
+ mtime: toStr(f.server_mtime || ""),
94
+ parentFsId: fsIdS,
95
+ fromUk: fromUkS,
96
+ msgId: msgIdS,
88
97
  }));
89
98
 
90
- return { files, hasMore: data.has_more === 1, page };
99
+ return { files, hasMore: data.has_more === 1, page, pageSize };
91
100
  }
92
101
 
102
+ // ── 列出分享库内容 (gls) ──────────────────────────────
103
+
104
+ async function listFiles(gid, fsId, options = {}) {
105
+ const gidS = toStr(gid);
106
+ const fsIdS = toStr(fsId);
107
+ const parentFsId = toStr(options.parentFsId || "");
108
+ const page = options.page || 1;
109
+ let pageSize = options.pageSize || 50;
110
+ pageSize = Math.min(Math.max(1, pageSize), 100);
111
+
112
+ // 来源解析:显式传入优先;否则仅在顶层分享中精确匹配(禁止"用第一个分享"兜底)
113
+ let fromUkS = toStr(options.fromUk || "");
114
+ let msgIdS = toStr(options.msgId || "");
115
+
116
+ if (!fromUkS || !msgIdS) {
117
+ const shares = await listShares(gidS);
118
+ const match = shares.find((s) => s.fsId === fsIdS);
119
+ if (!match) {
120
+ throw new Error(
121
+ `Cannot determine fromUk/msgId for fsId ${fsIdS} (not a top-level share). ` +
122
+ `Pass --from-uk and --msg-id explicitly.`
123
+ );
124
+ }
125
+ fromUkS = match.fromUk;
126
+ msgIdS = match.msgId;
127
+ }
128
+
129
+ // errno=-3 时依次降 page-size: 50 → 20 → 10
130
+ const attempts = [...new Set([pageSize, 50, 20, 10].filter((n) => n <= pageSize))];
131
+ let lastErrno = null;
132
+
133
+ for (const size of attempts) {
134
+ try {
135
+ const result = await shareInfoRequest(gidS, fsIdS, fromUkS, msgIdS, page, size);
136
+ return {
137
+ files: result.files,
138
+ hasMore: result.hasMore,
139
+ page,
140
+ pageSize: size,
141
+ fromUk: fromUkS,
142
+ msgId: msgIdS,
143
+ fallback: null,
144
+ };
145
+ } catch (e) {
146
+ if (e instanceof ShareApiError && e.errno === -3) {
147
+ lastErrno = -3;
148
+ continue; // 尝试更小的 page-size
149
+ }
150
+ throw e;
151
+ }
152
+ }
153
+
154
+ // 全部 page-size 都失败(errno=-3)→ 回退
155
+ if (parentFsId) {
156
+ // 回退到父目录,展示兄弟内容帮助 Agent 换路径
157
+ try {
158
+ const parent = await shareInfoRequest(gidS, toStr(parentFsId), fromUkS, msgIdS, 1, 50);
159
+ return {
160
+ files: parent.files,
161
+ hasMore: parent.hasMore,
162
+ page: 1,
163
+ pageSize: 50,
164
+ fromUk: fromUkS,
165
+ msgId: msgIdS,
166
+ fallback: {
167
+ reason: "errno=-3",
168
+ requestedFsId: fsIdS,
169
+ resolvedFsId: toStr(parentFsId),
170
+ level: "parent",
171
+ },
172
+ };
173
+ } catch (e) {
174
+ throw new ShareApiError(
175
+ -3,
176
+ `shareinfo rejected for fsId ${fsIdS} (errno=-3), and parent fallback also failed. ` +
177
+ `The share may be expired or the directory may be too large for the API.`
178
+ );
179
+ }
180
+ }
181
+
182
+ // 目标本身是顶层分享 → 退回 gshares 结果
183
+ try {
184
+ const shares = await listShares(gidS);
185
+ const match = shares.find((s) => s.fsId === fsIdS);
186
+ if (match) {
187
+ return {
188
+ files: [{
189
+ name: match.name,
190
+ path: match.name,
191
+ isDir: match.isDir,
192
+ size: match.size,
193
+ fsId: match.fsId,
194
+ md5: "",
195
+ category: 0,
196
+ ctime: "",
197
+ mtime: "",
198
+ parentFsId: "",
199
+ fromUk: match.fromUk,
200
+ msgId: match.msgId,
201
+ }],
202
+ hasMore: false,
203
+ page: 1,
204
+ pageSize: 50,
205
+ fromUk: fromUkS,
206
+ msgId: msgIdS,
207
+ fallback: {
208
+ reason: "errno=-3",
209
+ requestedFsId: fsIdS,
210
+ resolvedFsId: fsIdS,
211
+ level: "group-shares",
212
+ },
213
+ };
214
+ }
215
+ } catch {}
216
+
217
+ throw new ShareApiError(
218
+ -3,
219
+ `shareinfo rejected for fsId ${fsIdS} (errno=-3). ` +
220
+ `群分享 API 拒绝请求:已观察到大目录、错误的 msgId/fromUk、过期分享都可能触发。` +
221
+ `请减小 --page-size,或携带 gsearch 返回的 fromUk/msgId/parentFsId 信息重试。`
222
+ );
223
+ }
224
+
225
+ // ── 目录树 (gtree) ────────────────────────────────────
226
+
227
+ async function treeFiles(gid, options = {}) {
228
+ const gidS = toStr(gid);
229
+ const maxDepth = Math.min(Math.max(1, options.depth || 2), 5);
230
+ const concurrency = Math.min(Math.max(1, options.concurrency || 4), 8);
231
+ const maxNodes = options.maxNodes || 2000;
232
+
233
+ const shares = await listShares(gidS);
234
+ const tree = [];
235
+ const failed = [];
236
+
237
+ // BFS: queue 元素 { node, depth, share }
238
+ const queue = shares.map((s) => ({ node: { ...s, parentFsId: "" }, depth: 1, share: s }));
239
+ let scanned = 0;
240
+
241
+ // 逐层 BFS,层内并发
242
+ for (let depth = 1; depth <= maxDepth; depth++) {
243
+ const level = queue.filter((q) => q.depth === depth);
244
+ if (level.length === 0) break;
245
+
246
+ const results = await Promise.all(
247
+ level.map(async (entry) => {
248
+ const { node, share } = entry;
249
+ if (!node.isDir || node.size > 0) return null; // 非目录不进队列
250
+ scanned++;
251
+ try {
252
+ const { files } = await shareInfoRequest(gidS, node.fsId, share.fromUk, share.msgId, 1, 100);
253
+ 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
+ },
267
+ depth: depth + 1,
268
+ share,
269
+ }));
270
+ } catch {
271
+ failed.push({ fsId: node.fsId, name: node.name });
272
+ return null;
273
+ }
274
+ })
275
+ );
276
+
277
+ for (const list of results) {
278
+ if (!list) continue;
279
+ for (const item of list) {
280
+ tree.push(item.node);
281
+ if (tree.length >= maxNodes) break;
282
+ if (item.depth <= maxDepth && item.node.isDir) queue.push({ node: item.node, depth: item.depth, share: item.share });
283
+ }
284
+ if (tree.length >= maxNodes) break;
285
+ }
286
+ if (tree.length >= maxNodes) break;
287
+ }
288
+
289
+ return { tree, failed, scanned, maxDepth, truncated: tree.length >= maxNodes };
290
+ }
291
+
292
+ // ── 群文件搜索 (gsearch) ──────────────────────────────
293
+
93
294
  async function searchFiles(gid, keyword, options = {}) {
94
- const maxDepth = options.maxDepth || 1;
95
- const results = [];
295
+ const gidS = toStr(gid);
96
296
  const kw = keyword.toLowerCase();
97
297
 
98
- const shares = await listShares(gid);
298
+ const limit = options.limit || 50;
299
+ const page = options.page || 1;
300
+ const concurrency = Math.min(Math.max(1, options.concurrency || 4), 8);
301
+ const unique = options.unique !== false;
302
+ const fetchAll = options.all === true;
303
+ const depth = Math.min(Math.max(1, options.depth || 1), 5); // 默认 1 层(兼容旧行为)
304
+ const startIdx = (page - 1) * limit;
305
+ const stopAt = fetchAll ? Infinity : startIdx + limit + 1; // +1 判断 hasMore
306
+
307
+ const shares = await listShares(gidS);
308
+ const totalShares = shares.length;
309
+
310
+ const seen = new Set();
311
+ const results = [];
312
+ let scannedShares = 0;
313
+ let failedShares = 0;
314
+ let finished = false;
315
+
316
+ const pushIfMatch = (item) => {
317
+ if (finished) return;
318
+ const key = unique ? toStr(item.fsId) : `${toStr(item.fsId)}|${toStr(item.msgId)}`;
319
+ if (unique && seen.has(key)) return;
320
+ seen.add(key);
321
+ results.push(item);
322
+ if (results.length >= stopAt) finished = true;
323
+ };
99
324
 
325
+ // 阶段 1:顶层分享名称匹配(非目录分享在此完成扫描)
100
326
  for (const share of shares) {
327
+ if (finished) break;
328
+ if (!share.isDir) scannedShares++;
101
329
  if (share.name.toLowerCase().includes(kw)) {
102
- results.push({ ...share, group: gid });
330
+ pushIfMatch({
331
+ name: share.name,
332
+ path: share.name,
333
+ isDir: share.isDir,
334
+ size: share.size,
335
+ fsId: share.fsId,
336
+ md5: "",
337
+ category: 0,
338
+ ctime: "",
339
+ mtime: "",
340
+ parentFsId: "",
341
+ fromUk: share.fromUk,
342
+ msgId: share.msgId,
343
+ fromUser: share.fromUser,
344
+ group: gidS,
345
+ });
103
346
  }
347
+ }
348
+
349
+ // 阶段 2:BFS 递归扫描目录(每层 4 个一批并发,默认 depth=1 保持旧行为)
350
+ const dirShares = shares.filter((s) => s.isDir);
351
+ let queue = dirShares.map((s) => ({ fsId: s.fsId, fromUk: s.fromUk, msgId: s.msgId, depth: 1 }));
352
+
353
+ for (let level = 1; level <= depth && queue.length > 0 && !finished; level++) {
354
+ const current = queue;
355
+ queue = [];
104
356
 
105
- if (share.isDir && maxDepth > 0) {
106
- try {
107
- const { files } = await listFiles(gid, share.fsId, {
108
- fromUk: share.fromUk,
109
- msgId: share.msgId,
110
- });
111
- for (const f of files) {
112
- if (f.name.toLowerCase().includes(kw)) {
113
- results.push({ ...f, group: gid });
357
+ for (let i = 0; i < current.length && !finished; i += concurrency) {
358
+ const batch = current.slice(i, i + concurrency);
359
+ const batchResults = await Promise.all(
360
+ batch.map(async (share) => {
361
+ scannedShares++;
362
+ try {
363
+ const { files } = await shareInfoRequest(gidS, share.fsId, share.fromUk, share.msgId, 1, 100);
364
+ const matches = files.filter((f) => f.name.toLowerCase().includes(kw));
365
+ const subDirs = files.filter((f) => f.isDir);
366
+ return { matches, subDirs };
367
+ } catch {
368
+ failedShares++;
369
+ return { matches: [], subDirs: [] };
370
+ }
371
+ })
372
+ );
373
+
374
+ for (const { matches, subDirs } of batchResults) {
375
+ if (finished) break;
376
+ for (const f of matches) {
377
+ pushIfMatch({ ...f, group: gidS });
378
+ if (finished) break;
379
+ }
380
+ // 收集下一层待扫描目录
381
+ if (level < depth && !finished) {
382
+ for (const d of subDirs) {
383
+ queue.push({ fsId: d.fsId, fromUk: d.fromUk, msgId: d.msgId, depth: level + 1 });
114
384
  }
115
385
  }
116
- } catch {}
386
+ }
117
387
  }
118
388
  }
119
389
 
120
- return results;
390
+ const complete = scannedShares >= totalShares;
391
+ const hasMore = !fetchAll && results.length > startIdx + limit;
392
+ const pageSlice = fetchAll ? results : results.slice(startIdx, startIdx + limit);
393
+
394
+ return {
395
+ results: pageSlice,
396
+ page,
397
+ pageSize: limit,
398
+ returned: pageSlice.length,
399
+ total: null,
400
+ hasMore,
401
+ nextPage: hasMore ? page + 1 : null,
402
+ unique,
403
+ complete,
404
+ partial: failedShares > 0,
405
+ scannedShares,
406
+ totalShares,
407
+ failedShares,
408
+ depth,
409
+ };
121
410
  }
122
411
 
123
- module.exports = { listGroups, listShares, listFiles, searchFiles };
412
+ module.exports = { listGroups, listShares, listFiles, treeFiles, searchFiles, ShareApiError };
package/lib/http.js CHANGED
@@ -1,10 +1,15 @@
1
1
  const { spawnSync } = require("child_process");
2
2
  const config = require("./config");
3
3
 
4
+ function buildCookieHeader(cfg) {
5
+ if (cfg.cookie) return cfg.cookie;
6
+ return `BDUSS=${cfg.bduss}; STOKEN=${cfg.stoken}`;
7
+ }
8
+
4
9
  function curlGet(url, extraHeaders = {}) {
5
10
  const cfg = config.get();
6
11
  const headers = {
7
- Cookie: `BDUSS=${cfg.bduss}; STOKEN=${cfg.stoken}`,
12
+ Cookie: buildCookieHeader(cfg),
8
13
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
9
14
  Referer: "https://pan.baidu.com/disk/main",
10
15
  ...extraHeaders,
@@ -23,7 +28,7 @@ function curlGet(url, extraHeaders = {}) {
23
28
  function curlPost(url, body = "", extraHeaders = {}) {
24
29
  const cfg = config.get();
25
30
  const headers = {
26
- Cookie: `BDUSS=${cfg.bduss}; STOKEN=${cfg.stoken}`,
31
+ Cookie: buildCookieHeader(cfg),
27
32
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
28
33
  Referer: "https://pan.baidu.com/disk/main",
29
34
  "Content-Type": "application/x-www-form-urlencoded",
@@ -67,18 +72,45 @@ function curlJson(url, options = {}) {
67
72
  }
68
73
  }
69
74
 
75
+ async function webJson(url, options = {}) {
76
+ const cfg = config.get();
77
+ if (cfg.webTransport !== "browser") return curlJson(url, options);
78
+
79
+ const { browserJson } = require("./browser-login");
80
+ const headers = { ...(options.headers || {}) };
81
+ if (options.method === "POST" && !headers["Content-Type"]) {
82
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
83
+ }
84
+ return browserJson(
85
+ url,
86
+ {
87
+ method: options.method || "GET",
88
+ ...(Object.prototype.hasOwnProperty.call(options, "body") ? { body: options.body } : {}),
89
+ headers,
90
+ },
91
+ {
92
+ profileDir: cfg.browserProfile,
93
+ port: cfg.browserPort,
94
+ onPort: (browserPort) => {
95
+ if (browserPort !== cfg.browserPort) config.update({ browserPort });
96
+ },
97
+ }
98
+ );
99
+ }
100
+
70
101
  async function getBdstoken() {
71
102
  const fields = encodeURIComponent('["bdstoken","token","uk","isdocuser","servertime"]');
72
- const data = curlJson(
103
+ const data = await webJson(
73
104
  `https://pan.baidu.com/api/gettemplatevariable?clienttype=0&app_id=250528&web=1&fields=${fields}`
74
105
  );
75
106
  const token = data?.result?.bdstoken || null;
76
107
  if (!token) {
77
108
  const cfg = require("./config").get();
78
109
  throw new Error(
79
- `Cannot get bdstoken. Check BDUSS/STOKEN.\n` +
110
+ `Cannot get bdstoken. The web session is not authenticated.\n` +
80
111
  ` BDUSS: ${cfg.bduss ? "set" : "MISSING"} STOKEN: ${cfg.stoken ? "set" : "MISSING"}\n` +
81
- ` Run: bdp login --bduss <value> --stoken <value>`
112
+ ` Web API transport: ${cfg.webTransport}\n` +
113
+ ` Run: bdp login, or bdp login --bduss <value> --stoken <value>`
82
114
  );
83
115
  }
84
116
  return token;
@@ -87,4 +119,4 @@ async function getBdstoken() {
87
119
  const APP_ID = "250528";
88
120
  const PAN_BASE = "https://pan.baidu.com";
89
121
 
90
- module.exports = { curlGet, curlPost, curlRange, curlJson, getBdstoken, APP_ID, PAN_BASE };
122
+ module.exports = { curlGet, curlPost, curlRange, curlJson, webJson, getBdstoken, APP_ID, PAN_BASE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baiduwangpan-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "百度网盘 CLI — 全盘读写 · 免下载读取文件内容 · 群聊文件浏览 (Agent 友好)",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -8,11 +8,15 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/bdp.js",
11
+ "test": "node --test",
11
12
  "postinstall": "node scripts/postinstall.js"
12
13
  },
13
14
  "engines": {
14
15
  "node": ">=14"
15
16
  },
17
+ "dependencies": {
18
+ "ws": "^8.18.0"
19
+ },
16
20
  "license": "MIT",
17
21
  "keywords": [
18
22
  "baidu",
@@ -34,7 +38,7 @@
34
38
  ],
35
39
  "repository": {
36
40
  "type": "git",
37
- "url": "https://github.com/NkAntony777/baiduwangpan-cli.git"
41
+ "url": "git+https://github.com/NkAntony777/baiduwangpan-cli.git"
38
42
  },
39
43
  "homepage": "https://github.com/NkAntony777/baiduwangpan-cli",
40
44
  "bugs": {