@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,348 @@
1
+ "use strict";
2
+
3
+ // SystemDataAndroidAdapter — Plan A v0.1 (4-day slice, 2026-05-21).
4
+ //
5
+ // Reads a UI-produced JSON snapshot of the Android user's own ContentResolver
6
+ // (contacts) and PackageManager (installed apps) and normalises it into PDH
7
+ // entities. The snapshot is produced inside the Android app process (which
8
+ // owns the JVM and can call ContentResolver / PackageManager directly); the
9
+ // cc CLI subprocess then ingests that snapshot through this adapter.
10
+ //
11
+ // Why not extend PythonSidecarAdapter like the desktop `system-data`? Termux
12
+ // does not ship a forensics-bridge sidecar and the data we read here is the
13
+ // user's OWN device — no SQLite parsing or ADB pull is needed; ContentResolver
14
+ // returns clean records. Keep it pure JS, zero sidecar.
15
+ //
16
+ // Out of scope for v0.1 (deferred):
17
+ // - SMS / call_log (need READ_SMS / READ_CALL_LOG and stricter legal gates)
18
+ // - Wifi (no ContentResolver, would need SystemConfiguration JNI)
19
+ // - cc-driven pull (would need a BoundService + Unix socket; v0.1 is UI-pushed)
20
+
21
+ const { newId } = require("../../ids");
22
+ const {
23
+ ENTITY_TYPES,
24
+ PERSON_SUBTYPES,
25
+ ITEM_SUBTYPES,
26
+ CAPTURED_BY,
27
+ } = require("../../constants");
28
+
29
+ const NAME = "system-data-android";
30
+ const VERSION = "0.1.0";
31
+ const SNAPSHOT_SCHEMA_VERSION = 1;
32
+
33
+ // Stable per-source originalId — registry.putRawEvent rejects null originalId
34
+ // with a NOT NULL constraint, surfacing as invalidCount += rawCount on the
35
+ // SyncReport (real-device repro 2026-05-21: 1305 of 1305 raws "invalid"
36
+ // despite all entities being written). Re-deriving the same key on each
37
+ // sync also lets the raw_events store dedup naturally.
38
+ function contactOriginalId(c) {
39
+ const k =
40
+ (c && typeof c.lookupKey === "string" && c.lookupKey.length > 0 && c.lookupKey) ||
41
+ (c && typeof c.displayName === "string" && c.displayName) ||
42
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
43
+ return `android-contact:${k}`;
44
+ }
45
+ function appOriginalId(a) {
46
+ const k =
47
+ (a && typeof a.packageName === "string" && a.packageName) ||
48
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
49
+ return `android-app:${k}`;
50
+ }
51
+
52
+ class SystemDataAndroidAdapter {
53
+ constructor(opts = {}) {
54
+ this.name = NAME;
55
+ this.version = VERSION;
56
+ this.capabilities = [
57
+ "sync:android-content-provider",
58
+ "sync:android-package-manager",
59
+ ];
60
+ this.extractMode = "device-pull";
61
+ this.rateLimits = { perDay: 24 };
62
+ this.dataDisclosure = {
63
+ fields: [
64
+ "contacts:displayName,phones,emails,starred,organization,photoUri",
65
+ "installed_apps:packageName,label,versionName,versionCode,firstInstallTime,lastUpdateTime,isSystem",
66
+ ],
67
+ sensitivity: "medium",
68
+ legalGate: false,
69
+ defaultInclude: { contacts: true, apps: true },
70
+ };
71
+
72
+ // _deps for test injection — mirrors the pattern in cli-dev.md so test
73
+ // harness can swap fs without resorting to vi.mock("fs") which doesn't
74
+ // intercept require() under inlined CJS. `bridgeProvider` is lazy because
75
+ // the cc-android-bridge module sits in `packages/cli` and is not always
76
+ // available in environments that load this adapter directly (e.g. desktop
77
+ // CLI building a snapshot ingest pipeline). Resolves to null when bridge
78
+ // is unreachable, in which case sync() falls back to inputPath mode.
79
+ this._deps = {
80
+ fs: require("node:fs"),
81
+ bridgeProvider: () => null,
82
+ };
83
+ }
84
+
85
+ // ─── PersonalDataAdapter contract ──────────────────────────────────────
86
+
87
+ async authenticate(ctx = {}) {
88
+ if (!ctx || typeof ctx.inputPath !== "string" || ctx.inputPath.length === 0) {
89
+ return {
90
+ ok: false,
91
+ reason: "INPUT_PATH_REQUIRED",
92
+ message:
93
+ "system-data-android requires opts.inputPath pointing to a snapshot JSON written by the Android app",
94
+ };
95
+ }
96
+ try {
97
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
98
+ } catch (err) {
99
+ return {
100
+ ok: false,
101
+ reason: "INPUT_PATH_UNREADABLE",
102
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
103
+ };
104
+ }
105
+ return { ok: true, mode: "snapshot-file" };
106
+ }
107
+
108
+ async healthCheck() {
109
+ // The adapter itself is stateless — health is "always reachable" so long
110
+ // as a snapshot can be re-produced by the UI. Real device-status (whether
111
+ // the runtime permission was granted) lives in the Android-side UI.
112
+ return { ok: true, lastChecked: Date.now() };
113
+ }
114
+
115
+ async *sync(opts = {}) {
116
+ // Two ingestion modes (mutually exclusive — pick whichever fits the host):
117
+ // 1. snapshot mode: opts.inputPath points to JSON the Android UI wrote
118
+ // (works on any host that can read the file — desktop or device).
119
+ // 2. bridge mode: opts.useBridge === true, _deps.bridgeProvider() returns
120
+ // a live cc-android-bridge. Used inside in-APK cc when A6/A7 lands.
121
+ // If neither inputPath nor useBridge is set, bridge auto-engages when
122
+ // available (which only happens on Android with the JNI binding loaded,
123
+ // OR under CC_ANDROID_BRIDGE_OVERRIDE=1 in tests).
124
+ const wantBridge = opts.useBridge === true || (!opts.inputPath && this._bridgeAvailable());
125
+ if (wantBridge) {
126
+ yield* this._syncViaBridge(opts);
127
+ return;
128
+ }
129
+ if (!opts || typeof opts.inputPath !== "string") {
130
+ throw new Error(
131
+ "system-data-android.sync: needs opts.inputPath (snapshot mode) OR opts.useBridge=true (in-APK Android cc with cc-android-bridge.node loaded)"
132
+ );
133
+ }
134
+ yield* this._syncViaSnapshot(opts);
135
+ }
136
+
137
+ _bridgeAvailable() {
138
+ try {
139
+ const b = this._deps.bridgeProvider();
140
+ if (!b || typeof b.caps !== "function") return false;
141
+ const c = b.caps();
142
+ return c && c.available === true;
143
+ } catch (_e) {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ async *_syncViaBridge(opts) {
149
+ const bridge = this._deps.bridgeProvider();
150
+ if (!bridge || typeof bridge.invoke !== "function") {
151
+ throw new Error(
152
+ "system-data-android.sync: useBridge=true but cc-android-bridge is not loaded (run inside in-APK cc, or set CC_ANDROID_BRIDGE_OVERRIDE=1 for tests)"
153
+ );
154
+ }
155
+ const includeContacts = opts.include?.contacts !== false;
156
+ const includeApps = opts.include?.apps !== false;
157
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
158
+ const capturedAt = Date.now();
159
+ let emitted = 0;
160
+
161
+ if (includeContacts) {
162
+ const res = await bridge.invoke("contacts.query", {
163
+ since: Number.isInteger(opts.since) ? opts.since : undefined,
164
+ });
165
+ const arr = Array.isArray(res) ? res : Array.isArray(res?.contacts) ? res.contacts : [];
166
+ for (const c of arr) {
167
+ if (emitted >= limit) return;
168
+ // originalId required by registry.putRawEvent (NOT NULL column); use
169
+ // the stable Android lookupKey when present, else displayName.
170
+ yield {
171
+ kind: "contact",
172
+ originalId: contactOriginalId(c),
173
+ capturedAt,
174
+ payload: c,
175
+ };
176
+ emitted += 1;
177
+ }
178
+ }
179
+
180
+ if (includeApps) {
181
+ const res = await bridge.invoke("app.list", { includeSystem: false });
182
+ const arr = Array.isArray(res) ? res : Array.isArray(res?.apps) ? res.apps : [];
183
+ for (const a of arr) {
184
+ if (emitted >= limit) return;
185
+ yield {
186
+ kind: "app",
187
+ originalId: appOriginalId(a),
188
+ capturedAt,
189
+ payload: a,
190
+ };
191
+ emitted += 1;
192
+ }
193
+ }
194
+ }
195
+
196
+ async *_syncViaSnapshot(opts) {
197
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
198
+ const snapshot = JSON.parse(raw);
199
+ if (
200
+ !snapshot ||
201
+ typeof snapshot !== "object" ||
202
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
203
+ ) {
204
+ throw new Error(
205
+ `system-data-android.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`
206
+ );
207
+ }
208
+ const capturedAt =
209
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
210
+ ? Math.floor(snapshot.snapshottedAt)
211
+ : Date.now();
212
+
213
+ const includeContacts = opts.include?.contacts !== false;
214
+ const includeApps = opts.include?.apps !== false;
215
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
216
+ let emitted = 0;
217
+
218
+ if (includeContacts && Array.isArray(snapshot.contacts)) {
219
+ for (const c of snapshot.contacts) {
220
+ if (emitted >= limit) return;
221
+ yield {
222
+ kind: "contact",
223
+ originalId: contactOriginalId(c),
224
+ capturedAt,
225
+ payload: c,
226
+ };
227
+ emitted += 1;
228
+ }
229
+ }
230
+
231
+ if (includeApps && Array.isArray(snapshot.apps)) {
232
+ for (const a of snapshot.apps) {
233
+ if (emitted >= limit) return;
234
+ yield {
235
+ kind: "app",
236
+ originalId: appOriginalId(a),
237
+ capturedAt,
238
+ payload: a,
239
+ };
240
+ emitted += 1;
241
+ }
242
+ }
243
+ }
244
+
245
+ normalize(raw) {
246
+ const ingestedAt = Date.now();
247
+ const source = (originalId) => ({
248
+ adapter: NAME,
249
+ adapterVersion: VERSION,
250
+ capturedAt: raw.capturedAt,
251
+ capturedBy: CAPTURED_BY.API,
252
+ originalId,
253
+ });
254
+
255
+ if (raw.kind === "contact") {
256
+ const p = raw.payload || {};
257
+ // lookupKey is Android's "stable across rename + edits" identifier; fall
258
+ // back to displayName only if missing, which lets future runs still dedup
259
+ // by name for the dataset where lookupKey is absent.
260
+ const stableKey =
261
+ (typeof p.lookupKey === "string" && p.lookupKey.length > 0 && p.lookupKey) ||
262
+ (typeof p.displayName === "string" && p.displayName) ||
263
+ `unknown-${raw.capturedAt}`;
264
+ const displayName =
265
+ typeof p.displayName === "string" && p.displayName.trim().length > 0
266
+ ? p.displayName.trim()
267
+ : "(无名联系人)";
268
+ const identifiers = {};
269
+ if (Array.isArray(p.phones) && p.phones.length > 0) {
270
+ identifiers.phone = p.phones.filter((x) => typeof x === "string" && x.length > 0);
271
+ }
272
+ if (Array.isArray(p.emails) && p.emails.length > 0) {
273
+ identifiers.email = p.emails.filter((x) => typeof x === "string" && x.length > 0);
274
+ }
275
+
276
+ const person = {
277
+ id: `person-android-${stableKey}`,
278
+ type: ENTITY_TYPES.PERSON,
279
+ subtype: PERSON_SUBTYPES.CONTACT,
280
+ names: [displayName],
281
+ ingestedAt,
282
+ source: source(`android-contact:${stableKey}`),
283
+ };
284
+ if (Object.keys(identifiers).length > 0) person.identifiers = identifiers;
285
+ if (typeof p.organization === "string" && p.organization.trim().length > 0) {
286
+ person.relation = p.organization.trim();
287
+ }
288
+ const extra = {};
289
+ if (typeof p.starred === "boolean") extra.starred = p.starred;
290
+ if (typeof p.photoUri === "string" && p.photoUri.length > 0) extra.photoUri = p.photoUri;
291
+ if (Object.keys(extra).length > 0) person.extra = extra;
292
+
293
+ return {
294
+ events: [],
295
+ persons: [person],
296
+ places: [],
297
+ items: [],
298
+ topics: [],
299
+ };
300
+ }
301
+
302
+ if (raw.kind === "app") {
303
+ const a = raw.payload || {};
304
+ const pkgName =
305
+ (typeof a.packageName === "string" && a.packageName) || `unknown.${newId()}`;
306
+ const label =
307
+ typeof a.label === "string" && a.label.trim().length > 0
308
+ ? a.label.trim()
309
+ : pkgName;
310
+
311
+ const item = {
312
+ id: `item-android-app-${pkgName}`,
313
+ type: ENTITY_TYPES.ITEM,
314
+ subtype: ITEM_SUBTYPES.OTHER,
315
+ name: label,
316
+ category: a.isSystem === true ? "system-app" : "user-app",
317
+ ingestedAt,
318
+ source: source(`android-app:${pkgName}`),
319
+ extra: {
320
+ kind: "installed_app",
321
+ packageName: pkgName,
322
+ versionName: typeof a.versionName === "string" ? a.versionName : null,
323
+ versionCode: Number.isInteger(a.versionCode) ? a.versionCode : null,
324
+ firstInstallTime: Number.isInteger(a.firstInstallTime) ? a.firstInstallTime : null,
325
+ lastUpdateTime: Number.isInteger(a.lastUpdateTime) ? a.lastUpdateTime : null,
326
+ isSystem: a.isSystem === true,
327
+ },
328
+ };
329
+
330
+ return {
331
+ events: [],
332
+ persons: [],
333
+ places: [],
334
+ items: [item],
335
+ topics: [],
336
+ };
337
+ }
338
+
339
+ throw new Error(`system-data-android.normalize: unknown raw.kind=${raw.kind}`);
340
+ }
341
+ }
342
+
343
+ module.exports = {
344
+ SystemDataAndroidAdapter,
345
+ SYSTEM_DATA_ANDROID_NAME: NAME,
346
+ SYSTEM_DATA_ANDROID_VERSION: VERSION,
347
+ SNAPSHOT_SCHEMA_VERSION,
348
+ };
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const {
6
+ SystemDataAndroidAdapter,
7
+ SYSTEM_DATA_ANDROID_NAME,
8
+ SYSTEM_DATA_ANDROID_VERSION,
9
+ SNAPSHOT_SCHEMA_VERSION,
10
+ } = require("./adapter");
11
+
12
+ /**
13
+ * Path C — desktop-side helper. Given a hub instance + an inline snapshot
14
+ * payload from a mobile / browser client, write the snapshot to a staging
15
+ * file, run the adapter's snapshot-mode sync, then clean the staging file up.
16
+ *
17
+ * Centralizing here keeps the schemaVersion check + staging path convention
18
+ * + cleanup discipline in one place — IPC, WS, and P2P-mobile dispatch
19
+ * (3 separate transport adapters) all just call into this.
20
+ *
21
+ * @param {object} hub — hub-wiring output (must have `hubDir` + `registry.syncAdapter`)
22
+ * @param {object} snapshot — payload matching adapter.SNAPSHOT_SCHEMA_VERSION
23
+ * @param {object} [opts]
24
+ * @param {object} [opts.fs] — fs module override for tests (must expose
25
+ * mkdirSync, writeFileSync, existsSync, unlinkSync)
26
+ * @returns {Promise<object>} SyncReport from registry.syncAdapter
27
+ */
28
+ async function ingestSystemDataAndroidSnapshot(hub, snapshot, opts = {}) {
29
+ if (!hub || !hub.hubDir || !hub.registry) {
30
+ throw new Error(
31
+ "ingestSystemDataAndroidSnapshot: hub must expose hubDir + registry"
32
+ );
33
+ }
34
+ if (!snapshot || typeof snapshot !== "object") {
35
+ throw new Error(
36
+ "ingestSystemDataAndroidSnapshot: snapshot payload required"
37
+ );
38
+ }
39
+ if (snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
40
+ throw new Error(
41
+ `ingestSystemDataAndroidSnapshot: schemaVersion ${snapshot.schemaVersion} != expected ${SNAPSHOT_SCHEMA_VERSION}`
42
+ );
43
+ }
44
+
45
+ const fsImpl = opts.fs || fs;
46
+ const stagingDir = path.join(hub.hubDir, "staging");
47
+ fsImpl.mkdirSync(stagingDir, { recursive: true });
48
+ const stagingPath = path.join(
49
+ stagingDir,
50
+ `system-data-android-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`
51
+ );
52
+ fsImpl.writeFileSync(stagingPath, JSON.stringify(snapshot), "utf-8");
53
+
54
+ try {
55
+ return await hub.registry.syncAdapter(SYSTEM_DATA_ANDROID_NAME, {
56
+ inputPath: stagingPath,
57
+ });
58
+ } finally {
59
+ // best-effort cleanup; failures shouldn't shadow the (possibly successful) sync
60
+ try {
61
+ if (fsImpl.existsSync(stagingPath)) {
62
+ fsImpl.unlinkSync(stagingPath);
63
+ }
64
+ } catch (_e) {
65
+ /* ignore */
66
+ }
67
+ }
68
+ }
69
+
70
+ module.exports = {
71
+ SystemDataAndroidAdapter,
72
+ SYSTEM_DATA_ANDROID_NAME,
73
+ SYSTEM_DATA_ANDROID_VERSION,
74
+ SNAPSHOT_SCHEMA_VERSION,
75
+ ingestSystemDataAndroidSnapshot,
76
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Phase 9.2 — 12306 (China Railway) ticket adapter.
3
+ *
4
+ * Source format: 12306 doesn't have an official user export. We accept
5
+ * two file formats:
6
+ * 1. order-confirmation emails (already adapter-parsed by Phase 5 +
7
+ * Phase 5.4 travel template). Phase 9.2 reads those events back
8
+ * out of the vault and **re-normalizes** them into the
9
+ * adapter-neutral travel schema. This is the "rich vault →
10
+ * enrich" pattern.
11
+ * 2. user-uploaded JSON dump (e.g. exported from a 3rd-party 12306
12
+ * scraper, or hand-curated). Optional.
13
+ *
14
+ * For v0.5 we focus on (2) since (1) is purely vault-side derivation
15
+ * the AnalysisEngine can do at query time.
16
+ */
17
+
18
+ "use strict";
19
+
20
+ const fs = require("node:fs");
21
+ const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
22
+
23
+ const NAME = "travel-12306";
24
+ const VERSION = "0.5.0";
25
+
26
+ class Train12306Adapter {
27
+ constructor(opts = {}) {
28
+ if (!opts.account || !opts.account.username) {
29
+ throw new Error("Train12306Adapter: opts.account.username required (12306 user id)");
30
+ }
31
+ this.account = opts.account;
32
+ this._dataPath = opts.dataPath || null;
33
+
34
+ this.name = NAME;
35
+ this.version = VERSION;
36
+ this.capabilities = ["import:json", "parse:12306-orders"];
37
+ this.extractMode = "file-import";
38
+ this.rateLimits = {};
39
+ this.dataDisclosure = {
40
+ fields: [
41
+ "12306:orderId / passengerName / trainNumber / fromStation / toStation / departureTime / arrivalTime / seat / price",
42
+ ],
43
+ sensitivity: "medium",
44
+ legalGate: false,
45
+ };
46
+ }
47
+
48
+ async authenticate() {
49
+ return { ok: true, account: this.account.username };
50
+ }
51
+
52
+ async healthCheck() {
53
+ return { ok: true, lastChecked: Date.now() };
54
+ }
55
+
56
+ async *sync(opts = {}) {
57
+ const dataPath = opts.dataPath || this._dataPath;
58
+ if (!dataPath || !fs.existsSync(dataPath)) return;
59
+ const buf = fs.readFileSync(dataPath, "utf-8");
60
+ let records;
61
+ try {
62
+ records = parseRecords(buf);
63
+ } catch (err) {
64
+ throw new Error(`Train12306Adapter: parse failed: ${err.message}`);
65
+ }
66
+ for (const r of records) {
67
+ yield {
68
+ adapter: NAME,
69
+ originalId: String(r.recordId || r.orderId || r.ticketNumber),
70
+ capturedAt: r.bookedAt || r.departureMs || Date.now(),
71
+ payload: { record: r },
72
+ };
73
+ }
74
+ }
75
+
76
+ normalize(raw) {
77
+ if (!raw || !raw.payload || !raw.payload.record) {
78
+ throw new Error("Train12306Adapter.normalize: raw.payload.record missing");
79
+ }
80
+ return normalizeTravelRecord(raw.payload.record, {
81
+ adapterName: NAME,
82
+ adapterVersion: VERSION,
83
+ });
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Parse a 12306 dump file. Accepts either:
89
+ * - JSON array of order objects
90
+ * - JSON object { orders: [...] }
91
+ * - JSONL (one order per line)
92
+ */
93
+ function parseRecords(text) {
94
+ let raw;
95
+ try {
96
+ raw = JSON.parse(text);
97
+ } catch (_e) {
98
+ // Try JSONL
99
+ raw = text
100
+ .split(/\r?\n/)
101
+ .filter((l) => l.trim().startsWith("{"))
102
+ .map((l) => JSON.parse(l));
103
+ }
104
+ const orders = Array.isArray(raw) ? raw : raw.orders || [];
105
+ return orders.map(orderToRecord).filter(Boolean);
106
+ }
107
+
108
+ function orderToRecord(o) {
109
+ if (!o || typeof o !== "object") return null;
110
+ const recordId = o.orderId || o.ticketNumber || o.id || o.order_no;
111
+ if (!recordId) return null;
112
+ return {
113
+ vendorId: "12306",
114
+ recordId: String(recordId),
115
+ vehicleType: "train",
116
+ from: {
117
+ station: o.fromStation || o.from_station || o.from,
118
+ city: o.fromCity || o.from_city,
119
+ },
120
+ to: {
121
+ station: o.toStation || o.to_station || o.to,
122
+ city: o.toCity || o.to_city,
123
+ },
124
+ departureMs: numberOrParse(o.departureTime || o.departure_time || o.start_time),
125
+ arrivalMs: numberOrParse(o.arrivalTime || o.arrival_time || o.end_time),
126
+ carrier: "12306",
127
+ vehicleNumber: o.trainNumber || o.train_no || o.trainNo,
128
+ totalCost: o.price != null
129
+ ? { value: parseFloat(o.price), currency: "CNY" }
130
+ : null,
131
+ traveler: o.passengerName || o.passenger || o.name,
132
+ confirmationCode: o.ticketNumber || o.ticket_no || recordId,
133
+ bookedAt: numberOrParse(o.bookedAt || o.order_time),
134
+ extras: {
135
+ seat: o.seat || o.seatType,
136
+ seatNumber: o.seatNumber || o.seat_number,
137
+ idCardLast6: o.idLast6 || undefined, // for cross-source EntityResolver linking
138
+ },
139
+ };
140
+ }
141
+
142
+ function numberOrParse(v) {
143
+ if (Number.isFinite(v)) return v;
144
+ if (typeof v === "string") {
145
+ if (/^\d+$/.test(v)) return parseInt(v, 10);
146
+ return parseChineseDateTime(v);
147
+ }
148
+ return null;
149
+ }
150
+
151
+ module.exports = { Train12306Adapter, parseRecords, NAME, VERSION };