@chainlesschain/personal-data-hub 0.2.2 → 0.2.3

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.
@@ -1,59 +1,201 @@
1
1
  /**
2
- * Phase 7.3 — JD (京东) adapter.
2
+ * §2.4b 购物双联 v0.2 — JD (京东) adapter, dual-mode (snapshot + cookie).
3
3
  *
4
- * Parallels Taobao but with JD's order endpoint + JSON shape:
5
- * url: https://order.jd.com/center/list.action
6
- * fields: orderId, orderTotalPrice, orderStartTime, orderStatusText,
7
- * venderName, productList (with name/quantity/price)
4
+ * Mirror of social-weibo / travel-baidu-map two-mode pattern:
5
+ *
6
+ * 1. snapshot mode (opts.inputPath): in-APK Android cc reads a snapshot
7
+ * JSON produced by JdLocalCollector (WebView cookie scrape +
8
+ * `order.jd.com/center/list.action` OkHttp fetch). Desktop-independent.
9
+ * Adapter stateless — account.pin OPTIONAL at construction.
10
+ *
11
+ * 2. cookie mode (legacy Phase 7.3): existing web-api path — `cookies` +
12
+ * `fetchFn` injection seam fetches `order.jd.com/center/list.action`
13
+ * directly. account.pin REQUIRED in this mode (checked at sync, not
14
+ * construction).
15
+ *
16
+ * Snapshot schema (mirrors JdLocalCollector.SNAPSHOT_SCHEMA_VERSION):
17
+ *
18
+ * {
19
+ * "schemaVersion": 1,
20
+ * "snapshottedAt": <epoch-ms>,
21
+ * "vendor": "jd",
22
+ * "account": { "pin": "...", "displayName": "..." },
23
+ * "events": [
24
+ * { "kind": "order", "id": "order-<jdId>", "capturedAt": <ms>,
25
+ * "orderId": "...", "merchantName": "...",
26
+ * "items": [{ "name": ..., "quantity": ..., "unitPrice": ..., "sku": ... }],
27
+ * "placedAt": ..., "paidAt": ..., "status": "placed|shipped|delivered|cancelled|refunded",
28
+ * "totalAmount": { "value": ..., "currency": "CNY" },
29
+ * "recipient": "...", "shippingAddress": "...", "trackingNumber": "..." }
30
+ * ]
31
+ * }
32
+ *
33
+ * Future v0.3: SAF-exported HTML parsing (`Save As Webpage` from `order.jd.com`
34
+ * 端) — currently snapshot mode accepts JSON only; non-JSON inputPath throws.
8
35
  */
9
36
 
10
37
  "use strict";
11
38
 
39
+ const fs = require("node:fs");
12
40
  const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
13
41
 
14
42
  const NAME = "shopping-jd";
15
- const VERSION = "0.5.0";
43
+ const VERSION = "0.6.0";
44
+ const SNAPSHOT_SCHEMA_VERSION = 1;
45
+
46
+ const KIND_ORDER = "order";
47
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_ORDER]);
16
48
 
17
49
  const JD_ORDERS_URL = "https://order.jd.com/center/list.action";
18
50
 
19
51
  class JdAdapter {
20
52
  constructor(opts = {}) {
21
- if (!opts.account || !opts.account.pin) {
22
- throw new Error("JdAdapter: opts.account.pin required (JD user pin)");
23
- }
24
- this.account = opts.account;
25
- this._cookieAuth = new CookieAuth({
26
- platform: "jd",
27
- cookies: opts.account.cookies || "",
28
- });
53
+ // §2.4b v0.2: account.pin OPTIONAL — snapshot mode is stateless. Cookie
54
+ // mode requires it; checked at sync time, not construction.
55
+ this.account = opts.account || null;
56
+ this._cookieAuth = opts.account
57
+ ? new CookieAuth({ platform: "jd", cookies: opts.account.cookies || "" })
58
+ : null;
29
59
  this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
30
60
 
31
61
  this.name = NAME;
32
62
  this.version = VERSION;
33
- this.capabilities = ["sync:cookie-api", "parse:jd-orders"];
63
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:jd-orders"];
34
64
  this.extractMode = "web-api";
35
65
  this.rateLimits = { perMinute: 6, perDay: 200 };
36
66
  this.dataDisclosure = {
37
67
  fields: ["jd:orderId / venderName / productList / orderTotalPrice / address"],
38
68
  sensitivity: "high",
39
69
  legalGate: false,
70
+ defaultInclude: { order: true },
40
71
  };
72
+
73
+ // _deps injection seam — vi.mock fs doesn't intercept inlined CJS require
74
+ // (see .claude/rules/testing.md).
75
+ this._deps = { fs };
41
76
  }
42
77
 
43
- async authenticate() {
44
- const ok = await this._cookieAuth.validate();
45
- if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
46
- return { ok: true, account: this.account.pin };
78
+ async authenticate(ctx = {}) {
79
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
80
+ try {
81
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
82
+ } catch (err) {
83
+ return {
84
+ ok: false,
85
+ reason: "INPUT_PATH_UNREADABLE",
86
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
87
+ };
88
+ }
89
+ return { ok: true, mode: "snapshot-file" };
90
+ }
91
+ if (this._cookieAuth) {
92
+ const ok = await this._cookieAuth.validate();
93
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
94
+ if (!this.account || !this.account.pin) {
95
+ return { ok: false, reason: "NO_ACCOUNT_PIN", message: "cookie mode requires account.pin" };
96
+ }
97
+ return { ok: true, account: this.account.pin, mode: "cookie" };
98
+ }
99
+ return {
100
+ ok: false,
101
+ reason: "NO_INPUT",
102
+ message: "JdAdapter.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie mode)",
103
+ };
47
104
  }
48
105
 
49
106
  async healthCheck() {
50
- const r = await this.authenticate();
51
- return r.ok
52
- ? { ok: true, lastChecked: Date.now() }
53
- : { ok: false, reason: r.reason, error: r.error };
107
+ if (this._cookieAuth) {
108
+ const r = await this.authenticate();
109
+ return r.ok
110
+ ? { ok: true, lastChecked: Date.now() }
111
+ : { ok: false, reason: r.reason, error: r.error };
112
+ }
113
+ return { ok: true, lastChecked: Date.now() };
54
114
  }
55
115
 
56
116
  async *sync(opts = {}) {
117
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
118
+ yield* this._syncViaSnapshot(opts);
119
+ return;
120
+ }
121
+ if (this._cookieAuth) {
122
+ yield* this._syncViaCookie(opts);
123
+ return;
124
+ }
125
+ throw new Error(
126
+ "JdAdapter.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) OR opts.account.cookies (cookie mode)",
127
+ );
128
+ }
129
+
130
+ async *_syncViaSnapshot(opts) {
131
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
132
+ // v0.2 explicit JSON-only. HTML / CSV parsing (SAF-exported webpage from
133
+ // order.jd.com) is future v0.3 work.
134
+ let snapshot;
135
+ try {
136
+ snapshot = JSON.parse(raw);
137
+ } catch (err) {
138
+ throw new Error(
139
+ `shopping-jd.sync: snapshot must be JSON (v0.3 will add HTML parsing). Got parse error: ${err.message}`,
140
+ );
141
+ }
142
+ if (
143
+ !snapshot ||
144
+ typeof snapshot !== "object" ||
145
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
146
+ ) {
147
+ throw new Error(
148
+ `shopping-jd.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
149
+ );
150
+ }
151
+ const fallbackCapturedAt =
152
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
153
+ ? Math.floor(snapshot.snapshottedAt)
154
+ : Date.now();
155
+ const account =
156
+ snapshot.account && typeof snapshot.account === "object"
157
+ ? snapshot.account
158
+ : null;
159
+ const include = opts.include || {};
160
+ const limit =
161
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
162
+
163
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
164
+ let emitted = 0;
165
+ for (const ev of events) {
166
+ if (emitted >= limit) return;
167
+ if (!ev || typeof ev !== "object") continue;
168
+ const kind = ev.kind;
169
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
170
+ if (include[kind] === false) continue;
171
+
172
+ const capturedAt =
173
+ parseTime(ev.capturedAt) ||
174
+ parseTime(ev.placedAt) ||
175
+ parseTime(ev.paidAt) ||
176
+ fallbackCapturedAt;
177
+ const id =
178
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
179
+ ev.orderId ||
180
+ null;
181
+
182
+ yield {
183
+ adapter: NAME,
184
+ kind,
185
+ originalId: stableOriginalId(kind, id),
186
+ capturedAt,
187
+ payload: { ...ev, account },
188
+ };
189
+ emitted += 1;
190
+ }
191
+ }
192
+
193
+ async *_syncViaCookie(opts = {}) {
194
+ if (!this.account || !this.account.pin) {
195
+ throw new Error(
196
+ "JdAdapter._syncViaCookie: account.pin required (set via new JdAdapter({ account: { pin } }))",
197
+ );
198
+ }
57
199
  if (!(await this._cookieAuth.validate())) return;
58
200
  const sinceMs = opts.sinceWatermark != null
59
201
  ? parseInt(String(opts.sinceWatermark), 10) || 0
@@ -85,12 +227,69 @@ class JdAdapter {
85
227
  }
86
228
 
87
229
  normalize(raw) {
88
- return normalizeOrderRecord(raw.payload.record, {
230
+ if (!raw || !raw.payload) {
231
+ throw new Error("JdAdapter.normalize: payload missing");
232
+ }
233
+ // Cookie-mode payload carries `record`; snapshot-mode payload carries fields
234
+ // directly on the event.
235
+ if (raw.payload.record) {
236
+ return normalizeOrderRecord(raw.payload.record, {
237
+ adapterName: NAME, adapterVersion: VERSION,
238
+ });
239
+ }
240
+ // Snapshot-mode: rebuild OrderRecord from flat snapshot event.
241
+ const rec = snapshotEventToRecord(raw.payload);
242
+ return normalizeOrderRecord(rec, {
89
243
  adapterName: NAME, adapterVersion: VERSION,
90
244
  });
91
245
  }
92
246
  }
93
247
 
248
+ function stableOriginalId(kind, id) {
249
+ const stringified =
250
+ (typeof id === "string" && id.length > 0 && id) ||
251
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
252
+ null;
253
+ const safe =
254
+ stringified ||
255
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
256
+ return `jd:${kind}:${safe}`;
257
+ }
258
+
259
+ function snapshotEventToRecord(ev) {
260
+ const items = [];
261
+ const rawItems = Array.isArray(ev.items) ? ev.items : [];
262
+ for (const it of rawItems) {
263
+ if (!it) continue;
264
+ items.push({
265
+ name: it.name || it.productName || it.skuName,
266
+ quantity: parseInt(it.quantity || 1, 10),
267
+ unitPrice: parseFloat(it.unitPrice || 0),
268
+ sku: it.sku || null,
269
+ });
270
+ }
271
+ return {
272
+ vendorId: "jd",
273
+ orderId: String(ev.orderId || ev.id || "unknown"),
274
+ placedAt: parseTime(ev.placedAt),
275
+ paidAt: parseTime(ev.paidAt),
276
+ status: ev.status || "placed",
277
+ merchantName: ev.merchantName || "京东自营",
278
+ totalAmount:
279
+ ev.totalAmount && typeof ev.totalAmount === "object"
280
+ ? {
281
+ value: parseFloat(ev.totalAmount.value || 0),
282
+ currency: ev.totalAmount.currency || "CNY",
283
+ }
284
+ : { value: 0, currency: "CNY" },
285
+ items,
286
+ recipient: ev.recipient || null,
287
+ shippingAddress: ev.shippingAddress || null,
288
+ trackingNumber: ev.trackingNumber || null,
289
+ extras: { capturedBy: "snapshot" },
290
+ };
291
+ }
292
+
94
293
  function orderToRecord(o) {
95
294
  if (!o || typeof o !== "object") return null;
96
295
  const orderId = o.orderId || o.id;
@@ -128,6 +327,10 @@ function orderToRecord(o) {
128
327
  function parseTime(v) {
129
328
  if (Number.isFinite(v)) return v < 1e12 ? v * 1000 : v;
130
329
  if (typeof v === "string") {
330
+ if (/^\d+$/.test(v)) {
331
+ const n = parseInt(v, 10);
332
+ return n < 1e12 ? n * 1000 : n;
333
+ }
131
334
  const t = Date.parse(v);
132
335
  if (Number.isFinite(t)) return t;
133
336
  }
@@ -147,4 +350,4 @@ async function defaultFetch(_opts) {
147
350
  throw new Error("JdAdapter: no fetchFn configured");
148
351
  }
149
352
 
150
- module.exports = { JdAdapter, orderToRecord, NAME, VERSION };
353
+ module.exports = { JdAdapter, orderToRecord, NAME, VERSION, SNAPSHOT_SCHEMA_VERSION };
@@ -1,65 +1,204 @@
1
1
  /**
2
- * Phase 7.3b — Meituan (美团) adapter.
2
+ * §2.4b 购物双联 v0.2 — Meituan (美团) adapter, dual-mode (snapshot + cookie).
3
3
  *
4
- * Meituan covers 外卖 (food delivery) + 团购 (group buy) + 酒店 (hotel
5
- * — though Ctrip is more common). v0 focuses on 外卖 + 团购 since that's
6
- * where most users have the longest history.
4
+ * Meituan covers 外卖 (food delivery) + 团购 (group buy) + 酒店 (hotel — though
5
+ * Ctrip is more common). v0.2 expands the existing cookie-fetch flow with a
6
+ * snapshot mode for the Android in-APK cc path.
7
7
  *
8
- * Auth: cookie-based, similar to Taobao/JD.
8
+ * 1. snapshot mode (opts.inputPath): in-APK Android cc reads a snapshot
9
+ * JSON produced by MeituanLocalCollector (WebView cookie scrape +
10
+ * `h5.meituan.com/order/list` OkHttp fetch — both `waimai` and
11
+ * `groupbuy` platforms). Adapter stateless — account.userId OPTIONAL
12
+ * at construction.
13
+ *
14
+ * 2. cookie mode (legacy Phase 7.3b): existing web-api path — `cookies` +
15
+ * `fetchFn` injection seam fetches `h5.meituan.com/order/list` directly.
16
+ * account.userId REQUIRED in this mode (checked at sync time).
17
+ *
18
+ * Snapshot schema (mirrors MeituanLocalCollector.SNAPSHOT_SCHEMA_VERSION):
19
+ *
20
+ * {
21
+ * "schemaVersion": 1,
22
+ * "snapshottedAt": <epoch-ms>,
23
+ * "vendor": "meituan",
24
+ * "account": { "userId": "...", "displayName": "..." },
25
+ * "events": [
26
+ * { "kind": "order", "id": "order-<mtId>", "capturedAt": <ms>,
27
+ * "orderId": "...", "merchantName": "...", "platform": "waimai|groupbuy|hotel",
28
+ * "items": [{ "name": ..., "quantity": ..., "unitPrice": ... }],
29
+ * "placedAt": ..., "paidAt": ..., "status": "...",
30
+ * "totalAmount": { "value": ..., "currency": "CNY" },
31
+ * "recipient": "...", "shippingAddress": "..." }
32
+ * ]
33
+ * }
34
+ *
35
+ * Future v0.3: SAF-exported HTML parsing (Save As Webpage from h5.meituan.com)
36
+ * — currently snapshot mode accepts JSON only; non-JSON inputPath throws.
9
37
  */
10
38
 
11
39
  "use strict";
12
40
 
41
+ const fs = require("node:fs");
13
42
  const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
14
43
 
15
44
  const NAME = "shopping-meituan";
16
- const VERSION = "0.5.0";
45
+ const VERSION = "0.6.0";
46
+ const SNAPSHOT_SCHEMA_VERSION = 1;
47
+
48
+ const KIND_ORDER = "order";
49
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_ORDER]);
17
50
 
18
51
  const MEITUAN_ORDERS_URL = "https://h5.meituan.com/order/list";
19
52
 
20
53
  class MeituanAdapter {
21
54
  constructor(opts = {}) {
22
- if (!opts.account || !opts.account.userId) {
23
- throw new Error("MeituanAdapter: opts.account.userId required");
24
- }
25
- this.account = opts.account;
26
- this._cookieAuth = new CookieAuth({
27
- platform: "meituan",
28
- cookies: opts.account.cookies || "",
29
- });
55
+ // §2.4b v0.2: account.userId OPTIONAL — snapshot mode is stateless. Cookie
56
+ // mode requires it; checked at sync time.
57
+ this.account = opts.account || null;
58
+ this._cookieAuth = opts.account
59
+ ? new CookieAuth({ platform: "meituan", cookies: opts.account.cookies || "" })
60
+ : null;
30
61
  this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
31
62
 
32
63
  this.name = NAME;
33
64
  this.version = VERSION;
34
- this.capabilities = ["sync:cookie-api", "parse:meituan-orders"];
65
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:meituan-orders"];
35
66
  this.extractMode = "web-api";
36
67
  this.rateLimits = { perMinute: 8, perDay: 200 };
37
68
  this.dataDisclosure = {
38
69
  fields: ["meituan:orderId / poiName / dishes / totalPrice / deliveryAddress"],
39
70
  sensitivity: "high",
40
71
  legalGate: false,
72
+ defaultInclude: { order: true },
41
73
  };
74
+
75
+ this._deps = { fs };
42
76
  }
43
77
 
44
- async authenticate() {
45
- const ok = await this._cookieAuth.validate();
46
- if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
47
- return { ok: true, account: this.account.userId };
78
+ async authenticate(ctx = {}) {
79
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
80
+ try {
81
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
82
+ } catch (err) {
83
+ return {
84
+ ok: false,
85
+ reason: "INPUT_PATH_UNREADABLE",
86
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
87
+ };
88
+ }
89
+ return { ok: true, mode: "snapshot-file" };
90
+ }
91
+ if (this._cookieAuth) {
92
+ const ok = await this._cookieAuth.validate();
93
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
94
+ if (!this.account || !this.account.userId) {
95
+ return { ok: false, reason: "NO_ACCOUNT_USER_ID", message: "cookie mode requires account.userId" };
96
+ }
97
+ return { ok: true, account: this.account.userId, mode: "cookie" };
98
+ }
99
+ return {
100
+ ok: false,
101
+ reason: "NO_INPUT",
102
+ message: "MeituanAdapter.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie mode)",
103
+ };
48
104
  }
49
105
 
50
106
  async healthCheck() {
51
- const r = await this.authenticate();
52
- return r.ok
53
- ? { ok: true, lastChecked: Date.now() }
54
- : { ok: false, reason: r.reason, error: r.error };
107
+ if (this._cookieAuth) {
108
+ const r = await this.authenticate();
109
+ return r.ok
110
+ ? { ok: true, lastChecked: Date.now() }
111
+ : { ok: false, reason: r.reason, error: r.error };
112
+ }
113
+ return { ok: true, lastChecked: Date.now() };
55
114
  }
56
115
 
57
116
  async *sync(opts = {}) {
117
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
118
+ yield* this._syncViaSnapshot(opts);
119
+ return;
120
+ }
121
+ if (this._cookieAuth) {
122
+ yield* this._syncViaCookie(opts);
123
+ return;
124
+ }
125
+ throw new Error(
126
+ "MeituanAdapter.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) OR opts.account.cookies (cookie mode)",
127
+ );
128
+ }
129
+
130
+ async *_syncViaSnapshot(opts) {
131
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
132
+ let snapshot;
133
+ try {
134
+ snapshot = JSON.parse(raw);
135
+ } catch (err) {
136
+ throw new Error(
137
+ `shopping-meituan.sync: snapshot must be JSON (v0.3 will add HTML parsing). Got parse error: ${err.message}`,
138
+ );
139
+ }
140
+ if (
141
+ !snapshot ||
142
+ typeof snapshot !== "object" ||
143
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
144
+ ) {
145
+ throw new Error(
146
+ `shopping-meituan.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
147
+ );
148
+ }
149
+ const fallbackCapturedAt =
150
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
151
+ ? Math.floor(snapshot.snapshottedAt)
152
+ : Date.now();
153
+ const account =
154
+ snapshot.account && typeof snapshot.account === "object"
155
+ ? snapshot.account
156
+ : null;
157
+ const include = opts.include || {};
158
+ const limit =
159
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
160
+
161
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
162
+ let emitted = 0;
163
+ for (const ev of events) {
164
+ if (emitted >= limit) return;
165
+ if (!ev || typeof ev !== "object") continue;
166
+ const kind = ev.kind;
167
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
168
+ if (include[kind] === false) continue;
169
+
170
+ const capturedAt =
171
+ parseTime(ev.capturedAt) ||
172
+ parseTime(ev.placedAt) ||
173
+ parseTime(ev.paidAt) ||
174
+ fallbackCapturedAt;
175
+ const id =
176
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
177
+ ev.orderId ||
178
+ null;
179
+
180
+ yield {
181
+ adapter: NAME,
182
+ kind,
183
+ originalId: stableOriginalId(kind, id),
184
+ capturedAt,
185
+ payload: { ...ev, account },
186
+ };
187
+ emitted += 1;
188
+ }
189
+ }
190
+
191
+ async *_syncViaCookie(opts = {}) {
192
+ if (!this.account || !this.account.userId) {
193
+ throw new Error(
194
+ "MeituanAdapter._syncViaCookie: account.userId required (set via new MeituanAdapter({ account: { userId } }))",
195
+ );
196
+ }
58
197
  if (!(await this._cookieAuth.validate())) return;
59
198
  const sinceMs = opts.sinceWatermark != null
60
199
  ? parseInt(String(opts.sinceWatermark), 10) || 0
61
200
  : (Date.now() - 365 * 24 * 3600_000);
62
- const platforms = opts.platforms || ["waimai", "groupbuy"]; // sub-types
201
+ const platforms = opts.platforms || ["waimai", "groupbuy"];
63
202
 
64
203
  for (const platform of platforms) {
65
204
  let page = 1;
@@ -90,12 +229,65 @@ class MeituanAdapter {
90
229
  }
91
230
 
92
231
  normalize(raw) {
93
- return normalizeOrderRecord(raw.payload.record, {
232
+ if (!raw || !raw.payload) {
233
+ throw new Error("MeituanAdapter.normalize: payload missing");
234
+ }
235
+ if (raw.payload.record) {
236
+ return normalizeOrderRecord(raw.payload.record, {
237
+ adapterName: NAME, adapterVersion: VERSION,
238
+ });
239
+ }
240
+ const rec = snapshotEventToRecord(raw.payload);
241
+ return normalizeOrderRecord(rec, {
94
242
  adapterName: NAME, adapterVersion: VERSION,
95
243
  });
96
244
  }
97
245
  }
98
246
 
247
+ function stableOriginalId(kind, id) {
248
+ const stringified =
249
+ (typeof id === "string" && id.length > 0 && id) ||
250
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
251
+ null;
252
+ const safe =
253
+ stringified ||
254
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
255
+ return `meituan:${kind}:${safe}`;
256
+ }
257
+
258
+ function snapshotEventToRecord(ev) {
259
+ const items = [];
260
+ const rawItems = Array.isArray(ev.items) ? ev.items : [];
261
+ for (const it of rawItems) {
262
+ if (!it) continue;
263
+ items.push({
264
+ name: it.name || it.dishName || it.dealName,
265
+ quantity: parseInt(it.quantity || 1, 10),
266
+ unitPrice: parseFloat(it.unitPrice || 0),
267
+ sku: it.sku || null,
268
+ });
269
+ }
270
+ return {
271
+ vendorId: "meituan",
272
+ orderId: String(ev.orderId || ev.id || "unknown"),
273
+ placedAt: parseTime(ev.placedAt),
274
+ paidAt: parseTime(ev.paidAt),
275
+ status: ev.status || "placed",
276
+ merchantName: ev.merchantName || "美团",
277
+ totalAmount:
278
+ ev.totalAmount && typeof ev.totalAmount === "object"
279
+ ? {
280
+ value: parseFloat(ev.totalAmount.value || 0),
281
+ currency: ev.totalAmount.currency || "CNY",
282
+ }
283
+ : { value: 0, currency: "CNY" },
284
+ items,
285
+ recipient: ev.recipient || null,
286
+ shippingAddress: ev.shippingAddress || null,
287
+ extras: { platform: ev.platform || "waimai", capturedBy: "snapshot" },
288
+ };
289
+ }
290
+
99
291
  function orderToRecord(o, platform = "waimai") {
100
292
  if (!o || typeof o !== "object") return null;
101
293
  const orderId = o.orderId || o.viewOrderId || o.id;
@@ -132,6 +324,10 @@ function orderToRecord(o, platform = "waimai") {
132
324
  function parseTime(v) {
133
325
  if (Number.isFinite(v)) return v < 1e12 ? v * 1000 : v;
134
326
  if (typeof v === "string") {
327
+ if (/^\d+$/.test(v)) {
328
+ const n = parseInt(v, 10);
329
+ return n < 1e12 ? n * 1000 : n;
330
+ }
135
331
  const t = Date.parse(v);
136
332
  if (Number.isFinite(t)) return t;
137
333
  }
@@ -151,4 +347,4 @@ async function defaultFetch(_opts) {
151
347
  throw new Error("MeituanAdapter: no fetchFn configured");
152
348
  }
153
349
 
154
- module.exports = { MeituanAdapter, orderToRecord, NAME, VERSION };
350
+ module.exports = { MeituanAdapter, orderToRecord, NAME, VERSION, SNAPSHOT_SCHEMA_VERSION };