@chainlesschain/personal-data-hub 0.3.0 → 0.3.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.
@@ -103,7 +103,84 @@ describe("Train12306Adapter", () => {
103
103
  const r = assertAdapter(a);
104
104
  expect(r.ok).toBe(true);
105
105
  if (!r.ok) console.log(r.errors);
106
- expect(a.extractMode).toBe("file-import");
106
+ // v0.2: snapshot mode is the primary path (in-APK collector); legacy
107
+ // file-import preserved for backward compat. extractMode reports
108
+ // "device-pull" to match the snapshot-based social adapters family.
109
+ expect(a.extractMode).toBe("device-pull");
110
+ });
111
+
112
+ it("v0.2 snapshot mode — sync yields ticket events from snapshot file", async () => {
113
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "12306-snap-"));
114
+ const inputPath = path.join(dir, "travel-12306.json");
115
+ fs.writeFileSync(inputPath, JSON.stringify({
116
+ schemaVersion: 1,
117
+ snapshottedAt: 1_700_000_000_000,
118
+ vendor: "12306",
119
+ events: [
120
+ {
121
+ kind: "ticket",
122
+ id: "ticket-EE123:0",
123
+ capturedAt: 1_700_001_000_000,
124
+ orderSequenceNo: "EE123",
125
+ ticketNumber: "T-1",
126
+ passengerName: "张三",
127
+ passengerIdLast6: "123456",
128
+ trainNumber: "G123",
129
+ fromStation: "上海虹桥",
130
+ toStation: "北京南",
131
+ departureMs: 1_700_001_000_000,
132
+ arrivalMs: 1_700_018_000_000,
133
+ seatTypeName: "二等座",
134
+ coachNo: "05",
135
+ seatNo: "12A",
136
+ ticketPrice: 553.5,
137
+ orderDateMs: 1_699_950_000_000,
138
+ orderTotalPrice: 553.5,
139
+ isCompleted: true,
140
+ },
141
+ ],
142
+ }));
143
+ try {
144
+ const a = new Train12306Adapter(); // snapshot mode — no account needed
145
+ const auth = await a.authenticate({ inputPath });
146
+ expect(auth.ok).toBe(true);
147
+ expect(auth.mode).toBe("snapshot-file");
148
+ const raws = [];
149
+ for await (const r of a.sync({ inputPath })) raws.push(r);
150
+ expect(raws).toHaveLength(1);
151
+ expect(raws[0].adapter).toBe("travel-12306");
152
+ expect(raws[0].kind).toBe("ticket");
153
+ expect(raws[0].originalId).toMatch(/^12306:ticket:/);
154
+ expect(raws[0].payload.snapshot).toBe(true);
155
+ const batch = a.normalize(raws[0]);
156
+ const v = validateBatch(batch);
157
+ expect(v.valid).toBe(true);
158
+ } finally {
159
+ fs.rmSync(dir, { recursive: true, force: true });
160
+ }
161
+ });
162
+
163
+ it("v0.2 snapshot mode — rejects schemaVersion mismatch", async () => {
164
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "12306-snap-bad-"));
165
+ const inputPath = path.join(dir, "travel-12306.json");
166
+ fs.writeFileSync(inputPath, JSON.stringify({
167
+ schemaVersion: 99,
168
+ snapshottedAt: Date.now(),
169
+ events: [],
170
+ }));
171
+ try {
172
+ const a = new Train12306Adapter();
173
+ let threw = null;
174
+ try {
175
+ for await (const _r of a.sync({ inputPath })) { /* drain */ }
176
+ } catch (err) {
177
+ threw = err;
178
+ }
179
+ expect(threw).toBeTruthy();
180
+ expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
181
+ } finally {
182
+ fs.rmSync(dir, { recursive: true, force: true });
183
+ }
107
184
  });
108
185
 
109
186
  it("parseRecords parses JSON array", () => {
@@ -131,7 +208,7 @@ describe("Train12306Adapter", () => {
131
208
  expect(recs).toHaveLength(2);
132
209
  });
133
210
 
134
- it("sync yields raw events from a file", async () => {
211
+ it("legacy file-import mode — yields raw events from JSON file", async () => {
135
212
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "12306-"));
136
213
  const dataPath = path.join(dir, "12306.json");
137
214
  fs.writeFileSync(dataPath, JSON.stringify([
@@ -151,9 +228,24 @@ describe("Train12306Adapter", () => {
151
228
  }
152
229
  });
153
230
 
154
- it("rejects missing account.username", () => {
155
- expect(() => new Train12306Adapter({})).toThrow();
156
- expect(() => new Train12306Adapter({ account: {} })).toThrow(/username/);
231
+ it("v0.2: account.username optional at construction (snapshot mode is stateless)", () => {
232
+ // Previously threw — v0.2 lifts this since snapshot mode doesn't need it.
233
+ expect(() => new Train12306Adapter({})).not.toThrow();
234
+ expect(() => new Train12306Adapter()).not.toThrow();
235
+ });
236
+
237
+ it("authenticate without inputPath OR dataPath returns NO_INPUT", async () => {
238
+ const a = new Train12306Adapter();
239
+ const r = await a.authenticate({});
240
+ expect(r.ok).toBe(false);
241
+ expect(r.reason).toBe("NO_INPUT");
242
+ });
243
+
244
+ it("authenticate file-import mode without account.username returns NO_ACCOUNT_USERNAME", async () => {
245
+ const a = new Train12306Adapter({ dataPath: "/no/such/file.json" });
246
+ const r = await a.authenticate({});
247
+ expect(r.ok).toBe(false);
248
+ expect(r.reason).toBe("NO_ACCOUNT_USERNAME");
157
249
  });
158
250
  });
159
251
 
@@ -1,18 +1,39 @@
1
1
  /**
2
- * Phase 9.2 — 12306 (China Railway) ticket adapter.
2
+ * §2.5 v0.2 — 12306 (China Railway) ticket adapter, dual-mode.
3
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.
4
+ * 1. snapshot mode (opts.inputPath): in-APK Android cc reads a snapshot
5
+ * JSON produced by the phone's Kyfw12306LocalCollector. The collector
6
+ * uses captured login cookie to hit kyfw.12306.cn `/otn/queryOrder/
7
+ * queryMyOrder` + `/otn/queryOrder/queryMyOrderNoComplete` (cookie-only,
8
+ * no signing), parses each ticket into a structured event, writes JSON.
9
+ * Desktop-independent. account is OPTIONAL at construction.
13
10
  *
14
- * For v0.5 we focus on (2) since (1) is purely vault-side derivation
15
- * the AnalysisEngine can do at query time.
11
+ * 2. file-import mode (opts.dataPath, legacy v0.5): user-uploaded JSON
12
+ * dump from a 3rd-party 12306 scraper or hand-curated. Preserved for
13
+ * backward compat. account.username REQUIRED.
14
+ *
15
+ * Snapshot schema (mirrors Kyfw12306LocalCollector.SNAPSHOT_SCHEMA_VERSION):
16
+ *
17
+ * {
18
+ * "schemaVersion": 1,
19
+ * "snapshottedAt": <epoch-ms>,
20
+ * "vendor": "12306",
21
+ * "events": [
22
+ * { "kind": "ticket", "id": "ticket-<seqNo>:<n>", "capturedAt": <ms>,
23
+ * "orderSequenceNo": "...", "ticketNumber": "...",
24
+ * "passengerName": "张三", "passengerIdLast6": "123456",
25
+ * "trainNumber": "G123",
26
+ * "fromStation": "上海虹桥", "toStation": "北京南",
27
+ * "departureMs": <ms>, "arrivalMs": <ms>,
28
+ * "seatTypeName": "二等座", "coachNo": "05", "seatNo": "12A",
29
+ * "ticketPrice": 553.5, "orderDateMs": <ms>, "orderTotalPrice": 553.5,
30
+ * "isCompleted": true }
31
+ * ]
32
+ * }
33
+ *
34
+ * Sensitivity: medium — ticket history reveals travel patterns + 6 trailing
35
+ * digits of national ID (used for cross-source EntityResolver linking, never
36
+ * exposed in vault search). Snapshot file is purged after sync.
16
37
  */
17
38
 
18
39
  "use strict";
@@ -21,32 +42,75 @@ const fs = require("node:fs");
21
42
  const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
22
43
 
23
44
  const NAME = "travel-12306";
24
- const VERSION = "0.5.0";
45
+ const VERSION = "0.6.0";
46
+ const SNAPSHOT_SCHEMA_VERSION = 1;
47
+
48
+ const KIND_TICKET = "ticket";
49
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_TICKET]);
25
50
 
26
51
  class Train12306Adapter {
27
52
  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;
53
+ // §2.5 v0.2: account.username OPTIONAL — snapshot mode is stateless and
54
+ // doesn't need a pre-known username. file-import mode still requires it,
55
+ // checked at sync time, not construction.
56
+ this.account = opts.account || null;
32
57
  this._dataPath = opts.dataPath || null;
33
58
 
34
59
  this.name = NAME;
35
60
  this.version = VERSION;
36
- this.capabilities = ["import:json", "parse:12306-orders"];
37
- this.extractMode = "file-import";
61
+ this.capabilities = [
62
+ "sync:snapshot",
63
+ "import:json",
64
+ "parse:12306-orders",
65
+ ];
66
+ this.extractMode = "device-pull";
38
67
  this.rateLimits = {};
39
68
  this.dataDisclosure = {
40
69
  fields: [
41
- "12306:orderId / passengerName / trainNumber / fromStation / toStation / departureTime / arrivalTime / seat / price",
70
+ "12306:orderSequenceNo / ticketNumber / passengerName / trainNumber / fromStation / toStation / departureMs / arrivalMs / seat / price",
42
71
  ],
43
72
  sensitivity: "medium",
44
73
  legalGate: false,
74
+ defaultInclude: {
75
+ ticket: true,
76
+ },
77
+ };
78
+
79
+ // _deps injection seam — vi.mock fs doesn't intercept inlined CJS require.
80
+ this._deps = {
81
+ fs,
45
82
  };
46
83
  }
47
84
 
48
- async authenticate() {
49
- return { ok: true, account: this.account.username };
85
+ async authenticate(ctx = {}) {
86
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
87
+ try {
88
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
89
+ } catch (err) {
90
+ return {
91
+ ok: false,
92
+ reason: "INPUT_PATH_UNREADABLE",
93
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
94
+ };
95
+ }
96
+ return { ok: true, mode: "snapshot-file" };
97
+ }
98
+ if (this._dataPath || (ctx && typeof ctx.dataPath === "string")) {
99
+ if (!this.account || !this.account.username) {
100
+ return {
101
+ ok: false,
102
+ reason: "NO_ACCOUNT_USERNAME",
103
+ message: "travel-12306.authenticate: file-import mode requires account.username",
104
+ };
105
+ }
106
+ return { ok: true, account: this.account.username, mode: "file-import" };
107
+ }
108
+ return {
109
+ ok: false,
110
+ reason: "NO_INPUT",
111
+ message:
112
+ "travel-12306.authenticate: needs opts.inputPath (snapshot mode) OR opts.dataPath (file-import mode)",
113
+ };
50
114
  }
51
115
 
52
116
  async healthCheck() {
@@ -54,14 +118,83 @@ class Train12306Adapter {
54
118
  }
55
119
 
56
120
  async *sync(opts = {}) {
121
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
122
+ yield* this._syncViaSnapshot(opts);
123
+ return;
124
+ }
57
125
  const dataPath = opts.dataPath || this._dataPath;
58
- if (!dataPath || !fs.existsSync(dataPath)) return;
59
- const buf = fs.readFileSync(dataPath, "utf-8");
126
+ if (dataPath) {
127
+ yield* this._syncViaFileImport({ ...opts, dataPath });
128
+ return;
129
+ }
130
+ throw new Error(
131
+ "travel-12306.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) OR opts.dataPath (file-import mode, user-uploaded JSON)",
132
+ );
133
+ }
134
+
135
+ async *_syncViaSnapshot(opts) {
136
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
137
+ const snapshot = JSON.parse(raw);
138
+ if (
139
+ !snapshot ||
140
+ typeof snapshot !== "object" ||
141
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
142
+ ) {
143
+ throw new Error(
144
+ `travel-12306.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
145
+ );
146
+ }
147
+ const fallbackCapturedAt =
148
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
149
+ ? Math.floor(snapshot.snapshottedAt)
150
+ : Date.now();
151
+ const include = opts.include || {};
152
+ const limit =
153
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
154
+
155
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
156
+ let emitted = 0;
157
+ for (const ev of events) {
158
+ if (emitted >= limit) return;
159
+ if (!ev || typeof ev !== "object") continue;
160
+ const kind = ev.kind;
161
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
162
+ if (include[kind] === false) continue;
163
+
164
+ const capturedAt =
165
+ (Number.isFinite(ev.capturedAt) && ev.capturedAt) ||
166
+ (Number.isFinite(ev.departureMs) && ev.departureMs) ||
167
+ fallbackCapturedAt;
168
+ const id =
169
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
170
+ ev.orderSequenceNo ||
171
+ null;
172
+
173
+ yield {
174
+ adapter: NAME,
175
+ kind,
176
+ originalId: stableOriginalId(id || `unknown-${emitted}`),
177
+ capturedAt,
178
+ payload: { ...ev, snapshot: true },
179
+ };
180
+ emitted += 1;
181
+ }
182
+ }
183
+
184
+ async *_syncViaFileImport(opts) {
185
+ if (!this.account || !this.account.username) {
186
+ throw new Error(
187
+ "travel-12306._syncViaFileImport: account.username required (set via new Train12306Adapter({ account: { username } }))",
188
+ );
189
+ }
190
+ const dataPath = opts.dataPath;
191
+ if (!dataPath || !this._deps.fs.existsSync(dataPath)) return;
192
+ const buf = this._deps.fs.readFileSync(dataPath, "utf-8");
60
193
  let records;
61
194
  try {
62
195
  records = parseRecords(buf);
63
196
  } catch (err) {
64
- throw new Error(`Train12306Adapter: parse failed: ${err.message}`);
197
+ throw new Error(`travel-12306._syncViaFileImport: parse failed: ${err.message}`);
65
198
  }
66
199
  for (const r of records) {
67
200
  yield {
@@ -74,7 +207,18 @@ class Train12306Adapter {
74
207
  }
75
208
 
76
209
  normalize(raw) {
77
- if (!raw || !raw.payload || !raw.payload.record) {
210
+ if (!raw || !raw.payload) {
211
+ throw new Error("Train12306Adapter.normalize: payload missing");
212
+ }
213
+ // Snapshot-mode payload is the parsed event directly; legacy file-import
214
+ // payload has `.record` (already normalized shape).
215
+ if (raw.payload.snapshot) {
216
+ return normalizeTravelRecord(snapshotEventToRecord(raw.payload), {
217
+ adapterName: NAME,
218
+ adapterVersion: VERSION,
219
+ });
220
+ }
221
+ if (!raw.payload.record) {
78
222
  throw new Error("Train12306Adapter.normalize: raw.payload.record missing");
79
223
  }
80
224
  return normalizeTravelRecord(raw.payload.record, {
@@ -84,8 +228,43 @@ class Train12306Adapter {
84
228
  }
85
229
  }
86
230
 
231
+ function stableOriginalId(id) {
232
+ return `12306:ticket:${id}`;
233
+ }
234
+
235
+ /** Convert a v0.2 snapshot event into the adapter-neutral travel record
236
+ * shape that [normalizeTravelRecord] expects. */
237
+ function snapshotEventToRecord(ev) {
238
+ return {
239
+ vendorId: "12306",
240
+ recordId: String(ev.id || ev.orderSequenceNo || ev.ticketNumber),
241
+ vehicleType: "train",
242
+ from: { station: ev.fromStation },
243
+ to: { station: ev.toStation },
244
+ departureMs: ev.departureMs || null,
245
+ arrivalMs: ev.arrivalMs || null,
246
+ carrier: "12306",
247
+ vehicleNumber: ev.trainNumber,
248
+ totalCost:
249
+ Number.isFinite(ev.ticketPrice) && ev.ticketPrice > 0
250
+ ? { value: ev.ticketPrice, currency: "CNY" }
251
+ : null,
252
+ traveler: ev.passengerName,
253
+ confirmationCode: ev.ticketNumber || ev.orderSequenceNo,
254
+ bookedAt: ev.orderDateMs || null,
255
+ extras: {
256
+ seat: ev.seatTypeName,
257
+ coachNo: ev.coachNo,
258
+ seatNumber: ev.seatNo,
259
+ isCompleted: ev.isCompleted,
260
+ idLast6: ev.passengerIdLast6 || undefined,
261
+ orderTotalPrice: ev.orderTotalPrice || undefined,
262
+ },
263
+ };
264
+ }
265
+
87
266
  /**
88
- * Parse a 12306 dump file. Accepts either:
267
+ * Parse a 12306 dump file (legacy v0.5 file-import mode). Accepts either:
89
268
  * - JSON array of order objects
90
269
  * - JSON object { orders: [...] }
91
270
  * - JSONL (one order per line)
@@ -134,7 +313,7 @@ function orderToRecord(o) {
134
313
  extras: {
135
314
  seat: o.seat || o.seatType,
136
315
  seatNumber: o.seatNumber || o.seat_number,
137
- idCardLast6: o.idLast6 || undefined, // for cross-source EntityResolver linking
316
+ idCardLast6: o.idLast6 || undefined,
138
317
  },
139
318
  };
140
319
  }
@@ -148,4 +327,11 @@ function numberOrParse(v) {
148
327
  return null;
149
328
  }
150
329
 
151
- module.exports = { Train12306Adapter, parseRecords, NAME, VERSION };
330
+ module.exports = {
331
+ Train12306Adapter,
332
+ parseRecords,
333
+ NAME,
334
+ VERSION,
335
+ SNAPSHOT_SCHEMA_VERSION,
336
+ VALID_SNAPSHOT_KINDS,
337
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chainlesschain/personal-data-hub",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Personal Data Hub — UnifiedSchema + validators + KG ingest helpers for the data-back-to-the-individual middleware",
5
5
  "type": "commonjs",
6
6
  "main": "lib/index.js",