@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
|
@@ -9,6 +9,11 @@ const crypto = require("node:crypto");
|
|
|
9
9
|
const {
|
|
10
10
|
Train12306Adapter,
|
|
11
11
|
parseRecords,
|
|
12
|
+
ticketsFromOrder,
|
|
13
|
+
extractCompletedOrders,
|
|
14
|
+
extractPendingOrders,
|
|
15
|
+
parse12306DateTime,
|
|
16
|
+
parseYyyymmdd,
|
|
12
17
|
NAME,
|
|
13
18
|
VERSION,
|
|
14
19
|
SNAPSHOT_SCHEMA_VERSION,
|
|
@@ -62,7 +67,7 @@ function makeSnapshot(events, extra = {}) {
|
|
|
62
67
|
describe("constants", () => {
|
|
63
68
|
it("exposes name/version/schema", () => {
|
|
64
69
|
expect(NAME).toBe("travel-12306");
|
|
65
|
-
expect(VERSION).toBe("0.
|
|
70
|
+
expect(VERSION).toBe("0.7.0");
|
|
66
71
|
expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
|
|
67
72
|
expect([...VALID_SNAPSHOT_KINDS]).toEqual(["ticket"]);
|
|
68
73
|
});
|
|
@@ -277,3 +282,231 @@ describe("sync + parseRecords — file-import mode", () => {
|
|
|
277
282
|
await expect(collect(a.sync({}))).rejects.toThrow(/needs opts\.inputPath/);
|
|
278
283
|
});
|
|
279
284
|
});
|
|
285
|
+
|
|
286
|
+
// ─── §2.5 v0.3 cookie-api live fetch ────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
// One raw 12306 order object (queryMyOrder shape) carrying a single ticket.
|
|
289
|
+
function rawOrder(seq, overrides = {}) {
|
|
290
|
+
return {
|
|
291
|
+
sequence_no: seq,
|
|
292
|
+
order_date: "20240315",
|
|
293
|
+
ticket_total_price: "553.5",
|
|
294
|
+
tickets: [
|
|
295
|
+
{
|
|
296
|
+
ticket_no: "E123456789",
|
|
297
|
+
passenger_name: "张三",
|
|
298
|
+
passenger_id_no: "310101199001011234", // last6 = 011234
|
|
299
|
+
train_code: "G35",
|
|
300
|
+
from_station_name: "上海虹桥",
|
|
301
|
+
to_station_name: "北京南",
|
|
302
|
+
start_train_date_page: "2024-03-20 09:00",
|
|
303
|
+
arrive_train_date_page: "2024-03-20 14:00",
|
|
304
|
+
seat_type_name: "二等座",
|
|
305
|
+
coach_no: "05",
|
|
306
|
+
seat_no: "12A",
|
|
307
|
+
ticket_price: "553.5",
|
|
308
|
+
...overrides,
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const COOKIE = "tk=abc; JSESSIONID=xyz; RAIL_DEVICEID=dev";
|
|
315
|
+
|
|
316
|
+
describe("cookie-api helpers", () => {
|
|
317
|
+
it("extractCompletedOrders tolerates shape variants + junk", () => {
|
|
318
|
+
expect(extractCompletedOrders({ data: { OrderDTODataList: [1, 2] } })).toEqual([1, 2]);
|
|
319
|
+
expect(extractCompletedOrders({ data: { orderDTODataList: [3] } })).toEqual([3]);
|
|
320
|
+
expect(extractCompletedOrders(null)).toEqual([]);
|
|
321
|
+
expect(extractCompletedOrders({ data: {} })).toEqual([]);
|
|
322
|
+
expect(extractCompletedOrders("nope")).toEqual([]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("extractPendingOrders tolerates shape variants + junk", () => {
|
|
326
|
+
expect(extractPendingOrders({ data: { orderDBList: [1] } })).toEqual([1]);
|
|
327
|
+
expect(extractPendingOrders({ data: { orderDbList: [2] } })).toEqual([2]);
|
|
328
|
+
expect(extractPendingOrders({})).toEqual([]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("ticketsFromOrder flattens tickets with snake/camel fallbacks", () => {
|
|
332
|
+
const evs = ticketsFromOrder(rawOrder("SEQ1"), true);
|
|
333
|
+
expect(evs).toHaveLength(1);
|
|
334
|
+
expect(evs[0]).toMatchObject({
|
|
335
|
+
kind: "ticket",
|
|
336
|
+
id: "ticket-SEQ1:0",
|
|
337
|
+
orderSequenceNo: "SEQ1",
|
|
338
|
+
ticketNumber: "E123456789",
|
|
339
|
+
passengerName: "张三",
|
|
340
|
+
passengerIdLast6: "011234",
|
|
341
|
+
trainNumber: "G35",
|
|
342
|
+
fromStation: "上海虹桥",
|
|
343
|
+
toStation: "北京南",
|
|
344
|
+
seatTypeName: "二等座",
|
|
345
|
+
coachNo: "05",
|
|
346
|
+
seatNo: "12A",
|
|
347
|
+
ticketPrice: 553.5,
|
|
348
|
+
isCompleted: true,
|
|
349
|
+
capturedVia: "cookie-api",
|
|
350
|
+
});
|
|
351
|
+
// camelCase + *_page fallbacks
|
|
352
|
+
const camel = ticketsFromOrder(
|
|
353
|
+
{
|
|
354
|
+
sequenceNo: "SEQ2",
|
|
355
|
+
tickets: [
|
|
356
|
+
{
|
|
357
|
+
stationTrainCode: "D7",
|
|
358
|
+
passengerName: "李四",
|
|
359
|
+
from_station_name_page: "杭州东",
|
|
360
|
+
to_station_name_page: "南京南",
|
|
361
|
+
ticketPrice: "100",
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
false,
|
|
366
|
+
);
|
|
367
|
+
expect(camel[0]).toMatchObject({
|
|
368
|
+
trainNumber: "D7",
|
|
369
|
+
fromStation: "杭州东",
|
|
370
|
+
toStation: "南京南",
|
|
371
|
+
ticketPrice: 100,
|
|
372
|
+
isCompleted: false,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("ticketsFromOrder drops incomplete tickets + bad orders", () => {
|
|
377
|
+
expect(ticketsFromOrder(null, true)).toEqual([]);
|
|
378
|
+
expect(ticketsFromOrder({ tickets: [] }, true)).toEqual([]); // no sequence_no
|
|
379
|
+
// ticket missing passenger / train / stations → dropped
|
|
380
|
+
const evs = ticketsFromOrder(
|
|
381
|
+
{ sequence_no: "S", tickets: [{ passenger_name: "x" }] },
|
|
382
|
+
true,
|
|
383
|
+
);
|
|
384
|
+
expect(evs).toEqual([]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("parse12306DateTime handles date / date-time / Chinese / blank", () => {
|
|
388
|
+
// 2024-03-20 09:00 Shanghai = 2024-03-20 01:00 UTC
|
|
389
|
+
expect(parse12306DateTime("2024-03-20 09:00")).toBe(Date.UTC(2024, 2, 20, 1, 0));
|
|
390
|
+
expect(parse12306DateTime("2024-03-20")).toBe(Date.UTC(2024, 2, 19, 16, 0));
|
|
391
|
+
expect(parse12306DateTime("")).toBeNull();
|
|
392
|
+
expect(parse12306DateTime(null)).toBeNull();
|
|
393
|
+
expect(parse12306DateTime("garbage")).toBeNull();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("parseYyyymmdd parses compact dates", () => {
|
|
397
|
+
expect(parseYyyymmdd("20240315")).toBe(Date.UTC(2024, 2, 14, 16, 0));
|
|
398
|
+
expect(parseYyyymmdd("2024-03-15")).toBeNull();
|
|
399
|
+
expect(parseYyyymmdd(null)).toBeNull();
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("authenticate — cookie-api mode", () => {
|
|
404
|
+
it("ok when account.cookies present (username optional)", async () => {
|
|
405
|
+
const a = new Train12306Adapter({ account: { cookies: COOKIE } });
|
|
406
|
+
expect(await a.authenticate({})).toEqual({
|
|
407
|
+
ok: true,
|
|
408
|
+
account: null,
|
|
409
|
+
mode: "cookie",
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("carries username when supplied alongside cookies", async () => {
|
|
414
|
+
const a = new Train12306Adapter({
|
|
415
|
+
account: { cookies: COOKIE, username: "alice" },
|
|
416
|
+
});
|
|
417
|
+
expect((await a.authenticate({})).account).toBe("alice");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("cookie mode takes precedence over file-import", async () => {
|
|
421
|
+
const a = new Train12306Adapter({
|
|
422
|
+
account: { cookies: COOKIE },
|
|
423
|
+
dataPath: "x.json",
|
|
424
|
+
});
|
|
425
|
+
expect((await a.authenticate({})).mode).toBe("cookie");
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe("sync — cookie-api mode", () => {
|
|
430
|
+
it("yields flattened ticket events from completed + pending", async () => {
|
|
431
|
+
const calls = [];
|
|
432
|
+
const fetchFn = async ({ url, cookies, form }) => {
|
|
433
|
+
calls.push({ url, cookies, form });
|
|
434
|
+
if (url.includes("NoComplete")) {
|
|
435
|
+
return { data: { orderDBList: [rawOrder("PEND1")] } };
|
|
436
|
+
}
|
|
437
|
+
// single completed page (< PAGE_SIZE → stops)
|
|
438
|
+
return { data: { OrderDTODataList: [rawOrder("SEQ1")] } };
|
|
439
|
+
};
|
|
440
|
+
const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
|
|
441
|
+
const items = await collect(a.sync({}));
|
|
442
|
+
expect(items.map((i) => i.originalId)).toEqual([
|
|
443
|
+
"12306:ticket:ticket-SEQ1:0",
|
|
444
|
+
"12306:ticket:ticket-PEND1:0",
|
|
445
|
+
]);
|
|
446
|
+
expect(items[0].payload.snapshot).toBe(true);
|
|
447
|
+
expect(items[0].payload.capturedVia).toBe("cookie-api");
|
|
448
|
+
expect(items[1].payload.isCompleted).toBe(false);
|
|
449
|
+
// cookie header forwarded + completed form carries pagination params
|
|
450
|
+
expect(calls[0].cookies).toBe(COOKIE);
|
|
451
|
+
expect(calls[0].form).toMatchObject({
|
|
452
|
+
come_from_flag: "my_order",
|
|
453
|
+
pageSize: "50",
|
|
454
|
+
pageIndex: "1",
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("paginates completed orders until a short page", async () => {
|
|
459
|
+
const fetchFn = async ({ url, form }) => {
|
|
460
|
+
if (url.includes("NoComplete")) return { data: { orderDBList: [] } };
|
|
461
|
+
const page = parseInt(form.pageIndex, 10);
|
|
462
|
+
if (page === 1) {
|
|
463
|
+
// exactly PAGE_SIZE (50) → triggers page 2
|
|
464
|
+
const orders = Array.from({ length: 50 }, (_, i) => rawOrder(`P1-${i}`));
|
|
465
|
+
return { data: { OrderDTODataList: orders } };
|
|
466
|
+
}
|
|
467
|
+
if (page === 2) return { data: { OrderDTODataList: [rawOrder("P2-0")] } };
|
|
468
|
+
return { data: { OrderDTODataList: [] } };
|
|
469
|
+
};
|
|
470
|
+
const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
|
|
471
|
+
const items = await collect(a.sync({}));
|
|
472
|
+
expect(items).toHaveLength(51); // 50 + 1, then short page stops
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("honors limit + include gate", async () => {
|
|
476
|
+
const fetchFn = async ({ url }) =>
|
|
477
|
+
url.includes("NoComplete")
|
|
478
|
+
? { data: { orderDBList: [] } }
|
|
479
|
+
: { data: { OrderDTODataList: [rawOrder("A"), rawOrder("B")] } };
|
|
480
|
+
const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
|
|
481
|
+
expect(await collect(a.sync({ limit: 1 }))).toHaveLength(1);
|
|
482
|
+
expect(await collect(a.sync({ include: { ticket: false } }))).toHaveLength(0);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("empty responses yield nothing (expired cookie / no orders)", async () => {
|
|
486
|
+
const fetchFn = async () => ({});
|
|
487
|
+
const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
|
|
488
|
+
expect(await collect(a.sync({}))).toHaveLength(0);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("throws when cookie mode active but no fetchFn injected", async () => {
|
|
492
|
+
const a = new Train12306Adapter({ account: { cookies: COOKIE } });
|
|
493
|
+
await expect(collect(a.sync({}))).rejects.toThrow(/no fetchFn configured/);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("normalize maps a cookie ticket event → trip with capturedVia", async () => {
|
|
497
|
+
const fetchFn = async ({ url }) =>
|
|
498
|
+
url.includes("NoComplete")
|
|
499
|
+
? { data: { orderDBList: [] } }
|
|
500
|
+
: { data: { OrderDTODataList: [rawOrder("SEQ1")] } };
|
|
501
|
+
const a = new Train12306Adapter({ account: { cookies: COOKIE }, fetchFn });
|
|
502
|
+
const [item] = await collect(a.sync({}));
|
|
503
|
+
const batch = a.normalize(item);
|
|
504
|
+
const ev = batch.events[0];
|
|
505
|
+
expect(ev.subtype).toBe("trip");
|
|
506
|
+
expect(ev.content.title).toBe("train: 上海虹桥 → 北京南");
|
|
507
|
+
expect(ev.extra.vendorExtras).toMatchObject({
|
|
508
|
+
capturedVia: "cookie-api",
|
|
509
|
+
idLast6: "011234",
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
});
|
|
@@ -9,6 +9,8 @@ const crypto = require("node:crypto");
|
|
|
9
9
|
const {
|
|
10
10
|
CtripAdapter,
|
|
11
11
|
parseRecords,
|
|
12
|
+
orderToRecord,
|
|
13
|
+
extractOrders,
|
|
12
14
|
TYPE_MAP,
|
|
13
15
|
NAME,
|
|
14
16
|
VERSION,
|
|
@@ -43,7 +45,7 @@ const FLIGHT_ORDER = {
|
|
|
43
45
|
describe("constants + TYPE_MAP", () => {
|
|
44
46
|
it("exposes name/version", () => {
|
|
45
47
|
expect(NAME).toBe("travel-ctrip");
|
|
46
|
-
expect(VERSION).toBe("0.
|
|
48
|
+
expect(VERSION).toBe("0.7.0");
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
it("maps ctrip order types to vehicleType", () => {
|
|
@@ -201,3 +203,175 @@ describe("CtripAdapter", () => {
|
|
|
201
203
|
);
|
|
202
204
|
});
|
|
203
205
|
});
|
|
206
|
+
|
|
207
|
+
describe("orderToRecord web-API aliases (cookie-api mode)", () => {
|
|
208
|
+
it("maps web order field names (bizType / amount / orderDate / departCity)", () => {
|
|
209
|
+
const rec = orderToRecord(
|
|
210
|
+
{
|
|
211
|
+
orderId: "W1",
|
|
212
|
+
bizType: "Flight",
|
|
213
|
+
departCity: "广州",
|
|
214
|
+
arriveCity: "成都",
|
|
215
|
+
amount: "899.5",
|
|
216
|
+
departureDate: 1716383021000,
|
|
217
|
+
contactName: "王五",
|
|
218
|
+
orderDate: "2026-04-01",
|
|
219
|
+
},
|
|
220
|
+
{ capturedVia: "cookie-api" },
|
|
221
|
+
);
|
|
222
|
+
expect(rec).toMatchObject({
|
|
223
|
+
vendorId: "ctrip",
|
|
224
|
+
recordId: "W1",
|
|
225
|
+
vehicleType: "flight",
|
|
226
|
+
from: { city: "广州" },
|
|
227
|
+
to: { city: "成都" },
|
|
228
|
+
departureMs: 1716383021000,
|
|
229
|
+
traveler: "王五",
|
|
230
|
+
});
|
|
231
|
+
expect(rec.totalCost).toEqual({ value: 899.5, currency: "CNY" });
|
|
232
|
+
expect(rec.extras.capturedVia).toBe("cookie-api");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("file-import rows keep priority + no capturedVia by default", () => {
|
|
236
|
+
const rec = orderToRecord({ orderId: "F1", type: "hotel", price: "300" });
|
|
237
|
+
expect(rec.vehicleType).toBe("hotel");
|
|
238
|
+
expect(rec.totalCost).toEqual({ value: 300, currency: "CNY" });
|
|
239
|
+
expect(rec.extras.capturedVia).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("extractOrders", () => {
|
|
244
|
+
it("pulls the list from common Ctrip response shapes", () => {
|
|
245
|
+
expect(extractOrders({ orders: [{ orderId: "A" }] })).toHaveLength(1);
|
|
246
|
+
expect(extractOrders({ data: { orderList: [{ orderId: "B" }] } })).toHaveLength(1);
|
|
247
|
+
expect(extractOrders({ result: { list: [{ orderId: "C" }] } })).toHaveLength(1);
|
|
248
|
+
expect(extractOrders({})).toEqual([]);
|
|
249
|
+
expect(extractOrders(null)).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("CtripAdapter cookie-api mode", () => {
|
|
254
|
+
const COOKIES = "_bfa=abc; cticket=xyz; UUID=u1";
|
|
255
|
+
|
|
256
|
+
it("authenticate returns cookie mode (account OPTIONAL)", async () => {
|
|
257
|
+
const a = new CtripAdapter({ account: { cookies: COOKIES } });
|
|
258
|
+
expect(await a.authenticate()).toEqual({
|
|
259
|
+
ok: true,
|
|
260
|
+
account: null,
|
|
261
|
+
mode: "cookie",
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("authenticate fails on empty cookies", async () => {
|
|
266
|
+
const a = new CtripAdapter({ account: { cookies: "" } });
|
|
267
|
+
// empty cookies → no cookieAuth → falls through to ready (not cookie mode)
|
|
268
|
+
expect((await a.authenticate()).mode).toBe("ready");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("sync fetches, paginates, maps + normalizes end-to-end", async () => {
|
|
272
|
+
const pages = [
|
|
273
|
+
{
|
|
274
|
+
orderList: [
|
|
275
|
+
{
|
|
276
|
+
orderId: "CK1",
|
|
277
|
+
bizType: "flight",
|
|
278
|
+
departCity: "上海",
|
|
279
|
+
arriveCity: "北京",
|
|
280
|
+
amount: "1200",
|
|
281
|
+
orderDate: 1716383021000,
|
|
282
|
+
flightNumber: "MU500",
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
{ orderList: [] }, // page 2 empty → stop
|
|
287
|
+
];
|
|
288
|
+
const calls = [];
|
|
289
|
+
const fetchFn = async ({ url, cookies, query, sign }) => {
|
|
290
|
+
calls.push({ url, cookies, pageIndex: query.pageIndex, sign });
|
|
291
|
+
return pages[query.pageIndex - 1] || { orderList: [] };
|
|
292
|
+
};
|
|
293
|
+
const a = new CtripAdapter({
|
|
294
|
+
account: { cookies: COOKIES },
|
|
295
|
+
fetchFn,
|
|
296
|
+
// no signProvider → sign should be null (best-effort unsigned)
|
|
297
|
+
});
|
|
298
|
+
const items = await collect(a.sync({ sinceWatermark: 0 }));
|
|
299
|
+
expect(items).toHaveLength(1);
|
|
300
|
+
expect(items[0]).toMatchObject({ adapter: NAME, originalId: "CK1" });
|
|
301
|
+
expect(calls[0].cookies).toBe(COOKIES);
|
|
302
|
+
expect(calls[0].sign).toBe(null);
|
|
303
|
+
const batch = a.normalize(items[0]);
|
|
304
|
+
expect(batch.events[0].content.title).toBe("flight: 上海 → 北京");
|
|
305
|
+
expect(batch.events[0].content.amount).toMatchObject({ value: 1200 });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("invokes signProvider when configured", async () => {
|
|
309
|
+
const fetchFn = async ({ query }) =>
|
|
310
|
+
query.pageIndex === 1 ? { orders: [{ orderId: "S1", type: "hotel" }] } : { orders: [] };
|
|
311
|
+
const signCalls = [];
|
|
312
|
+
const signProvider = async (ctx) => {
|
|
313
|
+
signCalls.push(ctx);
|
|
314
|
+
return "SIGNED-TOKEN";
|
|
315
|
+
};
|
|
316
|
+
const a = new CtripAdapter({ account: { cookies: COOKIES }, fetchFn, signProvider });
|
|
317
|
+
const items = await collect(a.sync({ sinceWatermark: 0 }));
|
|
318
|
+
expect(items).toHaveLength(1);
|
|
319
|
+
expect(signCalls).toHaveLength(1);
|
|
320
|
+
expect(signCalls[0].cookies).toBe(COOKIES);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("stops at sinceWatermark (older orders dropped)", async () => {
|
|
324
|
+
const fetchFn = async () => ({
|
|
325
|
+
orderList: [
|
|
326
|
+
{ orderId: "NEW", type: "flight", orderDate: 2_000_000_000_000 },
|
|
327
|
+
{ orderId: "OLD", type: "flight", orderDate: 1_000_000_000_000 },
|
|
328
|
+
],
|
|
329
|
+
});
|
|
330
|
+
const a = new CtripAdapter({ account: { cookies: COOKIES }, fetchFn });
|
|
331
|
+
const items = await collect(a.sync({ sinceWatermark: 1_500_000_000_000, maxPages: 1 }));
|
|
332
|
+
expect(items.map((x) => x.originalId)).toEqual(["NEW"]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("respects opts.limit", async () => {
|
|
336
|
+
const fetchFn = async () => ({
|
|
337
|
+
orderList: [
|
|
338
|
+
{ orderId: "L1", type: "flight", orderDate: 2_000_000_000_000 },
|
|
339
|
+
{ orderId: "L2", type: "flight", orderDate: 2_000_000_000_001 },
|
|
340
|
+
],
|
|
341
|
+
});
|
|
342
|
+
const a = new CtripAdapter({ account: { cookies: COOKIES }, fetchFn });
|
|
343
|
+
const items = await collect(a.sync({ sinceWatermark: 0, limit: 1, maxPages: 1 }));
|
|
344
|
+
expect(items).toHaveLength(1);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("empty/login-redirect response yields zero (no crash)", async () => {
|
|
348
|
+
const a = new CtripAdapter({
|
|
349
|
+
account: { cookies: COOKIES },
|
|
350
|
+
fetchFn: async () => "<html>login</html>",
|
|
351
|
+
});
|
|
352
|
+
expect(await collect(a.sync({ sinceWatermark: 0 }))).toEqual([]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("default fetch throws when no fetchFn (wiring bug)", async () => {
|
|
356
|
+
const a = new CtripAdapter({ account: { cookies: COOKIES } });
|
|
357
|
+
await expect(collect(a.sync({ sinceWatermark: 0 }))).rejects.toThrow(
|
|
358
|
+
/no fetchFn configured/,
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("snapshot/file path takes priority over cookie mode", async () => {
|
|
363
|
+
const p = writeTmp(JSON.stringify([FLIGHT_ORDER]));
|
|
364
|
+
try {
|
|
365
|
+
const a = new CtripAdapter({
|
|
366
|
+
account: { cookies: COOKIES },
|
|
367
|
+
fetchFn: async () => {
|
|
368
|
+
throw new Error("fetchFn should NOT be called in file mode");
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
const items = await collect(a.sync({ inputPath: p }));
|
|
372
|
+
expect(items.map((x) => x.originalId)).toEqual(["CT1"]);
|
|
373
|
+
} finally {
|
|
374
|
+
fs.unlinkSync(p);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const crypto = require("node:crypto");
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
DidiAdapter,
|
|
11
|
+
parseRecords,
|
|
12
|
+
orderToRecord,
|
|
13
|
+
extractOrders,
|
|
14
|
+
parseFareYuan,
|
|
15
|
+
NAME,
|
|
16
|
+
VERSION,
|
|
17
|
+
} = require("../../lib/adapters/travel-didi");
|
|
18
|
+
|
|
19
|
+
function writeTmp(content) {
|
|
20
|
+
const p = path.join(os.tmpdir(), `cc-didi-${crypto.randomUUID()}.json`);
|
|
21
|
+
fs.writeFileSync(p, content, "utf-8");
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function collect(gen) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for await (const x of gen) out.push(x);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COOKIES = "es_token=abc; ticket=xyz";
|
|
32
|
+
|
|
33
|
+
const RIDE = {
|
|
34
|
+
orderId: "DD1",
|
|
35
|
+
fromAddress: "公司",
|
|
36
|
+
toAddress: "机场",
|
|
37
|
+
departTime: 1716383021000,
|
|
38
|
+
arriveTime: 1716386021000,
|
|
39
|
+
fare: "58.50",
|
|
40
|
+
productName: "专车",
|
|
41
|
+
passengerName: "张三",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe("constants + parseFareYuan", () => {
|
|
45
|
+
it("exposes name/version", () => {
|
|
46
|
+
expect(NAME).toBe("travel-didi");
|
|
47
|
+
expect(VERSION).toBe("0.1.0");
|
|
48
|
+
});
|
|
49
|
+
it("parseFareYuan: 元 decimal kept; large integer treated as 分", () => {
|
|
50
|
+
expect(parseFareYuan("58.50")).toBe(58.5);
|
|
51
|
+
expect(parseFareYuan(58.5)).toBe(58.5);
|
|
52
|
+
expect(parseFareYuan(5850)).toBe(58.5); // 分
|
|
53
|
+
expect(parseFareYuan(58)).toBe(58); // small int → 元
|
|
54
|
+
expect(parseFareYuan("x")).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("orderToRecord", () => {
|
|
59
|
+
it("maps a ride → car TravelRecord (from/to name, fare, product)", () => {
|
|
60
|
+
const rec = orderToRecord(RIDE, { capturedVia: "cookie-api" });
|
|
61
|
+
expect(rec).toMatchObject({
|
|
62
|
+
vendorId: "didi",
|
|
63
|
+
recordId: "DD1",
|
|
64
|
+
vehicleType: "car",
|
|
65
|
+
from: { name: "公司" },
|
|
66
|
+
to: { name: "机场" },
|
|
67
|
+
departureMs: 1716383021000,
|
|
68
|
+
arrivalMs: 1716386021000,
|
|
69
|
+
carrier: "滴滴",
|
|
70
|
+
traveler: "张三",
|
|
71
|
+
});
|
|
72
|
+
expect(rec.totalCost).toEqual({ value: 58.5, currency: "CNY" });
|
|
73
|
+
expect(rec.extras.productType).toBe("专车");
|
|
74
|
+
expect(rec.extras.capturedVia).toBe("cookie-api");
|
|
75
|
+
});
|
|
76
|
+
it("snake_case + seconds-epoch aliases; drops id-less", () => {
|
|
77
|
+
const rec = orderToRecord({ oid: "DD2", from_address: "A", to_address: "B", depart_time: 1716383021, total_fee: 5850 });
|
|
78
|
+
expect(rec.recordId).toBe("DD2");
|
|
79
|
+
expect(rec.departureMs).toBe(1716383021000); // seconds → ms
|
|
80
|
+
expect(rec.totalCost.value).toBe(58.5);
|
|
81
|
+
expect(orderToRecord({ from_address: "A" })).toBe(null);
|
|
82
|
+
});
|
|
83
|
+
it("extractOrders tolerant of shapes", () => {
|
|
84
|
+
expect(extractOrders({ orders: [{ orderId: 1 }] })).toHaveLength(1);
|
|
85
|
+
expect(extractOrders({ data: { list: [{ orderId: 1 }] } })).toHaveLength(1);
|
|
86
|
+
expect(extractOrders({})).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("DidiAdapter file/snapshot mode", () => {
|
|
91
|
+
it("authenticate ready without account; validates inputPath", async () => {
|
|
92
|
+
const a = new DidiAdapter();
|
|
93
|
+
expect(await a.authenticate({})).toEqual({ ok: true, account: null, mode: "ready" });
|
|
94
|
+
const p = writeTmp("[]");
|
|
95
|
+
try {
|
|
96
|
+
expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
|
|
97
|
+
expect((await a.authenticate({ inputPath: path.join(os.tmpdir(), "nope-dd.json") })).reason).toBe("INPUT_PATH_UNREADABLE");
|
|
98
|
+
} finally {
|
|
99
|
+
fs.unlinkSync(p);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("sync yields ride + normalize end-to-end (car trip)", async () => {
|
|
104
|
+
const p = writeTmp(JSON.stringify([RIDE]));
|
|
105
|
+
try {
|
|
106
|
+
const a = new DidiAdapter();
|
|
107
|
+
const items = await collect(a.sync({ inputPath: p }));
|
|
108
|
+
expect(items).toHaveLength(1);
|
|
109
|
+
expect(items[0]).toMatchObject({ adapter: NAME, originalId: "DD1" });
|
|
110
|
+
const batch = a.normalize(items[0]);
|
|
111
|
+
expect(batch.events[0].content.title).toBe("car: 公司 → 机场");
|
|
112
|
+
expect(batch.events[0].content.amount).toEqual({ value: 58.5, currency: "CNY", direction: "out" });
|
|
113
|
+
expect(batch.persons.find((x) => x.subtype === "merchant").names).toEqual(["滴滴"]);
|
|
114
|
+
} finally {
|
|
115
|
+
fs.unlinkSync(p);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("parseRecords JSONL + {orders} envelope; sync silent when no path", async () => {
|
|
120
|
+
expect(parseRecords(JSON.stringify({ orders: [RIDE] }))).toHaveLength(1);
|
|
121
|
+
const jsonl = `${JSON.stringify(RIDE)}\njunk\n${JSON.stringify({ ...RIDE, orderId: "DD9" })}`;
|
|
122
|
+
expect(parseRecords(jsonl).map((r) => r.recordId)).toEqual(["DD1", "DD9"]);
|
|
123
|
+
const a = new DidiAdapter();
|
|
124
|
+
expect(await collect(a.sync({}))).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("normalize throws on missing record", () => {
|
|
128
|
+
const a = new DidiAdapter();
|
|
129
|
+
expect(() => a.normalize({ payload: {} })).toThrow(/payload\.record missing/);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("DidiAdapter cookie-api mode", () => {
|
|
134
|
+
it("authenticate cookie mode (account optional)", async () => {
|
|
135
|
+
const a = new DidiAdapter({ account: { cookies: COOKIES } });
|
|
136
|
+
expect(await a.authenticate()).toEqual({ ok: true, account: null, mode: "cookie" });
|
|
137
|
+
const ready = new DidiAdapter({ account: { cookies: "" } });
|
|
138
|
+
expect((await ready.authenticate()).mode).toBe("ready");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("sync fetches, paginates, maps + normalizes", async () => {
|
|
142
|
+
const pages = [
|
|
143
|
+
{ data: { list: [{ orderId: "CK1", fromAddress: "家", toAddress: "公司", departTime: 1716383021, fare: 2300 }] } },
|
|
144
|
+
{ data: { list: [] } },
|
|
145
|
+
];
|
|
146
|
+
const calls = [];
|
|
147
|
+
const fetchFn = async ({ url, cookies, query, sign }) => {
|
|
148
|
+
calls.push({ url, cookies, pageIndex: query.pageIndex, sign });
|
|
149
|
+
return pages[query.pageIndex - 1] || { data: { list: [] } };
|
|
150
|
+
};
|
|
151
|
+
const a = new DidiAdapter({ account: { cookies: COOKIES }, fetchFn });
|
|
152
|
+
const items = await collect(a.sync({ sinceWatermark: 0 }));
|
|
153
|
+
expect(items).toHaveLength(1);
|
|
154
|
+
expect(items[0].originalId).toBe("CK1");
|
|
155
|
+
expect(calls[0].cookies).toBe(COOKIES);
|
|
156
|
+
expect(calls[0].sign).toBe(null);
|
|
157
|
+
const batch = a.normalize(items[0]);
|
|
158
|
+
expect(batch.events[0].content.title).toBe("car: 家 → 公司");
|
|
159
|
+
expect(batch.events[0].content.amount.value).toBe(23); // 2300 分 → 23 元
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("invokes signProvider when configured", async () => {
|
|
163
|
+
const signCalls = [];
|
|
164
|
+
const a = new DidiAdapter({
|
|
165
|
+
account: { cookies: COOKIES },
|
|
166
|
+
fetchFn: async ({ query }) => (query.pageIndex === 1 ? { orders: [{ orderId: "S1" }] } : { orders: [] }),
|
|
167
|
+
signProvider: async (ctx) => { signCalls.push(ctx); return "DD-SIG"; },
|
|
168
|
+
});
|
|
169
|
+
const items = await collect(a.sync({ sinceWatermark: 0 }));
|
|
170
|
+
expect(items).toHaveLength(1);
|
|
171
|
+
expect(signCalls.length).toBeGreaterThan(0);
|
|
172
|
+
expect(signCalls[0].cookies).toBe(COOKIES);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("sinceWatermark + limit + empty/login response + default fetch throws", async () => {
|
|
176
|
+
const a1 = new DidiAdapter({
|
|
177
|
+
account: { cookies: COOKIES },
|
|
178
|
+
fetchFn: async () => ({ orders: [
|
|
179
|
+
{ orderId: "NEW", departTime: 2_000_000_000_000 },
|
|
180
|
+
{ orderId: "OLD", departTime: 1_000_000_000_000 },
|
|
181
|
+
] }),
|
|
182
|
+
});
|
|
183
|
+
expect((await collect(a1.sync({ sinceWatermark: 1_500_000_000_000, maxPages: 1 }))).map((x) => x.originalId)).toEqual(["NEW"]);
|
|
184
|
+
|
|
185
|
+
const a2 = new DidiAdapter({ account: { cookies: COOKIES }, fetchFn: async () => "<html>login</html>" });
|
|
186
|
+
expect(await collect(a2.sync({ sinceWatermark: 0 }))).toEqual([]);
|
|
187
|
+
|
|
188
|
+
const a3 = new DidiAdapter({ account: { cookies: COOKIES } });
|
|
189
|
+
await expect(collect(a3.sync({ sinceWatermark: 0 }))).rejects.toThrow(/no fetchFn configured/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("file path takes priority over cookie mode", async () => {
|
|
193
|
+
const p = writeTmp(JSON.stringify([RIDE]));
|
|
194
|
+
try {
|
|
195
|
+
const a = new DidiAdapter({
|
|
196
|
+
account: { cookies: COOKIES },
|
|
197
|
+
fetchFn: async () => { throw new Error("must not fetch in file mode"); },
|
|
198
|
+
});
|
|
199
|
+
expect((await collect(a.sync({ inputPath: p }))).map((x) => x.originalId)).toEqual(["DD1"]);
|
|
200
|
+
} finally {
|
|
201
|
+
fs.unlinkSync(p);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|