@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.
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +58 -16
- package/__tests__/longtail-adapters.test.js +60 -14
- package/__tests__/messaging-qq-snapshot.test.js +294 -0
- package/__tests__/shopping-pinduoduo-snapshot.test.js +302 -0
- package/__tests__/shopping-snapshot.test.js +438 -0
- package/__tests__/social-adapters.test.js +28 -3
- package/__tests__/social-douyin-snapshot.test.js +253 -0
- package/__tests__/social-kuaishou-snapshot.test.js +309 -0
- package/__tests__/social-toutiao-snapshot.test.js +314 -0
- package/__tests__/social-weibo-snapshot.test.js +234 -0
- package/__tests__/social-xiaohongshu-snapshot.test.js +232 -0
- package/__tests__/travel-maps-snapshot.test.js +426 -0
- package/__tests__/vault-driver-error.test.js +74 -0
- package/lib/adapters/messaging-qq/index.js +498 -92
- package/lib/adapters/shopping-jd/index.js +228 -25
- package/lib/adapters/shopping-meituan/index.js +222 -26
- package/lib/adapters/shopping-pinduoduo/index.js +275 -0
- package/lib/adapters/social-douyin/index.js +454 -63
- package/lib/adapters/social-kuaishou/index.js +379 -127
- package/lib/adapters/social-toutiao/index.js +400 -130
- package/lib/adapters/social-weibo/index.js +393 -95
- package/lib/adapters/social-xiaohongshu/index.js +389 -49
- package/lib/adapters/travel-baidu-map/index.js +286 -26
- package/lib/adapters/travel-tencent-map/index.js +414 -0
- package/lib/index.js +5 -1
- package/lib/vault.js +60 -8
- package/package.json +2 -1
|
@@ -1,59 +1,201 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* §2.4b 购物双联 v0.2 — JD (京东) adapter, dual-mode (snapshot + cookie).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
this.
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2
|
+
* §2.4b 购物双联 v0.2 — Meituan (美团) adapter, dual-mode (snapshot + cookie).
|
|
3
3
|
*
|
|
4
|
-
* Meituan covers 外卖 (food delivery) + 团购 (group buy) + 酒店 (hotel
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
this.
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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"];
|
|
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
|
-
|
|
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 };
|