@chainlesschain/personal-data-hub 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
- package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
- package/__tests__/adapters/ai-chat-history.test.js +396 -0
- package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
- package/__tests__/adapters/ai-chat-vendors.test.js +874 -0
- package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
- package/__tests__/adapters/email-adapter.test.js +138 -1
- package/__tests__/adapters/email-classifier.test.js +347 -0
- package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
- package/__tests__/adapters/email-retry-progress.test.js +294 -0
- package/__tests__/adapters/email-templates.test.js +699 -0
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
- package/__tests__/adapters/system-data-adapter.test.js +440 -0
- package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
- package/__tests__/adapters/system-data-android.test.js +387 -0
- package/__tests__/adapters/system-data-disclosure.test.js +153 -0
- package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
- package/__tests__/adapters/wechat-env-probe.test.js +162 -0
- package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
- package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
- package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
- package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
- package/__tests__/analysis-skills.test.js +556 -0
- package/__tests__/analysis.test.js +329 -1
- package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
- package/__tests__/e2e/full-user-journey.test.js +188 -0
- package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
- package/__tests__/entity-resolver-stages.test.js +411 -0
- package/__tests__/entity-resolver-vault.test.js +246 -0
- package/__tests__/entity-resolver.test.js +526 -0
- package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
- package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
- package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
- package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
- package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
- package/__tests__/longtail-adapters.test.js +217 -0
- package/__tests__/mobile-extractor.test.js +288 -0
- package/__tests__/registry.test.js +4 -2
- package/__tests__/shopping-adapters.test.js +296 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
- package/__tests__/sidecar-supervisor.test.js +120 -0
- package/__tests__/social-adapters.test.js +206 -0
- package/__tests__/travel-adapters.test.js +325 -0
- package/__tests__/vault.test.js +3 -3
- package/__tests__/wechat-adapter.test.js +476 -0
- package/__tests__/whatsapp-adapter.test.js +135 -0
- package/lib/adapter-spec.js +12 -0
- package/lib/adapters/_python-sidecar-base.js +207 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +374 -0
- package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
- package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
- package/lib/adapters/ai-chat-history/health-checker.js +210 -0
- package/lib/adapters/ai-chat-history/http-client.js +211 -0
- package/lib/adapters/ai-chat-history/index.js +28 -0
- package/lib/adapters/ai-chat-history/schema-map.js +258 -0
- package/lib/adapters/ai-chat-history/vendor-spec.js +86 -0
- package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
- package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
- package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
- package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
- package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
- package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
- package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
- package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
- package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
- package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +311 -0
- package/lib/adapters/alipay-bill/counterparty.js +129 -0
- package/lib/adapters/alipay-bill/csv-parser.js +217 -0
- package/lib/adapters/alipay-bill/index.js +41 -0
- package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
- package/lib/adapters/email-imap/classifier.js +495 -0
- package/lib/adapters/email-imap/email-adapter.js +419 -8
- package/lib/adapters/email-imap/index.js +42 -0
- package/lib/adapters/email-imap/pdf-extractor.js +192 -0
- package/lib/adapters/email-imap/templates/bill.js +232 -0
- package/lib/adapters/email-imap/templates/government.js +120 -0
- package/lib/adapters/email-imap/templates/index.js +78 -0
- package/lib/adapters/email-imap/templates/order.js +186 -0
- package/lib/adapters/email-imap/templates/other.js +114 -0
- package/lib/adapters/email-imap/templates/register.js +113 -0
- package/lib/adapters/email-imap/templates/travel.js +157 -0
- package/lib/adapters/email-imap/templates/utils.js +275 -0
- package/lib/adapters/email-imap/transactions.js +234 -0
- package/lib/adapters/messaging-qq/index.js +158 -0
- package/lib/adapters/messaging-telegram/index.js +142 -0
- package/lib/adapters/messaging-whatsapp/index.js +189 -0
- package/lib/adapters/shopping-base/index.js +208 -0
- package/lib/adapters/shopping-jd/index.js +150 -0
- package/lib/adapters/shopping-meituan/index.js +154 -0
- package/lib/adapters/shopping-taobao/index.js +176 -0
- package/lib/adapters/social-bilibili/index.js +171 -0
- package/lib/adapters/social-douyin/index.js +116 -0
- package/lib/adapters/social-kuaishou/index.js +237 -0
- package/lib/adapters/social-toutiao/index.js +236 -0
- package/lib/adapters/social-weibo/index.js +164 -0
- package/lib/adapters/social-xiaohongshu/index.js +96 -0
- package/lib/adapters/system-data/disclosure.js +166 -0
- package/lib/adapters/system-data/index.js +34 -0
- package/lib/adapters/system-data/system-data-adapter.js +344 -0
- package/lib/adapters/system-data-android/adapter.js +348 -0
- package/lib/adapters/system-data-android/index.js +76 -0
- package/lib/adapters/travel-12306/index.js +151 -0
- package/lib/adapters/travel-amap/index.js +164 -0
- package/lib/adapters/travel-baidu-map/index.js +162 -0
- package/lib/adapters/travel-base/index.js +240 -0
- package/lib/adapters/travel-ctrip/index.js +151 -0
- package/lib/adapters/wechat/bootstrap.js +146 -0
- package/lib/adapters/wechat/content-parser.js +326 -0
- package/lib/adapters/wechat/db-reader.js +209 -0
- package/lib/adapters/wechat/env-probe.js +218 -0
- package/lib/adapters/wechat/frida-agent/loader.js +67 -0
- package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
- package/lib/adapters/wechat/index.js +37 -0
- package/lib/adapters/wechat/key-extractor.js +158 -0
- package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
- package/lib/adapters/wechat/key-providers/index.js +22 -0
- package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
- package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
- package/lib/adapters/wechat/normalize.js +220 -0
- package/lib/adapters/wechat/wechat-adapter.js +205 -0
- package/lib/analysis-skills/base.js +113 -0
- package/lib/analysis-skills/footprint.js +167 -0
- package/lib/analysis-skills/index.js +58 -0
- package/lib/analysis-skills/interests.js +161 -0
- package/lib/analysis-skills/relations.js +226 -0
- package/lib/analysis-skills/spending.js +219 -0
- package/lib/analysis-skills/timeline.js +167 -0
- package/lib/analysis.js +191 -2
- package/lib/entity-resolver/embedding-stage.js +198 -0
- package/lib/entity-resolver/entity-resolver.js +384 -0
- package/lib/entity-resolver/index.js +42 -0
- package/lib/entity-resolver/llm-stage.js +191 -0
- package/lib/entity-resolver/rule-stage.js +208 -0
- package/lib/entity-resolver/worker.js +149 -0
- package/lib/index.js +131 -0
- package/lib/migrations.js +73 -0
- package/lib/mobile-extractor/android.js +193 -0
- package/lib/mobile-extractor/index.js +9 -0
- package/lib/mobile-extractor/ios.js +223 -0
- package/lib/prompt-builder.js +11 -1
- package/lib/query-parser.js +7 -1
- package/lib/registry.js +42 -0
- package/lib/sidecar/index.js +15 -0
- package/lib/sidecar/supervisor.js +359 -0
- package/lib/vault.js +343 -0
- package/package.json +36 -3
- package/scripts/_make-fixture-all.js +126 -0
- package/scripts/_make-fixture-contacts.js +84 -0
- package/scripts/evaluate-entity-resolver.js +213 -0
- package/scripts/smoke-phase-5-5.js +196 -0
- package/scripts/smoke-phase-5-7.js +181 -0
- package/scripts/smoke-system-data-contacts.js +309 -0
- package/scripts/smoke-system-data.js +312 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// SystemDataAndroidAdapter — Plan A v0.1 (4-day slice, 2026-05-21).
|
|
4
|
+
//
|
|
5
|
+
// Reads a UI-produced JSON snapshot of the Android user's own ContentResolver
|
|
6
|
+
// (contacts) and PackageManager (installed apps) and normalises it into PDH
|
|
7
|
+
// entities. The snapshot is produced inside the Android app process (which
|
|
8
|
+
// owns the JVM and can call ContentResolver / PackageManager directly); the
|
|
9
|
+
// cc CLI subprocess then ingests that snapshot through this adapter.
|
|
10
|
+
//
|
|
11
|
+
// Why not extend PythonSidecarAdapter like the desktop `system-data`? Termux
|
|
12
|
+
// does not ship a forensics-bridge sidecar and the data we read here is the
|
|
13
|
+
// user's OWN device — no SQLite parsing or ADB pull is needed; ContentResolver
|
|
14
|
+
// returns clean records. Keep it pure JS, zero sidecar.
|
|
15
|
+
//
|
|
16
|
+
// Out of scope for v0.1 (deferred):
|
|
17
|
+
// - SMS / call_log (need READ_SMS / READ_CALL_LOG and stricter legal gates)
|
|
18
|
+
// - Wifi (no ContentResolver, would need SystemConfiguration JNI)
|
|
19
|
+
// - cc-driven pull (would need a BoundService + Unix socket; v0.1 is UI-pushed)
|
|
20
|
+
|
|
21
|
+
const { newId } = require("../../ids");
|
|
22
|
+
const {
|
|
23
|
+
ENTITY_TYPES,
|
|
24
|
+
PERSON_SUBTYPES,
|
|
25
|
+
ITEM_SUBTYPES,
|
|
26
|
+
CAPTURED_BY,
|
|
27
|
+
} = require("../../constants");
|
|
28
|
+
|
|
29
|
+
const NAME = "system-data-android";
|
|
30
|
+
const VERSION = "0.1.0";
|
|
31
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
// Stable per-source originalId — registry.putRawEvent rejects null originalId
|
|
34
|
+
// with a NOT NULL constraint, surfacing as invalidCount += rawCount on the
|
|
35
|
+
// SyncReport (real-device repro 2026-05-21: 1305 of 1305 raws "invalid"
|
|
36
|
+
// despite all entities being written). Re-deriving the same key on each
|
|
37
|
+
// sync also lets the raw_events store dedup naturally.
|
|
38
|
+
function contactOriginalId(c) {
|
|
39
|
+
const k =
|
|
40
|
+
(c && typeof c.lookupKey === "string" && c.lookupKey.length > 0 && c.lookupKey) ||
|
|
41
|
+
(c && typeof c.displayName === "string" && c.displayName) ||
|
|
42
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
43
|
+
return `android-contact:${k}`;
|
|
44
|
+
}
|
|
45
|
+
function appOriginalId(a) {
|
|
46
|
+
const k =
|
|
47
|
+
(a && typeof a.packageName === "string" && a.packageName) ||
|
|
48
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
49
|
+
return `android-app:${k}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class SystemDataAndroidAdapter {
|
|
53
|
+
constructor(opts = {}) {
|
|
54
|
+
this.name = NAME;
|
|
55
|
+
this.version = VERSION;
|
|
56
|
+
this.capabilities = [
|
|
57
|
+
"sync:android-content-provider",
|
|
58
|
+
"sync:android-package-manager",
|
|
59
|
+
];
|
|
60
|
+
this.extractMode = "device-pull";
|
|
61
|
+
this.rateLimits = { perDay: 24 };
|
|
62
|
+
this.dataDisclosure = {
|
|
63
|
+
fields: [
|
|
64
|
+
"contacts:displayName,phones,emails,starred,organization,photoUri",
|
|
65
|
+
"installed_apps:packageName,label,versionName,versionCode,firstInstallTime,lastUpdateTime,isSystem",
|
|
66
|
+
],
|
|
67
|
+
sensitivity: "medium",
|
|
68
|
+
legalGate: false,
|
|
69
|
+
defaultInclude: { contacts: true, apps: true },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// _deps for test injection — mirrors the pattern in cli-dev.md so test
|
|
73
|
+
// harness can swap fs without resorting to vi.mock("fs") which doesn't
|
|
74
|
+
// intercept require() under inlined CJS. `bridgeProvider` is lazy because
|
|
75
|
+
// the cc-android-bridge module sits in `packages/cli` and is not always
|
|
76
|
+
// available in environments that load this adapter directly (e.g. desktop
|
|
77
|
+
// CLI building a snapshot ingest pipeline). Resolves to null when bridge
|
|
78
|
+
// is unreachable, in which case sync() falls back to inputPath mode.
|
|
79
|
+
this._deps = {
|
|
80
|
+
fs: require("node:fs"),
|
|
81
|
+
bridgeProvider: () => null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── PersonalDataAdapter contract ──────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async authenticate(ctx = {}) {
|
|
88
|
+
if (!ctx || typeof ctx.inputPath !== "string" || ctx.inputPath.length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: "INPUT_PATH_REQUIRED",
|
|
92
|
+
message:
|
|
93
|
+
"system-data-android requires opts.inputPath pointing to a snapshot JSON written by the Android app",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: "INPUT_PATH_UNREADABLE",
|
|
102
|
+
message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, mode: "snapshot-file" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async healthCheck() {
|
|
109
|
+
// The adapter itself is stateless — health is "always reachable" so long
|
|
110
|
+
// as a snapshot can be re-produced by the UI. Real device-status (whether
|
|
111
|
+
// the runtime permission was granted) lives in the Android-side UI.
|
|
112
|
+
return { ok: true, lastChecked: Date.now() };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async *sync(opts = {}) {
|
|
116
|
+
// Two ingestion modes (mutually exclusive — pick whichever fits the host):
|
|
117
|
+
// 1. snapshot mode: opts.inputPath points to JSON the Android UI wrote
|
|
118
|
+
// (works on any host that can read the file — desktop or device).
|
|
119
|
+
// 2. bridge mode: opts.useBridge === true, _deps.bridgeProvider() returns
|
|
120
|
+
// a live cc-android-bridge. Used inside in-APK cc when A6/A7 lands.
|
|
121
|
+
// If neither inputPath nor useBridge is set, bridge auto-engages when
|
|
122
|
+
// available (which only happens on Android with the JNI binding loaded,
|
|
123
|
+
// OR under CC_ANDROID_BRIDGE_OVERRIDE=1 in tests).
|
|
124
|
+
const wantBridge = opts.useBridge === true || (!opts.inputPath && this._bridgeAvailable());
|
|
125
|
+
if (wantBridge) {
|
|
126
|
+
yield* this._syncViaBridge(opts);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!opts || typeof opts.inputPath !== "string") {
|
|
130
|
+
throw new Error(
|
|
131
|
+
"system-data-android.sync: needs opts.inputPath (snapshot mode) OR opts.useBridge=true (in-APK Android cc with cc-android-bridge.node loaded)"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
yield* this._syncViaSnapshot(opts);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_bridgeAvailable() {
|
|
138
|
+
try {
|
|
139
|
+
const b = this._deps.bridgeProvider();
|
|
140
|
+
if (!b || typeof b.caps !== "function") return false;
|
|
141
|
+
const c = b.caps();
|
|
142
|
+
return c && c.available === true;
|
|
143
|
+
} catch (_e) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async *_syncViaBridge(opts) {
|
|
149
|
+
const bridge = this._deps.bridgeProvider();
|
|
150
|
+
if (!bridge || typeof bridge.invoke !== "function") {
|
|
151
|
+
throw new Error(
|
|
152
|
+
"system-data-android.sync: useBridge=true but cc-android-bridge is not loaded (run inside in-APK cc, or set CC_ANDROID_BRIDGE_OVERRIDE=1 for tests)"
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const includeContacts = opts.include?.contacts !== false;
|
|
156
|
+
const includeApps = opts.include?.apps !== false;
|
|
157
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
158
|
+
const capturedAt = Date.now();
|
|
159
|
+
let emitted = 0;
|
|
160
|
+
|
|
161
|
+
if (includeContacts) {
|
|
162
|
+
const res = await bridge.invoke("contacts.query", {
|
|
163
|
+
since: Number.isInteger(opts.since) ? opts.since : undefined,
|
|
164
|
+
});
|
|
165
|
+
const arr = Array.isArray(res) ? res : Array.isArray(res?.contacts) ? res.contacts : [];
|
|
166
|
+
for (const c of arr) {
|
|
167
|
+
if (emitted >= limit) return;
|
|
168
|
+
// originalId required by registry.putRawEvent (NOT NULL column); use
|
|
169
|
+
// the stable Android lookupKey when present, else displayName.
|
|
170
|
+
yield {
|
|
171
|
+
kind: "contact",
|
|
172
|
+
originalId: contactOriginalId(c),
|
|
173
|
+
capturedAt,
|
|
174
|
+
payload: c,
|
|
175
|
+
};
|
|
176
|
+
emitted += 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (includeApps) {
|
|
181
|
+
const res = await bridge.invoke("app.list", { includeSystem: false });
|
|
182
|
+
const arr = Array.isArray(res) ? res : Array.isArray(res?.apps) ? res.apps : [];
|
|
183
|
+
for (const a of arr) {
|
|
184
|
+
if (emitted >= limit) return;
|
|
185
|
+
yield {
|
|
186
|
+
kind: "app",
|
|
187
|
+
originalId: appOriginalId(a),
|
|
188
|
+
capturedAt,
|
|
189
|
+
payload: a,
|
|
190
|
+
};
|
|
191
|
+
emitted += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async *_syncViaSnapshot(opts) {
|
|
197
|
+
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
198
|
+
const snapshot = JSON.parse(raw);
|
|
199
|
+
if (
|
|
200
|
+
!snapshot ||
|
|
201
|
+
typeof snapshot !== "object" ||
|
|
202
|
+
snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`system-data-android.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const capturedAt =
|
|
209
|
+
Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
|
|
210
|
+
? Math.floor(snapshot.snapshottedAt)
|
|
211
|
+
: Date.now();
|
|
212
|
+
|
|
213
|
+
const includeContacts = opts.include?.contacts !== false;
|
|
214
|
+
const includeApps = opts.include?.apps !== false;
|
|
215
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
216
|
+
let emitted = 0;
|
|
217
|
+
|
|
218
|
+
if (includeContacts && Array.isArray(snapshot.contacts)) {
|
|
219
|
+
for (const c of snapshot.contacts) {
|
|
220
|
+
if (emitted >= limit) return;
|
|
221
|
+
yield {
|
|
222
|
+
kind: "contact",
|
|
223
|
+
originalId: contactOriginalId(c),
|
|
224
|
+
capturedAt,
|
|
225
|
+
payload: c,
|
|
226
|
+
};
|
|
227
|
+
emitted += 1;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (includeApps && Array.isArray(snapshot.apps)) {
|
|
232
|
+
for (const a of snapshot.apps) {
|
|
233
|
+
if (emitted >= limit) return;
|
|
234
|
+
yield {
|
|
235
|
+
kind: "app",
|
|
236
|
+
originalId: appOriginalId(a),
|
|
237
|
+
capturedAt,
|
|
238
|
+
payload: a,
|
|
239
|
+
};
|
|
240
|
+
emitted += 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
normalize(raw) {
|
|
246
|
+
const ingestedAt = Date.now();
|
|
247
|
+
const source = (originalId) => ({
|
|
248
|
+
adapter: NAME,
|
|
249
|
+
adapterVersion: VERSION,
|
|
250
|
+
capturedAt: raw.capturedAt,
|
|
251
|
+
capturedBy: CAPTURED_BY.API,
|
|
252
|
+
originalId,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (raw.kind === "contact") {
|
|
256
|
+
const p = raw.payload || {};
|
|
257
|
+
// lookupKey is Android's "stable across rename + edits" identifier; fall
|
|
258
|
+
// back to displayName only if missing, which lets future runs still dedup
|
|
259
|
+
// by name for the dataset where lookupKey is absent.
|
|
260
|
+
const stableKey =
|
|
261
|
+
(typeof p.lookupKey === "string" && p.lookupKey.length > 0 && p.lookupKey) ||
|
|
262
|
+
(typeof p.displayName === "string" && p.displayName) ||
|
|
263
|
+
`unknown-${raw.capturedAt}`;
|
|
264
|
+
const displayName =
|
|
265
|
+
typeof p.displayName === "string" && p.displayName.trim().length > 0
|
|
266
|
+
? p.displayName.trim()
|
|
267
|
+
: "(无名联系人)";
|
|
268
|
+
const identifiers = {};
|
|
269
|
+
if (Array.isArray(p.phones) && p.phones.length > 0) {
|
|
270
|
+
identifiers.phone = p.phones.filter((x) => typeof x === "string" && x.length > 0);
|
|
271
|
+
}
|
|
272
|
+
if (Array.isArray(p.emails) && p.emails.length > 0) {
|
|
273
|
+
identifiers.email = p.emails.filter((x) => typeof x === "string" && x.length > 0);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const person = {
|
|
277
|
+
id: `person-android-${stableKey}`,
|
|
278
|
+
type: ENTITY_TYPES.PERSON,
|
|
279
|
+
subtype: PERSON_SUBTYPES.CONTACT,
|
|
280
|
+
names: [displayName],
|
|
281
|
+
ingestedAt,
|
|
282
|
+
source: source(`android-contact:${stableKey}`),
|
|
283
|
+
};
|
|
284
|
+
if (Object.keys(identifiers).length > 0) person.identifiers = identifiers;
|
|
285
|
+
if (typeof p.organization === "string" && p.organization.trim().length > 0) {
|
|
286
|
+
person.relation = p.organization.trim();
|
|
287
|
+
}
|
|
288
|
+
const extra = {};
|
|
289
|
+
if (typeof p.starred === "boolean") extra.starred = p.starred;
|
|
290
|
+
if (typeof p.photoUri === "string" && p.photoUri.length > 0) extra.photoUri = p.photoUri;
|
|
291
|
+
if (Object.keys(extra).length > 0) person.extra = extra;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
events: [],
|
|
295
|
+
persons: [person],
|
|
296
|
+
places: [],
|
|
297
|
+
items: [],
|
|
298
|
+
topics: [],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (raw.kind === "app") {
|
|
303
|
+
const a = raw.payload || {};
|
|
304
|
+
const pkgName =
|
|
305
|
+
(typeof a.packageName === "string" && a.packageName) || `unknown.${newId()}`;
|
|
306
|
+
const label =
|
|
307
|
+
typeof a.label === "string" && a.label.trim().length > 0
|
|
308
|
+
? a.label.trim()
|
|
309
|
+
: pkgName;
|
|
310
|
+
|
|
311
|
+
const item = {
|
|
312
|
+
id: `item-android-app-${pkgName}`,
|
|
313
|
+
type: ENTITY_TYPES.ITEM,
|
|
314
|
+
subtype: ITEM_SUBTYPES.OTHER,
|
|
315
|
+
name: label,
|
|
316
|
+
category: a.isSystem === true ? "system-app" : "user-app",
|
|
317
|
+
ingestedAt,
|
|
318
|
+
source: source(`android-app:${pkgName}`),
|
|
319
|
+
extra: {
|
|
320
|
+
kind: "installed_app",
|
|
321
|
+
packageName: pkgName,
|
|
322
|
+
versionName: typeof a.versionName === "string" ? a.versionName : null,
|
|
323
|
+
versionCode: Number.isInteger(a.versionCode) ? a.versionCode : null,
|
|
324
|
+
firstInstallTime: Number.isInteger(a.firstInstallTime) ? a.firstInstallTime : null,
|
|
325
|
+
lastUpdateTime: Number.isInteger(a.lastUpdateTime) ? a.lastUpdateTime : null,
|
|
326
|
+
isSystem: a.isSystem === true,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
events: [],
|
|
332
|
+
persons: [],
|
|
333
|
+
places: [],
|
|
334
|
+
items: [item],
|
|
335
|
+
topics: [],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
throw new Error(`system-data-android.normalize: unknown raw.kind=${raw.kind}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = {
|
|
344
|
+
SystemDataAndroidAdapter,
|
|
345
|
+
SYSTEM_DATA_ANDROID_NAME: NAME,
|
|
346
|
+
SYSTEM_DATA_ANDROID_VERSION: VERSION,
|
|
347
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
348
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const {
|
|
6
|
+
SystemDataAndroidAdapter,
|
|
7
|
+
SYSTEM_DATA_ANDROID_NAME,
|
|
8
|
+
SYSTEM_DATA_ANDROID_VERSION,
|
|
9
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
10
|
+
} = require("./adapter");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Path C — desktop-side helper. Given a hub instance + an inline snapshot
|
|
14
|
+
* payload from a mobile / browser client, write the snapshot to a staging
|
|
15
|
+
* file, run the adapter's snapshot-mode sync, then clean the staging file up.
|
|
16
|
+
*
|
|
17
|
+
* Centralizing here keeps the schemaVersion check + staging path convention
|
|
18
|
+
* + cleanup discipline in one place — IPC, WS, and P2P-mobile dispatch
|
|
19
|
+
* (3 separate transport adapters) all just call into this.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} hub — hub-wiring output (must have `hubDir` + `registry.syncAdapter`)
|
|
22
|
+
* @param {object} snapshot — payload matching adapter.SNAPSHOT_SCHEMA_VERSION
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {object} [opts.fs] — fs module override for tests (must expose
|
|
25
|
+
* mkdirSync, writeFileSync, existsSync, unlinkSync)
|
|
26
|
+
* @returns {Promise<object>} SyncReport from registry.syncAdapter
|
|
27
|
+
*/
|
|
28
|
+
async function ingestSystemDataAndroidSnapshot(hub, snapshot, opts = {}) {
|
|
29
|
+
if (!hub || !hub.hubDir || !hub.registry) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"ingestSystemDataAndroidSnapshot: hub must expose hubDir + registry"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"ingestSystemDataAndroidSnapshot: snapshot payload required"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`ingestSystemDataAndroidSnapshot: schemaVersion ${snapshot.schemaVersion} != expected ${SNAPSHOT_SCHEMA_VERSION}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fsImpl = opts.fs || fs;
|
|
46
|
+
const stagingDir = path.join(hub.hubDir, "staging");
|
|
47
|
+
fsImpl.mkdirSync(stagingDir, { recursive: true });
|
|
48
|
+
const stagingPath = path.join(
|
|
49
|
+
stagingDir,
|
|
50
|
+
`system-data-android-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`
|
|
51
|
+
);
|
|
52
|
+
fsImpl.writeFileSync(stagingPath, JSON.stringify(snapshot), "utf-8");
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return await hub.registry.syncAdapter(SYSTEM_DATA_ANDROID_NAME, {
|
|
56
|
+
inputPath: stagingPath,
|
|
57
|
+
});
|
|
58
|
+
} finally {
|
|
59
|
+
// best-effort cleanup; failures shouldn't shadow the (possibly successful) sync
|
|
60
|
+
try {
|
|
61
|
+
if (fsImpl.existsSync(stagingPath)) {
|
|
62
|
+
fsImpl.unlinkSync(stagingPath);
|
|
63
|
+
}
|
|
64
|
+
} catch (_e) {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
SystemDataAndroidAdapter,
|
|
72
|
+
SYSTEM_DATA_ANDROID_NAME,
|
|
73
|
+
SYSTEM_DATA_ANDROID_VERSION,
|
|
74
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
75
|
+
ingestSystemDataAndroidSnapshot,
|
|
76
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 9.2 — 12306 (China Railway) ticket adapter.
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* For v0.5 we focus on (2) since (1) is purely vault-side derivation
|
|
15
|
+
* the AnalysisEngine can do at query time.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
const fs = require("node:fs");
|
|
21
|
+
const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
|
|
22
|
+
|
|
23
|
+
const NAME = "travel-12306";
|
|
24
|
+
const VERSION = "0.5.0";
|
|
25
|
+
|
|
26
|
+
class Train12306Adapter {
|
|
27
|
+
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;
|
|
32
|
+
this._dataPath = opts.dataPath || null;
|
|
33
|
+
|
|
34
|
+
this.name = NAME;
|
|
35
|
+
this.version = VERSION;
|
|
36
|
+
this.capabilities = ["import:json", "parse:12306-orders"];
|
|
37
|
+
this.extractMode = "file-import";
|
|
38
|
+
this.rateLimits = {};
|
|
39
|
+
this.dataDisclosure = {
|
|
40
|
+
fields: [
|
|
41
|
+
"12306:orderId / passengerName / trainNumber / fromStation / toStation / departureTime / arrivalTime / seat / price",
|
|
42
|
+
],
|
|
43
|
+
sensitivity: "medium",
|
|
44
|
+
legalGate: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async authenticate() {
|
|
49
|
+
return { ok: true, account: this.account.username };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async healthCheck() {
|
|
53
|
+
return { ok: true, lastChecked: Date.now() };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async *sync(opts = {}) {
|
|
57
|
+
const dataPath = opts.dataPath || this._dataPath;
|
|
58
|
+
if (!dataPath || !fs.existsSync(dataPath)) return;
|
|
59
|
+
const buf = fs.readFileSync(dataPath, "utf-8");
|
|
60
|
+
let records;
|
|
61
|
+
try {
|
|
62
|
+
records = parseRecords(buf);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(`Train12306Adapter: parse failed: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
for (const r of records) {
|
|
67
|
+
yield {
|
|
68
|
+
adapter: NAME,
|
|
69
|
+
originalId: String(r.recordId || r.orderId || r.ticketNumber),
|
|
70
|
+
capturedAt: r.bookedAt || r.departureMs || Date.now(),
|
|
71
|
+
payload: { record: r },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
normalize(raw) {
|
|
77
|
+
if (!raw || !raw.payload || !raw.payload.record) {
|
|
78
|
+
throw new Error("Train12306Adapter.normalize: raw.payload.record missing");
|
|
79
|
+
}
|
|
80
|
+
return normalizeTravelRecord(raw.payload.record, {
|
|
81
|
+
adapterName: NAME,
|
|
82
|
+
adapterVersion: VERSION,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a 12306 dump file. Accepts either:
|
|
89
|
+
* - JSON array of order objects
|
|
90
|
+
* - JSON object { orders: [...] }
|
|
91
|
+
* - JSONL (one order per line)
|
|
92
|
+
*/
|
|
93
|
+
function parseRecords(text) {
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = JSON.parse(text);
|
|
97
|
+
} catch (_e) {
|
|
98
|
+
// Try JSONL
|
|
99
|
+
raw = text
|
|
100
|
+
.split(/\r?\n/)
|
|
101
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
102
|
+
.map((l) => JSON.parse(l));
|
|
103
|
+
}
|
|
104
|
+
const orders = Array.isArray(raw) ? raw : raw.orders || [];
|
|
105
|
+
return orders.map(orderToRecord).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function orderToRecord(o) {
|
|
109
|
+
if (!o || typeof o !== "object") return null;
|
|
110
|
+
const recordId = o.orderId || o.ticketNumber || o.id || o.order_no;
|
|
111
|
+
if (!recordId) return null;
|
|
112
|
+
return {
|
|
113
|
+
vendorId: "12306",
|
|
114
|
+
recordId: String(recordId),
|
|
115
|
+
vehicleType: "train",
|
|
116
|
+
from: {
|
|
117
|
+
station: o.fromStation || o.from_station || o.from,
|
|
118
|
+
city: o.fromCity || o.from_city,
|
|
119
|
+
},
|
|
120
|
+
to: {
|
|
121
|
+
station: o.toStation || o.to_station || o.to,
|
|
122
|
+
city: o.toCity || o.to_city,
|
|
123
|
+
},
|
|
124
|
+
departureMs: numberOrParse(o.departureTime || o.departure_time || o.start_time),
|
|
125
|
+
arrivalMs: numberOrParse(o.arrivalTime || o.arrival_time || o.end_time),
|
|
126
|
+
carrier: "12306",
|
|
127
|
+
vehicleNumber: o.trainNumber || o.train_no || o.trainNo,
|
|
128
|
+
totalCost: o.price != null
|
|
129
|
+
? { value: parseFloat(o.price), currency: "CNY" }
|
|
130
|
+
: null,
|
|
131
|
+
traveler: o.passengerName || o.passenger || o.name,
|
|
132
|
+
confirmationCode: o.ticketNumber || o.ticket_no || recordId,
|
|
133
|
+
bookedAt: numberOrParse(o.bookedAt || o.order_time),
|
|
134
|
+
extras: {
|
|
135
|
+
seat: o.seat || o.seatType,
|
|
136
|
+
seatNumber: o.seatNumber || o.seat_number,
|
|
137
|
+
idCardLast6: o.idLast6 || undefined, // for cross-source EntityResolver linking
|
|
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)) return parseInt(v, 10);
|
|
146
|
+
return parseChineseDateTime(v);
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { Train12306Adapter, parseRecords, NAME, VERSION };
|