@chainlesschain/personal-data-hub 0.4.18 → 0.4.23

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.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * §12.1 Phase 13+ ⭐⭐ — i 厦门 (com.xmgov.xmapp) adapter, "本地政务".
3
+ *
4
+ * ⚠️ BEST-EFFORT SCAFFOLD (user-requested). i 厦门 is a local-government
5
+ * super-app (社保 / 公积金 / 医保 / 政务办事 / 预约) behind real-name gov SSO.
6
+ * Unlike the document / shopping / travel adapters it has **no verifiable
7
+ * public API** — the cookie-api endpoint below is a FABRICATED placeholder
8
+ * (FAMILY-23 playbook: best-effort, overridable via opts.listUrl, NOT
9
+ * field-verified) and cannot actually authenticate without gov real-name
10
+ * login. The reliable path is therefore **snapshot mode** (the app / a manual
11
+ * export produces a JSON of the user's 办事记录). The cookie path is kept as a
12
+ * seam so it can be wired once a real device confirms the endpoint + sign.
13
+ *
14
+ * Personal footprint modelled: 政务办事记录 (government-service handling). Each
15
+ * record → an INTERACTION event ("办理: <服务名>") + a Topic for the service
16
+ * category (社保 / 公积金 / 医保 / ...) so the vault can group which kinds of
17
+ * civic services the user used. High sensitivity + legalGate (gov real-name
18
+ * data) — first collection requires explicit legal confirmation.
19
+ *
20
+ * 1. snapshot mode (opts.inputPath): JSON schemaVersion 1, stateless.
21
+ * 2. cookie-api mode (opts.account.cookies): best-effort, unverified —
22
+ * paginate the handle-list via injected fetchFn; signProvider seam for
23
+ * the gov gateway signature; best-effort unsigned when absent.
24
+ *
25
+ * Snapshot schema (schemaVersion 1):
26
+ * {
27
+ * "schemaVersion": 1, "snapshottedAt": <ms>,
28
+ * "account": { "userId": "...", "name": "..." },
29
+ * "events": [
30
+ * { "kind": "service", "id": "svc-<id>", "serviceId": "...",
31
+ * "serviceName": "城乡居民医保缴费", "category": "医保",
32
+ * "handledTime": <s|ms>, "status": "已办结", "dept": "厦门市医保局" }
33
+ * ]
34
+ * }
35
+ */
36
+
37
+ "use strict";
38
+
39
+ const fs = require("node:fs");
40
+ const { newId } = require("../../ids");
41
+ const { ENTITY_TYPES, EVENT_SUBTYPES, CAPTURED_BY } = require("../../constants");
42
+ const { CookieAuth } = require("../shopping-base");
43
+
44
+ const NAME = "gov-ixiamen";
45
+ const VERSION = "0.1.0";
46
+ const SNAPSHOT_SCHEMA_VERSION = 1;
47
+
48
+ const KIND_SERVICE = "service";
49
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_SERVICE]);
50
+
51
+ // FABRICATED best-effort handle-list endpoint — NOT field-verified.
52
+ // Overridable via opts.listUrl once a real device confirms the gov gateway.
53
+ const IXIAMEN_LIST_URL = "https://app.ixm.gov.cn/api/v1/handle/list";
54
+ const PAGE_SIZE = 20;
55
+
56
+ // Coarse service-category keyword map → grouping Topic name. Best-effort; the
57
+ // raw `category` (if present) always wins.
58
+ const CATEGORY_KEYWORDS = [
59
+ ["社保", /社保|社会保险|养老保险|失业|工伤/],
60
+ ["公积金", /公积金/],
61
+ ["医保", /医保|医疗保险|门诊|住院报销/],
62
+ ["户籍", /户籍|户口|身份证|居住证/],
63
+ ["车驾管", /驾驶证|行驶证|车辆|违章|车驾管/],
64
+ ["教育", /入学|学籍|教育|招生|学位/],
65
+ ["民政", /婚姻|结婚|离婚|低保|殡葬|民政/],
66
+ ["税务", /纳税|个税|税务|发票/],
67
+ ["出行", /公交|地铁|出行|交通卡/],
68
+ ["证照", /营业执照|证照|许可|备案/],
69
+ ];
70
+
71
+ function inferCategory(name, explicit) {
72
+ if (typeof explicit === "string" && explicit.trim()) return explicit.trim();
73
+ const s = String(name || "");
74
+ for (const [cat, re] of CATEGORY_KEYWORDS) {
75
+ if (re.test(s)) return cat;
76
+ }
77
+ return "其他政务";
78
+ }
79
+
80
+ function parseTime(v) {
81
+ if (Number.isFinite(v)) return v > 1e12 ? v : v >= 1e9 ? v * 1000 : v;
82
+ if (typeof v === "string") {
83
+ if (/^\d+$/.test(v)) {
84
+ const n = parseInt(v, 10);
85
+ return n > 1e12 ? n : n >= 1e9 ? n * 1000 : n;
86
+ }
87
+ const t = Date.parse(v);
88
+ return Number.isFinite(t) ? t : null;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function stableOriginalId(id) {
94
+ const safe =
95
+ (typeof id === "string" && id.length > 0 && id) ||
96
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
97
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
98
+ return `ixiamen:service:${safe}`;
99
+ }
100
+
101
+ /** Raw service record (snapshot or cookie shape) → canonical fields. */
102
+ function mapService(raw) {
103
+ if (!raw || typeof raw !== "object") return null;
104
+ const serviceId =
105
+ raw.serviceId || raw.service_id || raw.id || raw.bizId || raw.biz_id || raw.orderNo;
106
+ if (!serviceId) return null;
107
+ const serviceName =
108
+ raw.serviceName || raw.service_name || raw.name || raw.title || raw.itemName || "(未命名事项)";
109
+ return {
110
+ serviceId: String(serviceId),
111
+ serviceName: String(serviceName),
112
+ category: inferCategory(serviceName, raw.category || raw.categoryName || raw.type),
113
+ handledMs: parseTime(
114
+ raw.handledTime || raw.handle_time || raw.handledAt || raw.createTime || raw.create_time || raw.time,
115
+ ),
116
+ status: raw.status || raw.statusName || raw.state || null,
117
+ dept: raw.dept || raw.deptName || raw.department || raw.org || raw.handleOrg || null,
118
+ };
119
+ }
120
+
121
+ function extractList(resp) {
122
+ if (!resp || typeof resp !== "object") return [];
123
+ if (Array.isArray(resp.list)) return resp.list;
124
+ if (Array.isArray(resp.data)) return resp.data;
125
+ const d = resp.data;
126
+ if (d && typeof d === "object") {
127
+ if (Array.isArray(d.list)) return d.list;
128
+ if (Array.isArray(d.records)) return d.records;
129
+ if (Array.isArray(d.result)) return d.result;
130
+ }
131
+ return [];
132
+ }
133
+
134
+ class IXiamenAdapter {
135
+ constructor(opts = {}) {
136
+ this.account = opts.account || null;
137
+ this._cookieAuth =
138
+ opts.account && opts.account.cookies
139
+ ? new CookieAuth({ platform: "ixiamen", cookies: opts.account.cookies })
140
+ : null;
141
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
142
+ this._signProvider =
143
+ typeof opts.signProvider === "function" ? opts.signProvider : null;
144
+ this._listUrl =
145
+ typeof opts.listUrl === "string" && opts.listUrl.length > 0
146
+ ? opts.listUrl
147
+ : IXIAMEN_LIST_URL;
148
+
149
+ this.name = NAME;
150
+ this.version = VERSION;
151
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:ixiamen-service"];
152
+ this.extractMode = "web-api";
153
+ this.rateLimits = { perMinute: 6, perDay: 100 };
154
+ this.dataDisclosure = {
155
+ fields: ["ixiamen:service (serviceName / category / handledTime / status / dept)"],
156
+ // Gov real-name service records are sensitive personal data.
157
+ sensitivity: "high",
158
+ legalGate: true,
159
+ defaultInclude: { service: true },
160
+ };
161
+
162
+ this._deps = { fs };
163
+ }
164
+
165
+ async authenticate(ctx = {}) {
166
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
167
+ try {
168
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
169
+ } catch (err) {
170
+ return {
171
+ ok: false,
172
+ reason: "INPUT_PATH_UNREADABLE",
173
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
174
+ };
175
+ }
176
+ return { ok: true, mode: "snapshot-file" };
177
+ }
178
+ if (this._cookieAuth) {
179
+ const ok = await this._cookieAuth.validate();
180
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
181
+ return {
182
+ ok: true,
183
+ account: (this.account && this.account.userId) || null,
184
+ mode: "cookie",
185
+ // Honest signal: live path is unverified for this gov source.
186
+ unverified: true,
187
+ };
188
+ }
189
+ return {
190
+ ok: false,
191
+ reason: "NO_INPUT",
192
+ message:
193
+ "gov-ixiamen.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode, best-effort/unverified)",
194
+ };
195
+ }
196
+
197
+ async healthCheck() {
198
+ if (this._cookieAuth) {
199
+ const r = await this.authenticate();
200
+ return r.ok
201
+ ? { ok: true, lastChecked: Date.now(), unverified: true }
202
+ : { ok: false, reason: r.reason, error: r.error };
203
+ }
204
+ return { ok: true, lastChecked: Date.now() };
205
+ }
206
+
207
+ async *sync(opts = {}) {
208
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
209
+ yield* this._syncViaSnapshot(opts);
210
+ return;
211
+ }
212
+ if (this._cookieAuth) {
213
+ yield* this._syncViaCookie(opts);
214
+ return;
215
+ }
216
+ throw new Error(
217
+ "gov-ixiamen.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)",
218
+ );
219
+ }
220
+
221
+ async *_syncViaSnapshot(opts) {
222
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
223
+ const snapshot = JSON.parse(raw);
224
+ if (
225
+ !snapshot ||
226
+ typeof snapshot !== "object" ||
227
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
228
+ ) {
229
+ throw new Error(
230
+ `gov-ixiamen.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
231
+ );
232
+ }
233
+ const fallbackCapturedAt =
234
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
235
+ ? Math.floor(snapshot.snapshottedAt)
236
+ : Date.now();
237
+ const account =
238
+ snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
239
+ const include = opts.include || {};
240
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
241
+
242
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
243
+ let emitted = 0;
244
+ for (const ev of events) {
245
+ if (emitted >= limit) return;
246
+ if (!ev || typeof ev !== "object") continue;
247
+ if (!VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
248
+ if (include[ev.kind] === false) continue;
249
+
250
+ const rec = mapService(ev);
251
+ if (!rec) continue;
252
+ const capturedAt = parseTime(ev.capturedAt) || rec.handledMs || fallbackCapturedAt;
253
+ yield {
254
+ adapter: NAME,
255
+ kind: KIND_SERVICE,
256
+ originalId: stableOriginalId(rec.serviceId),
257
+ capturedAt,
258
+ payload: { record: rec, account },
259
+ };
260
+ emitted += 1;
261
+ }
262
+ }
263
+
264
+ async *_syncViaCookie(opts = {}) {
265
+ if (!(await this._cookieAuth.validate())) return;
266
+ const cookies = this._cookieAuth.toHeader();
267
+ const include = opts.include || {};
268
+ if (include[KIND_SERVICE] === false) return;
269
+ const sinceMs =
270
+ opts.sinceWatermark != null ? parseInt(String(opts.sinceWatermark), 10) || 0 : 0;
271
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
272
+ const maxPages =
273
+ Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
274
+
275
+ let emitted = 0;
276
+ let page = 1;
277
+ while (page <= maxPages) {
278
+ const query = { page, size: PAGE_SIZE };
279
+ let sign = null;
280
+ if (this._signProvider) {
281
+ sign = await this._signProvider({ url: this._listUrl, query, cookies });
282
+ }
283
+ const resp = await this._fetchFn({ url: this._listUrl, cookies, query, sign });
284
+ const items = extractList(resp);
285
+ if (!items.length) break;
286
+ let reachedWatermark = false;
287
+ for (const it of items) {
288
+ const rec = mapService(it);
289
+ if (!rec) continue;
290
+ const ts = rec.handledMs || null;
291
+ if (sinceMs && ts && ts < sinceMs) {
292
+ reachedWatermark = true;
293
+ break;
294
+ }
295
+ if (emitted >= limit) return;
296
+ yield {
297
+ adapter: NAME,
298
+ kind: KIND_SERVICE,
299
+ originalId: stableOriginalId(rec.serviceId),
300
+ capturedAt: ts || Date.now(),
301
+ payload: { record: rec, cookie: true },
302
+ };
303
+ emitted += 1;
304
+ }
305
+ if (reachedWatermark || items.length < PAGE_SIZE) break;
306
+ page += 1;
307
+ }
308
+ }
309
+
310
+ normalize(raw) {
311
+ if (!raw || !raw.payload || !raw.payload.record) {
312
+ throw new Error("IXiamenAdapter.normalize: payload.record missing");
313
+ }
314
+ const rec = raw.payload.record;
315
+ const ingestedAt = Date.now();
316
+ const occurredAt = rec.handledMs || raw.capturedAt || ingestedAt;
317
+ const source = {
318
+ adapter: NAME,
319
+ adapterVersion: VERSION,
320
+ originalId: raw.originalId,
321
+ capturedAt: raw.capturedAt || occurredAt,
322
+ capturedBy: CAPTURED_BY.API,
323
+ };
324
+ const topicId = `topic-ixiamen-cat-${rec.category}`;
325
+ return {
326
+ events: [
327
+ {
328
+ id: newId(),
329
+ type: ENTITY_TYPES.EVENT,
330
+ subtype: EVENT_SUBTYPES.INTERACTION,
331
+ occurredAt,
332
+ actor: "person-self",
333
+ content: {
334
+ title: `办理: ${rec.serviceName}`.slice(0, 80),
335
+ text: rec.serviceName,
336
+ },
337
+ ingestedAt,
338
+ source,
339
+ extra: {
340
+ platform: "ixiamen",
341
+ serviceId: rec.serviceId,
342
+ category: rec.category,
343
+ status: rec.status || null,
344
+ dept: rec.dept || null,
345
+ topicRef: topicId,
346
+ },
347
+ },
348
+ ],
349
+ persons: [],
350
+ places: [],
351
+ items: [],
352
+ topics: [
353
+ {
354
+ id: topicId,
355
+ type: ENTITY_TYPES.TOPIC,
356
+ name: rec.category,
357
+ ingestedAt,
358
+ source,
359
+ extra: { platform: "ixiamen", kind: "service-category" },
360
+ },
361
+ ],
362
+ };
363
+ }
364
+ }
365
+
366
+ async function defaultFetch(_opts) {
367
+ throw new Error("gov-ixiamen: no fetchFn configured for cookie-api mode");
368
+ }
369
+
370
+ module.exports = {
371
+ IXiamenAdapter,
372
+ mapService,
373
+ extractList,
374
+ inferCategory,
375
+ parseTime,
376
+ NAME,
377
+ VERSION,
378
+ SNAPSHOT_SCHEMA_VERSION,
379
+ VALID_SNAPSHOT_KINDS,
380
+ };