@chainlesschain/personal-data-hub 0.2.0 → 0.2.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.
Files changed (59) hide show
  1. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
  2. package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
  3. package/__tests__/adapters/ai-chat-history.test.js +8 -7
  4. package/__tests__/adapters/ai-chat-vendors.test.js +149 -8
  5. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  6. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  7. package/__tests__/adapters/system-data-android.test.js +387 -0
  8. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  9. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  10. package/__tests__/adapters/wechat-frida-agent.test.js +322 -0
  11. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  12. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  13. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  14. package/__tests__/analysis-skills.test.js +147 -0
  15. package/__tests__/analysis.test.js +329 -1
  16. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  17. package/__tests__/e2e/full-user-journey.test.js +188 -0
  18. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  19. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  20. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  21. package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
  22. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  23. package/__tests__/registry.test.js +4 -2
  24. package/__tests__/social-adapters.test.js +63 -14
  25. package/__tests__/social-bilibili-snapshot.test.js +278 -0
  26. package/__tests__/wechat-adapter.test.js +118 -0
  27. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
  28. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  29. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  30. package/lib/adapters/ai-chat-history/schema-map.js +42 -5
  31. package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
  32. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  33. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  34. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
  35. package/lib/adapters/social-bilibili/adapter.js +500 -0
  36. package/lib/adapters/social-bilibili/index.js +21 -169
  37. package/lib/adapters/social-kuaishou/index.js +237 -0
  38. package/lib/adapters/social-toutiao/index.js +236 -0
  39. package/lib/adapters/system-data-android/adapter.js +348 -0
  40. package/lib/adapters/system-data-android/index.js +76 -0
  41. package/lib/adapters/wechat/bootstrap.js +146 -0
  42. package/lib/adapters/wechat/content-parser.js +11 -2
  43. package/lib/adapters/wechat/db-reader.js +88 -10
  44. package/lib/adapters/wechat/env-probe.js +218 -0
  45. package/lib/adapters/wechat/frida-agent/loader.js +74 -0
  46. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +248 -0
  47. package/lib/adapters/wechat/index.js +9 -0
  48. package/lib/adapters/wechat/key-providers/frida-key-provider.js +252 -0
  49. package/lib/adapters/wechat/key-providers/index.js +22 -0
  50. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  51. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  52. package/lib/adapters/wechat/normalize.js +12 -3
  53. package/lib/analysis-skills/spending.js +4 -1
  54. package/lib/analysis.js +191 -2
  55. package/lib/index.js +16 -0
  56. package/lib/prompt-builder.js +11 -1
  57. package/lib/query-parser.js +7 -1
  58. package/lib/vault.js +77 -0
  59. package/package.json +8 -1
@@ -0,0 +1,500 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * BilibiliAdapter — A8 v0.1 (2026-05-22)
5
+ *
6
+ * Two sync modes, mutually exclusive based on opts:
7
+ *
8
+ * 1. snapshot mode (opts.inputPath): in-APK Android cc reads a snapshot
9
+ * JSON produced by the phone's own WebView+OkHttp pipeline. This is
10
+ * the desktop-independent path for Plan A v0.1; Android does cookie
11
+ * capture + HTTP fetch + parsing in Kotlin, then writes the snapshot
12
+ * to filesDir and asks LocalCcRunner to ingest it. Adapter is stateless.
13
+ *
14
+ * 2. sqlite mode (opts.dbPath, legacy): Phase 7.5 AndroidExtractor pulled
15
+ * the app DB via `adb backup`; this mode parses `history` + `bili_favourite`
16
+ * tables. Retained for backward compat — desktop users with rooted devices
17
+ * can still go this route.
18
+ *
19
+ * Snapshot schema (mirrors Android-side BilibiliLocalCollector.SCHEMA_VERSION):
20
+ *
21
+ * {
22
+ * "schemaVersion": 1,
23
+ * "snapshottedAt": <epoch-ms>,
24
+ * "account": { "uid": "12345", "displayName": "alice" },
25
+ * "events": [
26
+ * { "kind": "history", "id": "BV1xx", "capturedAt": <ms>,
27
+ * "title": "...", "bvid": "...", "avid": ..., "duration": ...,
28
+ * "uploader": "...", "uploaderMid": ..., "part": "..." },
29
+ * { "kind": "favourite", "id": "fav-<bvid>", "capturedAt": <ms>,
30
+ * "title": "...", "bvid": "...", "folderName": "...", "uploader": "..." },
31
+ * { "kind": "dynamic", "id": "dyn-<rid>", "capturedAt": <ms>,
32
+ * "summary": "...", "dynamicType": "video|text|image|...",
33
+ * "authorMid": ..., "authorName": "..." },
34
+ * { "kind": "follow", "id": "follow-<mid>", "capturedAt": <ms>,
35
+ * "mid": "...", "uname": "...", "face": "...", "sign": "..." }
36
+ * ]
37
+ * }
38
+ */
39
+
40
+ const fs = require("node:fs");
41
+ const { newId } = require("../../ids");
42
+ const {
43
+ ENTITY_TYPES,
44
+ PERSON_SUBTYPES,
45
+ EVENT_SUBTYPES,
46
+ ITEM_SUBTYPES,
47
+ CAPTURED_BY,
48
+ } = require("../../constants");
49
+
50
+ const NAME = "social-bilibili";
51
+ const VERSION = "0.6.0";
52
+ const SNAPSHOT_SCHEMA_VERSION = 1;
53
+
54
+ const KIND_HISTORY = "history";
55
+ const KIND_FAVOURITE = "favourite";
56
+ const KIND_DYNAMIC = "dynamic";
57
+ const KIND_FOLLOW = "follow";
58
+ const VALID_KINDS = Object.freeze([
59
+ KIND_HISTORY,
60
+ KIND_FAVOURITE,
61
+ KIND_DYNAMIC,
62
+ KIND_FOLLOW,
63
+ ]);
64
+
65
+ function stableOriginalId(kind, id) {
66
+ // Coerce numeric IDs to string — Bilibili APIs return mid/avid/rid as
67
+ // integers, but originalId is a string in raw_events schema. Without this
68
+ // coercion, `typeof 999 === "string"` is false → falls to unknown- prefix
69
+ // and breaks idempotency across syncs (every sync emits a new "unknown-"
70
+ // ID, raw_events table grows unbounded).
71
+ const stringified =
72
+ (typeof id === "string" && id.length > 0 && id) ||
73
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
74
+ null;
75
+ const safe =
76
+ stringified ||
77
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
78
+ return `bilibili:${kind}:${safe}`;
79
+ }
80
+
81
+ function parseTime(v) {
82
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
83
+ if (typeof v === "string") {
84
+ if (/^\d+$/.test(v)) {
85
+ const n = parseInt(v, 10);
86
+ return n > 1e12 ? n : n * 1000;
87
+ }
88
+ const t = Date.parse(v);
89
+ return Number.isFinite(t) ? t : null;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function trySelect(db, sql) {
95
+ try {
96
+ return db.prepare(sql).all();
97
+ } catch (_e) {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ class BilibiliAdapter {
103
+ constructor(opts = {}) {
104
+ // Stateless in snapshot mode — account.uid optional. Sqlite-mode still
105
+ // requires it (the legacy path before A8); see _syncViaSqlite below.
106
+ this.account = opts.account || null;
107
+ this._dbPath = opts.dbPath || null;
108
+
109
+ this.name = NAME;
110
+ this.version = VERSION;
111
+ this.capabilities = [
112
+ "sync:snapshot",
113
+ "sync:sqlite",
114
+ "parse:bilibili-history",
115
+ "parse:bilibili-favourite",
116
+ "parse:bilibili-dynamic",
117
+ "parse:bilibili-follow",
118
+ ];
119
+ this.extractMode = "device-pull";
120
+ this.rateLimits = {};
121
+ this.dataDisclosure = {
122
+ fields: [
123
+ "bilibili:history (avid / bvid / title / view_at / duration / uploader)",
124
+ "bilibili:favourite (folder / video / save_time / uploader)",
125
+ "bilibili:dynamic (rid / type / summary / author)",
126
+ "bilibili:follow (mid / uname / face)",
127
+ ],
128
+ sensitivity: "medium",
129
+ legalGate: false,
130
+ defaultInclude: {
131
+ history: true,
132
+ favourite: true,
133
+ dynamic: true,
134
+ follow: true,
135
+ },
136
+ };
137
+
138
+ // _deps injection seam (see .claude/rules/cli-dev.md — vi.mock("fs") does
139
+ // not intercept require under inlined CJS; tests override via _deps).
140
+ this._deps = {
141
+ fs,
142
+ dbDriverFactory: opts.dbDriverFactory || null,
143
+ };
144
+ }
145
+
146
+ async authenticate(ctx = {}) {
147
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
148
+ try {
149
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
150
+ } catch (err) {
151
+ return {
152
+ ok: false,
153
+ reason: "INPUT_PATH_UNREADABLE",
154
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
155
+ };
156
+ }
157
+ return { ok: true, mode: "snapshot-file" };
158
+ }
159
+ if (this._dbPath || (ctx && typeof ctx.dbPath === "string")) {
160
+ return { ok: true, mode: "sqlite" };
161
+ }
162
+ return {
163
+ ok: false,
164
+ reason: "NO_INPUT",
165
+ message:
166
+ "social-bilibili.authenticate: needs opts.inputPath (snapshot mode) OR opts.dbPath (sqlite mode)",
167
+ };
168
+ }
169
+
170
+ async healthCheck() {
171
+ return { ok: true, lastChecked: Date.now() };
172
+ }
173
+
174
+ async *sync(opts = {}) {
175
+ // Snapshot mode takes priority — the in-APK Android cc path always passes
176
+ // inputPath. Sqlite mode is the legacy Phase 7.5 desktop path; only kicks
177
+ // in when caller explicitly provides dbPath (no auto-engage to avoid
178
+ // surprising desktop users who upgrade from sqlite-only adapter).
179
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
180
+ yield* this._syncViaSnapshot(opts);
181
+ return;
182
+ }
183
+ const dbPath = opts.dbPath || this._dbPath;
184
+ if (dbPath) {
185
+ yield* this._syncViaSqlite({ ...opts, dbPath });
186
+ return;
187
+ }
188
+ throw new Error(
189
+ "social-bilibili.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) OR opts.dbPath (sqlite mode, Phase 7.5 desktop extractor)"
190
+ );
191
+ }
192
+
193
+ async *_syncViaSnapshot(opts) {
194
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
195
+ const snapshot = JSON.parse(raw);
196
+ if (
197
+ !snapshot ||
198
+ typeof snapshot !== "object" ||
199
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
200
+ ) {
201
+ throw new Error(
202
+ `social-bilibili.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`
203
+ );
204
+ }
205
+ const fallbackCapturedAt =
206
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
207
+ ? Math.floor(snapshot.snapshottedAt)
208
+ : Date.now();
209
+
210
+ const account = snapshot.account && typeof snapshot.account === "object"
211
+ ? snapshot.account
212
+ : null;
213
+ const include = opts.include || {};
214
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
215
+
216
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
217
+ let emitted = 0;
218
+ for (const ev of events) {
219
+ if (emitted >= limit) return;
220
+ if (!ev || typeof ev !== "object") continue;
221
+ const kind = ev.kind;
222
+ if (!VALID_KINDS.includes(kind)) continue;
223
+ // Per-kind include gate. Default: include everything.
224
+ if (include[kind] === false) continue;
225
+
226
+ const capturedAt =
227
+ parseTime(ev.capturedAt) ||
228
+ parseTime(ev.time) ||
229
+ fallbackCapturedAt;
230
+ const id =
231
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
232
+ ev.bvid ||
233
+ ev.mid ||
234
+ ev.rid ||
235
+ null;
236
+
237
+ yield {
238
+ adapter: NAME,
239
+ kind,
240
+ originalId: stableOriginalId(kind, id),
241
+ capturedAt,
242
+ payload: { ...ev, account },
243
+ };
244
+ emitted += 1;
245
+ }
246
+ }
247
+
248
+ async *_syncViaSqlite(opts) {
249
+ // Legacy Phase 7.5 path — requires account.uid in constructor and a DB
250
+ // pulled via the desktop AndroidExtractor. Preserved verbatim from the
251
+ // pre-A8 adapter so existing desktop users don't regress.
252
+ if (!this.account || !this.account.uid) {
253
+ throw new Error(
254
+ "social-bilibili._syncViaSqlite: account.uid required (set via new BilibiliAdapter({ account: { uid } }) in cli wiring)"
255
+ );
256
+ }
257
+ const dbPath = opts.dbPath;
258
+ if (!dbPath || !this._deps.fs.existsSync(dbPath)) return;
259
+ const Driver = this._deps.dbDriverFactory
260
+ ? this._deps.dbDriverFactory()
261
+ : require("better-sqlite3-multiple-ciphers");
262
+ const db = new Driver(dbPath, { readonly: true });
263
+ try {
264
+ const history = trySelect(db, "SELECT * FROM history ORDER BY view_at DESC LIMIT 5000") || [];
265
+ for (const row of history) {
266
+ yield {
267
+ adapter: NAME,
268
+ kind: KIND_HISTORY,
269
+ originalId: stableOriginalId(
270
+ KIND_HISTORY,
271
+ row.id || row._id || row.kid || row.bvid || row.avid
272
+ ),
273
+ capturedAt: parseTime(row.view_at || row.create_at || row.time),
274
+ payload: {
275
+ kind: KIND_HISTORY,
276
+ title: row.title || row.video_title,
277
+ bvid: row.bvid,
278
+ avid: row.avid,
279
+ duration: row.duration || row.progress,
280
+ uploader: row.uploader || row.up_name,
281
+ part: row.part_name,
282
+ _row: row,
283
+ },
284
+ };
285
+ }
286
+ const favs = trySelect(db, "SELECT * FROM bili_favourite ORDER BY save_time DESC LIMIT 5000") || [];
287
+ for (const row of favs) {
288
+ yield {
289
+ adapter: NAME,
290
+ kind: KIND_FAVOURITE,
291
+ originalId: stableOriginalId(
292
+ KIND_FAVOURITE,
293
+ row.id || row.fav_id || row.bvid
294
+ ),
295
+ capturedAt: parseTime(row.save_time || row.time),
296
+ payload: {
297
+ kind: KIND_FAVOURITE,
298
+ title: row.title || row.video_title,
299
+ bvid: row.bvid,
300
+ avid: row.avid,
301
+ folderName: row.folder_name,
302
+ uploader: row.uploader || row.up_name,
303
+ _row: row,
304
+ },
305
+ };
306
+ }
307
+ } finally {
308
+ try { db.close(); } catch (_e) { /* ignore */ }
309
+ }
310
+ }
311
+
312
+ normalize(raw) {
313
+ if (!raw || !raw.payload) {
314
+ throw new Error("BilibiliAdapter.normalize: payload missing");
315
+ }
316
+ const ingestedAt = Date.now();
317
+ const kind = raw.kind || raw.payload.kind;
318
+ const p = raw.payload;
319
+ const occurredAt = parseTime(p.capturedAt) || raw.capturedAt || ingestedAt;
320
+ const source = {
321
+ adapter: NAME,
322
+ adapterVersion: VERSION,
323
+ capturedAt: raw.capturedAt || occurredAt,
324
+ capturedBy: CAPTURED_BY.API,
325
+ originalId: raw.originalId,
326
+ };
327
+
328
+ if (kind === KIND_HISTORY) {
329
+ return normalizeHistory(p, source, occurredAt, ingestedAt);
330
+ }
331
+ if (kind === KIND_FAVOURITE) {
332
+ return normalizeFavourite(p, source, occurredAt, ingestedAt);
333
+ }
334
+ if (kind === KIND_DYNAMIC) {
335
+ return normalizeDynamic(p, source, occurredAt, ingestedAt);
336
+ }
337
+ if (kind === KIND_FOLLOW) {
338
+ return normalizeFollow(p, source, occurredAt, ingestedAt);
339
+ }
340
+ throw new Error(`BilibiliAdapter.normalize: unknown kind ${kind}`);
341
+ }
342
+ }
343
+
344
+ function normalizeHistory(p, source, occurredAt, ingestedAt) {
345
+ const title = p.title || "(no title)";
346
+ const bvid = p.bvid || null;
347
+ const itemId = bvid ? `item-bilibili-video-${bvid}` : `item-bilibili-video-${newId()}`;
348
+ const item = {
349
+ id: itemId,
350
+ type: ENTITY_TYPES.ITEM,
351
+ subtype: ITEM_SUBTYPES.MEDIA,
352
+ name: title,
353
+ ingestedAt,
354
+ source,
355
+ extra: {
356
+ kind: "bilibili-video",
357
+ bvid,
358
+ avid: p.avid || null,
359
+ uploader: p.uploader || null,
360
+ uploaderMid: p.uploaderMid || null,
361
+ },
362
+ };
363
+ return {
364
+ events: [{
365
+ id: newId(),
366
+ type: ENTITY_TYPES.EVENT,
367
+ subtype: EVENT_SUBTYPES.BROWSE,
368
+ occurredAt,
369
+ actor: "person-self",
370
+ content: { title },
371
+ ingestedAt,
372
+ source,
373
+ extra: {
374
+ platform: "bilibili",
375
+ bvid,
376
+ avid: p.avid || null,
377
+ duration: p.duration || null,
378
+ uploader: p.uploader || null,
379
+ part: p.part || null,
380
+ itemRef: itemId,
381
+ },
382
+ }],
383
+ persons: [],
384
+ places: [],
385
+ items: [item],
386
+ topics: [],
387
+ };
388
+ }
389
+
390
+ function normalizeFavourite(p, source, occurredAt, ingestedAt) {
391
+ const title = p.title || "(no title)";
392
+ const bvid = p.bvid || null;
393
+ const itemId = bvid ? `item-bilibili-video-${bvid}` : `item-bilibili-video-${newId()}`;
394
+ const item = {
395
+ id: itemId,
396
+ type: ENTITY_TYPES.ITEM,
397
+ subtype: ITEM_SUBTYPES.MEDIA,
398
+ name: title,
399
+ ingestedAt,
400
+ source,
401
+ extra: {
402
+ kind: "bilibili-video",
403
+ bvid,
404
+ avid: p.avid || null,
405
+ uploader: p.uploader || null,
406
+ },
407
+ };
408
+ return {
409
+ events: [{
410
+ id: newId(),
411
+ type: ENTITY_TYPES.EVENT,
412
+ subtype: EVENT_SUBTYPES.LIKE,
413
+ occurredAt,
414
+ actor: "person-self",
415
+ content: { title },
416
+ ingestedAt,
417
+ source,
418
+ extra: {
419
+ platform: "bilibili",
420
+ bvid,
421
+ avid: p.avid || null,
422
+ folderName: p.folderName || null,
423
+ uploader: p.uploader || null,
424
+ itemRef: itemId,
425
+ },
426
+ }],
427
+ persons: [],
428
+ places: [],
429
+ items: [item],
430
+ topics: [],
431
+ };
432
+ }
433
+
434
+ function normalizeDynamic(p, source, occurredAt, ingestedAt) {
435
+ const summary = p.summary || p.content || "(no summary)";
436
+ return {
437
+ events: [{
438
+ id: newId(),
439
+ type: ENTITY_TYPES.EVENT,
440
+ subtype: EVENT_SUBTYPES.BROWSE,
441
+ occurredAt,
442
+ actor: "person-self",
443
+ content: { title: summary.slice(0, 200) },
444
+ ingestedAt,
445
+ source,
446
+ extra: {
447
+ platform: "bilibili",
448
+ dynamicType: p.dynamicType || "unknown",
449
+ rid: p.rid || null,
450
+ authorMid: p.authorMid || null,
451
+ authorName: p.authorName || null,
452
+ summary,
453
+ },
454
+ }],
455
+ persons: [],
456
+ places: [],
457
+ items: [],
458
+ topics: [],
459
+ };
460
+ }
461
+
462
+ function normalizeFollow(p, source, occurredAt, ingestedAt) {
463
+ const mid =
464
+ (typeof p.mid === "string" && p.mid) ||
465
+ (typeof p.mid === "number" && String(p.mid)) ||
466
+ `unknown-${newId()}`;
467
+ const uname = p.uname || "(unnamed)";
468
+ const person = {
469
+ id: `person-bilibili-${mid}`,
470
+ type: ENTITY_TYPES.PERSON,
471
+ subtype: PERSON_SUBTYPES.CONTACT,
472
+ names: [uname],
473
+ ingestedAt,
474
+ source,
475
+ identifiers: {
476
+ "bilibili-mid": [mid],
477
+ },
478
+ extra: {
479
+ platform: "bilibili",
480
+ face: p.face || null,
481
+ sign: p.sign || null,
482
+ followedAt: occurredAt,
483
+ },
484
+ };
485
+ return {
486
+ events: [],
487
+ persons: [person],
488
+ places: [],
489
+ items: [],
490
+ topics: [],
491
+ };
492
+ }
493
+
494
+ module.exports = {
495
+ BilibiliAdapter,
496
+ NAME,
497
+ VERSION,
498
+ SNAPSHOT_SCHEMA_VERSION,
499
+ VALID_KINDS,
500
+ };
@@ -1,171 +1,23 @@
1
- /**
2
- * Phase 13.1 — Bilibili (B站) adapter.
3
- *
4
- * Source: B站 Android app stores user data in SQLite (per sjqz/parsers/
5
- * social.py BilibiliParser). Phase 7.5 AndroidExtractor pulls the DB
6
- * to a local cache; this adapter parses it.
7
- *
8
- * Tables (sjqz reference):
9
- * - history watched videos
10
- * - bili_favourite favorited videos / playlists
11
- * - bili_user user profile
12
- * - bili_message 私信
13
- *
14
- * Each row → Event with subtype "browse" (history) / "like" (favorites)
15
- * / "message" (DMs) per UnifiedSchema enum.
16
- */
17
-
18
1
  "use strict";
19
2
 
20
- const fs = require("node:fs");
21
- const { newId } = require("../../ids");
22
-
23
- const NAME = "social-bilibili";
24
- const VERSION = "0.5.0";
25
-
26
- class BilibiliAdapter {
27
- constructor(opts = {}) {
28
- if (!opts.account || !opts.account.uid) {
29
- throw new Error("BilibiliAdapter: opts.account.uid required");
30
- }
31
- this.account = opts.account;
32
- this._dbPath = opts.dbPath || null;
33
- this._dbDriverFactory = opts.dbDriverFactory || null;
34
-
35
- this.name = NAME;
36
- this.version = VERSION;
37
- this.capabilities = ["sync:sqlite", "parse:bilibili-history", "parse:bilibili-favourite"];
38
- this.extractMode = "device-pull";
39
- this.rateLimits = {};
40
- this.dataDisclosure = {
41
- fields: [
42
- "bilibili:history (avid / bvid / title / view_at / duration)",
43
- "bilibili:favourite (folder / video / save_time)",
44
- "bilibili:message (peer / content / time)",
45
- ],
46
- sensitivity: "medium",
47
- legalGate: false,
48
- };
49
- }
50
-
51
- async authenticate() {
52
- return { ok: true, account: this.account.uid };
53
- }
54
-
55
- async healthCheck() {
56
- return { ok: true, lastChecked: Date.now() };
57
- }
58
-
59
- async *sync(opts = {}) {
60
- const dbPath = opts.dbPath || this._dbPath;
61
- if (!dbPath || !fs.existsSync(dbPath)) return;
62
- const Driver = this._dbDriverFactory
63
- ? this._dbDriverFactory()
64
- : require("better-sqlite3-multiple-ciphers");
65
- const db = new Driver(dbPath, { readonly: true });
66
-
67
- try {
68
- const history = trySelect(db, "SELECT * FROM history ORDER BY view_at DESC LIMIT 5000") || [];
69
- for (const row of history) {
70
- yield {
71
- adapter: NAME,
72
- originalId: `history-${row.id || row._id || row.kid || row.bvid || row.avid}`,
73
- capturedAt: parseTime(row.view_at || row.create_at || row.time),
74
- payload: { row, kind: "history" },
75
- };
76
- }
77
-
78
- const favs = trySelect(db, "SELECT * FROM bili_favourite ORDER BY save_time DESC LIMIT 5000") || [];
79
- for (const row of favs) {
80
- yield {
81
- adapter: NAME,
82
- originalId: `fav-${row.id || row.fav_id || row.bvid}`,
83
- capturedAt: parseTime(row.save_time || row.time),
84
- payload: { row, kind: "favourite" },
85
- };
86
- }
87
- } finally {
88
- try { db.close(); } catch (_e) {}
89
- }
90
- }
91
-
92
- normalize(raw) {
93
- if (!raw || !raw.payload || !raw.payload.row) {
94
- throw new Error("BilibiliAdapter.normalize: row missing");
95
- }
96
- const { kind, row } = raw.payload;
97
- const now = Date.now();
98
- const occurredAt = parseTime(row.view_at || row.save_time || row.create_at || row.time) || now;
99
- const source = {
100
- adapter: NAME, adapterVersion: VERSION,
101
- originalId: raw.originalId, capturedAt: occurredAt,
102
- capturedBy: "sqlite",
103
- };
104
-
105
- if (kind === "favourite") {
106
- return {
107
- events: [{
108
- id: newId(),
109
- type: "event",
110
- subtype: "like",
111
- occurredAt,
112
- actor: "person-self",
113
- content: {
114
- title: row.title || row.video_title || "(no title)",
115
- },
116
- ingestedAt: now,
117
- source,
118
- extra: {
119
- bvid: row.bvid || null,
120
- avid: row.avid || null,
121
- folder: row.folder_name || null,
122
- uploader: row.uploader || row.up_name || null,
123
- },
124
- }],
125
- persons: [], places: [], items: [], topics: [],
126
- };
127
- }
128
- // history → browse event
129
- return {
130
- events: [{
131
- id: newId(),
132
- type: "event",
133
- subtype: "browse",
134
- occurredAt,
135
- actor: "person-self",
136
- content: {
137
- title: row.title || row.video_title || "(no title)",
138
- },
139
- ingestedAt: now,
140
- source,
141
- extra: {
142
- bvid: row.bvid || null,
143
- avid: row.avid || null,
144
- duration: row.duration || row.progress || null,
145
- uploader: row.uploader || row.up_name || null,
146
- part: row.part_name || null,
147
- },
148
- }],
149
- persons: [], places: [], items: [], topics: [],
150
- };
151
- }
152
- }
153
-
154
- function trySelect(db, sql) {
155
- try { return db.prepare(sql).all(); } catch (_e) { return null; }
156
- }
157
-
158
- function parseTime(v) {
159
- if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
160
- if (typeof v === "string") {
161
- if (/^\d+$/.test(v)) {
162
- const n = parseInt(v, 10);
163
- return n > 1e12 ? n : n * 1000;
164
- }
165
- const t = Date.parse(v);
166
- return Number.isFinite(t) ? t : null;
167
- }
168
- return null;
169
- }
170
-
171
- module.exports = { BilibiliAdapter, NAME, VERSION };
3
+ // Phase 13.1 → A8 v0.1 (2026-05-22): refactored into adapter.js with snapshot
4
+ // mode added for the Android in-APK cc path. Legacy sqlite mode preserved.
5
+ // This file is the public entry point — re-exports from adapter.js so callers
6
+ // using `require("@chainlesschain/personal-data-hub/lib/adapters/social-bilibili")`
7
+ // continue to see the same { BilibiliAdapter, NAME, VERSION } shape.
8
+
9
+ const {
10
+ BilibiliAdapter,
11
+ NAME,
12
+ VERSION,
13
+ SNAPSHOT_SCHEMA_VERSION,
14
+ VALID_KINDS,
15
+ } = require("./adapter");
16
+
17
+ module.exports = {
18
+ BilibiliAdapter,
19
+ NAME,
20
+ VERSION,
21
+ SNAPSHOT_SCHEMA_VERSION,
22
+ VALID_KINDS,
23
+ };