@chainlesschain/personal-data-hub 0.3.1 → 0.3.6

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.
Files changed (60) hide show
  1. package/__tests__/adapters/email-adapter-snapshot.test.js +237 -0
  2. package/__tests__/adapters/email-adapter.test.js +1 -1
  3. package/__tests__/adapters/email-pdf-extractor.test.js +1 -1
  4. package/__tests__/adapters/email-retry-progress.test.js +1 -1
  5. package/__tests__/adapters/email-templates.test.js +1 -1
  6. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +721 -0
  7. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +346 -0
  8. package/__tests__/adapters/social-bilibili-adb-collector.test.js +284 -0
  9. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +343 -0
  10. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +296 -0
  11. package/__tests__/adapters/social-douyin-adb-collector.test.js +254 -0
  12. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +304 -0
  13. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +216 -0
  14. package/__tests__/adapters/social-weibo-adb-api-client.test.js +362 -0
  15. package/__tests__/adapters/social-weibo-adb-collector.test.js +201 -0
  16. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +189 -0
  17. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +207 -0
  18. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +130 -0
  19. package/__tests__/adapters/system-data-android.test.js +32 -1
  20. package/__tests__/longtail-adapters.test.js +15 -2
  21. package/__tests__/shopping-adapters.test.js +96 -0
  22. package/__tests__/sign-providers.test.js +62 -0
  23. package/__tests__/travel-adapters.test.js +66 -0
  24. package/__tests__/whatsapp-adapter.test.js +5 -2
  25. package/lib/adapters/browser-history-chrome/chrome-db-reader.js +11 -1
  26. package/lib/adapters/email-imap/email-adapter.js +224 -17
  27. package/lib/adapters/messaging-telegram/index.js +15 -12
  28. package/lib/adapters/messaging-whatsapp/index.js +15 -12
  29. package/lib/adapters/shopping-taobao/index.js +161 -21
  30. package/lib/adapters/social-bilibili-adb/api-client.js +555 -0
  31. package/lib/adapters/social-bilibili-adb/chromium-cookies-reader.js +296 -0
  32. package/lib/adapters/social-bilibili-adb/collector.js +190 -0
  33. package/lib/adapters/social-bilibili-adb/cookies-extension.js +250 -0
  34. package/lib/adapters/social-bilibili-adb/index.js +51 -0
  35. package/lib/adapters/social-bilibili-adb/snapshot-builder.js +197 -0
  36. package/lib/adapters/social-douyin/index.js +4 -0
  37. package/lib/adapters/social-douyin-adb/collector.js +165 -0
  38. package/lib/adapters/social-douyin-adb/db-extension.js +281 -0
  39. package/lib/adapters/social-douyin-adb/im-db-parser.js +287 -0
  40. package/lib/adapters/social-douyin-adb/index.js +57 -0
  41. package/lib/adapters/social-douyin-adb/snapshot-builder.js +174 -0
  42. package/lib/adapters/social-weibo-adb/api-client.js +281 -0
  43. package/lib/adapters/social-weibo-adb/collector.js +169 -0
  44. package/lib/adapters/social-weibo-adb/cookies-extension.js +251 -0
  45. package/lib/adapters/social-weibo-adb/index.js +55 -0
  46. package/lib/adapters/social-weibo-adb/snapshot-builder.js +145 -0
  47. package/lib/adapters/social-xiaohongshu-adb/api-client.js +278 -0
  48. package/lib/adapters/social-xiaohongshu-adb/collector.js +158 -0
  49. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +211 -0
  50. package/lib/adapters/social-xiaohongshu-adb/index.js +50 -0
  51. package/lib/adapters/social-xiaohongshu-adb/sign.js +90 -0
  52. package/lib/adapters/social-xiaohongshu-adb/snapshot-builder.js +126 -0
  53. package/lib/adapters/system-data-android/adapter.js +77 -3
  54. package/lib/adapters/travel-amap/index.js +16 -10
  55. package/lib/adapters/travel-ctrip/index.js +25 -9
  56. package/lib/adapters/vscode/vscode-reader.js +7 -1
  57. package/lib/sign-providers/index.js +20 -0
  58. package/lib/sign-providers/interface.js +82 -0
  59. package/lib/sign-providers/null-sign-provider.js +30 -0
  60. package/package.json +6 -1
@@ -0,0 +1,555 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 1b (Bilibili C 路径 — 2026-05-25): Node-side BilibiliApiClient.
5
+ *
6
+ * Byte-identical port of
7
+ * `android-app/.../pdh/social/bilibili/BilibiliApiClient.kt`
8
+ * for the desktop PC + ADB path. Keep them in lockstep — if a real-device
9
+ * trap surfaces on Android (412 anti-spider / -101 / buvid3 / WBI key
10
+ * rotation) the fix usually lands here too.
11
+ *
12
+ * Pipeline of a single sync (called by BilibiliAdbCollector):
13
+ * 1. mintBuvid3() if not cached → POST-onload anonymous endpoint
14
+ * 2. ensureWbiMixinKey() if not cached → nav handshake
15
+ * 3. for each of {history, favourite, dynamic, follow}:
16
+ * prepareRequest(cookie, url) → substitute buvid3 + sign URL
17
+ * doGetJson(url, cookie) → browser-like headers
18
+ *
19
+ * Errors don't throw — endpoints that fail return [] and the collector
20
+ * proceeds with whatever it got (partial sync better than no sync). The
21
+ * UI surfaces `lastErrorCode` + `lastErrorMessage` so the user can tell
22
+ * "412 anti-spider, wait a bit" from "-101 not logged in, relog".
23
+ *
24
+ * Test seams (mirrors Kotlin's `internal var` pattern):
25
+ * - opts.fetch — substitute global fetch (default = global)
26
+ * - opts.now — current epoch ms (default = Date.now)
27
+ * - opts.baseUrl — override "https://api.bilibili.com/" (MockWebServer)
28
+ * - client.setMintedBuvid3ForTest(value)
29
+ * - client.setWbiMixinKeyForTest(value)
30
+ */
31
+
32
+ const crypto = require("node:crypto");
33
+
34
+ const DEFAULT_BASE_URL = "https://api.bilibili.com/";
35
+
36
+ // Bilibili WBI signature mixin key reorder table — fixed 64-index list the
37
+ // web client uses to derive `mixin_key` from `img_key + sub_key`. Mirrors
38
+ // BilibiliApiClient.kt line 25-30. If these indexes change, the JS that
39
+ // builds w_rid has changed; refresh from a browser session.
40
+ const WBI_MIXIN_KEY_TABLE = Object.freeze([
41
+ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
42
+ 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
43
+ 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
44
+ 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
45
+ ]);
46
+
47
+ // Chars Bilibili strips from query values before signing (matches their JS).
48
+ const WBI_FORBIDDEN_CHARS = new Set(["!", "'", "(", ")", "*"]);
49
+
50
+ // Pinned to Chrome 120 mobile UA — see BilibiliApiClient.kt:533 for why.
51
+ const BROWSER_UA =
52
+ "Mozilla/5.0 (Linux; Android 14; ChainlessChain) AppleWebKit/537.36 " +
53
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
54
+
55
+ const BROWSER_HEADERS = Object.freeze({
56
+ "User-Agent": BROWSER_UA,
57
+ Referer: "https://www.bilibili.com/",
58
+ Origin: "https://www.bilibili.com",
59
+ Accept: "application/json, text/plain, */*",
60
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
61
+ });
62
+
63
+ /**
64
+ * "https://i0.hdslb.com/bfs/wbi/abc123.png" → "abc123".
65
+ * Mirrors BilibiliApiClient.kt:extractWbiKeyFromUrl.
66
+ */
67
+ function extractWbiKeyFromUrl(url) {
68
+ if (typeof url !== "string" || url.length === 0) return null;
69
+ const lastSlash = url.lastIndexOf("/");
70
+ const lastDot = url.lastIndexOf(".");
71
+ if (lastSlash < 0 || lastDot <= lastSlash) return null;
72
+ const key = url.substring(lastSlash + 1, lastDot);
73
+ return key.length > 0 ? key : null;
74
+ }
75
+
76
+ /**
77
+ * Strip any existing `buvid3=...` from cookie and append the new one.
78
+ * Mirrors BilibiliApiClient.kt:substituteBuvid3.
79
+ */
80
+ function substituteBuvid3(cookie, newBuvid3) {
81
+ const parts = cookie
82
+ .split(";")
83
+ .map((p) => p.trim())
84
+ .filter((p) => p.length > 0 && !p.startsWith("buvid3="));
85
+ if (parts.length === 0) return `buvid3=${newBuvid3}`;
86
+ return parts.join("; ") + `; buvid3=${newBuvid3}`;
87
+ }
88
+
89
+ /** md5 hex digest of utf-8 input string. */
90
+ function md5Hex(input) {
91
+ return crypto.createHash("md5").update(input, "utf8").digest("hex");
92
+ }
93
+
94
+ /**
95
+ * URL-encode for WBI signature — same as encodeURIComponent except it
96
+ * uses uppercase hex (some Bilibili JS variants check `%2F` not `%2f`).
97
+ * Mirrors what `urlEncodeWbi` in Kotlin does via Java URLEncoder.
98
+ */
99
+ function urlEncodeWbi(s) {
100
+ return encodeURIComponent(String(s));
101
+ }
102
+
103
+ /**
104
+ * Strip WBI_FORBIDDEN_CHARS from a value before signing.
105
+ */
106
+ function stripForbiddenChars(value) {
107
+ let out = "";
108
+ for (const ch of String(value)) {
109
+ if (!WBI_FORBIDDEN_CHARS.has(ch)) out += ch;
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * Sign a URL by appending `wts` + `w_rid` query params derived from
116
+ * [mixinKey]. Mirrors BilibiliApiClient.kt:signUrl byte-for-byte:
117
+ * - wts = floor(epoch_ms / 1000)
118
+ * - merge existing query params + wts into a Map (Java LinkedHashMap →
119
+ * Node Map preserves insertion order, but we sort by key next anyway)
120
+ * - sort entries by key alphabetically
121
+ * - for each (k, v): strip forbidden chars from v, encodeURIComponent both
122
+ * - join as `k=v&k=v&...`
123
+ * - w_rid = md5(joined + mixinKey)
124
+ *
125
+ * @param {URL} url the URL to sign (Node URL object); will be mutated +
126
+ * returned (caller can read the result via url.toString())
127
+ * @param {string} mixinKey 32-char hex mixin key from ensureWbiMixinKey
128
+ * @param {{now?: () => number}} [opts] test seam for wts
129
+ * @returns {URL} same url object, with wts + w_rid appended
130
+ */
131
+ function signUrl(url, mixinKey, opts = {}) {
132
+ const now = opts.now || Date.now;
133
+ const wts = Math.floor(now() / 1000);
134
+ const params = new Map();
135
+ // Iterate existing params in insertion order (URL preserves the order
136
+ // we wrote them in).
137
+ for (const [k, v] of url.searchParams) {
138
+ params.set(k, v);
139
+ }
140
+ params.set("wts", String(wts));
141
+ const sortedEntries = Array.from(params.entries()).sort((a, b) =>
142
+ a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
143
+ );
144
+ const sortedQuery = sortedEntries
145
+ .map(([k, v]) => `${urlEncodeWbi(k)}=${urlEncodeWbi(stripForbiddenChars(v))}`)
146
+ .join("&");
147
+ const wRid = md5Hex(sortedQuery + mixinKey);
148
+ // Rebuild searchParams atomically — appending wts + w_rid on top of the
149
+ // existing ones (we already have wts in `params` above; URL keeps the
150
+ // earlier copy too if we just append, so wipe and re-set).
151
+ url.search = "";
152
+ for (const [k, v] of params.entries()) {
153
+ url.searchParams.append(k, v);
154
+ }
155
+ url.searchParams.append("w_rid", wRid);
156
+ return url;
157
+ }
158
+
159
+ /**
160
+ * Extract numeric uid from a Cookie header.
161
+ *
162
+ * "SESSDATA=...; DedeUserID=12345; ..." → 12345
163
+ * "SESSDATA=...; DedeUserID=0; ..." → null (logged-out marker)
164
+ * No DedeUserID → null
165
+ */
166
+ function extractUid(cookie) {
167
+ if (typeof cookie !== "string") return null;
168
+ for (const part of cookie.split(";")) {
169
+ const trimmed = part.trim();
170
+ if (trimmed.startsWith("DedeUserID=")) {
171
+ const value = trimmed.substring("DedeUserID=".length);
172
+ const n = parseInt(value, 10);
173
+ return Number.isFinite(n) && n > 0 ? n : null;
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+
179
+ class BilibiliApiClient {
180
+ constructor(opts = {}) {
181
+ this.baseUrl = opts.baseUrl || DEFAULT_BASE_URL;
182
+ if (!this.baseUrl.endsWith("/")) this.baseUrl += "/";
183
+ this._fetch = opts.fetch || globalThis.fetch;
184
+ if (typeof this._fetch !== "function") {
185
+ throw new Error(
186
+ "BilibiliApiClient: fetch not available — pass opts.fetch or run on Node 18+",
187
+ );
188
+ }
189
+ this._now = opts.now || Date.now;
190
+ this._mintedBuvid3 = null;
191
+ this._wbiMixinKey = null;
192
+ this.lastErrorCode = 0;
193
+ this.lastErrorMessage = null;
194
+ }
195
+
196
+ /** Test seams (lockstep with Kotlin internal var). */
197
+ setMintedBuvid3ForTest(value) {
198
+ this._mintedBuvid3 = value;
199
+ }
200
+ setWbiMixinKeyForTest(value) {
201
+ this._wbiMixinKey = value;
202
+ }
203
+
204
+ /**
205
+ * GET <baseUrl><path> with the cookie + browser headers. Returns parsed
206
+ * JSON object on success, null on transport / API error. Failure sets
207
+ * lastErrorCode + lastErrorMessage. Mirrors Kotlin doGetJson byte-for-byte.
208
+ *
209
+ * @param {URL} url fully-built request URL (with query + signature)
210
+ * @param {string} cookie Cookie header value
211
+ */
212
+ async _doGetJson(url, cookie) {
213
+ try {
214
+ const resp = await this._fetch(url.toString(), {
215
+ method: "GET",
216
+ headers: { ...BROWSER_HEADERS, Cookie: cookie },
217
+ });
218
+ const body = await resp.text();
219
+ if (!resp.ok) {
220
+ this._setLastError(resp.status, `HTTP ${resp.status}`);
221
+ return null;
222
+ }
223
+ let obj;
224
+ try {
225
+ obj = JSON.parse(body);
226
+ } catch (e) {
227
+ this._setLastError(-3, "parse: " + (e.message || String(e)));
228
+ return null;
229
+ }
230
+ const code = typeof obj.code === "number" ? obj.code : 0;
231
+ if (code !== 0) {
232
+ const msg = (obj.message || "").toString();
233
+ this._setLastError(code, msg);
234
+ return null;
235
+ }
236
+ this._clearLastError();
237
+ return obj;
238
+ } catch (e) {
239
+ this._setLastError(-2, "IO: " + (e.message || String(e)));
240
+ return null;
241
+ }
242
+ }
243
+
244
+ _setLastError(code, message) {
245
+ this.lastErrorCode = code;
246
+ this.lastErrorMessage = message;
247
+ }
248
+ _clearLastError() {
249
+ this.lastErrorCode = 0;
250
+ this.lastErrorMessage = null;
251
+ }
252
+
253
+ /**
254
+ * Mint a fresh buvid3 via /x/frontend/finger/spi. Cached for the
255
+ * process lifetime — buvid3 is a per-device fingerprint, not
256
+ * session-scoped, so one mint suffices across re-logins.
257
+ * Mirrors Kotlin mintBuvid3.
258
+ */
259
+ async _mintBuvid3() {
260
+ if (this._mintedBuvid3) return this._mintedBuvid3;
261
+ const url = new URL("x/frontend/finger/spi", this.baseUrl);
262
+ try {
263
+ const resp = await this._fetch(url.toString(), {
264
+ method: "GET",
265
+ headers: BROWSER_HEADERS,
266
+ });
267
+ if (!resp.ok) return null;
268
+ const body = await resp.text();
269
+ let obj;
270
+ try {
271
+ obj = JSON.parse(body);
272
+ } catch {
273
+ return null;
274
+ }
275
+ if (obj.code !== 0) return null;
276
+ const b3 = obj.data && obj.data.b_3;
277
+ if (typeof b3 === "string" && b3.length > 0) {
278
+ this._mintedBuvid3 = b3;
279
+ return b3;
280
+ }
281
+ return null;
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Fetch + cache the WBI mixin_key from /x/web-interface/nav. Returns
289
+ * the 32-char mixin key on success, null on transport / format error.
290
+ * Mirrors Kotlin ensureWbiMixinKey.
291
+ */
292
+ async _ensureWbiMixinKey() {
293
+ if (this._wbiMixinKey) return this._wbiMixinKey;
294
+ const url = new URL("x/web-interface/nav", this.baseUrl);
295
+ let body;
296
+ try {
297
+ const resp = await this._fetch(url.toString(), {
298
+ method: "GET",
299
+ headers: BROWSER_HEADERS,
300
+ });
301
+ if (!resp.ok) return null;
302
+ body = await resp.text();
303
+ } catch {
304
+ return null;
305
+ }
306
+ let obj;
307
+ try {
308
+ obj = JSON.parse(body);
309
+ } catch {
310
+ return null;
311
+ }
312
+ // nav returns code=-101 for unauthenticated, but wbi_img is still in
313
+ // `data` either way — don't gate on code.
314
+ const wbiImg = obj.data && obj.data.wbi_img;
315
+ if (!wbiImg) return null;
316
+ const imgKey = extractWbiKeyFromUrl(wbiImg.img_url);
317
+ const subKey = extractWbiKeyFromUrl(wbiImg.sub_url);
318
+ if (!imgKey || !subKey) return null;
319
+ const raw = imgKey + subKey;
320
+ if (raw.length < 64) return null;
321
+ let mixin = "";
322
+ for (const i of WBI_MIXIN_KEY_TABLE) {
323
+ if (i < raw.length) mixin += raw[i];
324
+ if (mixin.length >= 32) break;
325
+ }
326
+ if (mixin.length < 32) return null;
327
+ this._wbiMixinKey = mixin;
328
+ return mixin;
329
+ }
330
+
331
+ /**
332
+ * Compose buvid3 mint + WBI sign for a request URL. Returns
333
+ * `{cookie, url}` where cookie has the minted buvid3 substituted and
334
+ * url has wts + w_rid signature appended. If WBI key fetch fails,
335
+ * returns the unsigned url (degraded mode — preserves buvid3-only path).
336
+ */
337
+ async _prepareRequest(cookie, url) {
338
+ const b3 = await this._mintBuvid3();
339
+ const effectiveCookie = b3 ? substituteBuvid3(cookie, b3) : cookie;
340
+ const mixin = await this._ensureWbiMixinKey();
341
+ if (!mixin) return { cookie: effectiveCookie, url };
342
+ let signed;
343
+ try {
344
+ signed = signUrl(url, mixin, { now: this._now });
345
+ } catch {
346
+ signed = url;
347
+ }
348
+ return { cookie: effectiveCookie, url: signed };
349
+ }
350
+
351
+ /**
352
+ * Fetch watch history. Real-device path is /x/web-interface/history/cursor
353
+ * — Bilibili deprecated /x/v2/history/cursor in early 2026 (now returns
354
+ * HTML 404). Mirrors Kotlin fetchHistory.
355
+ *
356
+ * @param {string} cookie Cookie header value
357
+ * @param {{limit?: number}} [opts]
358
+ * @returns {Promise<Array<{bvid, avid, title, viewAt, duration, uploader, uploaderMid, part}>>}
359
+ */
360
+ async fetchHistory(cookie, opts = {}) {
361
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 200;
362
+ const rawUrl = new URL("x/web-interface/history/cursor", this.baseUrl);
363
+ rawUrl.searchParams.set("ps", "30");
364
+ rawUrl.searchParams.set("type", "archive");
365
+ const { cookie: effectiveCookie, url } = await this._prepareRequest(cookie, rawUrl);
366
+ const obj = await this._doGetJson(url, effectiveCookie);
367
+ if (!obj) return [];
368
+ const data = obj.data || {};
369
+ const list = Array.isArray(data.list) ? data.list : [];
370
+ const out = [];
371
+ for (let i = 0; i < Math.min(limit, list.length); i++) {
372
+ const item = list[i];
373
+ if (!item) continue;
374
+ const hist = item.history || {};
375
+ const owner = item.owner || {};
376
+ out.push({
377
+ bvid: hist.bvid || null,
378
+ avid: typeof hist.oid === "number" ? hist.oid : typeof item.oid === "number" ? item.oid : null,
379
+ title: item.title && item.title.length > 0 ? item.title : "(no title)",
380
+ viewAt: typeof item.view_at === "number" ? item.view_at : 0,
381
+ duration: typeof item.duration === "number" ? item.duration : null,
382
+ uploader: owner.name || null,
383
+ uploaderMid: typeof owner.mid === "number" ? owner.mid : null,
384
+ part: item.part || null,
385
+ });
386
+ }
387
+ return out;
388
+ }
389
+
390
+ /**
391
+ * Fetch favourites across all user-created folders. Two API calls per
392
+ * folder (folder list + items per folder). Mirrors Kotlin fetchFavourites.
393
+ *
394
+ * @param {string} cookie
395
+ * @param {number} uid numeric DedeUserID
396
+ * @param {{perFolderLimit?: number}} [opts]
397
+ * @returns {Promise<Array<{bvid, title, savedAt, folderName, uploader}>>}
398
+ */
399
+ async fetchFavourites(cookie, uid, opts = {}) {
400
+ const perFolderLimit =
401
+ Number.isInteger(opts.perFolderLimit) && opts.perFolderLimit > 0
402
+ ? opts.perFolderLimit
403
+ : 50;
404
+ const rawFoldersUrl = new URL("x/v3/fav/folder/created/list-all", this.baseUrl);
405
+ rawFoldersUrl.searchParams.set("up_mid", String(uid));
406
+ const { cookie: effectiveCookie, url: foldersUrl } = await this._prepareRequest(
407
+ cookie,
408
+ rawFoldersUrl,
409
+ );
410
+ const foldersJson = await this._doGetJson(foldersUrl, effectiveCookie);
411
+ if (!foldersJson) return [];
412
+ const foldersData = foldersJson.data || {};
413
+ const folders = Array.isArray(foldersData.list) ? foldersData.list : [];
414
+ const out = [];
415
+ for (const folder of folders) {
416
+ if (!folder) continue;
417
+ const folderId = typeof folder.id === "number" ? folder.id : 0;
418
+ if (folderId === 0) continue;
419
+ const folderName = folder.title || null;
420
+ const rawItemsUrl = new URL("x/v3/fav/resource/list", this.baseUrl);
421
+ rawItemsUrl.searchParams.set("media_id", String(folderId));
422
+ rawItemsUrl.searchParams.set("ps", String(perFolderLimit));
423
+ rawItemsUrl.searchParams.set("pn", "1");
424
+ // Real-device 2026-05-22: missing `platform=web` returns code=-400.
425
+ rawItemsUrl.searchParams.set("platform", "web");
426
+ // Sign the per-folder URL too (signature wraps each request).
427
+ const itemsUrl = this._wbiMixinKey
428
+ ? signUrl(rawItemsUrl, this._wbiMixinKey, { now: this._now })
429
+ : rawItemsUrl;
430
+ const itemsJson = await this._doGetJson(itemsUrl, effectiveCookie);
431
+ if (!itemsJson) continue;
432
+ const itemsData = itemsJson.data || {};
433
+ const medias = Array.isArray(itemsData.medias) ? itemsData.medias : [];
434
+ for (const m of medias) {
435
+ if (!m) continue;
436
+ const upper = m.upper || {};
437
+ const favSec =
438
+ typeof m.fav_time === "number" && m.fav_time > 0
439
+ ? m.fav_time
440
+ : typeof m.ctime === "number"
441
+ ? m.ctime
442
+ : 0;
443
+ out.push({
444
+ bvid: m.bvid || null,
445
+ title: m.title && m.title.length > 0 ? m.title : "(no title)",
446
+ savedAt: favSec * 1000,
447
+ folderName,
448
+ uploader: upper.name || null,
449
+ });
450
+ }
451
+ }
452
+ return out;
453
+ }
454
+
455
+ /**
456
+ * Fetch dynamic feed. Mirrors Kotlin fetchDynamics — type=all +
457
+ * platform=web + timezone_offset=-480 required, or anti-bot returns
458
+ * code=0 + empty page.
459
+ */
460
+ async fetchDynamics(cookie, opts = {}) {
461
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 50;
462
+ const rawUrl = new URL("x/polymer/web-dynamic/v1/feed/all", this.baseUrl);
463
+ rawUrl.searchParams.set("type", "all");
464
+ rawUrl.searchParams.set("platform", "web");
465
+ rawUrl.searchParams.set("timezone_offset", "-480");
466
+ const { cookie: effectiveCookie, url } = await this._prepareRequest(cookie, rawUrl);
467
+ const obj = await this._doGetJson(url, effectiveCookie);
468
+ if (!obj) return [];
469
+ const data = obj.data || {};
470
+ const items = Array.isArray(data.items) ? data.items : [];
471
+ const out = [];
472
+ for (let i = 0; i < Math.min(limit, items.length); i++) {
473
+ const it = items[i];
474
+ if (!it) continue;
475
+ const modules = it.modules || {};
476
+ const author = modules.module_author || {};
477
+ const dyn = modules.module_dynamic || {};
478
+ const desc = dyn.desc || {};
479
+ const archive = (dyn.major || {}).archive || {};
480
+ const summary =
481
+ (typeof desc.text === "string" && desc.text.length > 0 && desc.text) ||
482
+ archive.title ||
483
+ "(no summary)";
484
+ const rawType = typeof it.type === "string" ? it.type : "";
485
+ const dynamicType =
486
+ rawType.replace(/^DYNAMIC_TYPE_/, "").toLowerCase() || "unknown";
487
+ out.push({
488
+ rid: it.id_str || null,
489
+ summary,
490
+ dynamicType,
491
+ publishedAt: (typeof author.pub_ts === "number" ? author.pub_ts : 0) * 1000,
492
+ authorMid: typeof author.mid === "number" ? author.mid : null,
493
+ authorName: author.name || null,
494
+ });
495
+ }
496
+ return out;
497
+ }
498
+
499
+ /**
500
+ * Fetch following list. Mirrors Kotlin fetchFollows.
501
+ *
502
+ * @param {string} cookie
503
+ * @param {number} uid numeric DedeUserID
504
+ * @param {{limit?: number}} [opts]
505
+ * @returns {Promise<Array<{mid, uname, face, sign, followedAt}>>}
506
+ */
507
+ async fetchFollows(cookie, uid, opts = {}) {
508
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 200;
509
+ const rawUrl = new URL("x/relation/followings", this.baseUrl);
510
+ rawUrl.searchParams.set("vmid", String(uid));
511
+ rawUrl.searchParams.set("ps", "50");
512
+ rawUrl.searchParams.set("pn", "1");
513
+ rawUrl.searchParams.set("order", "desc");
514
+ rawUrl.searchParams.set("order_type", "attention");
515
+ const { cookie: effectiveCookie, url } = await this._prepareRequest(cookie, rawUrl);
516
+ const obj = await this._doGetJson(url, effectiveCookie);
517
+ if (!obj) return [];
518
+ const data = obj.data || {};
519
+ const list = Array.isArray(data.list) ? data.list : [];
520
+ const out = [];
521
+ for (let i = 0; i < Math.min(limit, list.length); i++) {
522
+ const it = list[i];
523
+ if (!it) continue;
524
+ const mid = typeof it.mid === "number" ? it.mid : 0;
525
+ if (mid === 0) continue;
526
+ out.push({
527
+ mid,
528
+ uname: it.uname && it.uname.length > 0 ? it.uname : "(unnamed)",
529
+ face: it.face || null,
530
+ sign: it.sign || null,
531
+ // mtime is unix-seconds modified time of the follow row.
532
+ followedAt: (typeof it.mtime === "number" ? it.mtime : 0) * 1000,
533
+ });
534
+ }
535
+ return out;
536
+ }
537
+ }
538
+
539
+ module.exports = {
540
+ BilibiliApiClient,
541
+ extractUid,
542
+ // Exposed for tests + future reuse (Weibo/Xhs may share md5+UA pattern)
543
+ _internals: {
544
+ extractWbiKeyFromUrl,
545
+ substituteBuvid3,
546
+ md5Hex,
547
+ urlEncodeWbi,
548
+ stripForbiddenChars,
549
+ signUrl,
550
+ WBI_MIXIN_KEY_TABLE,
551
+ WBI_FORBIDDEN_CHARS,
552
+ BROWSER_UA,
553
+ BROWSER_HEADERS,
554
+ },
555
+ };