@chainlesschain/personal-data-hub 0.2.4 → 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.
- package/__tests__/adapters/browser-history-chrome.test.js +377 -0
- package/__tests__/adapters/browser-history-edge.test.js +159 -0
- package/__tests__/adapters/git-activity.test.js +216 -0
- package/__tests__/adapters/local-files.test.js +264 -0
- package/__tests__/adapters/shell-history.test.js +180 -0
- package/__tests__/adapters/system-data-android.test.js +104 -3
- package/__tests__/adapters/vscode.test.js +299 -0
- package/__tests__/adapters/win-recent.test.js +192 -0
- package/__tests__/analysis.test.js +840 -1
- package/__tests__/categories.test.js +92 -0
- package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +146 -0
- package/__tests__/entity-resolver-vault.test.js +5 -2
- package/__tests__/integration/local-data-adapters-pipeline.test.js +373 -0
- package/__tests__/query-parser.test.js +66 -0
- package/__tests__/registry.test.js +114 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +24 -1
- package/__tests__/sidecar-supervisor.test.js +9 -1
- package/__tests__/social-kuaishou-snapshot.test.js +55 -2
- package/__tests__/social-toutiao-snapshot.test.js +54 -2
- package/__tests__/travel-adapters.test.js +97 -5
- package/__tests__/vault-search-helpers.test.js +104 -0
- package/__tests__/vault-search.test.js +423 -0
- package/__tests__/vault.test.js +77 -3
- package/lib/adapters/browser-history-chrome/adapter.js +247 -0
- package/lib/adapters/browser-history-chrome/bookmarks-reader.js +79 -0
- package/lib/adapters/browser-history-chrome/chrome-db-reader.js +223 -0
- package/lib/adapters/browser-history-chrome/index.js +23 -0
- package/lib/adapters/browser-history-edge/adapter.js +34 -0
- package/lib/adapters/browser-history-edge/index.js +13 -0
- package/lib/adapters/git-activity/adapter.js +155 -0
- package/lib/adapters/git-activity/git-reader.js +125 -0
- package/lib/adapters/git-activity/index.js +17 -0
- package/lib/adapters/local-files/adapter.js +149 -0
- package/lib/adapters/local-files/file-walker.js +125 -0
- package/lib/adapters/local-files/index.js +18 -0
- package/lib/adapters/shell-history/adapter.js +137 -0
- package/lib/adapters/shell-history/index.js +17 -0
- package/lib/adapters/shell-history/shell-reader.js +100 -0
- package/lib/adapters/social-kuaishou/index.js +57 -1
- package/lib/adapters/social-toutiao/index.js +59 -1
- package/lib/adapters/system-data-android/adapter.js +220 -3
- package/lib/adapters/travel-12306/index.js +215 -29
- package/lib/adapters/vscode/adapter.js +285 -0
- package/lib/adapters/vscode/index.js +18 -0
- package/lib/adapters/vscode/vscode-reader.js +191 -0
- package/lib/adapters/win-recent/adapter.js +150 -0
- package/lib/adapters/win-recent/index.js +16 -0
- package/lib/adapters/win-recent/win-recent-reader.js +72 -0
- package/lib/analysis.js +227 -9
- package/lib/categories.js +101 -0
- package/lib/index.js +61 -0
- package/lib/migrations.js +146 -0
- package/lib/query-parser.js +74 -0
- package/lib/registry.js +162 -0
- package/lib/vault.js +363 -2
- package/package.json +2 -1
- package/scripts/run-native-tests-sandbox.sh +53 -0
|
@@ -23,11 +23,18 @@ const {
|
|
|
23
23
|
ENTITY_TYPES,
|
|
24
24
|
PERSON_SUBTYPES,
|
|
25
25
|
ITEM_SUBTYPES,
|
|
26
|
+
EVENT_SUBTYPES,
|
|
26
27
|
CAPTURED_BY,
|
|
27
28
|
} = require("../../constants");
|
|
28
29
|
|
|
29
30
|
const NAME = "system-data-android";
|
|
30
|
-
|
|
31
|
+
// v0.3.0 (2026-05-24): added kind="media-file" via bridge mode
|
|
32
|
+
// (host-adb-bridge media.list across 5 /sdcard categories). Metadata
|
|
33
|
+
// only — path/size/mtime/ext, no file content.
|
|
34
|
+
// v0.2.0 (2026-05-24): added kind="sms" + kind="call" via bridge mode.
|
|
35
|
+
// Snapshot mode still v1 schema — sms/calls/media only land via
|
|
36
|
+
// bridge path until Android snapshot writer is updated to include them.
|
|
37
|
+
const VERSION = "0.3.0";
|
|
31
38
|
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
32
39
|
|
|
33
40
|
// Stable per-source originalId — registry.putRawEvent rejects null originalId
|
|
@@ -48,6 +55,25 @@ function appOriginalId(a) {
|
|
|
48
55
|
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
49
56
|
return `android-app:${k}`;
|
|
50
57
|
}
|
|
58
|
+
function smsOriginalId(s) {
|
|
59
|
+
// Stable across re-syncs: use SMS _id from the system content provider.
|
|
60
|
+
const k = (s && s.id != null && String(s.id)) ||
|
|
61
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
62
|
+
return `android-sms:${k}`;
|
|
63
|
+
}
|
|
64
|
+
function callOriginalId(c) {
|
|
65
|
+
// Stable across re-syncs: use call_log _id from the system content provider.
|
|
66
|
+
const k = (c && c.id != null && String(c.id)) ||
|
|
67
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
68
|
+
return `android-call:${k}`;
|
|
69
|
+
}
|
|
70
|
+
function mediaOriginalId(m) {
|
|
71
|
+
// Full filesystem path is stable as long as the file isn't moved/renamed.
|
|
72
|
+
// Path is unique within the device.
|
|
73
|
+
const k = (m && typeof m.path === "string" && m.path) ||
|
|
74
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
75
|
+
return `android-media:${k}`;
|
|
76
|
+
}
|
|
51
77
|
|
|
52
78
|
class SystemDataAndroidAdapter {
|
|
53
79
|
constructor(opts = {}) {
|
|
@@ -56,6 +82,9 @@ class SystemDataAndroidAdapter {
|
|
|
56
82
|
this.capabilities = [
|
|
57
83
|
"sync:android-content-provider",
|
|
58
84
|
"sync:android-package-manager",
|
|
85
|
+
"sync:android-sms",
|
|
86
|
+
"sync:android-call-log",
|
|
87
|
+
"sync:android-media-files",
|
|
59
88
|
];
|
|
60
89
|
this.extractMode = "device-pull";
|
|
61
90
|
this.rateLimits = { perDay: 24 };
|
|
@@ -63,10 +92,20 @@ class SystemDataAndroidAdapter {
|
|
|
63
92
|
fields: [
|
|
64
93
|
"contacts:displayName,phones,emails,starred,organization,photoUri",
|
|
65
94
|
"installed_apps:packageName,label,versionName,versionCode,firstInstallTime,lastUpdateTime,isSystem",
|
|
95
|
+
"sms:id,address,body,date,dateSent,type,threadId,read,subject",
|
|
96
|
+
"callLog:id,number,name,duration,date,type,geocoded",
|
|
97
|
+
// Media is metadata-only — file content never leaves the device.
|
|
98
|
+
"media:path,size,mtimeMs,ext,category(photos|pictures|videos|downloads|documents)",
|
|
66
99
|
],
|
|
67
|
-
sensitivity: "
|
|
100
|
+
sensitivity: "high",
|
|
68
101
|
legalGate: false,
|
|
69
|
-
defaultInclude: {
|
|
102
|
+
defaultInclude: {
|
|
103
|
+
contacts: true,
|
|
104
|
+
apps: true,
|
|
105
|
+
sms: true,
|
|
106
|
+
calls: true,
|
|
107
|
+
media: { photos: true, pictures: true, videos: true, downloads: true, documents: true },
|
|
108
|
+
},
|
|
70
109
|
};
|
|
71
110
|
|
|
72
111
|
// _deps for test injection — mirrors the pattern in cli-dev.md so test
|
|
@@ -191,6 +230,68 @@ class SystemDataAndroidAdapter {
|
|
|
191
230
|
emitted += 1;
|
|
192
231
|
}
|
|
193
232
|
}
|
|
233
|
+
|
|
234
|
+
const includeSms = opts.include?.sms !== false;
|
|
235
|
+
if (includeSms) {
|
|
236
|
+
const res = await bridge.invoke("sms.query", {
|
|
237
|
+
since: Number.isInteger(opts.since) ? opts.since : undefined,
|
|
238
|
+
});
|
|
239
|
+
const arr = Array.isArray(res) ? res : Array.isArray(res?.sms) ? res.sms : [];
|
|
240
|
+
for (const s of arr) {
|
|
241
|
+
if (emitted >= limit) return;
|
|
242
|
+
yield {
|
|
243
|
+
kind: "sms",
|
|
244
|
+
originalId: smsOriginalId(s),
|
|
245
|
+
capturedAt,
|
|
246
|
+
payload: s,
|
|
247
|
+
};
|
|
248
|
+
emitted += 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const includeCalls = opts.include?.calls !== false;
|
|
253
|
+
if (includeCalls) {
|
|
254
|
+
const res = await bridge.invoke("call.query", {
|
|
255
|
+
since: Number.isInteger(opts.since) ? opts.since : undefined,
|
|
256
|
+
});
|
|
257
|
+
const arr = Array.isArray(res) ? res : Array.isArray(res?.calls) ? res.calls : [];
|
|
258
|
+
for (const c of arr) {
|
|
259
|
+
if (emitted >= limit) return;
|
|
260
|
+
yield {
|
|
261
|
+
kind: "call",
|
|
262
|
+
originalId: callOriginalId(c),
|
|
263
|
+
capturedAt,
|
|
264
|
+
payload: c,
|
|
265
|
+
};
|
|
266
|
+
emitted += 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Media files — metadata only (path, size, mtime, ext). Never reads
|
|
271
|
+
// file content off the device. UI uses per-category include keys so
|
|
272
|
+
// a privacy-conscious user can keep photos but skip downloads, etc.
|
|
273
|
+
const MEDIA_CATEGORIES = ["photos", "pictures", "videos", "downloads", "documents"];
|
|
274
|
+
for (const cat of MEDIA_CATEGORIES) {
|
|
275
|
+
// Per-category include key: include.media.photos, include.media.videos, ...
|
|
276
|
+
// Top-level `include.media === false` disables ALL media in one switch.
|
|
277
|
+
if (opts.include?.media === false) break;
|
|
278
|
+
if (opts.include?.media?.[cat] === false) continue;
|
|
279
|
+
const res = await bridge.invoke("media.list", {
|
|
280
|
+
category: cat,
|
|
281
|
+
since: Number.isInteger(opts.since) ? opts.since : undefined,
|
|
282
|
+
});
|
|
283
|
+
const arr = Array.isArray(res) ? res : Array.isArray(res?.files) ? res.files : [];
|
|
284
|
+
for (const f of arr) {
|
|
285
|
+
if (emitted >= limit) return;
|
|
286
|
+
yield {
|
|
287
|
+
kind: "media-file",
|
|
288
|
+
originalId: mediaOriginalId(f),
|
|
289
|
+
capturedAt,
|
|
290
|
+
payload: f,
|
|
291
|
+
};
|
|
292
|
+
emitted += 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
194
295
|
}
|
|
195
296
|
|
|
196
297
|
async *_syncViaSnapshot(opts) {
|
|
@@ -336,6 +437,122 @@ class SystemDataAndroidAdapter {
|
|
|
336
437
|
};
|
|
337
438
|
}
|
|
338
439
|
|
|
440
|
+
if (raw.kind === "sms") {
|
|
441
|
+
const p = raw.payload || {};
|
|
442
|
+
// SMS type from Android SDK Telephony.Sms (Inbox.MESSAGE_TYPE_*):
|
|
443
|
+
// 1 INBOX, 2 SENT, 3 DRAFT, 4 OUTBOX, 5 FAILED, 6 QUEUED
|
|
444
|
+
const direction = p.type === 2 || p.type === 4 ? "out" : "in";
|
|
445
|
+
const eventId = `event-android-sms-${p.id || raw.capturedAt}`;
|
|
446
|
+
const occurredAt = Number.isInteger(p.date) ? p.date : raw.capturedAt;
|
|
447
|
+
const bodyText = typeof p.body === "string" ? p.body : "";
|
|
448
|
+
const event = {
|
|
449
|
+
id: eventId,
|
|
450
|
+
type: ENTITY_TYPES.EVENT,
|
|
451
|
+
subtype: EVENT_SUBTYPES.MESSAGE,
|
|
452
|
+
occurredAt,
|
|
453
|
+
ingestedAt,
|
|
454
|
+
source: source(`android-sms:${p.id || raw.capturedAt}`),
|
|
455
|
+
actor: direction === "in" ? p.address : "self",
|
|
456
|
+
// Participants on the OTHER side of the message.
|
|
457
|
+
participants: p.address ? [p.address] : [],
|
|
458
|
+
// Validator (lib/schemas.js validateEvent) requires `content` to be
|
|
459
|
+
// a plain object — title/text/etc go INSIDE this object, not on the
|
|
460
|
+
// event root.
|
|
461
|
+
content: {
|
|
462
|
+
title: bodyText.length > 0
|
|
463
|
+
? (bodyText.length > 80 ? bodyText.substring(0, 80) + "…" : bodyText)
|
|
464
|
+
: "(空短信)",
|
|
465
|
+
text: bodyText,
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
const extra = { direction, threadId: p.threadId };
|
|
469
|
+
if (typeof p.dateSent === "number") extra.dateSent = p.dateSent;
|
|
470
|
+
if (typeof p.read === "boolean") extra.read = p.read;
|
|
471
|
+
if (typeof p.subject === "string" && p.subject.length > 0) extra.subject = p.subject;
|
|
472
|
+
if (Number.isInteger(p.type)) extra.smsType = p.type;
|
|
473
|
+
event.extra = extra;
|
|
474
|
+
|
|
475
|
+
return { events: [event], persons: [], places: [], items: [], topics: [] };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (raw.kind === "call") {
|
|
479
|
+
const p = raw.payload || {};
|
|
480
|
+
// Call type from Android SDK CallLog.Calls.TYPE:
|
|
481
|
+
// 1 INCOMING, 2 OUTGOING, 3 MISSED, 4 VOICEMAIL, 5 REJECTED, 6 BLOCKED
|
|
482
|
+
const direction = p.type === 2 ? "out" : "in";
|
|
483
|
+
const eventId = `event-android-call-${p.id || raw.capturedAt}`;
|
|
484
|
+
const occurredAt = Number.isInteger(p.date) ? p.date : raw.capturedAt;
|
|
485
|
+
const callTypeName =
|
|
486
|
+
p.type === 1 ? "incoming" :
|
|
487
|
+
p.type === 2 ? "outgoing" :
|
|
488
|
+
p.type === 3 ? "missed" :
|
|
489
|
+
p.type === 4 ? "voicemail" :
|
|
490
|
+
p.type === 5 ? "rejected" :
|
|
491
|
+
p.type === 6 ? "blocked" : "unknown";
|
|
492
|
+
const titleName =
|
|
493
|
+
(typeof p.name === "string" && p.name.trim().length > 0) ? p.name.trim() : (p.number || "未知号码");
|
|
494
|
+
const title =
|
|
495
|
+
`${callTypeName === "missed" ? "未接 " : ""}${callTypeName === "outgoing" ? "拨打 " : ""}${callTypeName === "incoming" ? "来电 " : ""}${titleName}`;
|
|
496
|
+
const event = {
|
|
497
|
+
id: eventId,
|
|
498
|
+
type: ENTITY_TYPES.EVENT,
|
|
499
|
+
subtype: EVENT_SUBTYPES.CALL,
|
|
500
|
+
occurredAt,
|
|
501
|
+
ingestedAt,
|
|
502
|
+
source: source(`android-call:${p.id || raw.capturedAt}`),
|
|
503
|
+
actor: direction === "in" ? p.number : "self",
|
|
504
|
+
participants: p.number ? [p.number] : [],
|
|
505
|
+
// Schema-required `content` object — title goes here, not on root.
|
|
506
|
+
content: { title },
|
|
507
|
+
};
|
|
508
|
+
if (Number.isInteger(p.duration) && p.duration > 0) {
|
|
509
|
+
event.durationMs = p.duration * 1000;
|
|
510
|
+
}
|
|
511
|
+
const extra = { direction, callType: callTypeName };
|
|
512
|
+
if (Number.isInteger(p.type)) extra.androidCallType = p.type;
|
|
513
|
+
if (typeof p.geocoded === "string" && p.geocoded.length > 0) extra.geocoded = p.geocoded;
|
|
514
|
+
if (typeof p.name === "string" && p.name.length > 0) extra.name = p.name;
|
|
515
|
+
event.extra = extra;
|
|
516
|
+
|
|
517
|
+
return { events: [event], persons: [], places: [], items: [], topics: [] };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (raw.kind === "media-file") {
|
|
521
|
+
const p = raw.payload || {};
|
|
522
|
+
const path = typeof p.path === "string" ? p.path : "";
|
|
523
|
+
const fileName = path.includes("/") ? path.substring(path.lastIndexOf("/") + 1) : path;
|
|
524
|
+
// Category → item subtype + category string
|
|
525
|
+
let subtype = ITEM_SUBTYPES.OTHER;
|
|
526
|
+
let category = "media";
|
|
527
|
+
if (p.category === "photos" || p.category === "pictures" || p.category === "videos") {
|
|
528
|
+
subtype = ITEM_SUBTYPES.MEDIA;
|
|
529
|
+
category = p.category;
|
|
530
|
+
} else if (p.category === "documents") {
|
|
531
|
+
subtype = ITEM_SUBTYPES.DOCUMENT;
|
|
532
|
+
category = "documents";
|
|
533
|
+
} else if (p.category === "downloads") {
|
|
534
|
+
subtype = ITEM_SUBTYPES.OTHER;
|
|
535
|
+
category = "downloads";
|
|
536
|
+
}
|
|
537
|
+
const item = {
|
|
538
|
+
id: `item-android-media-${path}`,
|
|
539
|
+
type: ENTITY_TYPES.ITEM,
|
|
540
|
+
subtype,
|
|
541
|
+
name: fileName || "(无名)",
|
|
542
|
+
category,
|
|
543
|
+
ingestedAt,
|
|
544
|
+
source: source(`android-media:${path}`),
|
|
545
|
+
extra: {
|
|
546
|
+
path,
|
|
547
|
+
size: Number.isInteger(p.size) ? p.size : null,
|
|
548
|
+
mtimeMs: Number.isInteger(p.mtimeMs) ? p.mtimeMs : null,
|
|
549
|
+
ext: typeof p.ext === "string" ? p.ext : null,
|
|
550
|
+
androidCategory: p.category,
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
return { events: [], persons: [], places: [], items: [item], topics: [] };
|
|
554
|
+
}
|
|
555
|
+
|
|
339
556
|
throw new Error(`system-data-android.normalize: unknown raw.kind=${raw.kind}`);
|
|
340
557
|
}
|
|
341
558
|
}
|
|
@@ -1,18 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* §2.5 v0.2 — 12306 (China Railway) ticket adapter, dual-mode.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
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
|
-
|
|
29
|
-
|
|
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 = [
|
|
37
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
59
|
-
|
|
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(`
|
|
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
|
|
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,
|
|
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 = {
|
|
330
|
+
module.exports = {
|
|
331
|
+
Train12306Adapter,
|
|
332
|
+
parseRecords,
|
|
333
|
+
NAME,
|
|
334
|
+
VERSION,
|
|
335
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
336
|
+
VALID_SNAPSHOT_KINDS,
|
|
337
|
+
};
|