@chainlesschain/personal-data-hub 0.4.6 → 0.4.18
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/doc-baidu-netdisk.test.js +102 -0
- package/__tests__/adapters/doc-platforms.test.js +177 -0
- package/__tests__/adapters/music-kugou.test.js +187 -0
- package/__tests__/adapters/recruit-boss.test.js +180 -0
- package/__tests__/adapters/shopping-dianping.test.js +239 -0
- package/__tests__/adapters/social-csdn.test.js +175 -0
- package/__tests__/adapters/social-zhihu.test.js +246 -0
- package/__tests__/adapters/travel-12306.test.js +234 -1
- package/__tests__/adapters/travel-ctrip.test.js +175 -1
- package/__tests__/adapters/travel-didi.test.js +204 -0
- package/__tests__/adapters/travel-tongcheng.test.js +289 -0
- package/__tests__/adapters/video-platforms.test.js +152 -0
- package/lib/adapter-guide.js +13 -1
- package/lib/adapters/_document-base.js +370 -0
- package/lib/adapters/_video-base.js +331 -0
- package/lib/adapters/doc-baidu-netdisk/index.js +91 -0
- package/lib/adapters/doc-tencent-docs/index.js +94 -0
- package/lib/adapters/doc-wps/index.js +77 -0
- package/lib/adapters/music-kugou/index.js +418 -0
- package/lib/adapters/recruit-boss/index.js +442 -0
- package/lib/adapters/shopping-dianping/index.js +473 -0
- package/lib/adapters/social-csdn/index.js +444 -0
- package/lib/adapters/social-zhihu/index.js +488 -0
- package/lib/adapters/travel-12306/index.js +279 -5
- package/lib/adapters/travel-ctrip/index.js +255 -40
- package/lib/adapters/travel-didi/index.js +327 -0
- package/lib/adapters/travel-tongcheng/index.js +393 -0
- package/lib/adapters/video-iqiyi/index.js +75 -0
- package/lib/adapters/video-tencent/index.js +78 -0
- package/lib/index.js +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §2.4d 购物 long-tail — Dianping (大众点评) adapter, dual-mode (snapshot + cookie-api).
|
|
3
|
+
*
|
|
4
|
+
* 大众点评 is a Phase 7 inventory platform (ROI ⭐⭐⭐⭐ in
|
|
5
|
+
* docs/design/Personal_Data_Hub_Architecture.md §12.1) that was skipped when
|
|
6
|
+
* the shopping 三件套 (taobao/jd/meituan) + pinduoduo shipped. It is owned by
|
|
7
|
+
* Meituan and shares the same order/H5 backend, so this adapter mirrors
|
|
8
|
+
* shopping-meituan's structure: 团购订单 (group-buy orders) map cleanly onto
|
|
9
|
+
* the vendor-neutral OrderRecord (POI = merchantName, deals = items).
|
|
10
|
+
*
|
|
11
|
+
* 1. snapshot mode (opts.inputPath): ingest a snapshot JSON produced by a
|
|
12
|
+
* browser extension / Android in-APK collector / hand-roll. Stateless —
|
|
13
|
+
* account OPTIONAL at construction.
|
|
14
|
+
*
|
|
15
|
+
* 2. cookie-api mode (opts.account.cookies): fetch the Dianping order centre
|
|
16
|
+
* via the injected `fetchFn` (Android in-APK cc → OkHttp; desktop hub →
|
|
17
|
+
* Electron WebView net request), paginating with the `page` cursor and
|
|
18
|
+
* stopping at the `sinceWatermark`. account.userId REQUIRED in this mode.
|
|
19
|
+
*
|
|
20
|
+
* ── sign seam ──────────────────────────────────────────────────────────
|
|
21
|
+
* Dianping's H5 / m-API requests carry an anti-bot token (`_token` /
|
|
22
|
+
* `mtgsig`, analogous to 拼多多 anti_token / 美团 mtgsig) computed by
|
|
23
|
+
* client-side JS. No pure-Node implementation survives the rotation, so
|
|
24
|
+
* signing is injected via `opts.signProvider` (or constructor
|
|
25
|
+
* `signProvider`). When absent the request is still issued with
|
|
26
|
+
* `sign: null` — best-effort, the endpoint may reject it, which surfaces
|
|
27
|
+
* as zero events rather than a crash. The endpoint constant is best-effort
|
|
28
|
+
* and overridable via `opts.ordersUrl`; Dianping/Meituan rotate H5 paths,
|
|
29
|
+
* so adjust / pass opts.ordersUrl if it drifts (FAMILY-23 playbook —
|
|
30
|
+
* endpoints are not field-verified here).
|
|
31
|
+
*
|
|
32
|
+
* Snapshot schema (schemaVersion 1, mirrors the sibling shopping collectors):
|
|
33
|
+
*
|
|
34
|
+
* {
|
|
35
|
+
* "schemaVersion": 1,
|
|
36
|
+
* "snapshottedAt": <epoch-ms>,
|
|
37
|
+
* "vendor": "dianping",
|
|
38
|
+
* "account": { "userId": "...", "displayName": "..." },
|
|
39
|
+
* "events": [
|
|
40
|
+
* { "kind": "order", "id": "order-<orderId>", "capturedAt": <ms>,
|
|
41
|
+
* "orderId": "...", "merchantName": "...", "platform": "groupbuy|reservation",
|
|
42
|
+
* "items": [{ "name": ..., "quantity": ..., "unitPrice": ... }],
|
|
43
|
+
* "placedAt": ..., "paidAt": ..., "status": "...",
|
|
44
|
+
* "totalAmount": { "value": ..., "currency": "CNY" },
|
|
45
|
+
* "recipient": "...", "shippingAddress": "..." }
|
|
46
|
+
* ]
|
|
47
|
+
* }
|
|
48
|
+
*
|
|
49
|
+
* Future: 评价 / 收藏 (reviews / favorites — Dianping's signature UGC) once a
|
|
50
|
+
* review-shaped UnifiedSchema record type lands; v0.1 scopes to orders for
|
|
51
|
+
* parity with the other shopping adapters.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
"use strict";
|
|
55
|
+
|
|
56
|
+
const fs = require("node:fs");
|
|
57
|
+
const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
|
|
58
|
+
|
|
59
|
+
const NAME = "shopping-dianping";
|
|
60
|
+
const VERSION = "0.1.0";
|
|
61
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
62
|
+
|
|
63
|
+
const KIND_ORDER = "order";
|
|
64
|
+
const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_ORDER]);
|
|
65
|
+
|
|
66
|
+
// Best-effort Dianping order-centre list endpoint (shares Meituan's H5 infra).
|
|
67
|
+
// Overridable via opts.ordersUrl; the injected fetchFn host may also point at
|
|
68
|
+
// whichever order API the captured cookie is currently scoped to.
|
|
69
|
+
const DIANPING_ORDERS_URL = "https://m.dianping.com/order/list";
|
|
70
|
+
|
|
71
|
+
class DianpingAdapter {
|
|
72
|
+
constructor(opts = {}) {
|
|
73
|
+
// account OPTIONAL — snapshot mode is stateless. Cookie-api mode activates
|
|
74
|
+
// only when account.cookies is supplied; account.userId is then required
|
|
75
|
+
// (checked at sync time).
|
|
76
|
+
this.account = opts.account || null;
|
|
77
|
+
this._cookieAuth =
|
|
78
|
+
opts.account && opts.account.cookies
|
|
79
|
+
? new CookieAuth({ platform: "dianping", cookies: opts.account.cookies })
|
|
80
|
+
: null;
|
|
81
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
82
|
+
// sign seam — async fn({ url, query, cookies }) → string|null. When absent,
|
|
83
|
+
// requests carry sign: null (best-effort, the endpoint may reject).
|
|
84
|
+
this._signProvider =
|
|
85
|
+
typeof opts.signProvider === "function" ? opts.signProvider : null;
|
|
86
|
+
this._ordersUrl =
|
|
87
|
+
typeof opts.ordersUrl === "string" && opts.ordersUrl.length > 0
|
|
88
|
+
? opts.ordersUrl
|
|
89
|
+
: DIANPING_ORDERS_URL;
|
|
90
|
+
|
|
91
|
+
this.name = NAME;
|
|
92
|
+
this.version = VERSION;
|
|
93
|
+
this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:dianping-orders"];
|
|
94
|
+
this.extractMode = "web-api";
|
|
95
|
+
this.rateLimits = { perMinute: 8, perDay: 200 };
|
|
96
|
+
this.dataDisclosure = {
|
|
97
|
+
fields: ["dianping:orderId / poiName / deals / totalPrice / address"],
|
|
98
|
+
sensitivity: "high",
|
|
99
|
+
legalGate: false,
|
|
100
|
+
defaultInclude: { order: true },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// _deps injection seam — vi.mock fs doesn't intercept inlined CJS require
|
|
104
|
+
// (see .claude/rules/testing.md).
|
|
105
|
+
this._deps = { fs };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async authenticate(ctx = {}) {
|
|
109
|
+
if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
|
|
110
|
+
try {
|
|
111
|
+
this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
reason: "INPUT_PATH_UNREADABLE",
|
|
116
|
+
message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { ok: true, mode: "snapshot-file" };
|
|
120
|
+
}
|
|
121
|
+
if (this._cookieAuth) {
|
|
122
|
+
const ok = await this._cookieAuth.validate();
|
|
123
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
|
|
124
|
+
if (!this.account || !this.account.userId) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
reason: "NO_ACCOUNT_USER_ID",
|
|
128
|
+
message: "cookie-api mode requires account.userId",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, account: this.account.userId, mode: "cookie" };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: "NO_INPUT",
|
|
136
|
+
message:
|
|
137
|
+
"DianpingAdapter.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode — sign via signProvider)",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async healthCheck() {
|
|
142
|
+
if (this._cookieAuth) {
|
|
143
|
+
const r = await this.authenticate();
|
|
144
|
+
return r.ok
|
|
145
|
+
? { ok: true, lastChecked: Date.now() }
|
|
146
|
+
: { ok: false, reason: r.reason, error: r.error };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, lastChecked: Date.now() };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async *sync(opts = {}) {
|
|
152
|
+
if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
|
|
153
|
+
yield* this._syncViaSnapshot(opts);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (this._cookieAuth) {
|
|
157
|
+
yield* this._syncViaCookie(opts);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
throw new Error(
|
|
161
|
+
"DianpingAdapter.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode; Dianping's H5 API requires an anti-bot sign supplied via opts.signProvider)",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async *_syncViaSnapshot(opts) {
|
|
166
|
+
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
167
|
+
let snapshot;
|
|
168
|
+
try {
|
|
169
|
+
snapshot = JSON.parse(raw);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`shopping-dianping.sync: snapshot must be JSON. Got parse error: ${err.message}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
!snapshot ||
|
|
177
|
+
typeof snapshot !== "object" ||
|
|
178
|
+
snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
|
|
179
|
+
) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`shopping-dianping.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const fallbackCapturedAt =
|
|
185
|
+
Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
|
|
186
|
+
? Math.floor(snapshot.snapshottedAt)
|
|
187
|
+
: Date.now();
|
|
188
|
+
const account =
|
|
189
|
+
snapshot.account && typeof snapshot.account === "object"
|
|
190
|
+
? snapshot.account
|
|
191
|
+
: null;
|
|
192
|
+
const include = opts.include || {};
|
|
193
|
+
const limit =
|
|
194
|
+
Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
195
|
+
|
|
196
|
+
const events = Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
197
|
+
let emitted = 0;
|
|
198
|
+
for (const ev of events) {
|
|
199
|
+
if (emitted >= limit) return;
|
|
200
|
+
if (!ev || typeof ev !== "object") continue;
|
|
201
|
+
const kind = ev.kind;
|
|
202
|
+
if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
|
|
203
|
+
if (include[kind] === false) continue;
|
|
204
|
+
|
|
205
|
+
const capturedAt =
|
|
206
|
+
parseTime(ev.capturedAt) ||
|
|
207
|
+
parseTime(ev.placedAt) ||
|
|
208
|
+
parseTime(ev.paidAt) ||
|
|
209
|
+
fallbackCapturedAt;
|
|
210
|
+
const id =
|
|
211
|
+
(typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
|
|
212
|
+
ev.orderId ||
|
|
213
|
+
null;
|
|
214
|
+
|
|
215
|
+
yield {
|
|
216
|
+
adapter: NAME,
|
|
217
|
+
kind,
|
|
218
|
+
originalId: stableOriginalId(kind, id),
|
|
219
|
+
capturedAt,
|
|
220
|
+
payload: { ...ev, account },
|
|
221
|
+
};
|
|
222
|
+
emitted += 1;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async *_syncViaCookie(opts = {}) {
|
|
227
|
+
if (!this.account || !this.account.userId) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
"DianpingAdapter._syncViaCookie: account.userId required (set via new DianpingAdapter({ account: { userId, cookies } }))",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
233
|
+
const cookies = this._cookieAuth.toHeader();
|
|
234
|
+
const sinceMs =
|
|
235
|
+
opts.sinceWatermark != null
|
|
236
|
+
? parseInt(String(opts.sinceWatermark), 10) || 0
|
|
237
|
+
: Date.now() - 365 * 24 * 3600_000; // default last year
|
|
238
|
+
const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : 10;
|
|
239
|
+
const include = opts.include || {};
|
|
240
|
+
if (include[KIND_ORDER] === false) return;
|
|
241
|
+
const limit =
|
|
242
|
+
Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
243
|
+
|
|
244
|
+
let emitted = 0;
|
|
245
|
+
let page = 1;
|
|
246
|
+
while (true) {
|
|
247
|
+
const query = { page, pageSize, ts: Date.now() };
|
|
248
|
+
// sign seam — best-effort. null when no signProvider.
|
|
249
|
+
let sign = null;
|
|
250
|
+
if (this._signProvider) {
|
|
251
|
+
sign = await this._signProvider({ url: this._ordersUrl, query, cookies });
|
|
252
|
+
}
|
|
253
|
+
const resp = await this._fetchFn({ url: this._ordersUrl, cookies, query, sign });
|
|
254
|
+
const orders = extractOrders(resp);
|
|
255
|
+
if (!orders.length) break;
|
|
256
|
+
let pageHasNew = false;
|
|
257
|
+
let reachedWatermark = false;
|
|
258
|
+
for (const raw of orders) {
|
|
259
|
+
const rec = orderToRecord(raw);
|
|
260
|
+
if (!rec) continue;
|
|
261
|
+
if (rec.placedAt && rec.placedAt < sinceMs) {
|
|
262
|
+
reachedWatermark = true; // everything from here on is older
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
pageHasNew = true;
|
|
266
|
+
if (emitted >= limit) return;
|
|
267
|
+
yield {
|
|
268
|
+
adapter: NAME,
|
|
269
|
+
originalId: rec.orderId,
|
|
270
|
+
capturedAt: rec.paidAt || rec.placedAt || Date.now(),
|
|
271
|
+
payload: { record: rec },
|
|
272
|
+
};
|
|
273
|
+
emitted += 1;
|
|
274
|
+
}
|
|
275
|
+
if (reachedWatermark || !pageHasNew || orders.length < pageSize) break;
|
|
276
|
+
page += 1;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
normalize(raw) {
|
|
281
|
+
if (!raw || !raw.payload) {
|
|
282
|
+
throw new Error("DianpingAdapter.normalize: payload missing");
|
|
283
|
+
}
|
|
284
|
+
if (raw.payload.record) {
|
|
285
|
+
return normalizeOrderRecord(raw.payload.record, {
|
|
286
|
+
adapterName: NAME,
|
|
287
|
+
adapterVersion: VERSION,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const rec = snapshotEventToRecord(raw.payload);
|
|
291
|
+
return normalizeOrderRecord(rec, {
|
|
292
|
+
adapterName: NAME,
|
|
293
|
+
adapterVersion: VERSION,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function stableOriginalId(kind, id) {
|
|
299
|
+
const stringified =
|
|
300
|
+
(typeof id === "string" && id.length > 0 && id) ||
|
|
301
|
+
(typeof id === "number" && Number.isFinite(id) && String(id)) ||
|
|
302
|
+
null;
|
|
303
|
+
const safe =
|
|
304
|
+
stringified ||
|
|
305
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
306
|
+
return `dianping:${kind}:${safe}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Pull the order array out of a Dianping order-centre response. Dianping nests
|
|
311
|
+
* the list under different keys across H5 versions; the injected fetchFn may
|
|
312
|
+
* also pre-flatten to `{ orders }`. Tolerant of all common shapes.
|
|
313
|
+
*/
|
|
314
|
+
function extractOrders(resp) {
|
|
315
|
+
if (!resp || typeof resp !== "object") return [];
|
|
316
|
+
if (Array.isArray(resp.orders)) return resp.orders;
|
|
317
|
+
if (Array.isArray(resp.orderList)) return resp.orderList;
|
|
318
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
319
|
+
const data = resp.data && typeof resp.data === "object" ? resp.data : null;
|
|
320
|
+
if (data) {
|
|
321
|
+
if (Array.isArray(data.orders)) return data.orders;
|
|
322
|
+
if (Array.isArray(data.orderList)) return data.orderList;
|
|
323
|
+
if (Array.isArray(data.list)) return data.list;
|
|
324
|
+
if (Array.isArray(data.records)) return data.records;
|
|
325
|
+
}
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Map one Dianping order object → vendor-neutral OrderRecord. Field names are
|
|
331
|
+
* best-effort across H5 versions (camelCase + snake_case fallbacks). Amounts
|
|
332
|
+
* are treated as 元 (Dianping H5 returns decimal-yuan strings); an integer
|
|
333
|
+
* `*Fen` field is divided by 100.
|
|
334
|
+
*/
|
|
335
|
+
function orderToRecord(o) {
|
|
336
|
+
if (!o || typeof o !== "object") return null;
|
|
337
|
+
const orderId =
|
|
338
|
+
o.orderId || o.unifiedOrderId || o.dealId || o.id || o.order_id;
|
|
339
|
+
if (!orderId) return null;
|
|
340
|
+
const merchant =
|
|
341
|
+
o.shopName || o.poiName || o.dealTitle || o.title || o.merchantName || "大众点评";
|
|
342
|
+
|
|
343
|
+
const items = [];
|
|
344
|
+
const rawItems = o.dealList || o.deals || o.itemList || o.items || [];
|
|
345
|
+
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
|
346
|
+
if (!it) continue;
|
|
347
|
+
items.push({
|
|
348
|
+
name: it.name || it.dealName || it.dealTitle || it.title,
|
|
349
|
+
quantity: parseInt(it.quantity || it.count || it.num || 1, 10),
|
|
350
|
+
unitPrice: parseAmount(it.price || it.unitPrice || it.dealPrice || 0),
|
|
351
|
+
sku: it.dealId || it.sku || it.skuId || null,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
vendorId: "dianping",
|
|
357
|
+
orderId: String(orderId),
|
|
358
|
+
placedAt: parseTime(o.orderTime || o.addTime || o.createTime || o.order_time),
|
|
359
|
+
paidAt: parseTime(o.payTime || o.paidTime || o.pay_time),
|
|
360
|
+
status: mapStatus(o.statusText || o.statusDesc || o.statusName || o.status),
|
|
361
|
+
merchantName: merchant,
|
|
362
|
+
totalAmount: {
|
|
363
|
+
value: parseAmount(
|
|
364
|
+
o.totalPrice != null
|
|
365
|
+
? o.totalPrice
|
|
366
|
+
: o.payMoney != null
|
|
367
|
+
? o.payMoney
|
|
368
|
+
: o.payAmount != null
|
|
369
|
+
? o.payAmount
|
|
370
|
+
: o.totalFee != null
|
|
371
|
+
? o.totalFee
|
|
372
|
+
: o.amount,
|
|
373
|
+
o.totalFen != null ? o.totalFen : o.payFen,
|
|
374
|
+
),
|
|
375
|
+
currency: "CNY",
|
|
376
|
+
},
|
|
377
|
+
items,
|
|
378
|
+
recipient: o.recipientName || o.contactName || null,
|
|
379
|
+
shippingAddress: o.recipientAddress || o.deliveryAddress || o.address || null,
|
|
380
|
+
extras: { capturedBy: "cookie-api", platform: "dianping" },
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function snapshotEventToRecord(ev) {
|
|
385
|
+
const items = [];
|
|
386
|
+
const rawItems = Array.isArray(ev.items) ? ev.items : [];
|
|
387
|
+
for (const it of rawItems) {
|
|
388
|
+
if (!it) continue;
|
|
389
|
+
items.push({
|
|
390
|
+
name: it.name || it.dealName || it.title,
|
|
391
|
+
quantity: parseInt(it.quantity || it.count || 1, 10),
|
|
392
|
+
unitPrice: parseAmount(it.unitPrice || it.price || 0),
|
|
393
|
+
sku: it.sku || it.dealId || null,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
vendorId: "dianping",
|
|
398
|
+
orderId: String(ev.orderId || ev.id || "unknown"),
|
|
399
|
+
placedAt: parseTime(ev.placedAt),
|
|
400
|
+
paidAt: parseTime(ev.paidAt),
|
|
401
|
+
status: mapStatus(ev.status),
|
|
402
|
+
merchantName: ev.merchantName || "大众点评",
|
|
403
|
+
totalAmount:
|
|
404
|
+
ev.totalAmount && typeof ev.totalAmount === "object"
|
|
405
|
+
? {
|
|
406
|
+
value: parseFloat(ev.totalAmount.value || 0),
|
|
407
|
+
currency: ev.totalAmount.currency || "CNY",
|
|
408
|
+
}
|
|
409
|
+
: { value: 0, currency: "CNY" },
|
|
410
|
+
items,
|
|
411
|
+
recipient: ev.recipient || null,
|
|
412
|
+
shippingAddress: ev.shippingAddress || null,
|
|
413
|
+
extras: { platform: ev.platform || "dianping", capturedBy: "snapshot" },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Resolve a money value. Prefer an explicit 元 field; fall back to a 分 (cents)
|
|
419
|
+
* integer field divided by 100. Tolerates numeric or decimal-string input.
|
|
420
|
+
*/
|
|
421
|
+
function parseAmount(yuanVal, fenVal) {
|
|
422
|
+
if (yuanVal != null && yuanVal !== "") {
|
|
423
|
+
const n = parseFloat(yuanVal);
|
|
424
|
+
if (Number.isFinite(n)) return n;
|
|
425
|
+
}
|
|
426
|
+
if (fenVal != null && fenVal !== "") {
|
|
427
|
+
const n = Number(fenVal);
|
|
428
|
+
if (Number.isFinite(n)) return Math.round(n) / 100;
|
|
429
|
+
}
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function parseTime(v) {
|
|
434
|
+
if (Number.isFinite(v)) return v < 1e12 ? v * 1000 : v;
|
|
435
|
+
if (typeof v === "string") {
|
|
436
|
+
if (/^\d+$/.test(v)) {
|
|
437
|
+
const n = parseInt(v, 10);
|
|
438
|
+
return n < 1e12 ? n * 1000 : n;
|
|
439
|
+
}
|
|
440
|
+
const t = Date.parse(v);
|
|
441
|
+
if (Number.isFinite(t)) return t;
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function mapStatus(s) {
|
|
447
|
+
const t = String(s || "").toLowerCase();
|
|
448
|
+
if (t.includes("退款") || t.includes("退订") || t.includes("refund")) return "refunded";
|
|
449
|
+
if (t.includes("取消") || t.includes("cancel") || t.includes("已关闭")) return "cancelled";
|
|
450
|
+
if (t.includes("配送") || t.includes("派送") || t.includes("shipped")) return "shipped";
|
|
451
|
+
if (
|
|
452
|
+
t.includes("已完成") ||
|
|
453
|
+
t.includes("已消费") ||
|
|
454
|
+
t.includes("已使用") ||
|
|
455
|
+
t.includes("delivered")
|
|
456
|
+
)
|
|
457
|
+
return "delivered";
|
|
458
|
+
return "placed";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function defaultFetch(_opts) {
|
|
462
|
+
throw new Error("DianpingAdapter: no fetchFn configured for cookie-api mode");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
module.exports = {
|
|
466
|
+
DianpingAdapter,
|
|
467
|
+
orderToRecord,
|
|
468
|
+
extractOrders,
|
|
469
|
+
NAME,
|
|
470
|
+
VERSION,
|
|
471
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
472
|
+
VALID_SNAPSHOT_KINDS,
|
|
473
|
+
};
|