@chainlesschain/personal-data-hub 0.4.23 → 0.4.25

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 (39) 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/gov-ixiamen.test.js +2 -2
  7. package/__tests__/adapters/music-qq.test.js +112 -0
  8. package/__tests__/adapters/reading-family.test.js +108 -0
  9. package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
  10. package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
  11. package/__tests__/fitness-keep-snapshot.test.js +224 -0
  12. package/__tests__/shopping-eleme-snapshot.test.js +454 -0
  13. package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
  14. package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
  15. package/__tests__/social-douban-snapshot.test.js +351 -0
  16. package/lib/adapter-guide.js +19 -1
  17. package/lib/adapters/_bank-base.js +405 -0
  18. package/lib/adapters/_reading-base.js +315 -0
  19. package/lib/adapters/audio-ximalaya/index.js +414 -0
  20. package/lib/adapters/bank-bankcomm/index.js +27 -0
  21. package/lib/adapters/bank-boc/index.js +26 -0
  22. package/lib/adapters/bank-cmbc/index.js +26 -0
  23. package/lib/adapters/bank-icbc/index.js +27 -0
  24. package/lib/adapters/car-mercedesme/index.js +225 -0
  25. package/lib/adapters/finance-dcep/index.js +302 -0
  26. package/lib/adapters/fitness-joyrun/index.js +295 -0
  27. package/lib/adapters/fitness-keep/index.js +343 -0
  28. package/lib/adapters/gov-12123/index.js +391 -0
  29. package/lib/adapters/gov-ixiamen/index.js +17 -10
  30. package/lib/adapters/music-qq/index.js +372 -0
  31. package/lib/adapters/reading-fanqie/index.js +61 -0
  32. package/lib/adapters/reading-qimao/index.js +61 -0
  33. package/lib/adapters/shopping-eleme/index.js +441 -0
  34. package/lib/adapters/shopping-vipshop/index.js +429 -0
  35. package/lib/adapters/shopping-xianyu/index.js +454 -0
  36. package/lib/adapters/social-douban/index.js +564 -0
  37. package/lib/adapters/travel-didi-consumer/index.js +148 -0
  38. package/lib/index.js +36 -0
  39. package/package.json +1 -1
@@ -0,0 +1,454 @@
1
+ /**
2
+ * 购物/二手 — Xianyu (闲鱼) adapter, dual-mode (snapshot + cookie-api).
3
+ *
4
+ * 闲鱼 (goofish, com.taobao.idlefish) is Alibaba's C2C second-hand marketplace
5
+ * (shares the Taobao/Alipay account + cookie infrastructure under .taobao.com /
6
+ * .goofish.com). It is NOT on the §12.1 真机 roadmap nor the reference device,
7
+ * but is a high-value personal-data source — 二手买卖记录 carry purchase/sale
8
+ * history, counterparties, spend, and income. Mirrors shopping-eleme (snapshot
9
+ * + cookie-api dual path) with one twist: 闲鱼 is TWO-SIDED, so each order
10
+ * carries a `side` (buy = 我买入, sell = 我卖出) and the counterparty role flips
11
+ * accordingly (merchantName = seller on a buy, buyer on a sell).
12
+ *
13
+ * 1. snapshot mode (opts.inputPath): ingest a snapshot JSON produced by the
14
+ * Android in-APK cc (WebView cookie scrape + mtop fetch) or a hand-roll
15
+ * export. Stateless — account OPTIONAL.
16
+ *
17
+ * 2. cookie-api mode (opts.account.cookies): fetch the 闲鱼 order list via the
18
+ * injected `fetchFn`, paginating with a page cursor and stopping at the
19
+ * `sinceWatermark`. account.userId REQUIRED in this mode.
20
+ *
21
+ * ── signing seam ─────────────────────────────────────────────────────────
22
+ * 闲鱼'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). When no
26
+ * signProvider is configured the request is still issued with `sign: null` —
27
+ * best-effort: the endpoint may 4xx, surfacing as zero events not a crash.
28
+ *
29
+ * ⚠️ The default endpoint (XIANYU_ORDERS_URL) is best-effort and NOT
30
+ * field-verified — override via `opts.ordersUrl` once the real mtop path is
31
+ * captured (FAMILY-23 playbook). Snapshot mode is the reliable path.
32
+ *
33
+ * Snapshot schema:
34
+ *
35
+ * {
36
+ * "schemaVersion": 1,
37
+ * "snapshottedAt": <epoch-ms>,
38
+ * "vendor": "xianyu",
39
+ * "account": { "userId": "...", "displayName": "..." },
40
+ * "events": [
41
+ * { "kind": "order", "id": "order-<orderId>", "capturedAt": <ms>,
42
+ * "orderId": "...", "side": "buy|sell", "title": "...",
43
+ * "counterparty": "...", // seller (buy) or buyer (sell)
44
+ * "items": [{ "name": ..., "quantity": ..., "unitPrice": ... }],
45
+ * "placedAt": ..., "paidAt": ..., "status": "...",
46
+ * "totalAmount": { "value": ..., "currency": "CNY" },
47
+ * "recipient": "...", "shippingAddress": "..." }
48
+ * ]
49
+ * }
50
+ */
51
+
52
+ "use strict";
53
+
54
+ const fs = require("node:fs");
55
+ const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
56
+
57
+ const NAME = "shopping-xianyu";
58
+ const VERSION = "0.1.0";
59
+ const SNAPSHOT_SCHEMA_VERSION = 1;
60
+
61
+ const KIND_ORDER = "order";
62
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_ORDER]);
63
+
64
+ // Best-effort, NOT field-verified. Override via opts.ordersUrl.
65
+ const XIANYU_ORDERS_URL = "https://h5api.m.goofish.com/h5/mtop.idle.trade.order.list/1.0/";
66
+
67
+ class XianyuAdapter {
68
+ constructor(opts = {}) {
69
+ // account is OPTIONAL — snapshot mode is stateless. Cookie-api mode
70
+ // activates only when account.cookies is supplied; account.userId is then
71
+ // required (checked at sync time).
72
+ this.account = opts.account || null;
73
+ this._cookieAuth =
74
+ opts.account && opts.account.cookies
75
+ ? new CookieAuth({ platform: "xianyu", cookies: opts.account.cookies })
76
+ : null;
77
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
78
+ // mtop signing seam — see file header. Async fn({ url, query, cookies }) →
79
+ // string|null. When absent, requests carry sign: null.
80
+ this._signProvider =
81
+ typeof opts.signProvider === "function" ? opts.signProvider : null;
82
+ this._ordersUrl =
83
+ typeof opts.ordersUrl === "string" && opts.ordersUrl.length > 0
84
+ ? opts.ordersUrl
85
+ : XIANYU_ORDERS_URL;
86
+
87
+ this.name = NAME;
88
+ this.version = VERSION;
89
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:xianyu-orders"];
90
+ this.extractMode = "web-api";
91
+ this.rateLimits = { perMinute: 8, perDay: 200 };
92
+ this.dataDisclosure = {
93
+ fields: ["xianyu:orderId / side / itemTitle / counterparty / amount / address"],
94
+ sensitivity: "high",
95
+ legalGate: false,
96
+ defaultInclude: { order: true },
97
+ };
98
+
99
+ // _deps injection seam — vi.mock fs doesn't intercept inlined CJS require
100
+ // (see .claude/rules/testing.md).
101
+ this._deps = { fs };
102
+ }
103
+
104
+ async authenticate(ctx = {}) {
105
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
106
+ try {
107
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
108
+ } catch (err) {
109
+ return {
110
+ ok: false,
111
+ reason: "INPUT_PATH_UNREADABLE",
112
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
113
+ };
114
+ }
115
+ return { ok: true, mode: "snapshot-file" };
116
+ }
117
+ if (this._cookieAuth) {
118
+ const ok = await this._cookieAuth.validate();
119
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
120
+ if (!this.account || !this.account.userId) {
121
+ return {
122
+ ok: false,
123
+ reason: "NO_ACCOUNT_USER_ID",
124
+ message: "cookie-api mode requires account.userId",
125
+ };
126
+ }
127
+ return { ok: true, account: this.account.userId, mode: "cookie" };
128
+ }
129
+ return {
130
+ ok: false,
131
+ reason: "NO_INPUT",
132
+ message:
133
+ "XianyuAdapter.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode — mtop signing via signProvider)",
134
+ };
135
+ }
136
+
137
+ async healthCheck() {
138
+ if (this._cookieAuth) {
139
+ const r = await this.authenticate();
140
+ return r.ok
141
+ ? { ok: true, lastChecked: Date.now() }
142
+ : { ok: false, reason: r.reason, error: r.error };
143
+ }
144
+ return { ok: true, lastChecked: Date.now() };
145
+ }
146
+
147
+ async *sync(opts = {}) {
148
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
149
+ yield* this._syncViaSnapshot(opts);
150
+ return;
151
+ }
152
+ if (this._cookieAuth) {
153
+ yield* this._syncViaCookie(opts);
154
+ return;
155
+ }
156
+ throw new Error(
157
+ "XianyuAdapter.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode; 闲鱼's mtop API requires signing supplied via opts.signProvider)",
158
+ );
159
+ }
160
+
161
+ async *_syncViaSnapshot(opts) {
162
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
163
+ let snapshot;
164
+ try {
165
+ snapshot = JSON.parse(raw);
166
+ } catch (err) {
167
+ throw new Error(
168
+ `shopping-xianyu.sync: snapshot must be JSON (HTML parsing is future work). Got parse error: ${err.message}`,
169
+ );
170
+ }
171
+ if (
172
+ !snapshot ||
173
+ typeof snapshot !== "object" ||
174
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
175
+ ) {
176
+ throw new Error(
177
+ `shopping-xianyu.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
178
+ );
179
+ }
180
+ const fallbackCapturedAt =
181
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
182
+ ? Math.floor(snapshot.snapshottedAt)
183
+ : Date.now();
184
+ const account =
185
+ snapshot.account && typeof snapshot.account === "object"
186
+ ? snapshot.account
187
+ : null;
188
+ const include = opts.include || {};
189
+ const limit =
190
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
191
+
192
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
193
+ let emitted = 0;
194
+ for (const ev of events) {
195
+ if (emitted >= limit) return;
196
+ if (!ev || typeof ev !== "object") continue;
197
+ const kind = ev.kind;
198
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
199
+ if (include[kind] === false) continue;
200
+
201
+ const capturedAt =
202
+ parseTime(ev.capturedAt) ||
203
+ parseTime(ev.placedAt) ||
204
+ parseTime(ev.paidAt) ||
205
+ fallbackCapturedAt;
206
+ const id =
207
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
208
+ ev.orderId ||
209
+ null;
210
+
211
+ yield {
212
+ adapter: NAME,
213
+ kind,
214
+ originalId: stableOriginalId(kind, id),
215
+ capturedAt,
216
+ payload: { ...ev, account },
217
+ };
218
+ emitted += 1;
219
+ }
220
+ }
221
+
222
+ async *_syncViaCookie(opts = {}) {
223
+ if (!this.account || !this.account.userId) {
224
+ throw new Error(
225
+ "XianyuAdapter._syncViaCookie: account.userId required (set via new XianyuAdapter({ account: { userId, cookies } }))",
226
+ );
227
+ }
228
+ if (!(await this._cookieAuth.validate())) return;
229
+ const sinceMs =
230
+ opts.sinceWatermark != null
231
+ ? parseInt(String(opts.sinceWatermark), 10) || 0
232
+ : Date.now() - 365 * 24 * 3600_000; // default last year
233
+ const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : 10;
234
+ const include = opts.include || {};
235
+ if (include[KIND_ORDER] === false) return;
236
+ // 闲鱼 has separate buy/sell tabs; default to both. opts.sides overrides.
237
+ const sides = Array.isArray(opts.sides) && opts.sides.length ? opts.sides : ["buy", "sell"];
238
+
239
+ for (const side of sides) {
240
+ let pageNumber = 1;
241
+ while (true) {
242
+ const query = { pageNumber, pageSize, tab: side, userId: this.account.userId };
243
+ // mtop signing seam — best-effort. null when no signProvider.
244
+ let sign = null;
245
+ if (this._signProvider) {
246
+ sign = await this._signProvider({
247
+ url: this._ordersUrl,
248
+ query,
249
+ cookies: this._cookieAuth.toHeader(),
250
+ });
251
+ }
252
+ const resp = await this._fetchFn({
253
+ url: this._ordersUrl,
254
+ cookies: this._cookieAuth.toHeader(),
255
+ sign,
256
+ query,
257
+ });
258
+ const orders = extractOrders(resp);
259
+ if (!orders.length) break;
260
+ let pageHasNew = false;
261
+ let reachedWatermark = false;
262
+ for (const rawOrder of orders) {
263
+ const rec = orderToRecord(rawOrder, side);
264
+ if (!rec) continue;
265
+ if (rec.placedAt && rec.placedAt < sinceMs) {
266
+ reachedWatermark = true;
267
+ break;
268
+ }
269
+ pageHasNew = true;
270
+ yield {
271
+ adapter: NAME,
272
+ originalId: rec.orderId,
273
+ capturedAt: rec.paidAt || rec.placedAt || Date.now(),
274
+ payload: { record: rec },
275
+ };
276
+ }
277
+ if (reachedWatermark || !pageHasNew || orders.length < pageSize) break;
278
+ pageNumber += 1;
279
+ }
280
+ }
281
+ }
282
+
283
+ normalize(raw) {
284
+ if (!raw || !raw.payload) {
285
+ throw new Error("XianyuAdapter.normalize: payload missing");
286
+ }
287
+ if (raw.payload.record) {
288
+ return normalizeOrderRecord(raw.payload.record, {
289
+ adapterName: NAME,
290
+ adapterVersion: VERSION,
291
+ });
292
+ }
293
+ const rec = snapshotEventToRecord(raw.payload);
294
+ return normalizeOrderRecord(rec, {
295
+ adapterName: NAME,
296
+ adapterVersion: VERSION,
297
+ });
298
+ }
299
+ }
300
+
301
+ function stableOriginalId(kind, id) {
302
+ const stringified =
303
+ (typeof id === "string" && id.length > 0 && id) ||
304
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
305
+ null;
306
+ const safe =
307
+ stringified ||
308
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
309
+ return `xianyu:${kind}:${safe}`;
310
+ }
311
+
312
+ /**
313
+ * Pull the order array out of a 闲鱼 mtop order-list response. The nesting key
314
+ * varies; the injected fetchFn may also pre-flatten to `{ orders }`. Tolerant of
315
+ * all common shapes (mtop wraps under data.* ).
316
+ */
317
+ function extractOrders(resp) {
318
+ if (!resp || typeof resp !== "object") return [];
319
+ if (Array.isArray(resp)) return resp;
320
+ if (Array.isArray(resp.orders)) return resp.orders;
321
+ if (Array.isArray(resp.order_list)) return resp.order_list;
322
+ if (Array.isArray(resp.list)) return resp.list;
323
+ if (resp.data && Array.isArray(resp.data.orders)) return resp.data.orders;
324
+ if (resp.data && Array.isArray(resp.data.orderList)) return resp.data.orderList;
325
+ if (resp.data && Array.isArray(resp.data.list)) return resp.data.list;
326
+ if (resp.data && resp.data.cardList && Array.isArray(resp.data.cardList)) return resp.data.cardList;
327
+ return [];
328
+ }
329
+
330
+ /**
331
+ * Normalize a 闲鱼 side token to canonical "buy" | "sell". Accepts the platform's
332
+ * various spellings (bought/sold, 买到/卖出, role codes).
333
+ */
334
+ function normSide(side, o) {
335
+ const s = String(side || (o && (o.tab || o.bizType || o.role || o.orderType)) || "").toLowerCase();
336
+ if (s.includes("sell") || s.includes("sold") || s.includes("卖") || s === "2") return "sell";
337
+ if (s.includes("buy") || s.includes("bought") || s.includes("买") || s === "1") return "buy";
338
+ return "buy"; // default: most 闲鱼 history is purchases
339
+ }
340
+
341
+ /**
342
+ * Map one 闲鱼 order object → vendor-neutral OrderRecord. 闲鱼 mtop amounts are
343
+ * in 元 (yuan) strings/numbers; field names are best-effort across endpoint
344
+ * versions (camelCase + snake_case fallbacks). `side` flips the counterparty
345
+ * role (seller on a buy, buyer on a sell).
346
+ */
347
+ function orderToRecord(o, side) {
348
+ if (!o || typeof o !== "object") return null;
349
+ const orderId = o.order_id || o.orderId || o.bizOrderId || o.id || o.mainOrderId;
350
+ if (!orderId) return null;
351
+ const resolvedSide = normSide(side, o);
352
+ const title = o.title || o.itemTitle || o.item_title || o.subject || o.auctionTitle || "闲鱼商品";
353
+ // On a buy the counterparty is the seller; on a sell it's the buyer.
354
+ const counterparty =
355
+ resolvedSide === "sell"
356
+ ? o.buyer_nick || o.buyerNick || o.buyer || o.counterparty || null
357
+ : o.seller_nick || o.sellerNick || o.seller || o.counterparty || null;
358
+
359
+ const items = [{
360
+ name: title,
361
+ quantity: parseInt(o.quantity || o.itemNum || 1, 10),
362
+ unitPrice: parseFloat(o.price || o.item_price || o.unitPrice || o.actualFee || 0),
363
+ sku: o.item_id || o.itemId || o.auctionId || null,
364
+ }];
365
+
366
+ return {
367
+ vendorId: "xianyu",
368
+ orderId: String(orderId),
369
+ placedAt: parseTime(o.order_time || o.createTime || o.created_at || o.create_at || o.gmtCreate),
370
+ paidAt: parseTime(o.pay_time || o.payTime || o.paid_at),
371
+ status: mapStatus(o.status_text || o.statusText || o.status_desc || o.orderStatus || o.status),
372
+ merchantName: counterparty || (resolvedSide === "sell" ? "买家" : "卖家"),
373
+ totalAmount: {
374
+ value: parseFloat(o.total_amount || o.totalAmount || o.actualFee || o.payAmount || o.price || 0),
375
+ currency: "CNY",
376
+ },
377
+ items,
378
+ recipient: o.receiver || o.recipient || o.receiverName || null,
379
+ shippingAddress: o.address || o.delivery_address || o.deliveryAddress || null,
380
+ extras: { capturedBy: "cookie-api", platform: "xianyu", side: resolvedSide },
381
+ };
382
+ }
383
+
384
+ function snapshotEventToRecord(ev) {
385
+ const side = normSide(ev.side, ev);
386
+ const title = ev.title || ev.itemTitle || ev.name || "闲鱼商品";
387
+ const rawItems = Array.isArray(ev.items) && ev.items.length ? ev.items : [{ name: title }];
388
+ const items = [];
389
+ for (const it of rawItems) {
390
+ if (!it) continue;
391
+ items.push({
392
+ name: it.name || it.itemTitle || title,
393
+ quantity: parseInt(it.quantity || 1, 10),
394
+ unitPrice: parseFloat(it.unitPrice || it.price || 0),
395
+ sku: it.sku || it.itemId || null,
396
+ });
397
+ }
398
+ return {
399
+ vendorId: "xianyu",
400
+ orderId: String(ev.orderId || ev.id || "unknown"),
401
+ placedAt: parseTime(ev.placedAt),
402
+ paidAt: parseTime(ev.paidAt),
403
+ status: mapStatus(ev.status || "placed"),
404
+ merchantName: ev.counterparty || (side === "sell" ? "买家" : "卖家"),
405
+ totalAmount:
406
+ ev.totalAmount && typeof ev.totalAmount === "object"
407
+ ? {
408
+ value: parseFloat(ev.totalAmount.value || 0),
409
+ currency: ev.totalAmount.currency || "CNY",
410
+ }
411
+ : { value: 0, currency: "CNY" },
412
+ items,
413
+ recipient: ev.recipient || null,
414
+ shippingAddress: ev.shippingAddress || null,
415
+ extras: { capturedBy: "snapshot", platform: "xianyu", side },
416
+ };
417
+ }
418
+
419
+ function parseTime(v) {
420
+ if (Number.isFinite(v)) return v < 1e12 ? v * 1000 : v;
421
+ if (typeof v === "string") {
422
+ if (/^\d+$/.test(v)) {
423
+ const n = parseInt(v, 10);
424
+ return n < 1e12 ? n * 1000 : n;
425
+ }
426
+ const t = Date.parse(v);
427
+ if (Number.isFinite(t)) return t;
428
+ }
429
+ return null;
430
+ }
431
+
432
+ function mapStatus(s) {
433
+ const t = String(s || "").toLowerCase();
434
+ if (t.includes("退款") || t.includes("退货") || t.includes("refund")) return "refunded";
435
+ if (t.includes("取消") || t.includes("关闭") || t.includes("cancel")) return "cancelled";
436
+ if (t.includes("待收货") || t.includes("已发货") || t.includes("运送") || t.includes("shipped")) return "shipped";
437
+ if (t.includes("已完成") || t.includes("交易成功") || t.includes("已收货") || t.includes("delivered") || t.includes("success")) return "delivered";
438
+ return "placed";
439
+ }
440
+
441
+ async function defaultFetch(_opts) {
442
+ throw new Error("XianyuAdapter: no fetchFn configured");
443
+ }
444
+
445
+ module.exports = {
446
+ XianyuAdapter,
447
+ orderToRecord,
448
+ extractOrders,
449
+ normSide,
450
+ NAME,
451
+ VERSION,
452
+ SNAPSHOT_SCHEMA_VERSION,
453
+ VALID_SNAPSHOT_KINDS,
454
+ };