@chainlesschain/personal-data-hub 0.1.0 → 0.2.1

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 (154) 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 +396 -0
  4. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  5. package/__tests__/adapters/ai-chat-vendors.test.js +874 -0
  6. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  7. package/__tests__/adapters/email-adapter.test.js +138 -1
  8. package/__tests__/adapters/email-classifier.test.js +347 -0
  9. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  10. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  11. package/__tests__/adapters/email-templates.test.js +699 -0
  12. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  13. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  14. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  15. package/__tests__/adapters/system-data-android.test.js +387 -0
  16. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  17. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  18. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  19. package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
  20. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  21. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  22. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  23. package/__tests__/analysis-skills.test.js +556 -0
  24. package/__tests__/analysis.test.js +329 -1
  25. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  26. package/__tests__/e2e/full-user-journey.test.js +188 -0
  27. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  28. package/__tests__/entity-resolver-stages.test.js +411 -0
  29. package/__tests__/entity-resolver-vault.test.js +246 -0
  30. package/__tests__/entity-resolver.test.js +526 -0
  31. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  32. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  33. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  34. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  35. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  36. package/__tests__/longtail-adapters.test.js +217 -0
  37. package/__tests__/mobile-extractor.test.js +288 -0
  38. package/__tests__/registry.test.js +4 -2
  39. package/__tests__/shopping-adapters.test.js +296 -0
  40. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  41. package/__tests__/sidecar-supervisor.test.js +120 -0
  42. package/__tests__/social-adapters.test.js +206 -0
  43. package/__tests__/travel-adapters.test.js +325 -0
  44. package/__tests__/vault.test.js +3 -3
  45. package/__tests__/wechat-adapter.test.js +476 -0
  46. package/__tests__/whatsapp-adapter.test.js +135 -0
  47. package/lib/adapter-spec.js +12 -0
  48. package/lib/adapters/_python-sidecar-base.js +207 -0
  49. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +374 -0
  50. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  51. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  52. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  53. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  54. package/lib/adapters/ai-chat-history/index.js +28 -0
  55. package/lib/adapters/ai-chat-history/schema-map.js +258 -0
  56. package/lib/adapters/ai-chat-history/vendor-spec.js +86 -0
  57. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  58. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  59. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  60. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  61. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  62. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  63. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  64. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  65. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  66. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  67. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +311 -0
  68. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  69. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  70. package/lib/adapters/alipay-bill/index.js +41 -0
  71. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  72. package/lib/adapters/email-imap/classifier.js +495 -0
  73. package/lib/adapters/email-imap/email-adapter.js +419 -8
  74. package/lib/adapters/email-imap/index.js +42 -0
  75. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  76. package/lib/adapters/email-imap/templates/bill.js +232 -0
  77. package/lib/adapters/email-imap/templates/government.js +120 -0
  78. package/lib/adapters/email-imap/templates/index.js +78 -0
  79. package/lib/adapters/email-imap/templates/order.js +186 -0
  80. package/lib/adapters/email-imap/templates/other.js +114 -0
  81. package/lib/adapters/email-imap/templates/register.js +113 -0
  82. package/lib/adapters/email-imap/templates/travel.js +157 -0
  83. package/lib/adapters/email-imap/templates/utils.js +275 -0
  84. package/lib/adapters/email-imap/transactions.js +234 -0
  85. package/lib/adapters/messaging-qq/index.js +158 -0
  86. package/lib/adapters/messaging-telegram/index.js +142 -0
  87. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  88. package/lib/adapters/shopping-base/index.js +208 -0
  89. package/lib/adapters/shopping-jd/index.js +150 -0
  90. package/lib/adapters/shopping-meituan/index.js +154 -0
  91. package/lib/adapters/shopping-taobao/index.js +176 -0
  92. package/lib/adapters/social-bilibili/index.js +171 -0
  93. package/lib/adapters/social-douyin/index.js +116 -0
  94. package/lib/adapters/social-kuaishou/index.js +237 -0
  95. package/lib/adapters/social-toutiao/index.js +236 -0
  96. package/lib/adapters/social-weibo/index.js +164 -0
  97. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  98. package/lib/adapters/system-data/disclosure.js +166 -0
  99. package/lib/adapters/system-data/index.js +34 -0
  100. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  101. package/lib/adapters/system-data-android/adapter.js +348 -0
  102. package/lib/adapters/system-data-android/index.js +76 -0
  103. package/lib/adapters/travel-12306/index.js +151 -0
  104. package/lib/adapters/travel-amap/index.js +164 -0
  105. package/lib/adapters/travel-baidu-map/index.js +162 -0
  106. package/lib/adapters/travel-base/index.js +240 -0
  107. package/lib/adapters/travel-ctrip/index.js +151 -0
  108. package/lib/adapters/wechat/bootstrap.js +146 -0
  109. package/lib/adapters/wechat/content-parser.js +326 -0
  110. package/lib/adapters/wechat/db-reader.js +209 -0
  111. package/lib/adapters/wechat/env-probe.js +218 -0
  112. package/lib/adapters/wechat/frida-agent/loader.js +67 -0
  113. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
  114. package/lib/adapters/wechat/index.js +37 -0
  115. package/lib/adapters/wechat/key-extractor.js +158 -0
  116. package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
  117. package/lib/adapters/wechat/key-providers/index.js +22 -0
  118. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  119. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  120. package/lib/adapters/wechat/normalize.js +220 -0
  121. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  122. package/lib/analysis-skills/base.js +113 -0
  123. package/lib/analysis-skills/footprint.js +167 -0
  124. package/lib/analysis-skills/index.js +58 -0
  125. package/lib/analysis-skills/interests.js +161 -0
  126. package/lib/analysis-skills/relations.js +226 -0
  127. package/lib/analysis-skills/spending.js +219 -0
  128. package/lib/analysis-skills/timeline.js +167 -0
  129. package/lib/analysis.js +191 -2
  130. package/lib/entity-resolver/embedding-stage.js +198 -0
  131. package/lib/entity-resolver/entity-resolver.js +384 -0
  132. package/lib/entity-resolver/index.js +42 -0
  133. package/lib/entity-resolver/llm-stage.js +191 -0
  134. package/lib/entity-resolver/rule-stage.js +208 -0
  135. package/lib/entity-resolver/worker.js +149 -0
  136. package/lib/index.js +131 -0
  137. package/lib/migrations.js +73 -0
  138. package/lib/mobile-extractor/android.js +193 -0
  139. package/lib/mobile-extractor/index.js +9 -0
  140. package/lib/mobile-extractor/ios.js +223 -0
  141. package/lib/prompt-builder.js +11 -1
  142. package/lib/query-parser.js +7 -1
  143. package/lib/registry.js +42 -0
  144. package/lib/sidecar/index.js +15 -0
  145. package/lib/sidecar/supervisor.js +359 -0
  146. package/lib/vault.js +343 -0
  147. package/package.json +36 -3
  148. package/scripts/_make-fixture-all.js +126 -0
  149. package/scripts/_make-fixture-contacts.js +84 -0
  150. package/scripts/evaluate-entity-resolver.js +213 -0
  151. package/scripts/smoke-phase-5-5.js +196 -0
  152. package/scripts/smoke-phase-5-7.js +181 -0
  153. package/scripts/smoke-system-data-contacts.js +309 -0
  154. package/scripts/smoke-system-data.js +312 -0
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Cross-validate that Persons produced by the Python sidecar's
5
+ * `system.parse_contacts` method pass the hub-side UnifiedSchema validator.
6
+ *
7
+ * Without this test, sidecar and hub can drift silently: sidecar emits
8
+ * fields the schema rejects, or skips required ones. Run against a real
9
+ * sidecar subprocess + synthesized contacts2.db.
10
+ */
11
+
12
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
13
+ import path from "node:path";
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import { spawnSync } from "node:child_process";
17
+
18
+ const { SidecarSupervisor } = require("../lib/sidecar");
19
+ const { validatePerson } = require("../lib/schemas");
20
+ const Database = require("better-sqlite3-multiple-ciphers");
21
+
22
+ const SIDECAR_ROOT = path.resolve(
23
+ __dirname,
24
+ "..",
25
+ "..",
26
+ "personal-data-hub-bridge",
27
+ );
28
+
29
+ let pythonAvailable = true;
30
+ try {
31
+ const probe = spawnSync(
32
+ process.env.FORENSICS_BRIDGE_PYTHON || "python",
33
+ ["--version"],
34
+ { stdio: "ignore" },
35
+ );
36
+ if (probe.status !== 0) pythonAvailable = false;
37
+ } catch (_err) {
38
+ pythonAvailable = false;
39
+ }
40
+
41
+ const describePy = pythonAvailable ? describe : describe.skip;
42
+
43
+ function seedFixtureContactsDb(dbPath) {
44
+ const db = new Database(dbPath);
45
+ try {
46
+ db.exec(`
47
+ CREATE TABLE raw_contacts (
48
+ _id INTEGER PRIMARY KEY,
49
+ display_name TEXT,
50
+ starred INTEGER DEFAULT 0,
51
+ deleted INTEGER DEFAULT 0
52
+ );
53
+ CREATE TABLE mimetypes (
54
+ _id INTEGER PRIMARY KEY,
55
+ mimetype TEXT NOT NULL UNIQUE
56
+ );
57
+ CREATE TABLE data (
58
+ _id INTEGER PRIMARY KEY,
59
+ raw_contact_id INTEGER NOT NULL,
60
+ mimetype_id INTEGER NOT NULL,
61
+ data1 TEXT
62
+ );
63
+ `);
64
+ const mimetypes = {
65
+ "vnd.android.cursor.item/phone_v2": 5,
66
+ "vnd.android.cursor.item/email_v2": 1,
67
+ "vnd.android.cursor.item/organization": 4,
68
+ "vnd.android.cursor.item/note": 10,
69
+ };
70
+ const insertMime = db.prepare(
71
+ "INSERT INTO mimetypes (_id, mimetype) VALUES (?, ?)",
72
+ );
73
+ for (const [mt, mid] of Object.entries(mimetypes)) insertMime.run(mid, mt);
74
+
75
+ const insertContact = db.prepare(
76
+ "INSERT INTO raw_contacts (_id, display_name, starred, deleted) VALUES (?, ?, ?, 0)",
77
+ );
78
+ insertContact.run(1, "妈妈", 1);
79
+ insertContact.run(2, "张三", 0);
80
+
81
+ const insertData = db.prepare(
82
+ "INSERT INTO data (raw_contact_id, mimetype_id, data1) VALUES (?, ?, ?)",
83
+ );
84
+ insertData.run(1, mimetypes["vnd.android.cursor.item/phone_v2"], "13800001111");
85
+ insertData.run(1, mimetypes["vnd.android.cursor.item/phone_v2"], "13900002222");
86
+ insertData.run(1, mimetypes["vnd.android.cursor.item/email_v2"], "mom@example.com");
87
+ insertData.run(2, mimetypes["vnd.android.cursor.item/phone_v2"], "13711112222");
88
+ } finally {
89
+ db.close();
90
+ }
91
+ }
92
+
93
+ describePy("sidecar.system.parse_contacts × hub.validatePerson", () => {
94
+ let supervisor;
95
+ let tmpDir;
96
+ let dbPath;
97
+
98
+ beforeAll(async () => {
99
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "phdb-sidecar-contacts-"));
100
+ dbPath = path.join(tmpDir, "contacts2.db");
101
+ seedFixtureContactsDb(dbPath);
102
+
103
+ supervisor = new SidecarSupervisor({
104
+ command: process.env.FORENSICS_BRIDGE_PYTHON || "python",
105
+ args: ["-u", "-m", "forensics_bridge.ipc_server"],
106
+ cwd: SIDECAR_ROOT,
107
+ healthCheckIntervalMs: 0,
108
+ env: { PYTHONPATH: SIDECAR_ROOT },
109
+ });
110
+ await supervisor.start({ readyTimeoutMs: 8_000 });
111
+ }, 15_000);
112
+
113
+ afterAll(async () => {
114
+ if (supervisor) await supervisor.stop({ graceMs: 1500 });
115
+ if (tmpDir) {
116
+ try {
117
+ fs.rmSync(tmpDir, { recursive: true, force: true });
118
+ } catch (_err) {
119
+ /* best effort */
120
+ }
121
+ }
122
+ });
123
+
124
+ it("emits Persons that pass hub UnifiedSchema validation", async () => {
125
+ const persons = [];
126
+ const result = await supervisor.invoke(
127
+ "system.parse_contacts",
128
+ { data_path: dbPath, device_serial: "24115RA8EC-test" },
129
+ {
130
+ timeoutMs: 10_000,
131
+ onChunk: (batch) => {
132
+ for (const p of batch.persons || []) persons.push(p);
133
+ },
134
+ },
135
+ );
136
+
137
+ expect(result.status).toBe("ok");
138
+ expect(result.totalPersons).toBe(2);
139
+ expect(persons).toHaveLength(2);
140
+
141
+ for (const person of persons) {
142
+ const validation = validatePerson(person);
143
+ if (!validation.valid) {
144
+ // Dump the offender so CI failures are debuggable without re-running.
145
+ console.error(
146
+ "validatePerson failed for",
147
+ JSON.stringify(person, null, 2),
148
+ "errors:",
149
+ validation.errors,
150
+ );
151
+ }
152
+ expect(validation.valid).toBe(true);
153
+ expect(validation.errors).toEqual([]);
154
+ }
155
+
156
+ // Spot-check shape — mom has phones+email+starred+notes? (notes none in this fixture)
157
+ const mom = persons.find((p) => p.names[0] === "妈妈");
158
+ expect(mom.identifiers.phone).toEqual(["13800001111", "13900002222"]);
159
+ expect(mom.identifiers.email).toEqual(["mom@example.com"]);
160
+ expect(mom.extra.starred).toBe(true);
161
+ expect(mom.extra.deviceSerial).toBe("24115RA8EC-test");
162
+ });
163
+ });
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
4
+ import path from "node:path";
5
+
6
+ const {
7
+ SidecarSupervisor,
8
+ SidecarTimeoutError,
9
+ SidecarMethodError,
10
+ SidecarNotRunningError,
11
+ } = require("../lib/sidecar");
12
+
13
+ const SIDECAR_ROOT = path.resolve(
14
+ __dirname,
15
+ "..",
16
+ "..",
17
+ "personal-data-hub-bridge",
18
+ );
19
+
20
+ /**
21
+ * Spawn the forensics-bridge sidecar from this repo's sibling package.
22
+ * Tests are skipped if Python is unavailable on PATH (CI matrix coverage
23
+ * is the source of truth; local dev without Python should not be blocked).
24
+ */
25
+ function makeSupervisor() {
26
+ return new SidecarSupervisor({
27
+ command: process.env.FORENSICS_BRIDGE_PYTHON || "python",
28
+ args: ["-u", "-m", "forensics_bridge.ipc_server"],
29
+ cwd: SIDECAR_ROOT,
30
+ healthCheckIntervalMs: 0, // disable for tests — manual control only
31
+ env: { PYTHONPATH: SIDECAR_ROOT },
32
+ });
33
+ }
34
+
35
+ let pythonAvailable = true;
36
+ try {
37
+ // Cheap synchronous probe — spawn fails if python is not on PATH.
38
+ const { spawnSync } = require("node:child_process");
39
+ const probe = spawnSync(
40
+ process.env.FORENSICS_BRIDGE_PYTHON || "python",
41
+ ["--version"],
42
+ { stdio: "ignore" },
43
+ );
44
+ if (probe.status !== 0) pythonAvailable = false;
45
+ } catch (_err) {
46
+ pythonAvailable = false;
47
+ }
48
+
49
+ const itPy = pythonAvailable ? it : it.skip;
50
+
51
+ describe("SidecarSupervisor (forensics-bridge integration)", () => {
52
+ let supervisor;
53
+
54
+ beforeEach(() => {
55
+ supervisor = makeSupervisor();
56
+ });
57
+
58
+ afterEach(async () => {
59
+ if (supervisor) await supervisor.stop({ graceMs: 1500 });
60
+ });
61
+
62
+ itPy("starts the sidecar and round-trips sidecar.ping", async () => {
63
+ await supervisor.start({ readyTimeoutMs: 8_000 });
64
+ expect(supervisor.isRunning()).toBe(true);
65
+
66
+ const ping = await supervisor.invoke("sidecar.ping", {}, { timeoutMs: 3_000 });
67
+ expect(ping.version).toMatch(/^\d+\.\d+\.\d+/);
68
+ expect(ping.pythonVersion).toMatch(/^3\./);
69
+ });
70
+
71
+ itPy("sidecar.capabilities exposes registered methods", async () => {
72
+ await supervisor.start({ readyTimeoutMs: 8_000 });
73
+
74
+ const caps = await supervisor.invoke("sidecar.capabilities");
75
+ expect(caps.methods).toContain("sidecar.ping");
76
+ expect(caps.methods).toContain("sidecar.capabilities");
77
+ // Namespace registry grows as parsers/extractors land; Phase 4.5.2 brings
78
+ // the system parser online.
79
+ expect(caps.parsers).toContain("system");
80
+ expect(caps.extractors).toContain("android");
81
+ });
82
+
83
+ itPy("METHOD_NOT_FOUND surfaces as a typed SidecarMethodError", async () => {
84
+ await supervisor.start({ readyTimeoutMs: 8_000 });
85
+
86
+ await expect(
87
+ supervisor.invoke("definitely.not.a.real.method"),
88
+ ).rejects.toMatchObject({
89
+ name: "SidecarMethodError",
90
+ code: "METHOD_NOT_FOUND",
91
+ retryable: false,
92
+ });
93
+ });
94
+
95
+ itPy("invoke rejects with SidecarNotRunningError when sidecar is stopped", async () => {
96
+ await supervisor.start({ readyTimeoutMs: 8_000 });
97
+ await supervisor.stop({ graceMs: 1500 });
98
+ expect(supervisor.isRunning()).toBe(false);
99
+
100
+ await expect(supervisor.invoke("sidecar.ping")).rejects.toMatchObject({
101
+ name: "SidecarNotRunningError",
102
+ code: "SIDECAR_NOT_RUNNING",
103
+ });
104
+ });
105
+
106
+ itPy("two sequential invokes share one sidecar process", async () => {
107
+ await supervisor.start({ readyTimeoutMs: 8_000 });
108
+ const first = await supervisor.invoke("sidecar.ping");
109
+ const second = await supervisor.invoke("sidecar.capabilities");
110
+ expect(first.version).toBeDefined();
111
+ expect(second.methods).toContain("sidecar.ping");
112
+ });
113
+
114
+ itPy("error class hierarchy is exported", () => {
115
+ expect(typeof SidecarSupervisor).toBe("function");
116
+ expect(typeof SidecarTimeoutError).toBe("function");
117
+ expect(typeof SidecarMethodError).toBe("function");
118
+ expect(typeof SidecarNotRunningError).toBe("function");
119
+ });
120
+ });
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+
9
+ const { BilibiliAdapter, WeiboAdapter } = require("../lib");
10
+ const { assertAdapter } = require("../lib/adapter-spec");
11
+ const { validateBatch } = require("../lib/batch");
12
+
13
+ function makeMockDriver(scriptedRows) {
14
+ return function () {
15
+ return {
16
+ prepare(sql) {
17
+ return {
18
+ all() {
19
+ for (const [matchSubstr, rows] of scriptedRows) {
20
+ if (sql.includes(matchSubstr)) return rows;
21
+ }
22
+ throw new Error("no such table");
23
+ },
24
+ };
25
+ },
26
+ close() {},
27
+ };
28
+ };
29
+ }
30
+
31
+ // ─── BilibiliAdapter ────────────────────────────────────────────────────
32
+
33
+ describe("BilibiliAdapter", () => {
34
+ it("contract conformance", () => {
35
+ const a = new BilibiliAdapter({ account: { uid: "1234" } });
36
+ expect(assertAdapter(a).ok).toBe(true);
37
+ expect(a.extractMode).toBe("device-pull");
38
+ });
39
+
40
+ it("rejects missing account.uid", () => {
41
+ expect(() => new BilibiliAdapter({})).toThrow();
42
+ expect(() => new BilibiliAdapter({ account: {} })).toThrow(/uid/);
43
+ });
44
+
45
+ it("sync yields history + favourite records via mocked driver", async () => {
46
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-"));
47
+ const dbPath = path.join(dir, "bili.db");
48
+ fs.writeFileSync(dbPath, "fake");
49
+ try {
50
+ const mockDriver = makeMockDriver([
51
+ ["FROM history", [
52
+ { id: 1, bvid: "BV1abc", title: "趣味视频", view_at: 1700000000, uploader: "UpA" },
53
+ { id: 2, bvid: "BV1xyz", title: "教程", view_at: 1700000010, uploader: "UpB", duration: 300 },
54
+ ]],
55
+ ["FROM bili_favourite", [
56
+ { id: 1, bvid: "BV1fav", title: "收藏A", save_time: 1700001000, folder_name: "学习" },
57
+ ]],
58
+ ]);
59
+ const a = new BilibiliAdapter({
60
+ account: { uid: "1234" },
61
+ dbPath,
62
+ dbDriverFactory: () => mockDriver,
63
+ });
64
+ const raws = [];
65
+ for await (const r of a.sync()) raws.push(r);
66
+ expect(raws.length).toBe(3); // 2 history + 1 favourite
67
+ const histories = raws.filter((r) => r.payload.kind === "history");
68
+ const favs = raws.filter((r) => r.payload.kind === "favourite");
69
+ expect(histories).toHaveLength(2);
70
+ expect(favs).toHaveLength(1);
71
+
72
+ // Normalize each
73
+ for (const raw of raws) {
74
+ const batch = a.normalize(raw);
75
+ const v = validateBatch(batch);
76
+ expect(v.valid).toBe(true);
77
+ const subtype = batch.events[0].subtype;
78
+ if (raw.payload.kind === "history") expect(subtype).toBe("browse");
79
+ if (raw.payload.kind === "favourite") expect(subtype).toBe("like");
80
+ }
81
+ } finally {
82
+ fs.rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ it("idle when DB path missing", async () => {
87
+ const a = new BilibiliAdapter({ account: { uid: "1234" } });
88
+ const raws = [];
89
+ for await (const r of a.sync()) raws.push(r);
90
+ expect(raws).toHaveLength(0);
91
+ });
92
+
93
+ it("normalize captures bvid/avid/uploader into extra", async () => {
94
+ const a = new BilibiliAdapter({ account: { uid: "1234" } });
95
+ const raw = {
96
+ adapter: "social-bilibili",
97
+ originalId: "history-1",
98
+ capturedAt: 1700000000000,
99
+ payload: {
100
+ kind: "history",
101
+ row: {
102
+ id: 1, bvid: "BV1abc", avid: "1234",
103
+ title: "Test", view_at: 1700000000,
104
+ uploader: "UpA", duration: 300,
105
+ },
106
+ },
107
+ };
108
+ const batch = a.normalize(raw);
109
+ expect(batch.events[0].extra.bvid).toBe("BV1abc");
110
+ expect(batch.events[0].extra.avid).toBe("1234");
111
+ expect(batch.events[0].extra.uploader).toBe("UpA");
112
+ expect(batch.events[0].extra.duration).toBe(300);
113
+ });
114
+ });
115
+
116
+ // ─── WeiboAdapter ───────────────────────────────────────────────────────
117
+
118
+ describe("WeiboAdapter", () => {
119
+ it("contract conformance", () => {
120
+ const a = new WeiboAdapter({ account: { uid: "1234" } });
121
+ expect(assertAdapter(a).ok).toBe(true);
122
+ expect(a.extractMode).toBe("device-pull");
123
+ });
124
+
125
+ it("rejects missing account.uid", () => {
126
+ expect(() => new WeiboAdapter({})).toThrow();
127
+ expect(() => new WeiboAdapter({ account: {} })).toThrow(/uid/);
128
+ });
129
+
130
+ it("sync yields posts + search records via mocked driver", async () => {
131
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "weibo-"));
132
+ const dbPath = path.join(dir, "weibo.db");
133
+ fs.writeFileSync(dbPath, "fake");
134
+ try {
135
+ const mockDriver = makeMockDriver([
136
+ ["FROM post", [
137
+ { id: 1, mid: "M1", text: "今天天气真好", created_at: 1700000000, reposts_count: 5, comments_count: 3 },
138
+ ]],
139
+ ["FROM status", []],
140
+ ["FROM search_history", [
141
+ { id: 1, keyword: "iPhone", time: 1700001000 },
142
+ { id: 2, keyword: "音乐", time: 1700001100 },
143
+ ]],
144
+ ]);
145
+ const a = new WeiboAdapter({
146
+ account: { uid: "1234" },
147
+ dbPath,
148
+ dbDriverFactory: () => mockDriver,
149
+ });
150
+ const raws = [];
151
+ for await (const r of a.sync()) raws.push(r);
152
+ expect(raws.length).toBe(3); // 1 post + 2 searches
153
+
154
+ for (const raw of raws) {
155
+ const batch = a.normalize(raw);
156
+ const v = validateBatch(batch);
157
+ expect(v.valid).toBe(true);
158
+ const subtype = batch.events[0].subtype;
159
+ if (raw.payload.kind === "post") expect(subtype).toBe("post");
160
+ if (raw.payload.kind === "search") expect(subtype).toBe("interaction");
161
+ }
162
+ } finally {
163
+ fs.rmSync(dir, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ it("normalize captures post metrics", async () => {
168
+ const a = new WeiboAdapter({ account: { uid: "1234" } });
169
+ const raw = {
170
+ adapter: "social-weibo",
171
+ originalId: "post-M1",
172
+ capturedAt: 1700000000000,
173
+ payload: {
174
+ kind: "post",
175
+ row: {
176
+ id: 1, mid: "M1", text: "测试",
177
+ created_at: 1700000000,
178
+ reposts_count: 5, comments_count: 3, attitudes_count: 10,
179
+ location: "上海",
180
+ },
181
+ },
182
+ };
183
+ const batch = a.normalize(raw);
184
+ expect(batch.events[0].extra.weiboMid).toBe("M1");
185
+ expect(batch.events[0].extra.repostsCount).toBe(5);
186
+ expect(batch.events[0].extra.commentsCount).toBe(3);
187
+ expect(batch.events[0].extra.likesCount).toBe(10);
188
+ expect(batch.events[0].extra.location).toBe("上海");
189
+ });
190
+
191
+ it("normalize falls back when text is empty", async () => {
192
+ const a = new WeiboAdapter({ account: { uid: "1234" } });
193
+ const raw = {
194
+ adapter: "social-weibo",
195
+ originalId: "post-x",
196
+ capturedAt: Date.now(),
197
+ payload: {
198
+ kind: "post",
199
+ row: { id: 1, mid: "X", text: "", created_at: Math.floor(Date.now() / 1000) },
200
+ },
201
+ };
202
+ const batch = a.normalize(raw);
203
+ expect(batch.events[0].content.title).toBe("(空)");
204
+ expect(validateBatch(batch).valid).toBe(true);
205
+ });
206
+ });