@chainlesschain/personal-data-hub 0.4.5 → 0.4.6

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.
@@ -10,7 +10,10 @@ const {
10
10
  PinduoduoAdapter,
11
11
  SNAPSHOT_SCHEMA_VERSION,
12
12
  VALID_SNAPSHOT_KINDS,
13
+ orderToRecord,
14
+ extractOrders,
13
15
  } = require("../lib/adapters/shopping-pinduoduo");
16
+ const { assertAdapter } = require("../lib/adapter-spec");
14
17
  const { validateBatch } = require("../lib/batch");
15
18
 
16
19
  // §2.4c v0.2 — Pinduoduo snapshot-only adapter. Pinduoduo's web API requires
@@ -274,6 +277,13 @@ describe("PinduoduoAdapter snapshot mode", () => {
274
277
  expect(raws[0].capturedAt).toBe(ts);
275
278
  });
276
279
 
280
+ it("advertises both snapshot and cookie-api capabilities", () => {
281
+ const a = new PinduoduoAdapter();
282
+ expect(a.capabilities).toContain("sync:snapshot");
283
+ expect(a.capabilities).toContain("sync:cookie-api");
284
+ expect(assertAdapter(a).ok).toBe(true);
285
+ });
286
+
277
287
  it("snapshotEventToRecord handles snake_case goods_name/goods_price fallback", async () => {
278
288
  // Pinduoduo's internal field names use snake_case (goods_name, goods_price,
279
289
  // goods_count); the normalizer falls back to those if camelCase is absent.
@@ -300,3 +310,175 @@ describe("PinduoduoAdapter snapshot mode", () => {
300
310
  expect(JSON.stringify(batch)).toContain("纸巾");
301
311
  });
302
312
  });
313
+
314
+ // §2.4c v0.3 — Pinduoduo cookie-api mode. The actual HTTP + anti_token signing
315
+ // is injected (fetchFn / signProvider) so the adapter stays pure-Node.
316
+ describe("PinduoduoAdapter cookie-api mode", () => {
317
+ it("authenticate(cookie) ok when uid + cookies present", async () => {
318
+ const a = new PinduoduoAdapter({ account: { uid: "u-1", cookies: "PDDAccessToken=ok" } });
319
+ const res = await a.authenticate();
320
+ expect(res.ok).toBe(true);
321
+ expect(res.mode).toBe("cookie");
322
+ expect(res.account).toBe("u-1");
323
+ });
324
+
325
+ it("authenticate(cookie) requires account.uid", async () => {
326
+ const a = new PinduoduoAdapter({ account: { cookies: "PDDAccessToken=ok" } });
327
+ const res = await a.authenticate();
328
+ expect(res.ok).toBe(false);
329
+ expect(res.reason).toBe("NO_ACCOUNT_UID");
330
+ });
331
+
332
+ it("sync yields normalized records from fetchFn fixture (cents → 元)", async () => {
333
+ const fetchFn = async () => ({
334
+ orders: [
335
+ {
336
+ order_sn: "PDD-COOKIE-1",
337
+ mall_name: "拼多多旗舰店",
338
+ order_status_prompt: "已发货",
339
+ order_amount: 6200, // 分 → 62.00 元
340
+ order_time: 1700000000, // sec
341
+ pay_time: 1700000010,
342
+ receive_name: "李四",
343
+ address: "广州市天河区...",
344
+ goods_list: [
345
+ { goods_name: "纸巾100抽", goods_number: 5, goods_price: 990, sku_id: "sk1" },
346
+ ],
347
+ },
348
+ ],
349
+ });
350
+ const a = new PinduoduoAdapter({
351
+ account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
352
+ fetchFn,
353
+ });
354
+ const raws = [];
355
+ for await (const r of a.sync({ sinceWatermark: 0 })) raws.push(r);
356
+ expect(raws.length).toBe(1);
357
+ expect(raws[0].originalId).toBe("PDD-COOKIE-1");
358
+ expect(raws[0].payload.record.totalAmount.value).toBe(62);
359
+ expect(raws[0].payload.record.items[0].unitPrice).toBe(9.9);
360
+ expect(raws[0].payload.record.status).toBe("shipped");
361
+
362
+ const batch = a.normalize(raws[0]);
363
+ expect(validateBatch(batch).valid).toBe(true);
364
+ expect(JSON.stringify(batch)).toContain("纸巾100抽");
365
+ expect(JSON.stringify(batch)).toContain("李四");
366
+ });
367
+
368
+ it("invokes signProvider and passes antiToken to fetchFn", async () => {
369
+ let seenAntiToken = null;
370
+ const signProvider = async () => "ANTI-TOKEN-XYZ";
371
+ const fetchFn = async (opts) => {
372
+ seenAntiToken = opts.antiToken;
373
+ return { orders: [] };
374
+ };
375
+ const a = new PinduoduoAdapter({
376
+ account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
377
+ fetchFn,
378
+ signProvider,
379
+ });
380
+ for await (const _r of a.sync({ sinceWatermark: 0 })) { /* drain */ }
381
+ expect(seenAntiToken).toBe("ANTI-TOKEN-XYZ");
382
+ });
383
+
384
+ it("passes antiToken: null when no signProvider configured", async () => {
385
+ let seen = "unset";
386
+ const fetchFn = async (opts) => {
387
+ seen = opts.antiToken;
388
+ return { orders: [] };
389
+ };
390
+ const a = new PinduoduoAdapter({
391
+ account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
392
+ fetchFn,
393
+ });
394
+ for await (const _r of a.sync({ sinceWatermark: 0 })) { /* drain */ }
395
+ expect(seen).toBe(null);
396
+ });
397
+
398
+ it("paginates and stops at sinceWatermark", async () => {
399
+ const pages = {
400
+ 1: [
401
+ { order_sn: "p1-a", mall_name: "m", order_time: 1700000000, order_amount: 100,
402
+ goods_list: [] },
403
+ { order_sn: "p1-b", mall_name: "m", order_time: 1699000000, order_amount: 100,
404
+ goods_list: [] },
405
+ ],
406
+ 2: [
407
+ { order_sn: "p2-a", mall_name: "m", order_time: 1698000000, order_amount: 100,
408
+ goods_list: [] },
409
+ ],
410
+ };
411
+ const seenPages = [];
412
+ const fetchFn = async (opts) => {
413
+ seenPages.push(opts.query.pageNumber);
414
+ return { orders: pages[opts.query.pageNumber] || [] };
415
+ };
416
+ const a = new PinduoduoAdapter({
417
+ account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
418
+ fetchFn,
419
+ });
420
+ const raws = [];
421
+ // watermark between 1699000000s and 1700000000s → stops on the older order
422
+ for await (const r of a.sync({ sinceWatermark: 1699500000 * 1000, pageSize: 2 })) {
423
+ raws.push(r);
424
+ }
425
+ expect(raws.map((r) => r.originalId)).toEqual(["p1-a"]);
426
+ // stopped inside page 1 — never fetched page 2
427
+ expect(seenPages).toEqual([1]);
428
+ });
429
+
430
+ it("respects per-kind include opt-out in cookie mode", async () => {
431
+ let called = false;
432
+ const fetchFn = async () => {
433
+ called = true;
434
+ return { orders: [] };
435
+ };
436
+ const a = new PinduoduoAdapter({
437
+ account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
438
+ fetchFn,
439
+ });
440
+ const raws = [];
441
+ for await (const r of a.sync({ include: { order: false } })) raws.push(r);
442
+ expect(raws.length).toBe(0);
443
+ expect(called).toBe(false);
444
+ });
445
+
446
+ it("orderToRecord maps snake_case PDD fields with cents conversion", () => {
447
+ const rec = orderToRecord({
448
+ order_sn: "PDD-9",
449
+ mall_name: "店铺B",
450
+ order_status: 4, // numeric → 已完成 → delivered
451
+ order_amount: 1650,
452
+ order_time: 1700000000,
453
+ goods_list: [{ goods_name: "牙刷", goods_number: 2, goods_price: 825 }],
454
+ });
455
+ expect(rec.orderId).toBe("PDD-9");
456
+ expect(rec.merchantName).toBe("店铺B");
457
+ expect(rec.status).toBe("delivered");
458
+ expect(rec.totalAmount.value).toBe(16.5);
459
+ expect(rec.items[0].unitPrice).toBe(8.25);
460
+ expect(rec.extras.capturedBy).toBe("cookie-api");
461
+ });
462
+
463
+ it("extractOrders tolerates nested response shapes", () => {
464
+ expect(extractOrders({ orders: [1] })).toEqual([1]);
465
+ expect(extractOrders({ order_list: [2] })).toEqual([2]);
466
+ expect(extractOrders({ list: [3] })).toEqual([3]);
467
+ expect(extractOrders({ result: { order_list: [4] } })).toEqual([4]);
468
+ expect(extractOrders({ result: { list: [5] } })).toEqual([5]);
469
+ expect(extractOrders({})).toEqual([]);
470
+ expect(extractOrders(null)).toEqual([]);
471
+ });
472
+
473
+ it("default fetchFn throws a legible error when cookie mode used without injection", async () => {
474
+ const a = new PinduoduoAdapter({ account: { uid: "u-1", cookies: "PDDAccessToken=ok" } });
475
+ let threw = null;
476
+ try {
477
+ for await (const _r of a.sync({ sinceWatermark: 0 })) { /* drain */ }
478
+ } catch (err) {
479
+ threw = err;
480
+ }
481
+ expect(threw).toBeTruthy();
482
+ expect(String(threw.message)).toMatch(/no fetchFn configured/);
483
+ });
484
+ });
@@ -1,29 +1,32 @@
1
1
  /**
2
- * §2.4c 购物三联 v0.2 — Pinduoduo (拼多多) adapter, snapshot-only.
2
+ * §2.4c 购物三联 — Pinduoduo (拼多多) adapter, dual-mode (snapshot + cookie-api).
3
3
  *
4
- * Mirror of shopping-jd / shopping-meituan snapshot-mode pattern, **but
5
- * without a cookie-mode fallback** because:
4
+ * v0.3 brings 拼多多 to parity with shopping-taobao / shopping-jd /
5
+ * shopping-meituan by adding a cookie-api fetch path alongside the existing
6
+ * snapshot ingest. As with the other shopping adapters the actual HTTP call is
7
+ * delegated to an injected `fetchFn` (the Android in-APK cc uses OkHttp; the
8
+ * desktop hub uses an Electron WebView net request) so this module stays a
9
+ * pure-Node parser + orchestrator.
6
10
  *
7
- * 1. mobile.yangkeduo.com web endpoint `/proxy/api/galerie/transaction/
8
- * transaction_list` requires `anti_token` signing computed by client-side
9
- * JS (similar to 抖音 X-Bogus). No pure-Node implementation survives
10
- * pinduoduo's monthly anti_token rotation.
11
- * 2. Pinduoduo Android app has no built-in "export orders" feature, so
12
- * there's no SAF source-format to parse directly either.
11
+ * 1. snapshot mode (opts.inputPath): ingest a snapshot JSON produced by a
12
+ * browser extension / hand-roll (stateless account OPTIONAL).
13
13
  *
14
- * v0.2 deliverable = **scaffold + snapshot-mode JSON ingest**. User-facing
15
- * paths for producing the snapshot JSON:
14
+ * 2. cookie-api mode (opts.account.cookies): fetch
15
+ * `mobile.yangkeduo.com/proxy/api/galerie/transaction/transaction_list`
16
+ * via the injected `fetchFn`, paginating with the `pageNumber` cursor and
17
+ * stopping at the `sinceWatermark`. account.uid REQUIRED in this mode.
16
18
  *
17
- * a) Browser extension (planned v0.3) that scrapes yangkeduo.com order
18
- * pages while logged in and exports JSON matching this schema.
19
- * b) Manual hand-roll (rare; for testing).
19
+ * ── anti_token signing seam ──────────────────────────────────────────────
20
+ * Pinduoduo's transaction_list requires an `anti_token` (a.k.a.
21
+ * `anti-content`) computed by client-side JS analogous to 抖音 X-Bogus.
22
+ * No pure-Node implementation survives pinduoduo's anti_token rotation, so
23
+ * the signing itself is injected via `opts.signProvider` (or constructor
24
+ * `signProvider`). On Android the in-APK WebView JS VM produces the token;
25
+ * in tests a stub returns a fixed value. When no signProvider is configured
26
+ * the request is still issued with `antiToken: null` — best-effort, the
27
+ * endpoint may 403, which surfaces as zero events rather than a crash.
20
28
  *
21
- * UI surface: pinduoduo card appears alongside alipay/taobao/jd/meituan in
22
- * 推文 §"支付与购物" 大类, with an explicit "v0.2 待用户导出 — 需 web
23
- * extension 或手抄" banner so user knows the limitation.
24
- *
25
- * Snapshot schema (mirrors PinduoduoLocalCollector.SNAPSHOT_SCHEMA_VERSION
26
- * once the Kotlin collector lands in v0.3+):
29
+ * Snapshot schema (mirrors PinduoduoLocalCollector.SNAPSHOT_SCHEMA_VERSION):
27
30
  *
28
31
  * {
29
32
  * "schemaVersion": 1,
@@ -45,33 +48,46 @@
45
48
  * ]
46
49
  * }
47
50
  *
48
- * Future v0.3: HTML parsing (`Save As Webpage` from `mobile.yangkeduo.com/
51
+ * Future v0.4: HTML parsing (`Save As Webpage` from `mobile.yangkeduo.com/
49
52
  * users/orders.html` — pinduoduo's order list endpoint).
50
53
  */
51
54
 
52
55
  "use strict";
53
56
 
54
57
  const fs = require("node:fs");
55
- const { normalizeOrderRecord } = require("../shopping-base");
58
+ const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
56
59
 
57
60
  const NAME = "shopping-pinduoduo";
58
- const VERSION = "0.1.0";
61
+ const VERSION = "0.2.0";
59
62
  const SNAPSHOT_SCHEMA_VERSION = 1;
60
63
 
61
64
  const KIND_ORDER = "order";
62
65
  const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_ORDER]);
63
66
 
67
+ const PINDUODUO_ORDERS_URL =
68
+ "https://mobile.yangkeduo.com/proxy/api/galerie/transaction/transaction_list";
69
+
64
70
  class PinduoduoAdapter {
65
71
  constructor(opts = {}) {
66
- // §2.4c v0.2: account is OPTIONAL — snapshot mode is stateless. There's
67
- // no cookie mode at all (anti_token signing path deferred to v0.3+).
72
+ // §2.4c: account is OPTIONAL — snapshot mode is stateless. Cookie-api mode
73
+ // activates only when account.cookies is supplied; account.uid is then
74
+ // required (checked at sync time).
68
75
  this.account = opts.account || null;
76
+ this._cookieAuth =
77
+ opts.account && opts.account.cookies
78
+ ? new CookieAuth({ platform: "pinduoduo", cookies: opts.account.cookies })
79
+ : null;
80
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
81
+ // anti_token signing seam — see file header. Async fn({ url, query,
82
+ // cookies }) → string|null. When absent, requests carry antiToken: null.
83
+ this._signProvider =
84
+ typeof opts.signProvider === "function" ? opts.signProvider : null;
69
85
 
70
86
  this.name = NAME;
71
87
  this.version = VERSION;
72
- this.capabilities = ["sync:snapshot", "parse:pinduoduo-orders"];
73
- this.extractMode = "user-export";
74
- this.rateLimits = {};
88
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:pinduoduo-orders"];
89
+ this.extractMode = "web-api";
90
+ this.rateLimits = { perMinute: 8, perDay: 200 };
75
91
  this.dataDisclosure = {
76
92
  fields: [
77
93
  "pinduoduo:order_sn / mall_name / goods_list / order_amount / address",
@@ -99,15 +115,33 @@ class PinduoduoAdapter {
99
115
  }
100
116
  return { ok: true, mode: "snapshot-file" };
101
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.uid) {
122
+ return {
123
+ ok: false,
124
+ reason: "NO_ACCOUNT_UID",
125
+ message: "cookie-api mode requires account.uid",
126
+ };
127
+ }
128
+ return { ok: true, account: this.account.uid, mode: "cookie" };
129
+ }
102
130
  return {
103
131
  ok: false,
104
132
  reason: "NO_INPUT",
105
133
  message:
106
- "PinduoduoAdapter.authenticate: needs opts.inputPath (snapshot mode no cookie mode in v0.2)",
134
+ "PinduoduoAdapter.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode anti_token signing via signProvider)",
107
135
  };
108
136
  }
109
137
 
110
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
+ }
111
145
  return { ok: true, lastChecked: Date.now() };
112
146
  }
113
147
 
@@ -116,21 +150,25 @@ class PinduoduoAdapter {
116
150
  yield* this._syncViaSnapshot(opts);
117
151
  return;
118
152
  }
153
+ if (this._cookieAuth) {
154
+ yield* this._syncViaCookie(opts);
155
+ return;
156
+ }
119
157
  throw new Error(
120
- "PinduoduoAdapter.sync: needs opts.inputPath (snapshot mode; no cookie/api mode in v0.2 because pinduoduo's web API requires anti_token JS-VM signing)",
158
+ "PinduoduoAdapter.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode; pinduoduo's web API requires anti_token signing supplied via opts.signProvider)",
121
159
  );
122
160
  }
123
161
 
124
162
  async *_syncViaSnapshot(opts) {
125
163
  const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
126
164
  // v0.2 explicit JSON-only. HTML parsing (SAF-exported webpage from
127
- // yangkeduo.com order list) is future v0.3 work.
165
+ // yangkeduo.com order list) is future v0.4 work.
128
166
  let snapshot;
129
167
  try {
130
168
  snapshot = JSON.parse(raw);
131
169
  } catch (err) {
132
170
  throw new Error(
133
- `shopping-pinduoduo.sync: snapshot must be JSON (v0.3 will add HTML parsing). Got parse error: ${err.message}`,
171
+ `shopping-pinduoduo.sync: snapshot must be JSON (v0.4 will add HTML parsing). Got parse error: ${err.message}`,
134
172
  );
135
173
  }
136
174
  if (
@@ -184,11 +222,77 @@ class PinduoduoAdapter {
184
222
  }
185
223
  }
186
224
 
225
+ async *_syncViaCookie(opts = {}) {
226
+ if (!this.account || !this.account.uid) {
227
+ throw new Error(
228
+ "PinduoduoAdapter._syncViaCookie: account.uid required (set via new PinduoduoAdapter({ account: { uid, cookies } }))",
229
+ );
230
+ }
231
+ if (!(await this._cookieAuth.validate())) return;
232
+ const sinceMs =
233
+ opts.sinceWatermark != null
234
+ ? parseInt(String(opts.sinceWatermark), 10) || 0
235
+ : Date.now() - 365 * 24 * 3600_000; // default last year
236
+ const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : 10;
237
+ const include = opts.include || {};
238
+ if (include[KIND_ORDER] === false) return;
239
+
240
+ let pageNumber = 1;
241
+ while (true) {
242
+ const query = { pageNumber, pageSize, ts: Date.now() };
243
+ // anti_token signing seam — best-effort. null when no signProvider.
244
+ let antiToken = null;
245
+ if (this._signProvider) {
246
+ antiToken = await this._signProvider({
247
+ url: PINDUODUO_ORDERS_URL,
248
+ query,
249
+ cookies: this._cookieAuth.toHeader(),
250
+ });
251
+ }
252
+ const resp = await this._fetchFn({
253
+ url: PINDUODUO_ORDERS_URL,
254
+ cookies: this._cookieAuth.toHeader(),
255
+ antiToken,
256
+ query,
257
+ });
258
+ const orders = extractOrders(resp);
259
+ if (!orders.length) break;
260
+ let pageHasNew = false;
261
+ let reachedWatermark = false;
262
+ for (const raw of orders) {
263
+ const rec = orderToRecord(raw);
264
+ if (!rec) continue;
265
+ if (rec.placedAt && rec.placedAt < sinceMs) {
266
+ reachedWatermark = true; // everything from here on is older
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
+ // Stop once we've crossed the watermark, drained the page, or the page
278
+ // came back short (last page).
279
+ if (reachedWatermark || !pageHasNew || orders.length < pageSize) break;
280
+ pageNumber += 1;
281
+ }
282
+ }
283
+
187
284
  normalize(raw) {
188
285
  if (!raw || !raw.payload) {
189
286
  throw new Error("PinduoduoAdapter.normalize: payload missing");
190
287
  }
191
- // Snapshot-mode only payload carries fields directly on the event.
288
+ // Cookie-api mode wraps a normalized record under payload.record; snapshot
289
+ // mode carries the raw event fields directly on the payload.
290
+ if (raw.payload.record) {
291
+ return normalizeOrderRecord(raw.payload.record, {
292
+ adapterName: NAME,
293
+ adapterVersion: VERSION,
294
+ });
295
+ }
192
296
  const rec = snapshotEventToRecord(raw.payload);
193
297
  return normalizeOrderRecord(rec, {
194
298
  adapterName: NAME,
@@ -208,6 +312,104 @@ function stableOriginalId(kind, id) {
208
312
  return `pinduoduo:${kind}:${safe}`;
209
313
  }
210
314
 
315
+ /**
316
+ * Pull the order array out of a transaction_list response. Pinduoduo nests it
317
+ * under different keys across endpoint versions; the injected fetchFn may also
318
+ * pre-flatten to `{ orders }`. Tolerant of all common shapes.
319
+ */
320
+ function extractOrders(resp) {
321
+ if (!resp || typeof resp !== "object") return [];
322
+ if (Array.isArray(resp.orders)) return resp.orders;
323
+ if (Array.isArray(resp.order_list)) return resp.order_list;
324
+ if (Array.isArray(resp.list)) return resp.list;
325
+ if (resp.result && Array.isArray(resp.result.order_list)) return resp.result.order_list;
326
+ if (resp.result && Array.isArray(resp.result.list)) return resp.result.list;
327
+ return [];
328
+ }
329
+
330
+ /**
331
+ * Map one pinduoduo transaction_list order object → vendor-neutral OrderRecord.
332
+ * Pinduoduo amounts are in 分 (cents); converted to 元 here. Field names are
333
+ * best-effort across endpoint versions (camelCase + snake_case fallbacks).
334
+ */
335
+ function orderToRecord(o) {
336
+ if (!o || typeof o !== "object") return null;
337
+ const orderId = o.order_sn || o.orderSn || o.orderId || o.id;
338
+ if (!orderId) return null;
339
+ const merchant = o.mall_name || o.mallName || o.merchantName || o.shop_name || "拼多多";
340
+
341
+ const items = [];
342
+ const rawItems = o.goods_list || o.order_goods || o.goodsList || o.items || [];
343
+ for (const it of Array.isArray(rawItems) ? rawItems : []) {
344
+ if (!it) continue;
345
+ items.push({
346
+ name: it.goods_name || it.goodsName || it.name || it.skuName,
347
+ quantity: parseInt(it.goods_number || it.goods_count || it.quantity || 1, 10),
348
+ unitPrice: centsToYuan(it.goods_price || it.goodsPrice || it.unitPrice || 0),
349
+ sku: it.sku_id || it.skuId || it.goods_id || it.sku || null,
350
+ });
351
+ }
352
+
353
+ return {
354
+ vendorId: "pinduoduo",
355
+ orderId: String(orderId),
356
+ placedAt: parseTime(o.order_time || o.create_at || o.createAt || o.order_create_at),
357
+ paidAt: parseTime(o.pay_time || o.payTime || o.group_order_pay_time),
358
+ status: mapStatus(pickStatusText(o)),
359
+ merchantName: merchant,
360
+ totalAmount: {
361
+ value: centsToYuan(o.order_amount || o.orderAmount || o.pay_amount || o.total_amount || 0),
362
+ currency: "CNY",
363
+ },
364
+ items,
365
+ recipient: o.receive_name || o.receiver || o.recipient || null,
366
+ shippingAddress: o.address || o.receive_address || o.shippingAddress || null,
367
+ trackingNumber: o.tracking_number || o.waybill_no || o.trackingNumber || null,
368
+ extras: { capturedBy: "cookie-api", platform: "pinduoduo" },
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Pinduoduo carries a human-readable status under several keys; prefer text
374
+ * over the numeric `order_status` code so mapStatus's keyword match works.
375
+ */
376
+ function pickStatusText(o) {
377
+ const text =
378
+ o.order_status_prompt ||
379
+ o.orderStatusPrompt ||
380
+ o.status_prompt ||
381
+ o.statusPrompt ||
382
+ o.status_desc ||
383
+ null;
384
+ if (text) return text;
385
+ // Fall back to the numeric order_status code (best-effort PDD mapping).
386
+ const code = o.order_status != null ? o.order_status : o.orderStatus;
387
+ switch (Number(code)) {
388
+ case 1:
389
+ return "待付款";
390
+ case 2:
391
+ return "待发货";
392
+ case 3:
393
+ return "已发货";
394
+ case 4:
395
+ return "已完成";
396
+ case 5:
397
+ case 6:
398
+ return "已关闭";
399
+ default:
400
+ return o.status != null ? String(o.status) : "";
401
+ }
402
+ }
403
+
404
+ function centsToYuan(v) {
405
+ const n = Number(v);
406
+ if (!Number.isFinite(n)) return 0;
407
+ // Snapshot/test inputs may already be 元 with a decimal point; treat any
408
+ // non-integer as 元, integers as 分.
409
+ if (!Number.isInteger(n)) return n;
410
+ return Math.round(n) / 100;
411
+ }
412
+
211
413
  function snapshotEventToRecord(ev) {
212
414
  const items = [];
213
415
  const rawItems = Array.isArray(ev.items) ? ev.items : [];
@@ -266,8 +468,14 @@ function mapStatus(s) {
266
468
  return "placed";
267
469
  }
268
470
 
471
+ async function defaultFetch(_opts) {
472
+ throw new Error("PinduoduoAdapter: no fetchFn configured");
473
+ }
474
+
269
475
  module.exports = {
270
476
  PinduoduoAdapter,
477
+ orderToRecord,
478
+ extractOrders,
271
479
  NAME,
272
480
  VERSION,
273
481
  SNAPSHOT_SCHEMA_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chainlesschain/personal-data-hub",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Personal Data Hub — UnifiedSchema + validators + KG ingest helpers for the data-back-to-the-individual middleware",
5
5
  "type": "commonjs",
6
6
  "main": "lib/index.js",