@chainlesschain/personal-data-hub 0.4.23 → 0.4.24

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.
Files changed (37) hide show
  1. package/__tests__/adapters/bank-family.test.js +125 -0
  2. package/__tests__/adapters/car-mercedesme.test.js +74 -0
  3. package/__tests__/adapters/finance-dcep.test.js +74 -0
  4. package/__tests__/adapters/fitness-joyrun.test.js +82 -0
  5. package/__tests__/adapters/gov-12123.test.js +103 -0
  6. package/__tests__/adapters/music-qq.test.js +112 -0
  7. package/__tests__/adapters/reading-family.test.js +108 -0
  8. package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
  9. package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
  10. package/__tests__/fitness-keep-snapshot.test.js +224 -0
  11. package/__tests__/shopping-eleme-snapshot.test.js +454 -0
  12. package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
  13. package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
  14. package/__tests__/social-douban-snapshot.test.js +351 -0
  15. package/lib/adapter-guide.js +19 -1
  16. package/lib/adapters/_bank-base.js +405 -0
  17. package/lib/adapters/_reading-base.js +315 -0
  18. package/lib/adapters/audio-ximalaya/index.js +414 -0
  19. package/lib/adapters/bank-bankcomm/index.js +27 -0
  20. package/lib/adapters/bank-boc/index.js +26 -0
  21. package/lib/adapters/bank-cmbc/index.js +26 -0
  22. package/lib/adapters/bank-icbc/index.js +27 -0
  23. package/lib/adapters/car-mercedesme/index.js +225 -0
  24. package/lib/adapters/finance-dcep/index.js +302 -0
  25. package/lib/adapters/fitness-joyrun/index.js +295 -0
  26. package/lib/adapters/fitness-keep/index.js +343 -0
  27. package/lib/adapters/gov-12123/index.js +391 -0
  28. package/lib/adapters/music-qq/index.js +372 -0
  29. package/lib/adapters/reading-fanqie/index.js +61 -0
  30. package/lib/adapters/reading-qimao/index.js +61 -0
  31. package/lib/adapters/shopping-eleme/index.js +441 -0
  32. package/lib/adapters/shopping-vipshop/index.js +429 -0
  33. package/lib/adapters/shopping-xianyu/index.js +454 -0
  34. package/lib/adapters/social-douban/index.js +564 -0
  35. package/lib/adapters/travel-didi-consumer/index.js +148 -0
  36. package/lib/index.js +36 -0
  37. package/package.json +1 -1
@@ -0,0 +1,391 @@
1
+ /**
2
+ * §12.1 Phase 13+ ⭐⭐ — 交管12123 (com.tmri.app.main) adapter, "驾驶证 + 违章".
3
+ * BEST-EFFORT SCAFFOLD (user-requested).
4
+ *
5
+ * ⚠️ SENSITIVE (gov real-name traffic/vehicle data). The 12123 app has NO
6
+ * documented public API; the cookie-api endpoints below are FABRICATED
7
+ * placeholders (overridable, NOT field-verified — FAMILY-23 playbook) and
8
+ * cannot authenticate without the gov real-name login. **snapshot mode is the
9
+ * reliable path**; cookie path surfaces auth.unverified=true. Gated
10
+ * sensitivity:"high" + legalGate:true.
11
+ *
12
+ * Two record kinds:
13
+ * - "violation" 违章记录: { violationId, time, location, reason, fine, points }
14
+ * → EVENT(OTHER) "交通违章: <reason>".
15
+ * - "license" 驾驶证状态: { licenseId, status, cumulativePoints(累计记分),
16
+ * validUntil } → EVENT(OTHER) "驾驶证状态".
17
+ *
18
+ * Snapshot schema (schemaVersion 1):
19
+ * {
20
+ * "schemaVersion": 1, "snapshottedAt": <ms>,
21
+ * "account": { "userId": "...", "name": "..." },
22
+ * "events": [
23
+ * { "kind": "violation", "id": "v-<id>", "violationId": "...", "time": <s|ms>,
24
+ * "location": "...", "reason": "超速", "fine": 200, "points": 3 },
25
+ * { "kind": "license", "id": "l-<id>", "licenseId": "...", "status": "正常",
26
+ * "cumulativePoints": 3, "validUntil": <s|ms> }
27
+ * ]
28
+ * }
29
+ */
30
+
31
+ "use strict";
32
+
33
+ const fs = require("node:fs");
34
+ const { newId } = require("../../ids");
35
+ const { ENTITY_TYPES, EVENT_SUBTYPES, CAPTURED_BY } = require("../../constants");
36
+ const { CookieAuth } = require("../shopping-base");
37
+
38
+ const NAME = "gov-12123";
39
+ const VERSION = "0.1.0";
40
+ const SNAPSHOT_SCHEMA_VERSION = 1;
41
+ const KIND_VIOLATION = "violation";
42
+ const KIND_LICENSE = "license";
43
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_VIOLATION, KIND_LICENSE]);
44
+
45
+ // Real backend base VERIFIED from a connected device (2026-06-15): the 12123
46
+ // APK's assets/prov.json maps every province to `https://<province>.122.gov.cn/app`
47
+ // (ah/bj/cq/fj/gd/... — province two-letter code). So the host is
48
+ // province-prefixed `.122.gov.cn` with an `/app` base, NOT a single national
49
+ // host. The user's registered-province code must be supplied via opts.province
50
+ // (defaults to "bj"); the sub-paths below remain best-effort (the dex is
51
+ // protected by libNetHTProtect, so the exact API paths weren't statically
52
+ // recoverable — override via opts.violationUrl / opts.licenseUrl when known).
53
+ const DEFAULT_PROVINCE = "bj";
54
+ function provinceBase(province) {
55
+ const p = /^[a-z]{2}$/.test(String(province || "")) ? province : DEFAULT_PROVINCE;
56
+ return `https://${p}.122.gov.cn/app`;
57
+ }
58
+ const PAGE_SIZE = 30;
59
+
60
+ function parseTime(v) {
61
+ if (Number.isFinite(v)) return v > 1e12 ? v : v >= 1e9 ? v * 1000 : v;
62
+ if (typeof v === "string") {
63
+ if (/^\d+$/.test(v)) {
64
+ const n = parseInt(v, 10);
65
+ return n > 1e12 ? n : n >= 1e9 ? n * 1000 : n;
66
+ }
67
+ const t = Date.parse(v);
68
+ return Number.isFinite(t) ? t : null;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function toNum(v) {
74
+ if (Number.isFinite(v)) return v;
75
+ if (typeof v === "string") {
76
+ const n = parseFloat(v.replace(/[,,¥\s元分]/g, ""));
77
+ return Number.isFinite(n) ? n : null;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function mapViolation(raw) {
83
+ if (!raw || typeof raw !== "object") return null;
84
+ const id = raw.violationId || raw.violation_id || raw.id || raw.wzbh || raw.serialNo;
85
+ if (id == null) return null;
86
+ return {
87
+ violationId: String(id),
88
+ timeMs: parseTime(raw.time || raw.violationTime || raw.wfsj || raw.date),
89
+ location: raw.location || raw.address || raw.wfdz || raw.place || null,
90
+ reason: raw.reason || raw.behavior || raw.wfxw || raw.desc || "交通违法",
91
+ fine: toNum(raw.fine != null ? raw.fine : raw.fkje != null ? raw.fkje : raw.amount),
92
+ points: toNum(raw.points != null ? raw.points : raw.wfjfs != null ? raw.wfjfs : raw.score),
93
+ };
94
+ }
95
+
96
+ function mapLicense(raw) {
97
+ if (!raw || typeof raw !== "object") return null;
98
+ const id = raw.licenseId || raw.license_id || raw.id || raw.dabh || raw.fileNo;
99
+ if (id == null) return null;
100
+ return {
101
+ licenseId: String(id),
102
+ status: raw.status || raw.statusName || raw.zt || raw.state || null,
103
+ cumulativePoints: toNum(raw.cumulativePoints != null ? raw.cumulativePoints : raw.ljjf != null ? raw.ljjf : raw.totalPoints),
104
+ validUntilMs: parseTime(raw.validUntil || raw.yxqz || raw.expireDate || raw.valid_until),
105
+ };
106
+ }
107
+
108
+ function extractList(resp) {
109
+ if (!resp || typeof resp !== "object") return [];
110
+ if (Array.isArray(resp.list)) return resp.list;
111
+ if (Array.isArray(resp.data)) return resp.data;
112
+ const d = resp.data;
113
+ if (d && typeof d === "object") {
114
+ if (Array.isArray(d.list)) return d.list;
115
+ if (Array.isArray(d.records)) return d.records;
116
+ if (Array.isArray(d.result)) return d.result;
117
+ }
118
+ return [];
119
+ }
120
+
121
+ // License endpoint returns a single object (not a paginated list); wrap it.
122
+ function extractLicense(resp) {
123
+ if (!resp || typeof resp !== "object") return [];
124
+ if (Array.isArray(resp.list)) return resp.list;
125
+ if (resp.data && typeof resp.data === "object" && !Array.isArray(resp.data)) return [resp.data];
126
+ if (resp.licenseId || resp.dabh || resp.id) return [resp];
127
+ return extractList(resp);
128
+ }
129
+
130
+ function stableOriginalId(kind, id) {
131
+ const safe =
132
+ (typeof id === "string" && id.length > 0 && id) ||
133
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
134
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
135
+ return `12123:${kind}:${safe}`;
136
+ }
137
+
138
+ class Tmri12123Adapter {
139
+ constructor(opts = {}) {
140
+ this.account = opts.account || null;
141
+ this._cookieAuth =
142
+ opts.account && opts.account.cookies ? new CookieAuth({ platform: "12123", cookies: opts.account.cookies }) : null;
143
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
144
+ this._signProvider = typeof opts.signProvider === "function" ? opts.signProvider : null;
145
+ // Host VERIFIED province-prefixed `.122.gov.cn/app`; sub-paths best-effort.
146
+ this.province = /^[a-z]{2}$/.test(String(opts.province || "")) ? opts.province : DEFAULT_PROVINCE;
147
+ const base = provinceBase(this.province);
148
+ this._urls = {
149
+ violation: opts.violationUrl || opts.listUrl || `${base}/violation/list`,
150
+ license: opts.licenseUrl || `${base}/license/info`,
151
+ };
152
+
153
+ this.name = NAME;
154
+ this.version = VERSION;
155
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:12123-violation", "parse:12123-license"];
156
+ this.extractMode = "web-api";
157
+ this.rateLimits = { perMinute: 6, perDay: 100 };
158
+ this.dataDisclosure = {
159
+ fields: [
160
+ "12123:violation (time / location / reason / fine / points)",
161
+ "12123:license (status / cumulativePoints / validUntil)",
162
+ ],
163
+ sensitivity: "high",
164
+ legalGate: true,
165
+ defaultInclude: { violation: true, license: true },
166
+ };
167
+ this._deps = { fs };
168
+ }
169
+
170
+ async authenticate(ctx = {}) {
171
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
172
+ try {
173
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
174
+ } catch (err) {
175
+ return { ok: false, reason: "INPUT_PATH_UNREADABLE", message: `snapshot not readable at ${ctx.inputPath}: ${err.message}` };
176
+ }
177
+ return { ok: true, mode: "snapshot-file" };
178
+ }
179
+ if (this._cookieAuth) {
180
+ const ok = await this._cookieAuth.validate();
181
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
182
+ return { ok: true, account: (this.account && this.account.userId) || null, mode: "cookie", unverified: true };
183
+ }
184
+ return {
185
+ ok: false,
186
+ reason: "NO_INPUT",
187
+ message: "gov-12123.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode, best-effort/unverified)",
188
+ };
189
+ }
190
+
191
+ async healthCheck() {
192
+ if (this._cookieAuth) {
193
+ const r = await this.authenticate();
194
+ return r.ok ? { ok: true, lastChecked: Date.now(), unverified: true } : { ok: false, reason: r.reason, error: r.error };
195
+ }
196
+ return { ok: true, lastChecked: Date.now() };
197
+ }
198
+
199
+ async *sync(opts = {}) {
200
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
201
+ yield* this._syncViaSnapshot(opts);
202
+ return;
203
+ }
204
+ if (this._cookieAuth) {
205
+ yield* this._syncViaCookie(opts);
206
+ return;
207
+ }
208
+ throw new Error("gov-12123.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)");
209
+ }
210
+
211
+ async *_syncViaSnapshot(opts) {
212
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
213
+ const snapshot = JSON.parse(raw);
214
+ if (!snapshot || typeof snapshot !== "object" || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
215
+ throw new Error(`gov-12123.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`);
216
+ }
217
+ const fallbackCapturedAt =
218
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0 ? Math.floor(snapshot.snapshottedAt) : Date.now();
219
+ const account = snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
220
+ const include = opts.include || {};
221
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
222
+
223
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
224
+ let emitted = 0;
225
+ for (const ev of events) {
226
+ if (emitted >= limit) return;
227
+ if (!ev || typeof ev !== "object") continue;
228
+ if (!VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
229
+ if (include[ev.kind] === false) continue;
230
+ const rec = ev.kind === KIND_VIOLATION ? mapViolation(ev) : mapLicense(ev);
231
+ if (!rec) continue;
232
+ const recTime = ev.kind === KIND_VIOLATION ? rec.timeMs : rec.validUntilMs;
233
+ const capturedAt = parseTime(ev.capturedAt) || recTime || fallbackCapturedAt;
234
+ yield {
235
+ adapter: NAME,
236
+ kind: ev.kind,
237
+ originalId: stableOriginalId(ev.kind, ev.kind === KIND_VIOLATION ? rec.violationId : rec.licenseId),
238
+ capturedAt,
239
+ payload: { record: rec, kind: ev.kind, account },
240
+ };
241
+ emitted += 1;
242
+ }
243
+ }
244
+
245
+ async *_syncViaCookie(opts = {}) {
246
+ if (!(await this._cookieAuth.validate())) return;
247
+ const cookies = this._cookieAuth.toHeader();
248
+ const include = opts.include || {};
249
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
250
+ const maxPages = Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
251
+
252
+ let emitted = 0;
253
+
254
+ // violations (paginated)
255
+ if (include[KIND_VIOLATION] !== false) {
256
+ let page = 1;
257
+ while (page <= maxPages) {
258
+ const query = { page, size: PAGE_SIZE };
259
+ let sign = null;
260
+ if (this._signProvider) sign = await this._signProvider({ url: this._urls.violation, query, cookies });
261
+ const resp = await this._fetchFn({ url: this._urls.violation, cookies, query, sign });
262
+ const items = extractList(resp);
263
+ if (!items.length) break;
264
+ for (const it of items) {
265
+ const rec = mapViolation(it);
266
+ if (!rec) continue;
267
+ if (emitted >= limit) return;
268
+ yield {
269
+ adapter: NAME,
270
+ kind: KIND_VIOLATION,
271
+ originalId: stableOriginalId(KIND_VIOLATION, rec.violationId),
272
+ capturedAt: rec.timeMs || Date.now(),
273
+ payload: { record: rec, kind: KIND_VIOLATION, cookie: true },
274
+ };
275
+ emitted += 1;
276
+ }
277
+ if (items.length < PAGE_SIZE) break;
278
+ page += 1;
279
+ }
280
+ }
281
+
282
+ // license (single fetch)
283
+ if (include[KIND_LICENSE] !== false) {
284
+ let sign = null;
285
+ if (this._signProvider) sign = await this._signProvider({ url: this._urls.license, query: {}, cookies });
286
+ const resp = await this._fetchFn({ url: this._urls.license, cookies, query: {}, sign });
287
+ for (const it of extractLicense(resp)) {
288
+ const rec = mapLicense(it);
289
+ if (!rec) continue;
290
+ if (emitted >= limit) return;
291
+ yield {
292
+ adapter: NAME,
293
+ kind: KIND_LICENSE,
294
+ originalId: stableOriginalId(KIND_LICENSE, rec.licenseId),
295
+ capturedAt: rec.validUntilMs || Date.now(),
296
+ payload: { record: rec, kind: KIND_LICENSE, cookie: true },
297
+ };
298
+ emitted += 1;
299
+ }
300
+ }
301
+ }
302
+
303
+ normalize(raw) {
304
+ if (!raw || !raw.payload || !raw.payload.record) {
305
+ throw new Error("Tmri12123Adapter.normalize: payload.record missing");
306
+ }
307
+ const kind = raw.kind || raw.payload.kind;
308
+ const rec = raw.payload.record;
309
+ const ingestedAt = Date.now();
310
+ const source = {
311
+ adapter: NAME,
312
+ adapterVersion: VERSION,
313
+ originalId: raw.originalId,
314
+ capturedAt: raw.capturedAt || ingestedAt,
315
+ capturedBy: CAPTURED_BY.API,
316
+ };
317
+
318
+ if (kind === KIND_VIOLATION) {
319
+ const occurredAt = rec.timeMs || raw.capturedAt || ingestedAt;
320
+ return {
321
+ events: [
322
+ {
323
+ id: newId(),
324
+ type: ENTITY_TYPES.EVENT,
325
+ subtype: EVENT_SUBTYPES.OTHER,
326
+ occurredAt,
327
+ actor: "person-self",
328
+ content: { title: `交通违章: ${rec.reason}`.slice(0, 80), text: rec.reason },
329
+ ingestedAt,
330
+ source,
331
+ extra: {
332
+ platform: "12123",
333
+ kind: KIND_VIOLATION,
334
+ location: rec.location || null,
335
+ fine: rec.fine,
336
+ points: rec.points,
337
+ },
338
+ },
339
+ ],
340
+ persons: [],
341
+ places: [],
342
+ items: [],
343
+ topics: [],
344
+ };
345
+ }
346
+ // license
347
+ const occurredAt = rec.validUntilMs || raw.capturedAt || ingestedAt;
348
+ return {
349
+ events: [
350
+ {
351
+ id: newId(),
352
+ type: ENTITY_TYPES.EVENT,
353
+ subtype: EVENT_SUBTYPES.OTHER,
354
+ occurredAt,
355
+ actor: "person-self",
356
+ content: { title: `驾驶证状态: ${rec.status || "未知"}`.slice(0, 80), text: "驾驶证状态" },
357
+ ingestedAt,
358
+ source,
359
+ extra: {
360
+ platform: "12123",
361
+ kind: KIND_LICENSE,
362
+ status: rec.status || null,
363
+ cumulativePoints: rec.cumulativePoints,
364
+ validUntilMs: rec.validUntilMs || null,
365
+ },
366
+ },
367
+ ],
368
+ persons: [],
369
+ places: [],
370
+ items: [],
371
+ topics: [],
372
+ };
373
+ }
374
+ }
375
+
376
+ async function defaultFetch(_opts) {
377
+ throw new Error("gov-12123: no fetchFn configured for cookie-api mode");
378
+ }
379
+
380
+ module.exports = {
381
+ Tmri12123Adapter,
382
+ mapViolation,
383
+ mapLicense,
384
+ extractList,
385
+ extractLicense,
386
+ parseTime,
387
+ NAME,
388
+ VERSION,
389
+ SNAPSHOT_SCHEMA_VERSION,
390
+ VALID_SNAPSHOT_KINDS,
391
+ };