@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,429 @@
1
+ /**
2
+ * 购物 — VIP.com (唯品会) adapter, dual-mode (snapshot + cookie-api).
3
+ *
4
+ * 唯品会 (com.achievo.vipshop) is a major Chinese flash-sale (品牌特卖) e-commerce
5
+ * platform. It is NOT on the §12.1 真机 roadmap nor the reference device, but is
6
+ * a high-value personal-data source — 订单 carry brand/品类 preferences, spend,
7
+ * and shipping addresses. Mirrors shopping-eleme / shopping-taobao (snapshot +
8
+ * cookie-api dual path, 元 amounts) with VIP's own order endpoint + signing seam.
9
+ *
10
+ * 1. snapshot mode (opts.inputPath): ingest a snapshot JSON produced by the
11
+ * Android in-APK cc (WebView cookie scrape + order fetch) or a hand-roll
12
+ * export. Stateless — account OPTIONAL.
13
+ *
14
+ * 2. cookie-api mode (opts.account.cookies): fetch the VIP order list via the
15
+ * injected `fetchFn`, paginating with a page cursor and stopping at the
16
+ * `sinceWatermark`. account.userId REQUIRED in this mode.
17
+ *
18
+ * ── signing seam ─────────────────────────────────────────────────────────
19
+ * VIP's mapi endpoints require an `api_sign`/`fdc_area_id` style token. No
20
+ * pure-Node implementation survives the rotation, so signing is injected via
21
+ * `opts.signProvider` (async ({ url, query, cookies }) → string|null). When no
22
+ * signProvider is configured the request is still issued with `sign: null` —
23
+ * best-effort: the endpoint may 4xx, surfacing as zero events not a crash.
24
+ *
25
+ * ⚠️ The default endpoint (VIPSHOP_ORDERS_URL) is best-effort and NOT
26
+ * field-verified — override via `opts.ordersUrl` once the real path is
27
+ * captured (FAMILY-23 playbook). Snapshot mode is the reliable path.
28
+ *
29
+ * Snapshot schema:
30
+ *
31
+ * {
32
+ * "schemaVersion": 1,
33
+ * "snapshottedAt": <epoch-ms>,
34
+ * "vendor": "vipshop",
35
+ * "account": { "userId": "...", "displayName": "..." },
36
+ * "events": [
37
+ * { "kind": "order", "id": "order-<orderSn>", "capturedAt": <ms>,
38
+ * "orderId": "...", "merchantName": "...", // brand / store
39
+ * "items": [{ "name": ..., "quantity": ..., "unitPrice": ... }],
40
+ * "placedAt": ..., "paidAt": ..., "status": "...",
41
+ * "totalAmount": { "value": ..., "currency": "CNY" },
42
+ * "recipient": "...", "shippingAddress": "..." }
43
+ * ]
44
+ * }
45
+ */
46
+
47
+ "use strict";
48
+
49
+ const fs = require("node:fs");
50
+ const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
51
+
52
+ const NAME = "shopping-vipshop";
53
+ const VERSION = "0.1.0";
54
+ const SNAPSHOT_SCHEMA_VERSION = 1;
55
+
56
+ const KIND_ORDER = "order";
57
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_ORDER]);
58
+
59
+ // Best-effort, NOT field-verified. Override via opts.ordersUrl.
60
+ const VIPSHOP_ORDERS_URL = "https://mapi.vip.com/vips-mobile/rest/order/list/v2";
61
+
62
+ class VipshopAdapter {
63
+ constructor(opts = {}) {
64
+ // account is OPTIONAL — snapshot mode is stateless. Cookie-api mode
65
+ // activates only when account.cookies is supplied; account.userId is then
66
+ // required (checked at sync time).
67
+ this.account = opts.account || null;
68
+ this._cookieAuth =
69
+ opts.account && opts.account.cookies
70
+ ? new CookieAuth({ platform: "vipshop", cookies: opts.account.cookies })
71
+ : null;
72
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
73
+ // VIP signing seam — see file header. Async fn({ url, query, cookies }) →
74
+ // string|null. When absent, requests carry sign: null.
75
+ this._signProvider =
76
+ typeof opts.signProvider === "function" ? opts.signProvider : null;
77
+ this._ordersUrl =
78
+ typeof opts.ordersUrl === "string" && opts.ordersUrl.length > 0
79
+ ? opts.ordersUrl
80
+ : VIPSHOP_ORDERS_URL;
81
+
82
+ this.name = NAME;
83
+ this.version = VERSION;
84
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:vipshop-orders"];
85
+ this.extractMode = "web-api";
86
+ this.rateLimits = { perMinute: 8, perDay: 200 };
87
+ this.dataDisclosure = {
88
+ fields: ["vipshop:orderSn / brandName / goods / amount / deliveryAddress"],
89
+ sensitivity: "high",
90
+ legalGate: false,
91
+ defaultInclude: { order: true },
92
+ };
93
+
94
+ // _deps injection seam — vi.mock fs doesn't intercept inlined CJS require
95
+ // (see .claude/rules/testing.md).
96
+ this._deps = { fs };
97
+ }
98
+
99
+ async authenticate(ctx = {}) {
100
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
101
+ try {
102
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
103
+ } catch (err) {
104
+ return {
105
+ ok: false,
106
+ reason: "INPUT_PATH_UNREADABLE",
107
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
108
+ };
109
+ }
110
+ return { ok: true, mode: "snapshot-file" };
111
+ }
112
+ if (this._cookieAuth) {
113
+ const ok = await this._cookieAuth.validate();
114
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
115
+ if (!this.account || !this.account.userId) {
116
+ return {
117
+ ok: false,
118
+ reason: "NO_ACCOUNT_USER_ID",
119
+ message: "cookie-api mode requires account.userId",
120
+ };
121
+ }
122
+ return { ok: true, account: this.account.userId, mode: "cookie" };
123
+ }
124
+ return {
125
+ ok: false,
126
+ reason: "NO_INPUT",
127
+ message:
128
+ "VipshopAdapter.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode — signing via signProvider)",
129
+ };
130
+ }
131
+
132
+ async healthCheck() {
133
+ if (this._cookieAuth) {
134
+ const r = await this.authenticate();
135
+ return r.ok
136
+ ? { ok: true, lastChecked: Date.now() }
137
+ : { ok: false, reason: r.reason, error: r.error };
138
+ }
139
+ return { ok: true, lastChecked: Date.now() };
140
+ }
141
+
142
+ async *sync(opts = {}) {
143
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
144
+ yield* this._syncViaSnapshot(opts);
145
+ return;
146
+ }
147
+ if (this._cookieAuth) {
148
+ yield* this._syncViaCookie(opts);
149
+ return;
150
+ }
151
+ throw new Error(
152
+ "VipshopAdapter.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode; VIP's mapi requires signing supplied via opts.signProvider)",
153
+ );
154
+ }
155
+
156
+ async *_syncViaSnapshot(opts) {
157
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
158
+ let snapshot;
159
+ try {
160
+ snapshot = JSON.parse(raw);
161
+ } catch (err) {
162
+ throw new Error(
163
+ `shopping-vipshop.sync: snapshot must be JSON (HTML parsing is future work). Got parse error: ${err.message}`,
164
+ );
165
+ }
166
+ if (
167
+ !snapshot ||
168
+ typeof snapshot !== "object" ||
169
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
170
+ ) {
171
+ throw new Error(
172
+ `shopping-vipshop.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
173
+ );
174
+ }
175
+ const fallbackCapturedAt =
176
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
177
+ ? Math.floor(snapshot.snapshottedAt)
178
+ : Date.now();
179
+ const account =
180
+ snapshot.account && typeof snapshot.account === "object"
181
+ ? snapshot.account
182
+ : null;
183
+ const include = opts.include || {};
184
+ const limit =
185
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
186
+
187
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
188
+ let emitted = 0;
189
+ for (const ev of events) {
190
+ if (emitted >= limit) return;
191
+ if (!ev || typeof ev !== "object") continue;
192
+ const kind = ev.kind;
193
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
194
+ if (include[kind] === false) continue;
195
+
196
+ const capturedAt =
197
+ parseTime(ev.capturedAt) ||
198
+ parseTime(ev.placedAt) ||
199
+ parseTime(ev.paidAt) ||
200
+ fallbackCapturedAt;
201
+ const id =
202
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
203
+ ev.orderId ||
204
+ null;
205
+
206
+ yield {
207
+ adapter: NAME,
208
+ kind,
209
+ originalId: stableOriginalId(kind, id),
210
+ capturedAt,
211
+ payload: { ...ev, account },
212
+ };
213
+ emitted += 1;
214
+ }
215
+ }
216
+
217
+ async *_syncViaCookie(opts = {}) {
218
+ if (!this.account || !this.account.userId) {
219
+ throw new Error(
220
+ "VipshopAdapter._syncViaCookie: account.userId required (set via new VipshopAdapter({ account: { userId, cookies } }))",
221
+ );
222
+ }
223
+ if (!(await this._cookieAuth.validate())) return;
224
+ const sinceMs =
225
+ opts.sinceWatermark != null
226
+ ? parseInt(String(opts.sinceWatermark), 10) || 0
227
+ : Date.now() - 365 * 24 * 3600_000; // default last year
228
+ const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : 10;
229
+ const include = opts.include || {};
230
+ if (include[KIND_ORDER] === false) return;
231
+
232
+ let page = 1;
233
+ while (true) {
234
+ const query = { page, pageSize, userId: this.account.userId };
235
+ // VIP signing seam — best-effort. null when no signProvider.
236
+ let sign = null;
237
+ if (this._signProvider) {
238
+ sign = await this._signProvider({
239
+ url: this._ordersUrl,
240
+ query,
241
+ cookies: this._cookieAuth.toHeader(),
242
+ });
243
+ }
244
+ const resp = await this._fetchFn({
245
+ url: this._ordersUrl,
246
+ cookies: this._cookieAuth.toHeader(),
247
+ sign,
248
+ query,
249
+ });
250
+ const orders = extractOrders(resp);
251
+ if (!orders.length) break;
252
+ let pageHasNew = false;
253
+ let reachedWatermark = false;
254
+ for (const rawOrder of orders) {
255
+ const rec = orderToRecord(rawOrder);
256
+ if (!rec) continue;
257
+ if (rec.placedAt && rec.placedAt < sinceMs) {
258
+ reachedWatermark = true;
259
+ break;
260
+ }
261
+ pageHasNew = true;
262
+ yield {
263
+ adapter: NAME,
264
+ originalId: rec.orderId,
265
+ capturedAt: rec.paidAt || rec.placedAt || Date.now(),
266
+ payload: { record: rec },
267
+ };
268
+ }
269
+ if (reachedWatermark || !pageHasNew || orders.length < pageSize) break;
270
+ page += 1;
271
+ }
272
+ }
273
+
274
+ normalize(raw) {
275
+ if (!raw || !raw.payload) {
276
+ throw new Error("VipshopAdapter.normalize: payload missing");
277
+ }
278
+ if (raw.payload.record) {
279
+ return normalizeOrderRecord(raw.payload.record, {
280
+ adapterName: NAME,
281
+ adapterVersion: VERSION,
282
+ });
283
+ }
284
+ const rec = snapshotEventToRecord(raw.payload);
285
+ return normalizeOrderRecord(rec, {
286
+ adapterName: NAME,
287
+ adapterVersion: VERSION,
288
+ });
289
+ }
290
+ }
291
+
292
+ function stableOriginalId(kind, id) {
293
+ const stringified =
294
+ (typeof id === "string" && id.length > 0 && id) ||
295
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
296
+ null;
297
+ const safe =
298
+ stringified ||
299
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
300
+ return `vipshop:${kind}:${safe}`;
301
+ }
302
+
303
+ /**
304
+ * Pull the order array out of a VIP order-list response. The nesting key varies
305
+ * across endpoint versions; the injected fetchFn may also pre-flatten to
306
+ * `{ orders }`. Tolerant of all common shapes (VIP wraps under data.orders).
307
+ */
308
+ function extractOrders(resp) {
309
+ if (!resp || typeof resp !== "object") return [];
310
+ if (Array.isArray(resp)) return resp;
311
+ if (Array.isArray(resp.orders)) return resp.orders;
312
+ if (Array.isArray(resp.orderList)) return resp.orderList;
313
+ if (Array.isArray(resp.list)) return resp.list;
314
+ if (resp.data && Array.isArray(resp.data.orders)) return resp.data.orders;
315
+ if (resp.data && Array.isArray(resp.data.orderList)) return resp.data.orderList;
316
+ if (resp.data && Array.isArray(resp.data.list)) return resp.data.list;
317
+ return [];
318
+ }
319
+
320
+ /**
321
+ * Map one VIP order object → vendor-neutral OrderRecord. VIP amounts are in 元
322
+ * (yuan, with decimals); field names are best-effort across endpoint versions
323
+ * (camelCase + snake_case fallbacks).
324
+ */
325
+ function orderToRecord(o) {
326
+ if (!o || typeof o !== "object") return null;
327
+ const orderId = o.order_sn || o.orderSn || o.order_id || o.orderId || o.id;
328
+ if (!orderId) return null;
329
+ const merchant =
330
+ o.brand_name || o.brandName || o.store_name || o.storeName || o.supplier_name || o.merchantName || "唯品会";
331
+
332
+ const items = [];
333
+ const goods = o.goods_list || o.goodsList || o.goods || o.products || o.items || [];
334
+ for (const it of Array.isArray(goods) ? goods : []) {
335
+ if (!it) continue;
336
+ items.push({
337
+ name: it.goods_name || it.goodsName || it.product_name || it.name || it.title,
338
+ quantity: parseInt(it.goods_num || it.num || it.quantity || it.count || 1, 10),
339
+ unitPrice: parseFloat(it.vipshop_price || it.price || it.unitPrice || it.sale_price || 0),
340
+ sku: it.size_id || it.sizeId || it.goods_id || it.sku || null,
341
+ });
342
+ }
343
+
344
+ return {
345
+ vendorId: "vipshop",
346
+ orderId: String(orderId),
347
+ placedAt: parseTime(o.add_time || o.order_time || o.create_time || o.createTime || o.created_at),
348
+ paidAt: parseTime(o.pay_time || o.payTime || o.paid_at),
349
+ status: mapStatus(o.order_status_name || o.statusName || o.status_text || o.statusText || o.order_status || o.status),
350
+ merchantName: merchant,
351
+ totalAmount: {
352
+ value: parseFloat(o.money || o.order_amount || o.orderAmount || o.total_amount || o.pay_total || o.payAmount || 0),
353
+ currency: "CNY",
354
+ },
355
+ items,
356
+ recipient: o.consignee || o.receiver || o.recipient || o.user_name || null,
357
+ shippingAddress: o.address || o.delivery_address || o.deliveryAddress || o.addr || null,
358
+ extras: { capturedBy: "cookie-api", platform: "vipshop" },
359
+ };
360
+ }
361
+
362
+ function snapshotEventToRecord(ev) {
363
+ const items = [];
364
+ const rawItems = Array.isArray(ev.items) ? ev.items : [];
365
+ for (const it of rawItems) {
366
+ if (!it) continue;
367
+ items.push({
368
+ name: it.name || it.goods_name || it.product_name,
369
+ quantity: parseInt(it.quantity || it.num || 1, 10),
370
+ unitPrice: parseFloat(it.unitPrice || it.price || 0),
371
+ sku: it.sku || it.size_id || null,
372
+ });
373
+ }
374
+ return {
375
+ vendorId: "vipshop",
376
+ orderId: String(ev.orderId || ev.id || "unknown"),
377
+ placedAt: parseTime(ev.placedAt),
378
+ paidAt: parseTime(ev.paidAt),
379
+ status: mapStatus(ev.status || "placed"),
380
+ merchantName: ev.merchantName || ev.brand_name || "唯品会",
381
+ totalAmount:
382
+ ev.totalAmount && typeof ev.totalAmount === "object"
383
+ ? {
384
+ value: parseFloat(ev.totalAmount.value || 0),
385
+ currency: ev.totalAmount.currency || "CNY",
386
+ }
387
+ : { value: 0, currency: "CNY" },
388
+ items,
389
+ recipient: ev.recipient || null,
390
+ shippingAddress: ev.shippingAddress || null,
391
+ extras: { capturedBy: "snapshot", platform: "vipshop" },
392
+ };
393
+ }
394
+
395
+ function parseTime(v) {
396
+ if (Number.isFinite(v)) return v < 1e12 ? v * 1000 : v;
397
+ if (typeof v === "string") {
398
+ if (/^\d+$/.test(v)) {
399
+ const n = parseInt(v, 10);
400
+ return n < 1e12 ? n * 1000 : n;
401
+ }
402
+ const t = Date.parse(v);
403
+ if (Number.isFinite(t)) return t;
404
+ }
405
+ return null;
406
+ }
407
+
408
+ function mapStatus(s) {
409
+ const t = String(s || "").toLowerCase();
410
+ if (t.includes("退款") || t.includes("退货") || t.includes("refund")) return "refunded";
411
+ if (t.includes("取消") || t.includes("关闭") || t.includes("cancel")) return "cancelled";
412
+ if (t.includes("待收货") || t.includes("已发货") || t.includes("配送") || t.includes("shipped")) return "shipped";
413
+ if (t.includes("已完成") || t.includes("交易成功") || t.includes("已收货") || t.includes("delivered") || t.includes("success")) return "delivered";
414
+ return "placed";
415
+ }
416
+
417
+ async function defaultFetch(_opts) {
418
+ throw new Error("VipshopAdapter: no fetchFn configured");
419
+ }
420
+
421
+ module.exports = {
422
+ VipshopAdapter,
423
+ orderToRecord,
424
+ extractOrders,
425
+ NAME,
426
+ VERSION,
427
+ SNAPSHOT_SCHEMA_VERSION,
428
+ VALID_SNAPSHOT_KINDS,
429
+ };