@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.
- package/__tests__/adapters/email-adapter-snapshot.test.js +237 -0
- package/__tests__/adapters/email-adapter.test.js +1 -1
- package/__tests__/adapters/email-pdf-extractor.test.js +1 -1
- package/__tests__/adapters/email-retry-progress.test.js +1 -1
- package/__tests__/adapters/email-templates.test.js +1 -1
- package/__tests__/adapters/social-bilibili-adb-api-client.test.js +721 -0
- package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +346 -0
- package/__tests__/adapters/social-bilibili-adb-collector.test.js +284 -0
- package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +343 -0
- package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +296 -0
- package/__tests__/adapters/social-douyin-adb-collector.test.js +254 -0
- package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +304 -0
- package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +216 -0
- package/__tests__/adapters/social-weibo-adb-api-client.test.js +362 -0
- package/__tests__/adapters/social-weibo-adb-collector.test.js +201 -0
- package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +189 -0
- package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +207 -0
- package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +130 -0
- package/__tests__/adapters/system-data-android.test.js +32 -1
- package/__tests__/longtail-adapters.test.js +15 -2
- package/__tests__/shopping-adapters.test.js +96 -0
- package/__tests__/sign-providers.test.js +62 -0
- package/__tests__/travel-adapters.test.js +66 -0
- package/__tests__/whatsapp-adapter.test.js +5 -2
- package/lib/adapters/browser-history-chrome/chrome-db-reader.js +11 -1
- package/lib/adapters/email-imap/email-adapter.js +224 -17
- package/lib/adapters/messaging-telegram/index.js +15 -12
- package/lib/adapters/messaging-whatsapp/index.js +15 -12
- package/lib/adapters/shopping-taobao/index.js +161 -21
- package/lib/adapters/social-bilibili-adb/api-client.js +555 -0
- package/lib/adapters/social-bilibili-adb/chromium-cookies-reader.js +296 -0
- package/lib/adapters/social-bilibili-adb/collector.js +190 -0
- package/lib/adapters/social-bilibili-adb/cookies-extension.js +250 -0
- package/lib/adapters/social-bilibili-adb/index.js +51 -0
- package/lib/adapters/social-bilibili-adb/snapshot-builder.js +197 -0
- package/lib/adapters/social-douyin/index.js +4 -0
- package/lib/adapters/social-douyin-adb/collector.js +165 -0
- package/lib/adapters/social-douyin-adb/db-extension.js +281 -0
- package/lib/adapters/social-douyin-adb/im-db-parser.js +287 -0
- package/lib/adapters/social-douyin-adb/index.js +57 -0
- package/lib/adapters/social-douyin-adb/snapshot-builder.js +174 -0
- package/lib/adapters/social-weibo-adb/api-client.js +281 -0
- package/lib/adapters/social-weibo-adb/collector.js +169 -0
- package/lib/adapters/social-weibo-adb/cookies-extension.js +251 -0
- package/lib/adapters/social-weibo-adb/index.js +55 -0
- package/lib/adapters/social-weibo-adb/snapshot-builder.js +145 -0
- package/lib/adapters/social-xiaohongshu-adb/api-client.js +278 -0
- package/lib/adapters/social-xiaohongshu-adb/collector.js +158 -0
- package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +211 -0
- package/lib/adapters/social-xiaohongshu-adb/index.js +50 -0
- package/lib/adapters/social-xiaohongshu-adb/sign.js +90 -0
- package/lib/adapters/social-xiaohongshu-adb/snapshot-builder.js +126 -0
- package/lib/adapters/system-data-android/adapter.js +77 -3
- package/lib/adapters/travel-amap/index.js +16 -10
- package/lib/adapters/travel-ctrip/index.js +25 -9
- package/lib/adapters/vscode/vscode-reader.js +7 -1
- package/lib/sign-providers/index.js +20 -0
- package/lib/sign-providers/interface.js +82 -0
- package/lib/sign-providers/null-sign-provider.js +30 -0
- 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
|
+
};
|