@chainlesschain/personal-data-hub 0.1.0 → 0.2.0

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 (116) hide show
  1. package/__tests__/adapters/ai-chat-history.test.js +395 -0
  2. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  3. package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
  4. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  5. package/__tests__/adapters/email-adapter.test.js +138 -1
  6. package/__tests__/adapters/email-classifier.test.js +347 -0
  7. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  8. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  9. package/__tests__/adapters/email-templates.test.js +699 -0
  10. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  11. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  12. package/__tests__/analysis-skills.test.js +409 -0
  13. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  14. package/__tests__/entity-resolver-stages.test.js +411 -0
  15. package/__tests__/entity-resolver-vault.test.js +246 -0
  16. package/__tests__/entity-resolver.test.js +526 -0
  17. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  18. package/__tests__/longtail-adapters.test.js +217 -0
  19. package/__tests__/mobile-extractor.test.js +288 -0
  20. package/__tests__/shopping-adapters.test.js +296 -0
  21. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  22. package/__tests__/sidecar-supervisor.test.js +120 -0
  23. package/__tests__/social-adapters.test.js +206 -0
  24. package/__tests__/travel-adapters.test.js +325 -0
  25. package/__tests__/vault.test.js +3 -3
  26. package/__tests__/wechat-adapter.test.js +476 -0
  27. package/__tests__/whatsapp-adapter.test.js +135 -0
  28. package/lib/adapter-spec.js +12 -0
  29. package/lib/adapters/_python-sidecar-base.js +207 -0
  30. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
  31. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  32. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  33. package/lib/adapters/ai-chat-history/index.js +28 -0
  34. package/lib/adapters/ai-chat-history/schema-map.js +221 -0
  35. package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
  36. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  37. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  38. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  39. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  40. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  41. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  42. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  43. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  44. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
  45. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  46. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  47. package/lib/adapters/alipay-bill/index.js +41 -0
  48. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  49. package/lib/adapters/email-imap/classifier.js +495 -0
  50. package/lib/adapters/email-imap/email-adapter.js +419 -8
  51. package/lib/adapters/email-imap/index.js +42 -0
  52. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  53. package/lib/adapters/email-imap/templates/bill.js +232 -0
  54. package/lib/adapters/email-imap/templates/government.js +120 -0
  55. package/lib/adapters/email-imap/templates/index.js +78 -0
  56. package/lib/adapters/email-imap/templates/order.js +186 -0
  57. package/lib/adapters/email-imap/templates/other.js +114 -0
  58. package/lib/adapters/email-imap/templates/register.js +113 -0
  59. package/lib/adapters/email-imap/templates/travel.js +157 -0
  60. package/lib/adapters/email-imap/templates/utils.js +275 -0
  61. package/lib/adapters/email-imap/transactions.js +234 -0
  62. package/lib/adapters/messaging-qq/index.js +158 -0
  63. package/lib/adapters/messaging-telegram/index.js +142 -0
  64. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  65. package/lib/adapters/shopping-base/index.js +208 -0
  66. package/lib/adapters/shopping-jd/index.js +150 -0
  67. package/lib/adapters/shopping-meituan/index.js +154 -0
  68. package/lib/adapters/shopping-taobao/index.js +176 -0
  69. package/lib/adapters/social-bilibili/index.js +171 -0
  70. package/lib/adapters/social-douyin/index.js +116 -0
  71. package/lib/adapters/social-weibo/index.js +164 -0
  72. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  73. package/lib/adapters/system-data/disclosure.js +166 -0
  74. package/lib/adapters/system-data/index.js +34 -0
  75. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  76. package/lib/adapters/travel-12306/index.js +151 -0
  77. package/lib/adapters/travel-amap/index.js +164 -0
  78. package/lib/adapters/travel-baidu-map/index.js +162 -0
  79. package/lib/adapters/travel-base/index.js +240 -0
  80. package/lib/adapters/travel-ctrip/index.js +151 -0
  81. package/lib/adapters/wechat/content-parser.js +326 -0
  82. package/lib/adapters/wechat/db-reader.js +209 -0
  83. package/lib/adapters/wechat/index.js +28 -0
  84. package/lib/adapters/wechat/key-extractor.js +158 -0
  85. package/lib/adapters/wechat/normalize.js +220 -0
  86. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  87. package/lib/analysis-skills/base.js +113 -0
  88. package/lib/analysis-skills/footprint.js +167 -0
  89. package/lib/analysis-skills/index.js +58 -0
  90. package/lib/analysis-skills/interests.js +161 -0
  91. package/lib/analysis-skills/relations.js +226 -0
  92. package/lib/analysis-skills/spending.js +216 -0
  93. package/lib/analysis-skills/timeline.js +167 -0
  94. package/lib/entity-resolver/embedding-stage.js +198 -0
  95. package/lib/entity-resolver/entity-resolver.js +384 -0
  96. package/lib/entity-resolver/index.js +42 -0
  97. package/lib/entity-resolver/llm-stage.js +191 -0
  98. package/lib/entity-resolver/rule-stage.js +208 -0
  99. package/lib/entity-resolver/worker.js +149 -0
  100. package/lib/index.js +115 -0
  101. package/lib/migrations.js +73 -0
  102. package/lib/mobile-extractor/android.js +193 -0
  103. package/lib/mobile-extractor/index.js +9 -0
  104. package/lib/mobile-extractor/ios.js +223 -0
  105. package/lib/registry.js +42 -0
  106. package/lib/sidecar/index.js +15 -0
  107. package/lib/sidecar/supervisor.js +359 -0
  108. package/lib/vault.js +266 -0
  109. package/package.json +29 -3
  110. package/scripts/_make-fixture-all.js +126 -0
  111. package/scripts/_make-fixture-contacts.js +84 -0
  112. package/scripts/evaluate-entity-resolver.js +213 -0
  113. package/scripts/smoke-phase-5-5.js +196 -0
  114. package/scripts/smoke-phase-5-7.js +181 -0
  115. package/scripts/smoke-system-data-contacts.js +309 -0
  116. package/scripts/smoke-system-data.js +312 -0
@@ -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
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Phase 9.3 — Ctrip (携程) order adapter.
3
+ *
4
+ * Ctrip has no official user export. Two input paths:
5
+ * 1. JSON dump from a 3rd-party scraper or user-curated file
6
+ * 2. Email order-confirmation events from Phase 5 (vault-side derive)
7
+ *
8
+ * Ctrip orders cover 4 sub-types: flight / hotel / train / cruise.
9
+ * We map each to the appropriate `vehicleType` in TravelRecord:
10
+ * flight → "flight", hotel → "hotel", train → "train", cruise → "cruise"
11
+ */
12
+
13
+ "use strict";
14
+
15
+ const fs = require("node:fs");
16
+ const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
17
+
18
+ const NAME = "travel-ctrip";
19
+ const VERSION = "0.5.0";
20
+
21
+ class CtripAdapter {
22
+ constructor(opts = {}) {
23
+ if (!opts.account || !opts.account.email) {
24
+ throw new Error("CtripAdapter: opts.account.email required");
25
+ }
26
+ this.account = opts.account;
27
+ this._dataPath = opts.dataPath || null;
28
+
29
+ this.name = NAME;
30
+ this.version = VERSION;
31
+ this.capabilities = ["import:json", "parse:ctrip-orders"];
32
+ this.extractMode = "file-import";
33
+ this.rateLimits = {};
34
+ this.dataDisclosure = {
35
+ fields: [
36
+ "ctrip:orderId / type / fromCity / toCity / dates / passengerName / price / carrier",
37
+ ],
38
+ sensitivity: "medium",
39
+ legalGate: false,
40
+ };
41
+ }
42
+
43
+ async authenticate() {
44
+ return { ok: true, account: this.account.email };
45
+ }
46
+
47
+ async healthCheck() {
48
+ return { ok: true, lastChecked: Date.now() };
49
+ }
50
+
51
+ async *sync(opts = {}) {
52
+ const dataPath = opts.dataPath || this._dataPath;
53
+ if (!dataPath || !fs.existsSync(dataPath)) return;
54
+ const text = fs.readFileSync(dataPath, "utf-8");
55
+ let records;
56
+ try {
57
+ records = parseRecords(text);
58
+ } catch (err) {
59
+ throw new Error(`CtripAdapter: parse failed: ${err.message}`);
60
+ }
61
+ for (const r of records) {
62
+ yield {
63
+ adapter: NAME,
64
+ originalId: r.recordId,
65
+ capturedAt: r.bookedAt || r.departureMs || Date.now(),
66
+ payload: { record: r },
67
+ };
68
+ }
69
+ }
70
+
71
+ normalize(raw) {
72
+ if (!raw || !raw.payload || !raw.payload.record) {
73
+ throw new Error("CtripAdapter.normalize: raw.payload.record missing");
74
+ }
75
+ return normalizeTravelRecord(raw.payload.record, {
76
+ adapterName: NAME,
77
+ adapterVersion: VERSION,
78
+ });
79
+ }
80
+ }
81
+
82
+ const TYPE_MAP = {
83
+ flight: "flight",
84
+ airline: "flight",
85
+ hotel: "hotel",
86
+ train: "train",
87
+ cruise: "cruise",
88
+ bus: "bus",
89
+ car: "car",
90
+ };
91
+
92
+ function parseRecords(text) {
93
+ let raw;
94
+ try {
95
+ raw = JSON.parse(text);
96
+ } catch (_e) {
97
+ // Try JSONL
98
+ raw = text
99
+ .split(/\r?\n/)
100
+ .filter((l) => l.trim().startsWith("{"))
101
+ .map((l) => JSON.parse(l));
102
+ }
103
+ const orders = Array.isArray(raw) ? raw : raw.orders || [];
104
+ return orders.map(orderToRecord).filter(Boolean);
105
+ }
106
+
107
+ function orderToRecord(o) {
108
+ if (!o || typeof o !== "object") return null;
109
+ const recordId = o.orderId || o.id || o.order_no;
110
+ if (!recordId) return null;
111
+ const type = (o.type || o.orderType || "").toLowerCase();
112
+ const vehicleType = TYPE_MAP[type] || "trip";
113
+
114
+ return {
115
+ vendorId: "ctrip",
116
+ recordId: String(recordId),
117
+ vehicleType,
118
+ from: o.fromCity || o.from_city || o.depCity
119
+ ? { city: o.fromCity || o.from_city || o.depCity }
120
+ : null,
121
+ to: o.toCity || o.to_city || o.arrCity || o.hotelCity
122
+ ? { city: o.toCity || o.to_city || o.arrCity || o.hotelCity }
123
+ : null,
124
+ departureMs: numberOrParse(o.departureTime || o.dep_time || o.checkIn || o.check_in),
125
+ arrivalMs: numberOrParse(o.arrivalTime || o.arr_time || o.checkOut || o.check_out),
126
+ carrier: o.carrier || o.airline || o.hotelName || o.hotel_name || "携程",
127
+ vehicleNumber: o.flightNumber || o.flight_no || o.trainNumber || o.train_no,
128
+ totalCost: o.price != null
129
+ ? { value: parseFloat(o.price), currency: o.currency || "CNY" }
130
+ : null,
131
+ traveler: o.passengerName || o.passenger || o.guestName || o.guest_name,
132
+ confirmationCode: o.confirmationCode || o.pnr || o.confirmation_no,
133
+ bookedAt: numberOrParse(o.bookedAt || o.order_time),
134
+ extras: {
135
+ type,
136
+ ...(o.hotel ? { hotel: o.hotel } : {}),
137
+ ...(o.nights != null ? { nights: o.nights } : {}),
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) && v.length >= 10) return parseInt(v, 10);
146
+ return parseChineseDateTime(v);
147
+ }
148
+ return null;
149
+ }
150
+
151
+ module.exports = { CtripAdapter, parseRecords, TYPE_MAP, NAME, VERSION };