@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,296 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 1a (Bilibili C 路径 — 2026-05-25): Chromium WebView Cookies sqlite
5
+ * reader.
6
+ *
7
+ * Android Bilibili App uses an internal Chromium WebView for the login/web
8
+ * surface, which persists cookies to:
9
+ * /data/data/tv.danmaku.bili/app_webview/Default/Cookies
10
+ *
11
+ * The schema is the standard Chromium `cookies` table (verbatim across
12
+ * desktop Chrome / Android System WebView / Android WebView component
13
+ * since Chromium 80+). Schema reference:
14
+ * https://chromium.googlesource.com/chromium/src/+/refs/heads/main/net/extras/sqlite/sqlite_persistent_cookie_store.cc
15
+ *
16
+ * CREATE TABLE cookies (
17
+ * creation_utc INTEGER, -- WebKit µs since 1601
18
+ * host_key TEXT, -- e.g. ".bilibili.com" or ".api.bilibili.com"
19
+ * top_frame_site_key TEXT,
20
+ * name TEXT, -- "SESSDATA" / "bili_jct" / ...
21
+ * value TEXT, -- plaintext on Android in most cases
22
+ * encrypted_value BLOB, -- AES-GCM-encrypted under OS Keychain
23
+ * on desktop; on Android typically empty
24
+ * (Android Keystore wrap is opt-in only
25
+ * in newer Chromium; field stays empty
26
+ * for the unwrapped App-WebView case)
27
+ * path TEXT,
28
+ * expires_utc INTEGER,
29
+ * is_secure INTEGER,
30
+ * is_httponly INTEGER,
31
+ * last_access_utc INTEGER,
32
+ * has_expires INTEGER,
33
+ * is_persistent INTEGER,
34
+ * priority INTEGER,
35
+ * samesite INTEGER,
36
+ * source_scheme INTEGER,
37
+ * source_port INTEGER,
38
+ * is_same_party INTEGER,
39
+ * last_update_utc INTEGER
40
+ * );
41
+ *
42
+ * What this reader does NOT do (intentionally — gated to Phase 1b+):
43
+ * - Decrypt Android-Keystore-wrapped `encrypted_value` (rare in the
44
+ * App-WebView case, but possible on newer Android 14+ builds with
45
+ * aggressive Chromium upgrades). We log a warning + skip rows where
46
+ * `value` is empty and `encrypted_value` is non-empty.
47
+ * - Schema-version sniffing for ancient Chromium (<80) — old App WebViews
48
+ * used a `meta` table version <13; we treat unknown schemas as a hard
49
+ * error so the caller can surface "App too old, please update Bilibili"
50
+ * rather than producing garbage cookies.
51
+ *
52
+ * Test seam: callers can inject a synthetic `_databaseClass` to bypass the
53
+ * dual-load probe — the unit test builds a real sqlite db on disk with
54
+ * the chromium schema + a few rows.
55
+ */
56
+
57
+ const path = require("node:path");
58
+
59
+ // Dual-load: bs3mc tracks Electron's ABI 140 (runtime path), plain
60
+ // better-sqlite3 tracks Node's ABI 127 (test path). Mirrors the pattern in
61
+ // chrome-db-reader.js — see memory bs3mc_bs3_abi_dual_load_adapter.md.
62
+ function loadDatabaseClass() {
63
+ for (const mod of ["better-sqlite3-multiple-ciphers", "better-sqlite3"]) {
64
+ let cls;
65
+ try {
66
+ // eslint-disable-next-line global-require
67
+ cls = require(mod);
68
+ } catch (_e) {
69
+ continue;
70
+ }
71
+ try {
72
+ const probe = new cls(":memory:");
73
+ probe.close();
74
+ return cls;
75
+ } catch (_e) {
76
+ // ABI mismatch — try next candidate
77
+ }
78
+ }
79
+ throw new Error(
80
+ "chromium-cookies-reader: neither better-sqlite3-multiple-ciphers nor better-sqlite3 loaded — both ABI-mismatched",
81
+ );
82
+ }
83
+
84
+ // WebKit timestamps are microseconds since 1601-01-01 UTC. Convert to
85
+ // epoch-ms by shifting the epoch (11644473600 seconds × 1e6 µs/s).
86
+ // Mirrors chrome-db-reader.js.
87
+ const WEBKIT_EPOCH_DELTA_US = 11_644_473_600_000_000n;
88
+ function webkitUsToEpochMs(wkUs) {
89
+ if (wkUs == null) return null;
90
+ const bn = typeof wkUs === "bigint" ? wkUs : BigInt(wkUs);
91
+ return Number((bn - WEBKIT_EPOCH_DELTA_US) / 1000n);
92
+ }
93
+
94
+ /**
95
+ * Match a chromium `host_key` value against a domain like "bilibili.com".
96
+ *
97
+ * host_key formats in the wild:
98
+ * ".bilibili.com" — domain cookie (most common)
99
+ * "bilibili.com" — without leading dot (older Chromium)
100
+ * ".api.bilibili.com" — subdomain pinned
101
+ * "api.bilibili.com" — host-only
102
+ *
103
+ * We accept all four for a `domain` of "bilibili.com" — same semantics
104
+ * Chromium itself uses when matching cookies to requests.
105
+ */
106
+ function hostKeyMatches(hostKey, domain) {
107
+ if (!hostKey || !domain) return false;
108
+ // Normalize: strip leading dot from both
109
+ const h = hostKey.startsWith(".") ? hostKey.substring(1) : hostKey;
110
+ const d = domain.startsWith(".") ? domain.substring(1) : domain;
111
+ // Exact or subdomain match
112
+ return h === d || h.endsWith("." + d);
113
+ }
114
+
115
+ /**
116
+ * Read cookies matching `domain` from a Chromium-shape sqlite at `dbPath`.
117
+ *
118
+ * Returns an array of `{name, value, hostKey, expiresMs, isSecure,
119
+ * isHttponly, isPersistent}` objects. The caller is responsible for
120
+ * filtering by cookie name (e.g. "SESSDATA" / "bili_jct" / ...) and
121
+ * assembling them into a Cookie header.
122
+ *
123
+ * Note: this opens the DB read-only and closes after the synchronous read.
124
+ * Even though we read from a temp copy (ADB pulled to a host-side temp
125
+ * dir), opening read-only is good hygiene.
126
+ *
127
+ * @param {string} dbPath absolute path to a chromium Cookies sqlite file
128
+ * @param {string} domain cookie host to filter, e.g. "bilibili.com"
129
+ * @param {{_databaseClass?: any, _now?: number}} [opts] test seams
130
+ * @returns {Array<{name: string, value: string, hostKey: string,
131
+ * expiresMs: number|null, isSecure: boolean, isHttponly: boolean,
132
+ * isPersistent: boolean, hasEncryptedValue: boolean}>}
133
+ */
134
+ function readChromiumCookies(dbPath, domain, opts = {}) {
135
+ if (typeof dbPath !== "string" || dbPath.length === 0) {
136
+ throw new TypeError("readChromiumCookies: dbPath must be a non-empty string");
137
+ }
138
+ if (typeof domain !== "string" || domain.length === 0) {
139
+ throw new TypeError("readChromiumCookies: domain must be a non-empty string");
140
+ }
141
+ const Database = opts._databaseClass || loadDatabaseClass();
142
+ const db = new Database(dbPath, { readonly: true });
143
+ try {
144
+ // Detect schema before relying on column names — old WebViews drop
145
+ // columns (no top_frame_site_key) and new ones add columns (no
146
+ // is_same_party in some 110+ builds). We only need name / value /
147
+ // host_key / expires_utc / encrypted_value / is_secure / is_httponly /
148
+ // is_persistent — verify each is present.
149
+ let tableInfo;
150
+ try {
151
+ tableInfo = db.prepare("PRAGMA table_info(cookies)").all();
152
+ } catch (_e) {
153
+ throw new Error(
154
+ "chromium-cookies-reader: `cookies` table not found — DB is not a Chromium Cookies sqlite",
155
+ );
156
+ }
157
+ if (!Array.isArray(tableInfo) || tableInfo.length === 0) {
158
+ throw new Error(
159
+ "chromium-cookies-reader: `cookies` table empty / unreadable",
160
+ );
161
+ }
162
+ const columns = new Set(tableInfo.map((c) => c.name));
163
+ const required = ["name", "value", "host_key", "expires_utc"];
164
+ for (const col of required) {
165
+ if (!columns.has(col)) {
166
+ throw new Error(
167
+ `chromium-cookies-reader: required column "${col}" missing from cookies schema (App version too old?)`,
168
+ );
169
+ }
170
+ }
171
+ const hasEncrypted = columns.has("encrypted_value");
172
+ const hasSecure = columns.has("is_secure");
173
+ const hasHttpOnly = columns.has("is_httponly");
174
+ const hasPersistent = columns.has("is_persistent");
175
+
176
+ // Build a defensive SELECT picking only the columns we know exist.
177
+ const cols = [
178
+ "name",
179
+ "value",
180
+ "host_key",
181
+ "expires_utc",
182
+ hasEncrypted ? "encrypted_value" : "NULL AS encrypted_value",
183
+ hasSecure ? "is_secure" : "0 AS is_secure",
184
+ hasHttpOnly ? "is_httponly" : "0 AS is_httponly",
185
+ hasPersistent ? "is_persistent" : "0 AS is_persistent",
186
+ ].join(", ");
187
+ const rows = db.prepare(`SELECT ${cols} FROM cookies`).all();
188
+
189
+ const out = [];
190
+ let skippedEncrypted = 0;
191
+ for (const r of rows) {
192
+ if (!hostKeyMatches(r.host_key, domain)) continue;
193
+ const enc = r.encrypted_value;
194
+ const hasEnc = enc != null && enc.length > 0;
195
+ const value = typeof r.value === "string" ? r.value : "";
196
+ if (value.length === 0 && hasEnc) {
197
+ // Android-Keystore-wrapped — we don't decrypt these yet (Phase 1b+).
198
+ // Skip but report so the caller can warn the user / collect telemetry.
199
+ skippedEncrypted += 1;
200
+ continue;
201
+ }
202
+ out.push({
203
+ name: r.name,
204
+ value,
205
+ hostKey: r.host_key,
206
+ expiresMs: webkitUsToEpochMs(r.expires_utc),
207
+ isSecure: !!r.is_secure,
208
+ isHttponly: !!r.is_httponly,
209
+ isPersistent: !!r.is_persistent,
210
+ hasEncryptedValue: hasEnc,
211
+ });
212
+ }
213
+ if (skippedEncrypted > 0) {
214
+ // Attach as a non-enumerable diagnostic so it doesn't show up in
215
+ // JSON.stringify but tests can read it. Phase 1b will turn this into
216
+ // a hard error if we land Keystore unwrap.
217
+ Object.defineProperty(out, "_skippedEncryptedCount", {
218
+ value: skippedEncrypted,
219
+ enumerable: false,
220
+ });
221
+ }
222
+ return out;
223
+ } finally {
224
+ db.close();
225
+ }
226
+ }
227
+
228
+ /**
229
+ * The Bilibili-relevant cookie names. These are what api.bilibili.com
230
+ * endpoints check (per BilibiliApiClient.kt + BilibiliCredentialsStore.kt
231
+ * Android implementation). buvid3 is required for anti-spam; bili_jct is
232
+ * the CSRF token; SESSDATA is the session cookie; DedeUserID is the
233
+ * numeric UID; DedeUserID__ckMd5 is its integrity hash.
234
+ *
235
+ * Frozen so callers can't accidentally mutate the list.
236
+ */
237
+ const BILIBILI_COOKIE_NAMES = Object.freeze([
238
+ "SESSDATA",
239
+ "bili_jct",
240
+ "DedeUserID",
241
+ "DedeUserID__ckMd5",
242
+ "buvid3",
243
+ ]);
244
+
245
+ /**
246
+ * Assemble a Bilibili-suitable Cookie header value from a cookies array
247
+ * (as returned by [readChromiumCookies]).
248
+ *
249
+ * Returns `{header, present, missing}`:
250
+ * - header: the assembled `name=value; name=value; ...` string, or null
251
+ * if any required cookie is missing
252
+ * - present: Set of cookie names found
253
+ * - missing: Array of required cookie names not found (empty when OK)
254
+ *
255
+ * "Required" = all 5 names in [BILIBILI_COOKIE_NAMES]. Bilibili will
256
+ * partially work with just SESSDATA + bili_jct, but our anti-spam history
257
+ * showed silent `{code:0,data:[]}` returns when buvid3 is absent
258
+ * ([[bilibili-post-onload-cookie-race]]), so we treat any missing as a
259
+ * hard fail and let UI prompt the user to relog on the phone.
260
+ */
261
+ function assembleBilibiliCookieHeader(cookies) {
262
+ if (!Array.isArray(cookies)) {
263
+ throw new TypeError("assembleBilibiliCookieHeader: cookies must be an array");
264
+ }
265
+ const byName = new Map();
266
+ for (const c of cookies) {
267
+ // Most-recently-set wins if duplicate names exist across host_keys.
268
+ // ".bilibili.com" + "api.bilibili.com" sometimes both have SESSDATA;
269
+ // prefer the longer host (more specific) — though in practice
270
+ // Bilibili sets all on ".bilibili.com" so this rarely matters.
271
+ if (!byName.has(c.name) || c.hostKey.length > (byName.get(c.name).hostKey || "").length) {
272
+ byName.set(c.name, c);
273
+ }
274
+ }
275
+ const missing = BILIBILI_COOKIE_NAMES.filter((n) => !byName.has(n));
276
+ const present = new Set(byName.keys());
277
+ if (missing.length > 0) {
278
+ return { header: null, present, missing };
279
+ }
280
+ // Preserve the canonical order (matches what Bilibili's own web client
281
+ // sends — not strictly required but reduces fingerprinting differences).
282
+ const header = BILIBILI_COOKIE_NAMES.map((n) => `${n}=${byName.get(n).value}`).join("; ");
283
+ return { header, present, missing: [] };
284
+ }
285
+
286
+ module.exports = {
287
+ readChromiumCookies,
288
+ assembleBilibiliCookieHeader,
289
+ BILIBILI_COOKIE_NAMES,
290
+ // Exposed for tests + future Weibo/Xhs/Douyin reuse
291
+ _internals: {
292
+ hostKeyMatches,
293
+ webkitUsToEpochMs,
294
+ loadDatabaseClass,
295
+ },
296
+ };
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 1b (Bilibili C 路径 — 2026-05-25): end-to-end orchestrator.
5
+ *
6
+ * bridge.invoke("bilibili.cookies") ← Phase 1a cookies extension
7
+ * │
8
+ * ▼ {cookie, uid}
9
+ * BilibiliApiClient ← Phase 1b Node-side WBI port
10
+ * fetchHistory / fetchFavourites (4 endpoints, partial-failure OK)
11
+ * fetchDynamics / fetchFollows
12
+ * │
13
+ * ▼ 4 arrays
14
+ * buildSnapshot + writeSnapshotJson ← Phase 1b snapshot builder
15
+ * │
16
+ * ▼ staging JSON path
17
+ * registry.syncAdapter("social-bilibili", { inputPath }) ← reuses existing
18
+ * snapshot mode
19
+ *
20
+ * The collector does NOT register itself as a separate adapter — it
21
+ * piggy-backs on the existing `social-bilibili` adapter's snapshot mode
22
+ * so vault schema / event types / dedup all stay single-source-of-truth.
23
+ *
24
+ * Failure modes:
25
+ * - bridge.invoke throws → propagates (caller catches; UI shows root /
26
+ * not-installed / cookie-incomplete error from Phase 1a)
27
+ * - any of the 4 API endpoints fails → empty array; sync proceeds with
28
+ * partial data and `lastErrorCode` surfaces the cause for UI
29
+ * - all 4 endpoints empty → still produces a valid snapshot with 0
30
+ * events; adapter sync emits a clean report (better than throwing —
31
+ * user can read the error in lastErrorMessage to diagnose)
32
+ * - staging file write failure → throws (genuine "we can't continue")
33
+ */
34
+
35
+ const { BilibiliApiClient } = require("./api-client");
36
+ const {
37
+ buildSnapshot,
38
+ writeSnapshotJson,
39
+ cleanupSnapshotJson,
40
+ } = require("./snapshot-builder");
41
+
42
+ /**
43
+ * Pull cookies → fetch 4 endpoints → write a snapshot JSON. Returns the
44
+ * staging path + counts + diagnostic — caller decides what to do with
45
+ * the snapshot (typically pass to registry.syncAdapter then cleanup).
46
+ *
47
+ * @param {object} bridge the host-adb-bridge instance — must have
48
+ * `bilibili.cookies` extension registered (Phase 1a)
49
+ * @param {{
50
+ * apiClient?: BilibiliApiClient,
51
+ * limits?: {history?: number, favourite?: number, dynamic?: number, follow?: number},
52
+ * stagingDir?: string,
53
+ * displayName?: string,
54
+ * now?: () => number,
55
+ * }} [opts]
56
+ * @returns {Promise<{
57
+ * snapshotPath: string,
58
+ * uid: number,
59
+ * eventCounts: {history: number, favourite: number, dynamic: number, follow: number, total: number},
60
+ * lastErrorCode: number,
61
+ * lastErrorMessage: string|null,
62
+ * cookieDiagnostic: object,
63
+ * }>}
64
+ */
65
+ async function collect(bridge, opts = {}) {
66
+ if (!bridge || typeof bridge.invoke !== "function") {
67
+ throw new TypeError(
68
+ "BilibiliAdbCollector.collect: bridge must expose invoke(method, params)",
69
+ );
70
+ }
71
+ const limits = opts.limits || {};
72
+ const client =
73
+ opts.apiClient || new BilibiliApiClient({ now: opts.now });
74
+ const now = opts.now || Date.now;
75
+
76
+ // 1. Pull cookies via Phase 1a extension.
77
+ const { cookie, uid, diagnostic: cookieDiagnostic } = await bridge.invoke(
78
+ "bilibili.cookies",
79
+ );
80
+ if (!cookie || !Number.isFinite(uid) || uid <= 0) {
81
+ throw new Error(
82
+ "BilibiliAdbCollector.collect: bridge.invoke('bilibili.cookies') returned malformed payload — got cookie=" +
83
+ typeof cookie +
84
+ " uid=" +
85
+ uid,
86
+ );
87
+ }
88
+
89
+ // 2. Fetch 4 endpoints in parallel — independent calls, partial failure
90
+ // tolerated. Promise.all rejects on first throw, but the client returns
91
+ // [] on error rather than throwing, so all four resolve.
92
+ const [history, favourites, dynamics, follows] = await Promise.all([
93
+ client.fetchHistory(cookie, {
94
+ limit: Number.isInteger(limits.history) ? limits.history : undefined,
95
+ }),
96
+ client.fetchFavourites(cookie, uid, {
97
+ perFolderLimit: Number.isInteger(limits.favourite) ? limits.favourite : undefined,
98
+ }),
99
+ client.fetchDynamics(cookie, {
100
+ limit: Number.isInteger(limits.dynamic) ? limits.dynamic : undefined,
101
+ }),
102
+ client.fetchFollows(cookie, uid, {
103
+ limit: Number.isInteger(limits.follow) ? limits.follow : undefined,
104
+ }),
105
+ ]);
106
+
107
+ // 3. Build snapshot + write to staging.
108
+ const snapshot = buildSnapshot({
109
+ uid,
110
+ displayName: opts.displayName,
111
+ history,
112
+ favourites,
113
+ dynamics,
114
+ follows,
115
+ snapshottedAt: now(),
116
+ });
117
+ const snapshotPath = writeSnapshotJson(snapshot, { dir: opts.stagingDir });
118
+
119
+ return {
120
+ snapshotPath,
121
+ uid,
122
+ eventCounts: {
123
+ history: history.length,
124
+ favourite: favourites.length,
125
+ dynamic: dynamics.length,
126
+ follow: follows.length,
127
+ total: snapshot.events.length,
128
+ },
129
+ // Surface the API client's last error so UI can disambiguate "all 4
130
+ // empty because anti-spider rate-limited" from "all 4 empty because
131
+ // user is new and has no history".
132
+ lastErrorCode: client.lastErrorCode,
133
+ lastErrorMessage: client.lastErrorMessage,
134
+ cookieDiagnostic: cookieDiagnostic || null,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * One-shot convenience: collect + syncAdapter("social-bilibili") + cleanup.
140
+ *
141
+ * Returns the SyncReport from registry.syncAdapter merged with the
142
+ * collector's diagnostic fields (eventCounts, cookieDiagnostic,
143
+ * lastErrorCode/Message) so UI gets one object with everything.
144
+ *
145
+ * Cleanup is always attempted — even if syncAdapter throws — so the
146
+ * temp .json doesn't leak.
147
+ *
148
+ * @param {object} bridge host-adb-bridge
149
+ * @param {object} registry AdapterRegistry (must already have
150
+ * "social-bilibili" adapter registered)
151
+ * @param {object} [opts] forwarded to `collect()`
152
+ * @returns {Promise<object>} SyncReport + collector diagnostic
153
+ */
154
+ async function collectAndSync(bridge, registry, opts = {}) {
155
+ if (!registry || typeof registry.syncAdapter !== "function") {
156
+ throw new TypeError(
157
+ "BilibiliAdbCollector.collectAndSync: registry must expose syncAdapter(name, options)",
158
+ );
159
+ }
160
+ const collectResult = await collect(bridge, opts);
161
+ let syncReport = null;
162
+ let cleanupFailed = false;
163
+ try {
164
+ syncReport = await registry.syncAdapter("social-bilibili", {
165
+ inputPath: collectResult.snapshotPath,
166
+ });
167
+ } finally {
168
+ try {
169
+ cleanupSnapshotJson(collectResult.snapshotPath);
170
+ } catch (_e) {
171
+ cleanupFailed = true;
172
+ }
173
+ }
174
+ return {
175
+ ...syncReport,
176
+ bilibili: {
177
+ uid: collectResult.uid,
178
+ eventCounts: collectResult.eventCounts,
179
+ lastErrorCode: collectResult.lastErrorCode,
180
+ lastErrorMessage: collectResult.lastErrorMessage,
181
+ cookieDiagnostic: collectResult.cookieDiagnostic,
182
+ cleanupFailed,
183
+ },
184
+ };
185
+ }
186
+
187
+ module.exports = {
188
+ collect,
189
+ collectAndSync,
190
+ };