@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,164 @@
1
+ /**
2
+ * Phase 9.4 — Amap (高德地图) location history adapter.
3
+ *
4
+ * Source: Amap stores recent navigation / search history in app-local
5
+ * SQLite DBs. Per sjqz/parsers/amap.py, the relevant tables are:
6
+ * - history_search (search queries)
7
+ * - history_route (planned routes)
8
+ * - favourites (saved locations like 公司 / 家)
9
+ *
10
+ * Adapter extractMode is "device-pull" — relies on Phase 7.5
11
+ * AndroidExtractor to pull the .db files from Amap's app-private
12
+ * directory. For v0.5 we accept a pre-pulled local path (file-import
13
+ * fallback) so users without root can hand-extract via adb backup.
14
+ */
15
+
16
+ "use strict";
17
+
18
+ const fs = require("node:fs");
19
+ const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
20
+
21
+ const NAME = "travel-amap";
22
+ const VERSION = "0.5.0";
23
+
24
+ class AmapAdapter {
25
+ constructor(opts = {}) {
26
+ if (!opts.account || !opts.account.deviceId) {
27
+ throw new Error("AmapAdapter: opts.account.deviceId required");
28
+ }
29
+ this.account = opts.account;
30
+ this._dbPath = opts.dbPath || null;
31
+ this._dbDriverFactory = opts.dbDriverFactory || null;
32
+
33
+ this.name = NAME;
34
+ this.version = VERSION;
35
+ this.capabilities = ["sync:sqlite", "parse:amap-history"];
36
+ this.extractMode = "device-pull";
37
+ this.rateLimits = {};
38
+ this.dataDisclosure = {
39
+ fields: [
40
+ "amap:search_history (query / time / location)",
41
+ "amap:route_history (from / to / mode / time)",
42
+ "amap:favourites (name / address / coords)",
43
+ ],
44
+ sensitivity: "medium",
45
+ legalGate: false,
46
+ };
47
+ }
48
+
49
+ async authenticate() {
50
+ return { ok: true, account: this.account.deviceId };
51
+ }
52
+
53
+ async healthCheck() {
54
+ return { ok: true, lastChecked: Date.now() };
55
+ }
56
+
57
+ async *sync(opts = {}) {
58
+ const dbPath = opts.dbPath || this._dbPath;
59
+ if (!dbPath || !fs.existsSync(dbPath)) return;
60
+ const Database = this._dbDriverFactory || (() => require("better-sqlite3-multiple-ciphers"));
61
+ const Driver = typeof Database === "function" ? Database() : Database;
62
+ const db = new Driver(dbPath, { readonly: true });
63
+
64
+ try {
65
+ // History routes (most analytically valuable)
66
+ const routes = trySelect(db, "SELECT * FROM history_route LIMIT 5000")
67
+ || trySelect(db, "SELECT * FROM ROUTE_HISTORY LIMIT 5000")
68
+ || [];
69
+ for (const r of routes) {
70
+ const rec = routeRowToRecord(r);
71
+ if (rec) {
72
+ yield {
73
+ adapter: NAME,
74
+ originalId: rec.recordId,
75
+ capturedAt: rec.bookedAt || Date.now(),
76
+ payload: { record: rec, kind: "route" },
77
+ };
78
+ }
79
+ }
80
+ // History search (queries — produce trip events of type "visit")
81
+ const searches = trySelect(db, "SELECT * FROM history_search LIMIT 5000") || [];
82
+ for (const r of searches) {
83
+ const rec = searchRowToRecord(r);
84
+ if (rec) {
85
+ yield {
86
+ adapter: NAME,
87
+ originalId: rec.recordId,
88
+ capturedAt: rec.bookedAt || Date.now(),
89
+ payload: { record: rec, kind: "search" },
90
+ };
91
+ }
92
+ }
93
+ } finally {
94
+ try { db.close(); } catch (_e) {}
95
+ }
96
+ }
97
+
98
+ normalize(raw) {
99
+ if (!raw || !raw.payload || !raw.payload.record) {
100
+ throw new Error("AmapAdapter.normalize: raw.payload.record missing");
101
+ }
102
+ return normalizeTravelRecord(raw.payload.record, {
103
+ adapterName: NAME,
104
+ adapterVersion: VERSION,
105
+ });
106
+ }
107
+ }
108
+
109
+ function trySelect(db, sql) {
110
+ try {
111
+ return db.prepare(sql).all();
112
+ } catch (_e) {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function routeRowToRecord(row) {
118
+ if (!row) return null;
119
+ const id = row.id || row._id || row.uid || row.guid;
120
+ if (!id) return null;
121
+ return {
122
+ vendorId: "amap",
123
+ recordId: `route-${id}`,
124
+ vehicleType: row.mode === "drive" ? "car" : (row.mode || "trip"),
125
+ from: { name: row.from_name || row.fromName || row.start, lat: row.from_lat || null, lng: row.from_lng || null },
126
+ to: { name: row.to_name || row.toName || row.dest, lat: row.to_lat || null, lng: row.to_lng || null },
127
+ departureMs: numberOrParse(row.time || row.create_time || row.start_time),
128
+ carrier: "高德地图",
129
+ extras: { mode: row.mode },
130
+ };
131
+ }
132
+
133
+ function searchRowToRecord(row) {
134
+ if (!row) return null;
135
+ const id = row.id || row._id || row.guid;
136
+ if (!id) return null;
137
+ // Search = a "visit" intent
138
+ return {
139
+ vendorId: "amap",
140
+ recordId: `search-${id}`,
141
+ vehicleType: "visit",
142
+ to: { name: row.keyword || row.query || row.poiname, lat: row.lat || null, lng: row.lng || null, city: row.city },
143
+ departureMs: numberOrParse(row.time || row.create_time),
144
+ carrier: "高德地图",
145
+ extras: { query: row.keyword || row.query },
146
+ };
147
+ }
148
+
149
+ function numberOrParse(v) {
150
+ if (Number.isFinite(v)) {
151
+ // Amap timestamps are sometimes seconds — heuristic upgrade to ms
152
+ return v > 1e12 ? v : (v > 1e10 ? v : v * 1000);
153
+ }
154
+ if (typeof v === "string") {
155
+ if (/^\d+$/.test(v)) {
156
+ const n = parseInt(v, 10);
157
+ return n > 1e12 ? n : (n > 1e10 ? n : n * 1000);
158
+ }
159
+ return parseChineseDateTime(v);
160
+ }
161
+ return null;
162
+ }
163
+
164
+ module.exports = { AmapAdapter, NAME, VERSION };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Phase 9.4b — Baidu Map (百度地图) location history adapter.
3
+ *
4
+ * Parallels travel-amap but uses Baidu's table names. Per
5
+ * sjqz/parsers/baidumap.py the key tables are:
6
+ * - search_history (queries)
7
+ * - route_history (planned routes)
8
+ * - my_favourite (saved places)
9
+ * - offline_map (downloaded offline maps; v2)
10
+ */
11
+
12
+ "use strict";
13
+
14
+ const fs = require("node:fs");
15
+ const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
16
+
17
+ const NAME = "travel-baidu-map";
18
+ const VERSION = "0.5.0";
19
+
20
+ class BaiduMapAdapter {
21
+ constructor(opts = {}) {
22
+ if (!opts.account || !opts.account.deviceId) {
23
+ throw new Error("BaiduMapAdapter: opts.account.deviceId required");
24
+ }
25
+ this.account = opts.account;
26
+ this._dbPath = opts.dbPath || null;
27
+ this._dbDriverFactory = opts.dbDriverFactory || null;
28
+
29
+ this.name = NAME;
30
+ this.version = VERSION;
31
+ this.capabilities = ["sync:sqlite", "parse:baidu-map-history"];
32
+ this.extractMode = "device-pull";
33
+ this.rateLimits = {};
34
+ this.dataDisclosure = {
35
+ fields: [
36
+ "baidu:search_history",
37
+ "baidu:route_history",
38
+ "baidu:my_favourite",
39
+ ],
40
+ sensitivity: "medium",
41
+ legalGate: false,
42
+ };
43
+ }
44
+
45
+ async authenticate() {
46
+ return { ok: true, account: this.account.deviceId };
47
+ }
48
+
49
+ async healthCheck() {
50
+ return { ok: true, lastChecked: Date.now() };
51
+ }
52
+
53
+ async *sync(opts = {}) {
54
+ const dbPath = opts.dbPath || this._dbPath;
55
+ if (!dbPath || !fs.existsSync(dbPath)) return;
56
+ const Database = this._dbDriverFactory || (() => require("better-sqlite3-multiple-ciphers"));
57
+ const Driver = typeof Database === "function" ? Database() : Database;
58
+ const db = new Driver(dbPath, { readonly: true });
59
+
60
+ try {
61
+ const routes = trySelect(db, "SELECT * FROM route_history LIMIT 5000")
62
+ || trySelect(db, "SELECT * FROM bd_route_history LIMIT 5000") || [];
63
+ for (const r of routes) {
64
+ const rec = routeRowToRecord(r);
65
+ if (rec) {
66
+ yield {
67
+ adapter: NAME,
68
+ originalId: rec.recordId,
69
+ capturedAt: rec.bookedAt || Date.now(),
70
+ payload: { record: rec, kind: "route" },
71
+ };
72
+ }
73
+ }
74
+ const searches = trySelect(db, "SELECT * FROM search_history LIMIT 5000") || [];
75
+ for (const r of searches) {
76
+ const rec = searchRowToRecord(r);
77
+ if (rec) {
78
+ yield {
79
+ adapter: NAME,
80
+ originalId: rec.recordId,
81
+ capturedAt: rec.bookedAt || Date.now(),
82
+ payload: { record: rec, kind: "search" },
83
+ };
84
+ }
85
+ }
86
+ } finally {
87
+ try { db.close(); } catch (_e) {}
88
+ }
89
+ }
90
+
91
+ normalize(raw) {
92
+ if (!raw || !raw.payload || !raw.payload.record) {
93
+ throw new Error("BaiduMapAdapter.normalize: raw.payload.record missing");
94
+ }
95
+ return normalizeTravelRecord(raw.payload.record, {
96
+ adapterName: NAME,
97
+ adapterVersion: VERSION,
98
+ });
99
+ }
100
+ }
101
+
102
+ function trySelect(db, sql) {
103
+ try {
104
+ return db.prepare(sql).all();
105
+ } catch (_e) {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function routeRowToRecord(row) {
111
+ if (!row) return null;
112
+ const id = row._id || row.id || row.uid;
113
+ if (!id) return null;
114
+ return {
115
+ vendorId: "baidumap",
116
+ recordId: `route-${id}`,
117
+ vehicleType: detectVehicle(row.type || row.mode),
118
+ from: { name: row.start_name || row.from_name, lat: row.start_lat || null, lng: row.start_lng || null },
119
+ to: { name: row.end_name || row.to_name, lat: row.end_lat || null, lng: row.end_lng || null },
120
+ departureMs: numberOrParse(row.time || row.create_time),
121
+ carrier: "百度地图",
122
+ extras: { mode: row.type || row.mode },
123
+ };
124
+ }
125
+
126
+ function searchRowToRecord(row) {
127
+ if (!row) return null;
128
+ const id = row._id || row.id;
129
+ if (!id) return null;
130
+ return {
131
+ vendorId: "baidumap",
132
+ recordId: `search-${id}`,
133
+ vehicleType: "visit",
134
+ to: { name: row.key || row.query, lat: row.lat || null, lng: row.lng || null, city: row.city },
135
+ departureMs: numberOrParse(row.time || row.create_time),
136
+ carrier: "百度地图",
137
+ extras: { query: row.key || row.query },
138
+ };
139
+ }
140
+
141
+ function detectVehicle(v) {
142
+ const s = String(v || "").toLowerCase();
143
+ if (s.includes("drive") || s.includes("car")) return "car";
144
+ if (s.includes("walk")) return "walk";
145
+ if (s.includes("bike") || s.includes("cycle")) return "bike";
146
+ if (s.includes("bus") || s.includes("transit")) return "bus";
147
+ return "trip";
148
+ }
149
+
150
+ function numberOrParse(v) {
151
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
152
+ if (typeof v === "string") {
153
+ if (/^\d+$/.test(v)) {
154
+ const n = parseInt(v, 10);
155
+ return n > 1e12 ? n : n * 1000;
156
+ }
157
+ return parseChineseDateTime(v);
158
+ }
159
+ return null;
160
+ }
161
+
162
+ module.exports = { BaiduMapAdapter, NAME, VERSION };
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Phase 9 — shared travel adapter base.
3
+ *
4
+ * Common normalize logic for the 4 travel sources (12306 / Ctrip /
5
+ * Amap / Baidu Map). Each per-vendor adapter parses its source format
6
+ * into a `TravelRecord` then calls `normalizeTravelRecord()` here.
7
+ *
8
+ * TravelRecord shape (vendor-neutral):
9
+ * {
10
+ * vendorId: "12306" | "ctrip" | "amap" | "baidumap"
11
+ * recordId: string (vendor's order/trip id, unique)
12
+ * vehicleType: "flight" | "train" | "hotel" | "bus" | "car" | "visit"
13
+ * from?: { city?, station?, lat?, lng? }
14
+ * to?: { city?, station?, lat?, lng? }
15
+ * departureMs?: number (ms epoch)
16
+ * arrivalMs?: number
17
+ * carrier?: string ("国航" / "12306" / "携程酒店预订" / ...)
18
+ * vehicleNumber?: string ("CA1234" / "G35" / "京A88888")
19
+ * totalCost?: { value, currency }
20
+ * traveler?: string (passenger name)
21
+ * confirmationCode?:string
22
+ * bookedAt?: number
23
+ * extras?: { ... vendor-specific }
24
+ * }
25
+ *
26
+ * normalizeTravelRecord() returns a NormalizedBatch with:
27
+ * - 1 Event of subtype `trip`
28
+ * - 0-2 Place entities (from / to)
29
+ * - 0-2 Person entities (traveler if known + carrier as merchant)
30
+ */
31
+
32
+ "use strict";
33
+
34
+ const { newId } = require("../../ids");
35
+
36
+ /**
37
+ * Convert a TravelRecord to a NormalizedBatch.
38
+ *
39
+ * @param {TravelRecord} rec
40
+ * @param {object} ctx optional context for cross-source link
41
+ * @param {string} ctx.accountKey typically email or username
42
+ * @param {string} ctx.adapterName the actual adapter.name string
43
+ * @param {string} ctx.adapterVersion
44
+ * @returns {NormalizedBatch}
45
+ */
46
+ function normalizeTravelRecord(rec, ctx = {}) {
47
+ if (!rec || typeof rec !== "object") {
48
+ throw new Error("normalizeTravelRecord: rec required");
49
+ }
50
+ if (!rec.recordId) throw new Error("normalizeTravelRecord: rec.recordId required");
51
+
52
+ const now = Date.now();
53
+ const occurredAt = Number.isFinite(rec.departureMs)
54
+ ? rec.departureMs
55
+ : Number.isFinite(rec.bookedAt)
56
+ ? rec.bookedAt
57
+ : now;
58
+
59
+ const adapterName = ctx.adapterName || rec.vendorId || "travel";
60
+ const adapterVersion = ctx.adapterVersion || "0.1.0";
61
+ const source = {
62
+ adapter: adapterName,
63
+ adapterVersion,
64
+ originalId: String(rec.recordId),
65
+ capturedAt: occurredAt,
66
+ capturedBy: "export",
67
+ };
68
+
69
+ // Places
70
+ const places = [];
71
+ const fromPlaceId = rec.from ? placeIdFor(rec.from, adapterName) : null;
72
+ const toPlaceId = rec.to ? placeIdFor(rec.to, adapterName) : null;
73
+ if (fromPlaceId) {
74
+ places.push(makePlace(fromPlaceId, rec.from, now, source));
75
+ }
76
+ if (toPlaceId) {
77
+ places.push(makePlace(toPlaceId, rec.to, now, source));
78
+ }
79
+
80
+ // Carrier as merchant Person (so spending skill can attribute)
81
+ const persons = [];
82
+ let carrierPersonId = null;
83
+ if (rec.carrier) {
84
+ carrierPersonId = `person-${adapterName}-carrier-${slug(rec.carrier)}`;
85
+ persons.push({
86
+ id: carrierPersonId,
87
+ type: "person",
88
+ subtype: "merchant",
89
+ names: [rec.carrier],
90
+ identifiers: {},
91
+ ingestedAt: now,
92
+ source,
93
+ extra: { fromAdapter: adapterName, carrier: true },
94
+ });
95
+ }
96
+ // Traveler (if not self)
97
+ let travelerPersonId = null;
98
+ if (rec.traveler && rec.traveler !== ctx.selfName) {
99
+ travelerPersonId = `person-${adapterName}-traveler-${slug(rec.traveler)}`;
100
+ persons.push({
101
+ id: travelerPersonId,
102
+ type: "person",
103
+ subtype: "contact",
104
+ names: [rec.traveler],
105
+ identifiers: {},
106
+ ingestedAt: now,
107
+ source,
108
+ extra: { fromAdapter: adapterName, role: "traveler" },
109
+ });
110
+ }
111
+
112
+ // Event
113
+ const eventId = newId();
114
+ const event = {
115
+ id: eventId,
116
+ type: "event",
117
+ subtype: "trip",
118
+ occurredAt,
119
+ actor: travelerPersonId || "person-self",
120
+ participants: dedup(["person-self", travelerPersonId, carrierPersonId].filter(Boolean)),
121
+ content: {
122
+ title: buildTitle(rec),
123
+ ...(rec.extras && rec.extras.note ? { text: rec.extras.note } : {}),
124
+ ...(rec.totalCost && Number.isFinite(rec.totalCost.value)
125
+ ? { amount: { value: rec.totalCost.value, currency: rec.totalCost.currency || "CNY", direction: "out" } }
126
+ : {}),
127
+ },
128
+ ingestedAt: now,
129
+ source,
130
+ extra: {
131
+ vehicleType: rec.vehicleType,
132
+ vendorId: rec.vendorId,
133
+ ...(rec.from ? { from: rec.from.station || rec.from.city || formatPlace(rec.from) } : {}),
134
+ ...(rec.to ? { to: rec.to.station || rec.to.city || formatPlace(rec.to) } : {}),
135
+ ...(rec.from ? { fromPlaceId } : {}),
136
+ ...(rec.to ? { toPlaceId } : {}),
137
+ ...(rec.arrivalMs ? { arrivalMs: rec.arrivalMs } : {}),
138
+ ...(rec.vehicleNumber ? { vehicleNumber: rec.vehicleNumber } : {}),
139
+ ...(rec.confirmationCode ? { confirmationCode: rec.confirmationCode } : {}),
140
+ ...(rec.bookedAt ? { bookedAt: rec.bookedAt } : {}),
141
+ ...(carrierPersonId ? { carrier: rec.carrier, carrierPersonId } : {}),
142
+ ...(rec.extras ? { vendorExtras: rec.extras } : {}),
143
+ },
144
+ };
145
+
146
+ return { events: [event], persons, places, items: [], topics: [] };
147
+ }
148
+
149
+ // ─── helpers ────────────────────────────────────────────────────────────
150
+
151
+ function buildTitle(rec) {
152
+ const vt = rec.vehicleType || "trip";
153
+ const from = rec.from ? (rec.from.station || rec.from.city || "?") : "";
154
+ const to = rec.to ? (rec.to.station || rec.to.city || "?") : "";
155
+ if (from && to) return `${vt}: ${from} → ${to}`;
156
+ if (to) return `${vt}: → ${to}`;
157
+ return `${vt}: ${rec.carrier || rec.recordId}`;
158
+ }
159
+
160
+ function formatPlace(p) {
161
+ if (!p) return "";
162
+ const parts = [p.city, p.station, p.name].filter(Boolean);
163
+ return parts.join(" ");
164
+ }
165
+
166
+ function placeIdFor(p, adapterName) {
167
+ // Stable id keyed by station/city/lat-lng so cross-trip places dedup
168
+ if (!p) return null;
169
+ const key = (p.station || p.city || p.name || `${p.lat},${p.lng}` || "").toString().toLowerCase();
170
+ if (!key) return null;
171
+ return `place-${adapterName}-${slug(key)}`;
172
+ }
173
+
174
+ function makePlace(id, p, now, source) {
175
+ return {
176
+ id,
177
+ type: "place",
178
+ subtype: "venue",
179
+ name: p.station || p.city || p.name || id,
180
+ address: p.address || undefined,
181
+ aliases: [p.station, p.city, p.name].filter(Boolean),
182
+ coordinates: (Number.isFinite(p.lat) && Number.isFinite(p.lng))
183
+ ? { lat: p.lat, lng: p.lng }
184
+ : undefined,
185
+ ingestedAt: now,
186
+ source,
187
+ extra: {},
188
+ };
189
+ }
190
+
191
+ function slug(s) {
192
+ return String(s || "")
193
+ .toLowerCase()
194
+ .replace(/\s+/g, "-")
195
+ .replace(/[^\w一-鿿-]/g, "")
196
+ .slice(0, 80);
197
+ }
198
+
199
+ function dedup(arr) {
200
+ const seen = new Set();
201
+ const out = [];
202
+ for (const x of arr) {
203
+ if (x == null || seen.has(x)) continue;
204
+ seen.add(x);
205
+ out.push(x);
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * Parse a Chinese date string ("2026年4月15日 14:30") to ms epoch.
212
+ * Handles 4 common formats; returns null on failure.
213
+ */
214
+ function parseChineseDateTime(s) {
215
+ if (typeof s !== "string" || s.length === 0) return null;
216
+ // YYYY-MM-DD HH:MM:SS or YYYY-MM-DDTHH:MM
217
+ let m = /^(\d{4})-(\d{1,2})-(\d{1,2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(s);
218
+ if (m) {
219
+ return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6] || 0).getTime();
220
+ }
221
+ // YYYY/MM/DD HH:MM
222
+ m = /^(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})/.exec(s);
223
+ if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]).getTime();
224
+ // 2026年4月15日 14:30
225
+ m = /^(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})/.exec(s);
226
+ if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]).getTime();
227
+ // 2026年4月15日 (no time)
228
+ m = /^(\d{4})年(\d{1,2})月(\d{1,2})日/.exec(s);
229
+ if (m) return new Date(+m[1], +m[2] - 1, +m[3]).getTime();
230
+ // Fallback: Date.parse
231
+ const t = Date.parse(s);
232
+ return Number.isFinite(t) ? t : null;
233
+ }
234
+
235
+ module.exports = {
236
+ normalizeTravelRecord,
237
+ parseChineseDateTime,
238
+ placeIdFor,
239
+ slug,
240
+ };