@chainlesschain/personal-data-hub 0.4.6 → 0.4.7

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.
@@ -9,6 +9,11 @@ const crypto = require("node:crypto");
9
9
  const {
10
10
  Train12306Adapter,
11
11
  parseRecords,
12
+ ticketsFromOrder,
13
+ extractCompletedOrders,
14
+ extractPendingOrders,
15
+ parse12306DateTime,
16
+ parseYyyymmdd,
12
17
  NAME,
13
18
  VERSION,
14
19
  SNAPSHOT_SCHEMA_VERSION,
@@ -62,7 +67,7 @@ function makeSnapshot(events, extra = {}) {
62
67
  describe("constants", () => {
63
68
  it("exposes name/version/schema", () => {
64
69
  expect(NAME).toBe("travel-12306");
65
- expect(VERSION).toBe("0.6.0");
70
+ expect(VERSION).toBe("0.7.0");
66
71
  expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
67
72
  expect([...VALID_SNAPSHOT_KINDS]).toEqual(["ticket"]);
68
73
  });
@@ -277,3 +282,231 @@ describe("sync + parseRecords — file-import mode", () => {
277
282
  await expect(collect(a.sync({}))).rejects.toThrow(/needs opts\.inputPath/);
278
283
  });
279
284
  });
285
+
286
+ // ─── §2.5 v0.3 cookie-api live fetch ────────────────────────────────────────
287
+
288
+ // One raw 12306 order object (queryMyOrder shape) carrying a single ticket.
289
+ function rawOrder(seq, overrides = {}) {
290
+ return {
291
+ sequence_no: seq,
292
+ order_date: "20240315",
293
+ ticket_total_price: "553.5",
294
+ tickets: [
295
+ {
296
+ ticket_no: "E123456789",
297
+ passenger_name: "张三",
298
+ passenger_id_no: "310101199001011234", // last6 = 011234
299
+ train_code: "G35",
300
+ from_station_name: "上海虹桥",
301
+ to_station_name: "北京南",
302
+ start_train_date_page: "2024-03-20 09:00",
303
+ arrive_train_date_page: "2024-03-20 14:00",
304
+ seat_type_name: "二等座",
305
+ coach_no: "05",
306
+ seat_no: "12A",
307
+ ticket_price: "553.5",
308
+ ...overrides,
309
+ },
310
+ ],
311
+ };
312
+ }
313
+
314
+ const COOKIE = "tk=abc; JSESSIONID=xyz; RAIL_DEVICEID=dev";
315
+
316
+ describe("cookie-api helpers", () => {
317
+ it("extractCompletedOrders tolerates shape variants + junk", () => {
318
+ expect(extractCompletedOrders({ data: { OrderDTODataList: [1, 2] } })).toEqual([1, 2]);
319
+ expect(extractCompletedOrders({ data: { orderDTODataList: [3] } })).toEqual([3]);
320
+ expect(extractCompletedOrders(null)).toEqual([]);
321
+ expect(extractCompletedOrders({ data: {} })).toEqual([]);
322
+ expect(extractCompletedOrders("nope")).toEqual([]);
323
+ });
324
+
325
+ it("extractPendingOrders tolerates shape variants + junk", () => {
326
+ expect(extractPendingOrders({ data: { orderDBList: [1] } })).toEqual([1]);
327
+ expect(extractPendingOrders({ data: { orderDbList: [2] } })).toEqual([2]);
328
+ expect(extractPendingOrders({})).toEqual([]);
329
+ });
330
+
331
+ it("ticketsFromOrder flattens tickets with snake/camel fallbacks", () => {
332
+ const evs = ticketsFromOrder(rawOrder("SEQ1"), true);
333
+ expect(evs).toHaveLength(1);
334
+ expect(evs[0]).toMatchObject({
335
+ kind: "ticket",
336
+ id: "ticket-SEQ1:0",
337
+ orderSequenceNo: "SEQ1",
338
+ ticketNumber: "E123456789",
339
+ passengerName: "张三",
340
+ passengerIdLast6: "011234",
341
+ trainNumber: "G35",
342
+ fromStation: "上海虹桥",
343
+ toStation: "北京南",
344
+ seatTypeName: "二等座",
345
+ coachNo: "05",
346
+ seatNo: "12A",
347
+ ticketPrice: 553.5,
348
+ isCompleted: true,
349
+ capturedVia: "cookie-api",
350
+ });
351
+ // camelCase + *_page fallbacks
352
+ const camel = ticketsFromOrder(
353
+ {
354
+ sequenceNo: "SEQ2",
355
+ tickets: [
356
+ {
357
+ stationTrainCode: "D7",
358
+ passengerName: "李四",
359
+ from_station_name_page: "杭州东",
360
+ to_station_name_page: "南京南",
361
+ ticketPrice: "100",
362
+ },
363
+ ],
364
+ },
365
+ false,
366
+ );
367
+ expect(camel[0]).toMatchObject({
368
+ trainNumber: "D7",
369
+ fromStation: "杭州东",
370
+ toStation: "南京南",
371
+ ticketPrice: 100,
372
+ isCompleted: false,
373
+ });
374
+ });
375
+
376
+ it("ticketsFromOrder drops incomplete tickets + bad orders", () => {
377
+ expect(ticketsFromOrder(null, true)).toEqual([]);
378
+ expect(ticketsFromOrder({ tickets: [] }, true)).toEqual([]); // no sequence_no
379
+ // ticket missing passenger / train / stations → dropped
380
+ const evs = ticketsFromOrder(
381
+ { sequence_no: "S", tickets: [{ passenger_name: "x" }] },
382
+ true,
383
+ );
384
+ expect(evs).toEqual([]);
385
+ });
386
+
387
+ it("parse12306DateTime handles date / date-time / Chinese / blank", () => {
388
+ // 2024-03-20 09:00 Shanghai = 2024-03-20 01:00 UTC
389
+ expect(parse12306DateTime("2024-03-20 09:00")).toBe(Date.UTC(2024, 2, 20, 1, 0));
390
+ expect(parse12306DateTime("2024-03-20")).toBe(Date.UTC(2024, 2, 19, 16, 0));
391
+ expect(parse12306DateTime("")).toBeNull();
392
+ expect(parse12306DateTime(null)).toBeNull();
393
+ expect(parse12306DateTime("garbage")).toBeNull();
394
+ });
395
+
396
+ it("parseYyyymmdd parses compact dates", () => {
397
+ expect(parseYyyymmdd("20240315")).toBe(Date.UTC(2024, 2, 14, 16, 0));
398
+ expect(parseYyyymmdd("2024-03-15")).toBeNull();
399
+ expect(parseYyyymmdd(null)).toBeNull();
400
+ });
401
+ });
402
+
403
+ describe("authenticate — cookie-api mode", () => {
404
+ it("ok when account.cookies present (username optional)", async () => {
405
+ const a = new Train12306Adapter({ account: { cookies: COOKIE } });
406
+ expect(await a.authenticate({})).toEqual({
407
+ ok: true,
408
+ account: null,
409
+ mode: "cookie",
410
+ });
411
+ });
412
+
413
+ it("carries username when supplied alongside cookies", async () => {
414
+ const a = new Train12306Adapter({
415
+ account: { cookies: COOKIE, username: "alice" },
416
+ });
417
+ expect((await a.authenticate({})).account).toBe("alice");
418
+ });
419
+
420
+ it("cookie mode takes precedence over file-import", async () => {
421
+ const a = new Train12306Adapter({
422
+ account: { cookies: COOKIE },
423
+ dataPath: "x.json",
424
+ });
425
+ expect((await a.authenticate({})).mode).toBe("cookie");
426
+ });
427
+ });
428
+
429
+ describe("sync — cookie-api mode", () => {
430
+ it("yields flattened ticket events from completed + pending", async () => {
431
+ const calls = [];
432
+ const fetchFn = async ({ url, cookies, form }) => {
433
+ calls.push({ url, cookies, form });
434
+ if (url.includes("NoComplete")) {
435
+ return { data: { orderDBList: [rawOrder("PEND1")] } };
436
+ }
437
+ // single completed page (< PAGE_SIZE → stops)
438
+ return { data: { OrderDTODataList: [rawOrder("SEQ1")] } };
439
+ };
440
+ const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
441
+ const items = await collect(a.sync({}));
442
+ expect(items.map((i) => i.originalId)).toEqual([
443
+ "12306:ticket:ticket-SEQ1:0",
444
+ "12306:ticket:ticket-PEND1:0",
445
+ ]);
446
+ expect(items[0].payload.snapshot).toBe(true);
447
+ expect(items[0].payload.capturedVia).toBe("cookie-api");
448
+ expect(items[1].payload.isCompleted).toBe(false);
449
+ // cookie header forwarded + completed form carries pagination params
450
+ expect(calls[0].cookies).toBe(COOKIE);
451
+ expect(calls[0].form).toMatchObject({
452
+ come_from_flag: "my_order",
453
+ pageSize: "50",
454
+ pageIndex: "1",
455
+ });
456
+ });
457
+
458
+ it("paginates completed orders until a short page", async () => {
459
+ const fetchFn = async ({ url, form }) => {
460
+ if (url.includes("NoComplete")) return { data: { orderDBList: [] } };
461
+ const page = parseInt(form.pageIndex, 10);
462
+ if (page === 1) {
463
+ // exactly PAGE_SIZE (50) → triggers page 2
464
+ const orders = Array.from({ length: 50 }, (_, i) => rawOrder(`P1-${i}`));
465
+ return { data: { OrderDTODataList: orders } };
466
+ }
467
+ if (page === 2) return { data: { OrderDTODataList: [rawOrder("P2-0")] } };
468
+ return { data: { OrderDTODataList: [] } };
469
+ };
470
+ const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
471
+ const items = await collect(a.sync({}));
472
+ expect(items).toHaveLength(51); // 50 + 1, then short page stops
473
+ });
474
+
475
+ it("honors limit + include gate", async () => {
476
+ const fetchFn = async ({ url }) =>
477
+ url.includes("NoComplete")
478
+ ? { data: { orderDBList: [] } }
479
+ : { data: { OrderDTODataList: [rawOrder("A"), rawOrder("B")] } };
480
+ const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
481
+ expect(await collect(a.sync({ limit: 1 }))).toHaveLength(1);
482
+ expect(await collect(a.sync({ include: { ticket: false } }))).toHaveLength(0);
483
+ });
484
+
485
+ it("empty responses yield nothing (expired cookie / no orders)", async () => {
486
+ const fetchFn = async () => ({});
487
+ const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
488
+ expect(await collect(a.sync({}))).toHaveLength(0);
489
+ });
490
+
491
+ it("throws when cookie mode active but no fetchFn injected", async () => {
492
+ const a = new Train12306Adapter({ account: { cookies: COOKIE } });
493
+ await expect(collect(a.sync({}))).rejects.toThrow(/no fetchFn configured/);
494
+ });
495
+
496
+ it("normalize maps a cookie ticket event → trip with capturedVia", async () => {
497
+ const fetchFn = async ({ url }) =>
498
+ url.includes("NoComplete")
499
+ ? { data: { orderDBList: [] } }
500
+ : { data: { OrderDTODataList: [rawOrder("SEQ1")] } };
501
+ const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
502
+ const [item] = await collect(a.sync({}));
503
+ const batch = a.normalize(item);
504
+ const ev = batch.events[0];
505
+ expect(ev.subtype).toBe("trip");
506
+ expect(ev.content.title).toBe("train: 上海虹桥 → 北京南");
507
+ expect(ev.extra.vendorExtras).toMatchObject({
508
+ capturedVia: "cookie-api",
509
+ idLast6: "011234",
510
+ });
511
+ });
512
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * §2.5 v0.2 — 12306 (China Railway) ticket adapter, dual-mode.
2
+ * §2.5 v0.3 — 12306 (China Railway) ticket adapter, tri-mode.
3
3
  *
4
4
  * 1. snapshot mode (opts.inputPath): in-APK Android cc reads a snapshot
5
5
  * JSON produced by the phone's Kyfw12306LocalCollector. The collector
@@ -8,7 +8,20 @@
8
8
  * no signing), parses each ticket into a structured event, writes JSON.
9
9
  * Desktop-independent. account is OPTIONAL at construction.
10
10
  *
11
- * 2. file-import mode (opts.dataPath, legacy v0.5): user-uploaded JSON
11
+ * 2. cookie-api mode (opts.account.cookies, v0.3): fetch the SAME two
12
+ * kyfw.12306.cn endpoints directly from the hub, so collection no longer
13
+ * requires a phone. 12306's order endpoints are **cookie-only, no signing**
14
+ * (no _signature / X-Bogus / anti-content SDK — unlike 抖音/拼多多), so no
15
+ * signProvider is needed. The actual HTTP call is delegated to an injected
16
+ * `fetchFn` (Android in-APK cc → OkHttp / Kyfw12306ApiClient; desktop hub →
17
+ * Electron WebView net request) so this module stays a pure-Node parser +
18
+ * orchestrator. Completed orders paginate via `pageIndex` (≤50/page, last
19
+ * 90 days by default); pending orders are a single call. account OPTIONAL —
20
+ * the cookie carries identity. Cookie expires after ~30min idle → the
21
+ * endpoint returns an HTML login redirect, surfaced by the fetchFn as a
22
+ * non-object / empty response, which yields zero events rather than crashing.
23
+ *
24
+ * 3. file-import mode (opts.dataPath, legacy v0.5): user-uploaded JSON
12
25
  * dump from a 3rd-party 12306 scraper or hand-curated. Preserved for
13
26
  * backward compat. account.username REQUIRED.
14
27
  *
@@ -40,14 +53,22 @@
40
53
 
41
54
  const fs = require("node:fs");
42
55
  const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
56
+ const { CookieAuth } = require("../shopping-base");
43
57
 
44
58
  const NAME = "travel-12306";
45
- const VERSION = "0.6.0";
59
+ const VERSION = "0.7.0"; // §2.5 v0.3 — cookie-api live fetch path
46
60
  const SNAPSHOT_SCHEMA_VERSION = 1;
47
61
 
48
62
  const KIND_TICKET = "ticket";
49
63
  const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_TICKET]);
50
64
 
65
+ const KYFW_COMPLETED_URL =
66
+ "https://kyfw.12306.cn/otn/queryOrder/queryMyOrder";
67
+ const KYFW_PENDING_URL =
68
+ "https://kyfw.12306.cn/otn/queryOrder/queryMyOrderNoComplete";
69
+ const DAYS_90_MS = 90 * 24 * 3600_000;
70
+ const PAGE_SIZE = 50; // 12306 returns ≤50 completed orders per page
71
+
51
72
  class Train12306Adapter {
52
73
  constructor(opts = {}) {
53
74
  // §2.5 v0.2: account.username OPTIONAL — snapshot mode is stateless and
@@ -56,10 +77,22 @@ class Train12306Adapter {
56
77
  this.account = opts.account || null;
57
78
  this._dataPath = opts.dataPath || null;
58
79
 
80
+ // §2.5 v0.3 cookie-api mode — activates when account.cookies is supplied.
81
+ // 12306 order endpoints are cookie-only (no signing), so no signProvider.
82
+ this._cookieAuth =
83
+ opts.account && opts.account.cookies
84
+ ? new CookieAuth({ platform: "12306", cookies: opts.account.cookies })
85
+ : null;
86
+ // The actual HTTP call is delegated to an injected fetchFn so this module
87
+ // stays a pure-Node parser/orchestrator (same seam as the shopping
88
+ // adapters). fetchFn({ url, cookies, form }) → parsed JSON response object.
89
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
90
+
59
91
  this.name = NAME;
60
92
  this.version = VERSION;
61
93
  this.capabilities = [
62
94
  "sync:snapshot",
95
+ "sync:cookie-api",
63
96
  "import:json",
64
97
  "parse:12306-orders",
65
98
  ];
@@ -95,6 +128,18 @@ class Train12306Adapter {
95
128
  }
96
129
  return { ok: true, mode: "snapshot-file" };
97
130
  }
131
+ if (this._cookieAuth) {
132
+ const ok = await this._cookieAuth.validate();
133
+ if (!ok) {
134
+ return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
135
+ }
136
+ // account is OPTIONAL in cookie mode — the 12306 cookie carries identity.
137
+ return {
138
+ ok: true,
139
+ account: (this.account && this.account.username) || null,
140
+ mode: "cookie",
141
+ };
142
+ }
98
143
  if (this._dataPath || (ctx && typeof ctx.dataPath === "string")) {
99
144
  if (!this.account || !this.account.username) {
100
145
  return {
@@ -109,7 +154,7 @@ class Train12306Adapter {
109
154
  ok: false,
110
155
  reason: "NO_INPUT",
111
156
  message:
112
- "travel-12306.authenticate: needs opts.inputPath (snapshot mode) OR opts.dataPath (file-import mode)",
157
+ "travel-12306.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode) OR opts.dataPath (file-import mode)",
113
158
  };
114
159
  }
115
160
 
@@ -122,16 +167,95 @@ class Train12306Adapter {
122
167
  yield* this._syncViaSnapshot(opts);
123
168
  return;
124
169
  }
170
+ if (this._cookieAuth) {
171
+ yield* this._syncViaCookie(opts);
172
+ return;
173
+ }
125
174
  const dataPath = opts.dataPath || this._dataPath;
126
175
  if (dataPath) {
127
176
  yield* this._syncViaFileImport({ ...opts, dataPath });
128
177
  return;
129
178
  }
130
179
  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)",
180
+ "travel-12306.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) OR opts.account.cookies (cookie-api mode, kyfw.12306.cn live fetch) OR opts.dataPath (file-import mode, user-uploaded JSON)",
132
181
  );
133
182
  }
134
183
 
184
+ /**
185
+ * §2.5 v0.3 — cookie-api live fetch. Hits the same two kyfw.12306.cn order
186
+ * endpoints the on-device collector uses (cookie-only, no signing). The
187
+ * injected fetchFn performs the HTTP; this method paginates, flattens each
188
+ * order's tickets into snapshot-shaped ticket events, and yields them so the
189
+ * existing snapshot normalize path applies unchanged.
190
+ */
191
+ async *_syncViaCookie(opts = {}) {
192
+ if (!(await this._cookieAuth.validate())) return;
193
+ const cookies = this._cookieAuth.toHeader();
194
+ const include = opts.include || {};
195
+ if (include[KIND_TICKET] === false) return;
196
+
197
+ const limit =
198
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
199
+ const maxPages =
200
+ Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 4;
201
+ const endDateMs = Number.isFinite(opts.endDateMs) ? opts.endDateMs : Date.now();
202
+ // queryMyOrder caps the range at 90 days; honour an explicit start /
203
+ // sinceWatermark but never reach back further than 90d in one query.
204
+ const requestedStart = Number.isFinite(opts.startDateMs)
205
+ ? opts.startDateMs
206
+ : opts.sinceWatermark != null
207
+ ? parseInt(String(opts.sinceWatermark), 10) || endDateMs - DAYS_90_MS
208
+ : endDateMs - DAYS_90_MS;
209
+ const startDateMs = Math.max(requestedStart, endDateMs - DAYS_90_MS);
210
+ const queryStartDate = fmtDateShanghai(startDateMs);
211
+ const queryEndDate = fmtDateShanghai(endDateMs);
212
+
213
+ let emitted = 0;
214
+
215
+ // ── completed orders (paginated) ──────────────────────────────────────
216
+ let page = 1;
217
+ while (page <= maxPages) {
218
+ const form = {
219
+ come_from_flag: "my_order",
220
+ queryStartDate,
221
+ queryEndDate,
222
+ queryType: "1",
223
+ sequeue_train_name: "",
224
+ pageSize: String(PAGE_SIZE),
225
+ pageIndex: String(page),
226
+ query_where: "G",
227
+ };
228
+ const resp = await this._fetchFn({ url: KYFW_COMPLETED_URL, cookies, form });
229
+ const orders = extractCompletedOrders(resp);
230
+ if (!orders.length) break;
231
+ for (const order of orders) {
232
+ for (const ev of ticketsFromOrder(order, true)) {
233
+ if (emitted >= limit) return;
234
+ yield cookieEventToRecord(ev);
235
+ emitted += 1;
236
+ }
237
+ }
238
+ if (orders.length < PAGE_SIZE) break; // last page
239
+ page += 1;
240
+ }
241
+
242
+ // ── pending orders (single call, usually empty) ───────────────────────
243
+ if (emitted < limit) {
244
+ const resp = await this._fetchFn({
245
+ url: KYFW_PENDING_URL,
246
+ cookies,
247
+ form: {},
248
+ });
249
+ for (const order of extractPendingOrders(resp)) {
250
+ for (const ev of ticketsFromOrder(order, false)) {
251
+ if (emitted >= limit) return;
252
+ yield cookieEventToRecord(ev);
253
+ emitted += 1;
254
+ }
255
+ }
256
+ }
257
+ }
258
+
135
259
  async *_syncViaSnapshot(opts) {
136
260
  const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
137
261
  const snapshot = JSON.parse(raw);
@@ -259,6 +383,7 @@ function snapshotEventToRecord(ev) {
259
383
  isCompleted: ev.isCompleted,
260
384
  idLast6: ev.passengerIdLast6 || undefined,
261
385
  orderTotalPrice: ev.orderTotalPrice || undefined,
386
+ capturedVia: ev.capturedVia || undefined,
262
387
  },
263
388
  };
264
389
  }
@@ -327,9 +452,158 @@ function numberOrParse(v) {
327
452
  return null;
328
453
  }
329
454
 
455
+ // ─── cookie-api (v0.3) helpers ──────────────────────────────────────────────
456
+
457
+ /** Wrap a cookie-mode ticket event into the same yield shape the snapshot path
458
+ * produces, so normalize() routes it through snapshotEventToRecord unchanged. */
459
+ function cookieEventToRecord(ev) {
460
+ const capturedAt =
461
+ (Number.isFinite(ev.departureMs) && ev.departureMs) ||
462
+ (Number.isFinite(ev.orderDateMs) && ev.orderDateMs) ||
463
+ Date.now();
464
+ return {
465
+ adapter: NAME,
466
+ kind: KIND_TICKET,
467
+ originalId: stableOriginalId(ev.id || ev.orderSequenceNo),
468
+ capturedAt,
469
+ payload: { ...ev, snapshot: true },
470
+ };
471
+ }
472
+
473
+ /** Pull the completed-order array out of a queryMyOrder response. */
474
+ function extractCompletedOrders(resp) {
475
+ const data = resp && typeof resp === "object" ? resp.data : null;
476
+ if (!data || typeof data !== "object") return [];
477
+ const list = data.OrderDTODataList || data.orderDTODataList;
478
+ return Array.isArray(list) ? list : [];
479
+ }
480
+
481
+ /** Pull the pending-order array out of a queryMyOrderNoComplete response. */
482
+ function extractPendingOrders(resp) {
483
+ const data = resp && typeof resp === "object" ? resp.data : null;
484
+ if (!data || typeof data !== "object") return [];
485
+ const list = data.orderDBList || data.orderDbList;
486
+ return Array.isArray(list) ? list : [];
487
+ }
488
+
489
+ /**
490
+ * Flatten one 12306 order JSON object into per-ticket snapshot events. Mirrors
491
+ * Kyfw12306ApiClient.parseOrderTickets — one order carries 1..N tickets, each
492
+ * flattened so the vault maps 1:1 to "I rode train G123 on date D". Field names
493
+ * are best-effort across endpoint versions (snake_case + camelCase fallbacks).
494
+ */
495
+ function ticketsFromOrder(order, isCompleted) {
496
+ if (!order || typeof order !== "object") return [];
497
+ const sequenceNo = pickStr(order.sequence_no) || pickStr(order.sequenceNo);
498
+ if (!sequenceNo) return [];
499
+ const orderDateMs = parseYyyymmdd(pickStr(order.order_date) || pickStr(order.orderDate));
500
+ const orderTotalPrice = toNum(order.ticket_total_price || order.ticketTotalPrice);
501
+ const tickets = Array.isArray(order.tickets) ? order.tickets : [];
502
+ const out = [];
503
+ tickets.forEach((t, i) => {
504
+ if (!t || typeof t !== "object") return;
505
+ const passengerName = pickStr(t.passenger_name) || pickStr(t.passengerName);
506
+ if (!passengerName) return;
507
+ const trainNumber = pickStr(t.train_code) || pickStr(t.stationTrainCode);
508
+ if (!trainNumber) return;
509
+ const fromStation =
510
+ pickStr(t.from_station_name) || pickStr(t.from_station_name_page);
511
+ const toStation =
512
+ pickStr(t.to_station_name) || pickStr(t.to_station_name_page);
513
+ if (!fromStation || !toStation) return;
514
+ const idNo = pickStr(t.passenger_id_no) || pickStr(t.passengerIdNo);
515
+ const idLast6 = idNo && idNo.length >= 6 ? idNo.slice(-6) : null;
516
+ out.push({
517
+ kind: KIND_TICKET,
518
+ id: `ticket-${sequenceNo}:${i}`,
519
+ orderSequenceNo: sequenceNo,
520
+ ticketNumber: pickStr(t.ticket_no) || pickStr(t.ticketNo) || null,
521
+ passengerName,
522
+ passengerIdLast6: idLast6 || undefined,
523
+ trainNumber,
524
+ fromStation,
525
+ toStation,
526
+ departureMs: parse12306DateTime(
527
+ pickStr(t.start_train_date_page) || pickStr(t.start_train_date),
528
+ ),
529
+ arrivalMs: parse12306DateTime(pickStr(t.arrive_train_date_page)),
530
+ seatTypeName: pickStr(t.seat_type_name) || pickStr(t.seatTypeName) || null,
531
+ coachNo: pickStr(t.coach_no) || pickStr(t.coachNo) || null,
532
+ seatNo: pickStr(t.seat_no) || pickStr(t.seatNo) || null,
533
+ ticketPrice:
534
+ toNum(t.ticket_price) || toNum(t.ticketPrice) || 0,
535
+ orderDateMs,
536
+ orderTotalPrice,
537
+ isCompleted,
538
+ capturedVia: "cookie-api",
539
+ });
540
+ });
541
+ return out;
542
+ }
543
+
544
+ /** Format epoch-ms as a 12306 `yyyy-MM-dd` query date in Asia/Shanghai. */
545
+ function fmtDateShanghai(ms) {
546
+ const d = new Date(ms + 8 * 3600_000);
547
+ const y = d.getUTCFullYear();
548
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
549
+ const day = String(d.getUTCDate()).padStart(2, "0");
550
+ return `${y}-${m}-${day}`;
551
+ }
552
+
553
+ /**
554
+ * Parse the date shapes 12306 returns into epoch-ms (Asia/Shanghai):
555
+ * "2024-03-20" / "2024-03-20 09:00" / "2024年03月20日 09:00".
556
+ * Returns null on blank/unparseable so capturedAt can fall back.
557
+ */
558
+ function parse12306DateTime(s) {
559
+ if (!s || typeof s !== "string") return null;
560
+ const str = s.trim();
561
+ if (!str) return null;
562
+ if (str.includes("年")) return parseChineseDateTime(str);
563
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/.exec(str);
564
+ if (m) {
565
+ const [, y, mo, d, hh, mm] = m;
566
+ // Shanghai is UTC+8 → subtract 8h from the wall-clock to get UTC ms.
567
+ return Date.UTC(+y, +mo - 1, +d, (hh ? +hh : 0) - 8, mm ? +mm : 0);
568
+ }
569
+ const t = Date.parse(str);
570
+ return Number.isFinite(t) ? t : null;
571
+ }
572
+
573
+ /** Parse a `yyyyMMdd` order date into epoch-ms (Asia/Shanghai). */
574
+ function parseYyyymmdd(s) {
575
+ if (!s || !/^\d{8}$/.test(s)) return null;
576
+ return Date.UTC(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8), -8, 0);
577
+ }
578
+
579
+ function pickStr(v) {
580
+ if (v == null) return null;
581
+ const s = String(v).trim();
582
+ return s.length > 0 ? s : null;
583
+ }
584
+
585
+ function toNum(v) {
586
+ if (v == null) return 0;
587
+ const n = typeof v === "number" ? v : parseFloat(String(v));
588
+ return Number.isFinite(n) ? n : 0;
589
+ }
590
+
591
+ async function defaultFetch(_opts) {
592
+ // Pure-Node has no HTTP layer; the host (Android cc → Kyfw12306ApiClient /
593
+ // desktop hub → Electron WebView net) injects a real fetchFn. Mirrors the
594
+ // shopping adapters' defaultFetch — a missing fetchFn is a wiring bug, not a
595
+ // runtime data condition, so it throws loudly rather than silently emitting 0.
596
+ throw new Error("travel-12306: no fetchFn configured for cookie-api mode");
597
+ }
598
+
330
599
  module.exports = {
331
600
  Train12306Adapter,
332
601
  parseRecords,
602
+ ticketsFromOrder,
603
+ extractCompletedOrders,
604
+ extractPendingOrders,
605
+ parse12306DateTime,
606
+ parseYyyymmdd,
333
607
  NAME,
334
608
  VERSION,
335
609
  SNAPSHOT_SCHEMA_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chainlesschain/personal-data-hub",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
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",