@chainlesschain/personal-data-hub 0.4.18 → 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 (53) hide show
  1. package/__tests__/adapters/bank-family.test.js +125 -0
  2. package/__tests__/adapters/biz-tianyancha.test.js +159 -0
  3. package/__tests__/adapters/car-mercedesme.test.js +74 -0
  4. package/__tests__/adapters/doc-camscanner.test.js +147 -0
  5. package/__tests__/adapters/finance-dcep.test.js +74 -0
  6. package/__tests__/adapters/fitness-joyrun.test.js +82 -0
  7. package/__tests__/adapters/gov-12123.test.js +103 -0
  8. package/__tests__/adapters/gov-ixiamen.test.js +150 -0
  9. package/__tests__/adapters/gov-tax.test.js +135 -0
  10. package/__tests__/adapters/health-meiyou.test.js +125 -0
  11. package/__tests__/adapters/music-qq.test.js +112 -0
  12. package/__tests__/adapters/reading-family.test.js +108 -0
  13. package/__tests__/adapters/social-dongchedi.test.js +165 -0
  14. package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
  15. package/__tests__/adapters/video-xigua.test.js +106 -0
  16. package/__tests__/adapters/wework-pc.test.js +124 -0
  17. package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
  18. package/__tests__/fitness-keep-snapshot.test.js +224 -0
  19. package/__tests__/shopping-eleme-snapshot.test.js +454 -0
  20. package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
  21. package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
  22. package/__tests__/social-douban-snapshot.test.js +351 -0
  23. package/lib/adapter-guide.js +31 -3
  24. package/lib/adapters/_bank-base.js +405 -0
  25. package/lib/adapters/_reading-base.js +315 -0
  26. package/lib/adapters/audio-ximalaya/index.js +414 -0
  27. package/lib/adapters/bank-bankcomm/index.js +27 -0
  28. package/lib/adapters/bank-boc/index.js +26 -0
  29. package/lib/adapters/bank-cmbc/index.js +26 -0
  30. package/lib/adapters/bank-icbc/index.js +27 -0
  31. package/lib/adapters/biz-tianyancha/index.js +348 -0
  32. package/lib/adapters/car-mercedesme/index.js +225 -0
  33. package/lib/adapters/doc-camscanner/index.js +102 -0
  34. package/lib/adapters/finance-dcep/index.js +302 -0
  35. package/lib/adapters/fitness-joyrun/index.js +295 -0
  36. package/lib/adapters/fitness-keep/index.js +343 -0
  37. package/lib/adapters/gov-12123/index.js +391 -0
  38. package/lib/adapters/gov-ixiamen/index.js +380 -0
  39. package/lib/adapters/gov-tax/index.js +451 -0
  40. package/lib/adapters/health-meiyou/index.js +393 -0
  41. package/lib/adapters/music-qq/index.js +372 -0
  42. package/lib/adapters/reading-fanqie/index.js +61 -0
  43. package/lib/adapters/reading-qimao/index.js +61 -0
  44. package/lib/adapters/shopping-eleme/index.js +441 -0
  45. package/lib/adapters/shopping-vipshop/index.js +429 -0
  46. package/lib/adapters/shopping-xianyu/index.js +454 -0
  47. package/lib/adapters/social-dongchedi/index.js +360 -0
  48. package/lib/adapters/social-douban/index.js +564 -0
  49. package/lib/adapters/travel-didi-consumer/index.js +148 -0
  50. package/lib/adapters/video-xigua/index.js +68 -0
  51. package/lib/adapters/wework-pc/index.js +31 -0
  52. package/lib/index.js +52 -0
  53. package/package.json +1 -1
@@ -0,0 +1,441 @@
1
+ /**
2
+ * 购物/外卖 — Ele.me (饿了么) adapter, dual-mode (snapshot + cookie-api).
3
+ *
4
+ * 饿了么 is China's #2 food-delivery platform (Alibaba-owned, shares the
5
+ * Taobao/Alipay account + cookie infrastructure). It is NOT on the §12.1 真机
6
+ * inventory roadmap (the reference Redmi device didn't have it installed) but
7
+ * is a high-value personal-data source — 外卖订单 carry eating habits, delivery
8
+ * addresses, merchants, and spend. This adapter mirrors shopping-meituan (the
9
+ * sibling 外卖 platform) for normalize parity, plus pinduoduo's `signProvider`
10
+ * seam (Alibaba mtop endpoints need a client-computed signature like 拼多多's
11
+ * anti_token).
12
+ *
13
+ * 1. snapshot mode (opts.inputPath): ingest a snapshot JSON produced by the
14
+ * Android in-APK cc (WebView cookie scrape + OkHttp fetch of the order
15
+ * list) or a hand-roll export. Stateless — account OPTIONAL.
16
+ *
17
+ * 2. cookie-api mode (opts.account.cookies): fetch the Ele.me order list via
18
+ * the injected `fetchFn`, paginating with a limit/offset cursor and
19
+ * stopping at the `sinceWatermark`. account.userId REQUIRED in this mode.
20
+ *
21
+ * ── signing seam ─────────────────────────────────────────────────────────
22
+ * Ele.me's order endpoints sit behind Alibaba's mtop gateway, which usually
23
+ * requires an `x-sign`/`x-mini-wua` token computed by client-side JS. No
24
+ * pure-Node implementation survives the rotation, so signing is injected via
25
+ * `opts.signProvider` (async ({ url, query, cookies }) → string|null). On
26
+ * Android the in-APK WebView JS VM produces it; in tests a stub returns a
27
+ * fixed value. When no signProvider is configured the request is still issued
28
+ * with `sign: null` — best-effort: the endpoint may 4xx, which surfaces as
29
+ * zero events rather than a crash.
30
+ *
31
+ * ⚠️ The default endpoint (ELEME_ORDERS_URL) is best-effort and NOT
32
+ * field-verified — override via `opts.ordersUrl` once the real SOA path is
33
+ * captured (FAMILY-23 playbook). Snapshot mode is the reliable path.
34
+ *
35
+ * Snapshot schema (mirrors a future ElemeLocalCollector.SNAPSHOT_SCHEMA_VERSION):
36
+ *
37
+ * {
38
+ * "schemaVersion": 1,
39
+ * "snapshottedAt": <epoch-ms>,
40
+ * "vendor": "eleme",
41
+ * "account": { "userId": "...", "displayName": "..." },
42
+ * "events": [
43
+ * { "kind": "order", "id": "order-<orderId>", "capturedAt": <ms>,
44
+ * "orderId": "...", "merchantName": "...",
45
+ * "items": [{ "name": ..., "quantity": ..., "unitPrice": ... }],
46
+ * "placedAt": ..., "paidAt": ..., "status": "...",
47
+ * "totalAmount": { "value": ..., "currency": "CNY" },
48
+ * "recipient": "...", "shippingAddress": "..." }
49
+ * ]
50
+ * }
51
+ */
52
+
53
+ "use strict";
54
+
55
+ const fs = require("node:fs");
56
+ const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
57
+
58
+ const NAME = "shopping-eleme";
59
+ const VERSION = "0.1.0";
60
+ const SNAPSHOT_SCHEMA_VERSION = 1;
61
+
62
+ const KIND_ORDER = "order";
63
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_ORDER]);
64
+
65
+ // Best-effort, NOT field-verified. Override via opts.ordersUrl.
66
+ const ELEME_ORDERS_URL = "https://www.ele.me/restapi/bos/v2/users/orders";
67
+
68
+ class ElemeAdapter {
69
+ constructor(opts = {}) {
70
+ // account is OPTIONAL — snapshot mode is stateless. Cookie-api mode
71
+ // activates only when account.cookies is supplied; account.userId is then
72
+ // required (checked at sync time).
73
+ this.account = opts.account || null;
74
+ this._cookieAuth =
75
+ opts.account && opts.account.cookies
76
+ ? new CookieAuth({ platform: "eleme", cookies: opts.account.cookies })
77
+ : null;
78
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
79
+ // mtop signing seam — see file header. Async fn({ url, query, cookies }) →
80
+ // string|null. When absent, requests carry sign: null.
81
+ this._signProvider =
82
+ typeof opts.signProvider === "function" ? opts.signProvider : null;
83
+ this._ordersUrl =
84
+ typeof opts.ordersUrl === "string" && opts.ordersUrl.length > 0
85
+ ? opts.ordersUrl
86
+ : ELEME_ORDERS_URL;
87
+
88
+ this.name = NAME;
89
+ this.version = VERSION;
90
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:eleme-orders"];
91
+ this.extractMode = "web-api";
92
+ this.rateLimits = { perMinute: 8, perDay: 200 };
93
+ this.dataDisclosure = {
94
+ fields: ["eleme:orderId / restaurantName / dishes / totalAmount / deliveryAddress"],
95
+ sensitivity: "high",
96
+ legalGate: false,
97
+ defaultInclude: { order: true },
98
+ };
99
+
100
+ // _deps injection seam — vi.mock fs doesn't intercept inlined CJS require
101
+ // (see .claude/rules/testing.md).
102
+ this._deps = { fs };
103
+ }
104
+
105
+ async authenticate(ctx = {}) {
106
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
107
+ try {
108
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
109
+ } catch (err) {
110
+ return {
111
+ ok: false,
112
+ reason: "INPUT_PATH_UNREADABLE",
113
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
114
+ };
115
+ }
116
+ return { ok: true, mode: "snapshot-file" };
117
+ }
118
+ if (this._cookieAuth) {
119
+ const ok = await this._cookieAuth.validate();
120
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
121
+ if (!this.account || !this.account.userId) {
122
+ return {
123
+ ok: false,
124
+ reason: "NO_ACCOUNT_USER_ID",
125
+ message: "cookie-api mode requires account.userId",
126
+ };
127
+ }
128
+ return { ok: true, account: this.account.userId, mode: "cookie" };
129
+ }
130
+ return {
131
+ ok: false,
132
+ reason: "NO_INPUT",
133
+ message:
134
+ "ElemeAdapter.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode — mtop signing via signProvider)",
135
+ };
136
+ }
137
+
138
+ async healthCheck() {
139
+ if (this._cookieAuth) {
140
+ const r = await this.authenticate();
141
+ return r.ok
142
+ ? { ok: true, lastChecked: Date.now() }
143
+ : { ok: false, reason: r.reason, error: r.error };
144
+ }
145
+ return { ok: true, lastChecked: Date.now() };
146
+ }
147
+
148
+ async *sync(opts = {}) {
149
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
150
+ yield* this._syncViaSnapshot(opts);
151
+ return;
152
+ }
153
+ if (this._cookieAuth) {
154
+ yield* this._syncViaCookie(opts);
155
+ return;
156
+ }
157
+ throw new Error(
158
+ "ElemeAdapter.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode; Ele.me's mtop API requires signing supplied via opts.signProvider)",
159
+ );
160
+ }
161
+
162
+ async *_syncViaSnapshot(opts) {
163
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
164
+ let snapshot;
165
+ try {
166
+ snapshot = JSON.parse(raw);
167
+ } catch (err) {
168
+ throw new Error(
169
+ `shopping-eleme.sync: snapshot must be JSON (HTML parsing is future work). Got parse error: ${err.message}`,
170
+ );
171
+ }
172
+ if (
173
+ !snapshot ||
174
+ typeof snapshot !== "object" ||
175
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
176
+ ) {
177
+ throw new Error(
178
+ `shopping-eleme.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
179
+ );
180
+ }
181
+ const fallbackCapturedAt =
182
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
183
+ ? Math.floor(snapshot.snapshottedAt)
184
+ : Date.now();
185
+ const account =
186
+ snapshot.account && typeof snapshot.account === "object"
187
+ ? snapshot.account
188
+ : null;
189
+ const include = opts.include || {};
190
+ const limit =
191
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
192
+
193
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
194
+ let emitted = 0;
195
+ for (const ev of events) {
196
+ if (emitted >= limit) return;
197
+ if (!ev || typeof ev !== "object") continue;
198
+ const kind = ev.kind;
199
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
200
+ if (include[kind] === false) continue;
201
+
202
+ const capturedAt =
203
+ parseTime(ev.capturedAt) ||
204
+ parseTime(ev.placedAt) ||
205
+ parseTime(ev.paidAt) ||
206
+ fallbackCapturedAt;
207
+ const id =
208
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
209
+ ev.orderId ||
210
+ null;
211
+
212
+ yield {
213
+ adapter: NAME,
214
+ kind,
215
+ originalId: stableOriginalId(kind, id),
216
+ capturedAt,
217
+ payload: { ...ev, account },
218
+ };
219
+ emitted += 1;
220
+ }
221
+ }
222
+
223
+ async *_syncViaCookie(opts = {}) {
224
+ if (!this.account || !this.account.userId) {
225
+ throw new Error(
226
+ "ElemeAdapter._syncViaCookie: account.userId required (set via new ElemeAdapter({ account: { userId, cookies } }))",
227
+ );
228
+ }
229
+ if (!(await this._cookieAuth.validate())) return;
230
+ const sinceMs =
231
+ opts.sinceWatermark != null
232
+ ? parseInt(String(opts.sinceWatermark), 10) || 0
233
+ : Date.now() - 365 * 24 * 3600_000; // default last year
234
+ const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : 10;
235
+ const include = opts.include || {};
236
+ if (include[KIND_ORDER] === false) return;
237
+
238
+ let offset = 0;
239
+ while (true) {
240
+ const query = { limit: pageSize, offset, userId: this.account.userId };
241
+ // mtop signing seam — best-effort. null when no signProvider.
242
+ let sign = null;
243
+ if (this._signProvider) {
244
+ sign = await this._signProvider({
245
+ url: this._ordersUrl,
246
+ query,
247
+ cookies: this._cookieAuth.toHeader(),
248
+ });
249
+ }
250
+ const resp = await this._fetchFn({
251
+ url: this._ordersUrl,
252
+ cookies: this._cookieAuth.toHeader(),
253
+ sign,
254
+ query,
255
+ });
256
+ const orders = extractOrders(resp);
257
+ if (!orders.length) break;
258
+ let pageHasNew = false;
259
+ let reachedWatermark = false;
260
+ for (const rawOrder of orders) {
261
+ const rec = orderToRecord(rawOrder);
262
+ if (!rec) continue;
263
+ if (rec.placedAt && rec.placedAt < sinceMs) {
264
+ reachedWatermark = true; // everything from here on is older
265
+ break;
266
+ }
267
+ pageHasNew = true;
268
+ yield {
269
+ adapter: NAME,
270
+ originalId: rec.orderId,
271
+ capturedAt: rec.paidAt || rec.placedAt || Date.now(),
272
+ payload: { record: rec },
273
+ };
274
+ }
275
+ if (reachedWatermark || !pageHasNew || orders.length < pageSize) break;
276
+ offset += pageSize;
277
+ }
278
+ }
279
+
280
+ normalize(raw) {
281
+ if (!raw || !raw.payload) {
282
+ throw new Error("ElemeAdapter.normalize: payload missing");
283
+ }
284
+ if (raw.payload.record) {
285
+ return normalizeOrderRecord(raw.payload.record, {
286
+ adapterName: NAME,
287
+ adapterVersion: VERSION,
288
+ });
289
+ }
290
+ const rec = snapshotEventToRecord(raw.payload);
291
+ return normalizeOrderRecord(rec, {
292
+ adapterName: NAME,
293
+ adapterVersion: VERSION,
294
+ });
295
+ }
296
+ }
297
+
298
+ function stableOriginalId(kind, id) {
299
+ const stringified =
300
+ (typeof id === "string" && id.length > 0 && id) ||
301
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
302
+ null;
303
+ const safe =
304
+ stringified ||
305
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
306
+ return `eleme:${kind}:${safe}`;
307
+ }
308
+
309
+ /**
310
+ * Pull the order array out of an Ele.me order-list response. The nesting key
311
+ * varies across endpoint versions; the injected fetchFn may also pre-flatten to
312
+ * `{ orders }`. Tolerant of all common shapes.
313
+ */
314
+ function extractOrders(resp) {
315
+ if (!resp || typeof resp !== "object") return [];
316
+ if (Array.isArray(resp)) return resp;
317
+ if (Array.isArray(resp.orders)) return resp.orders;
318
+ if (Array.isArray(resp.order_list)) return resp.order_list;
319
+ if (Array.isArray(resp.list)) return resp.list;
320
+ if (resp.data && Array.isArray(resp.data.orders)) return resp.data.orders;
321
+ if (resp.data && Array.isArray(resp.data.list)) return resp.data.list;
322
+ if (resp.result && Array.isArray(resp.result.orders)) return resp.result.orders;
323
+ return [];
324
+ }
325
+
326
+ /**
327
+ * Map one Ele.me order object → vendor-neutral OrderRecord. Ele.me restapi
328
+ * amounts are in 元 (yuan, with decimals); field names are best-effort across
329
+ * endpoint versions (camelCase + snake_case fallbacks).
330
+ */
331
+ function orderToRecord(o) {
332
+ if (!o || typeof o !== "object") return null;
333
+ const orderId = o.order_id || o.orderId || o.id || o.unique_id || o.uniqueId;
334
+ if (!orderId) return null;
335
+ const merchant =
336
+ o.restaurant_name ||
337
+ o.restaurantName ||
338
+ o.shop_name ||
339
+ o.shopName ||
340
+ o.merchantName ||
341
+ "饿了么";
342
+
343
+ const items = [];
344
+ const dishes =
345
+ o.basket || o.dishes || o.items || o.item_list || o.itemList || o.foods || [];
346
+ for (const it of Array.isArray(dishes) ? dishes : []) {
347
+ if (!it) continue;
348
+ items.push({
349
+ name: it.name || it.dishName || it.food_name || it.title,
350
+ quantity: parseInt(it.quantity || it.count || it.num || 1, 10),
351
+ unitPrice: parseFloat(it.price || it.unitPrice || it.unit_price || 0),
352
+ sku: it.sku_id || it.skuId || it.id || it.sku || null,
353
+ });
354
+ }
355
+
356
+ return {
357
+ vendorId: "eleme",
358
+ orderId: String(orderId),
359
+ placedAt: parseTime(o.time_pass_string || o.order_time || o.createTime || o.created_at || o.create_at),
360
+ paidAt: parseTime(o.pay_time || o.payTime || o.paid_at),
361
+ status: mapStatus(o.status_bar_text || o.statusText || o.status_text || o.status),
362
+ merchantName: merchant,
363
+ totalAmount: {
364
+ value: parseFloat(o.total_amount || o.totalAmount || o.total || o.pay_amount || o.original_price || 0),
365
+ currency: "CNY",
366
+ },
367
+ items,
368
+ recipient: o.consignee || o.recipient || o.receiver || o.user_name || null,
369
+ shippingAddress: o.address || o.delivery_address || o.deliveryAddress || o.addr || null,
370
+ extras: { capturedBy: "cookie-api", platform: "eleme" },
371
+ };
372
+ }
373
+
374
+ function snapshotEventToRecord(ev) {
375
+ const items = [];
376
+ const rawItems = Array.isArray(ev.items) ? ev.items : [];
377
+ for (const it of rawItems) {
378
+ if (!it) continue;
379
+ items.push({
380
+ name: it.name || it.dishName || it.food_name,
381
+ quantity: parseInt(it.quantity || it.count || 1, 10),
382
+ unitPrice: parseFloat(it.unitPrice || it.price || 0),
383
+ sku: it.sku || it.sku_id || null,
384
+ });
385
+ }
386
+ return {
387
+ vendorId: "eleme",
388
+ orderId: String(ev.orderId || ev.id || "unknown"),
389
+ placedAt: parseTime(ev.placedAt),
390
+ paidAt: parseTime(ev.paidAt),
391
+ status: mapStatus(ev.status || "placed"),
392
+ merchantName: ev.merchantName || ev.restaurant_name || "饿了么",
393
+ totalAmount:
394
+ ev.totalAmount && typeof ev.totalAmount === "object"
395
+ ? {
396
+ value: parseFloat(ev.totalAmount.value || 0),
397
+ currency: ev.totalAmount.currency || "CNY",
398
+ }
399
+ : { value: 0, currency: "CNY" },
400
+ items,
401
+ recipient: ev.recipient || null,
402
+ shippingAddress: ev.shippingAddress || null,
403
+ extras: { capturedBy: "snapshot", platform: "eleme" },
404
+ };
405
+ }
406
+
407
+ function parseTime(v) {
408
+ if (Number.isFinite(v)) return v < 1e12 ? v * 1000 : v;
409
+ if (typeof v === "string") {
410
+ if (/^\d+$/.test(v)) {
411
+ const n = parseInt(v, 10);
412
+ return n < 1e12 ? n * 1000 : n;
413
+ }
414
+ const t = Date.parse(v);
415
+ if (Number.isFinite(t)) return t;
416
+ }
417
+ return null;
418
+ }
419
+
420
+ function mapStatus(s) {
421
+ const t = String(s || "").toLowerCase();
422
+ if (t.includes("退款") || t.includes("refund")) return "refunded";
423
+ if (t.includes("取消") || t.includes("cancel")) return "cancelled";
424
+ if (t.includes("配送中") || t.includes("派送") || t.includes("商家已接单") || t.includes("shipped")) return "shipped";
425
+ if (t.includes("已完成") || t.includes("已送达") || t.includes("已收货") || t.includes("delivered")) return "delivered";
426
+ return "placed";
427
+ }
428
+
429
+ async function defaultFetch(_opts) {
430
+ throw new Error("ElemeAdapter: no fetchFn configured");
431
+ }
432
+
433
+ module.exports = {
434
+ ElemeAdapter,
435
+ orderToRecord,
436
+ extractOrders,
437
+ NAME,
438
+ VERSION,
439
+ SNAPSHOT_SCHEMA_VERSION,
440
+ VALID_SNAPSHOT_KINDS,
441
+ };