@chainlesschain/personal-data-hub 0.4.5 → 0.4.7
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.
|
@@ -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
|
+
});
|
|
@@ -10,7 +10,10 @@ const {
|
|
|
10
10
|
PinduoduoAdapter,
|
|
11
11
|
SNAPSHOT_SCHEMA_VERSION,
|
|
12
12
|
VALID_SNAPSHOT_KINDS,
|
|
13
|
+
orderToRecord,
|
|
14
|
+
extractOrders,
|
|
13
15
|
} = require("../lib/adapters/shopping-pinduoduo");
|
|
16
|
+
const { assertAdapter } = require("../lib/adapter-spec");
|
|
14
17
|
const { validateBatch } = require("../lib/batch");
|
|
15
18
|
|
|
16
19
|
// §2.4c v0.2 — Pinduoduo snapshot-only adapter. Pinduoduo's web API requires
|
|
@@ -274,6 +277,13 @@ describe("PinduoduoAdapter snapshot mode", () => {
|
|
|
274
277
|
expect(raws[0].capturedAt).toBe(ts);
|
|
275
278
|
});
|
|
276
279
|
|
|
280
|
+
it("advertises both snapshot and cookie-api capabilities", () => {
|
|
281
|
+
const a = new PinduoduoAdapter();
|
|
282
|
+
expect(a.capabilities).toContain("sync:snapshot");
|
|
283
|
+
expect(a.capabilities).toContain("sync:cookie-api");
|
|
284
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
277
287
|
it("snapshotEventToRecord handles snake_case goods_name/goods_price fallback", async () => {
|
|
278
288
|
// Pinduoduo's internal field names use snake_case (goods_name, goods_price,
|
|
279
289
|
// goods_count); the normalizer falls back to those if camelCase is absent.
|
|
@@ -300,3 +310,175 @@ describe("PinduoduoAdapter snapshot mode", () => {
|
|
|
300
310
|
expect(JSON.stringify(batch)).toContain("纸巾");
|
|
301
311
|
});
|
|
302
312
|
});
|
|
313
|
+
|
|
314
|
+
// §2.4c v0.3 — Pinduoduo cookie-api mode. The actual HTTP + anti_token signing
|
|
315
|
+
// is injected (fetchFn / signProvider) so the adapter stays pure-Node.
|
|
316
|
+
describe("PinduoduoAdapter cookie-api mode", () => {
|
|
317
|
+
it("authenticate(cookie) ok when uid + cookies present", async () => {
|
|
318
|
+
const a = new PinduoduoAdapter({ account: { uid: "u-1", cookies: "PDDAccessToken=ok" } });
|
|
319
|
+
const res = await a.authenticate();
|
|
320
|
+
expect(res.ok).toBe(true);
|
|
321
|
+
expect(res.mode).toBe("cookie");
|
|
322
|
+
expect(res.account).toBe("u-1");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("authenticate(cookie) requires account.uid", async () => {
|
|
326
|
+
const a = new PinduoduoAdapter({ account: { cookies: "PDDAccessToken=ok" } });
|
|
327
|
+
const res = await a.authenticate();
|
|
328
|
+
expect(res.ok).toBe(false);
|
|
329
|
+
expect(res.reason).toBe("NO_ACCOUNT_UID");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("sync yields normalized records from fetchFn fixture (cents → 元)", async () => {
|
|
333
|
+
const fetchFn = async () => ({
|
|
334
|
+
orders: [
|
|
335
|
+
{
|
|
336
|
+
order_sn: "PDD-COOKIE-1",
|
|
337
|
+
mall_name: "拼多多旗舰店",
|
|
338
|
+
order_status_prompt: "已发货",
|
|
339
|
+
order_amount: 6200, // 分 → 62.00 元
|
|
340
|
+
order_time: 1700000000, // sec
|
|
341
|
+
pay_time: 1700000010,
|
|
342
|
+
receive_name: "李四",
|
|
343
|
+
address: "广州市天河区...",
|
|
344
|
+
goods_list: [
|
|
345
|
+
{ goods_name: "纸巾100抽", goods_number: 5, goods_price: 990, sku_id: "sk1" },
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
});
|
|
350
|
+
const a = new PinduoduoAdapter({
|
|
351
|
+
account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
|
|
352
|
+
fetchFn,
|
|
353
|
+
});
|
|
354
|
+
const raws = [];
|
|
355
|
+
for await (const r of a.sync({ sinceWatermark: 0 })) raws.push(r);
|
|
356
|
+
expect(raws.length).toBe(1);
|
|
357
|
+
expect(raws[0].originalId).toBe("PDD-COOKIE-1");
|
|
358
|
+
expect(raws[0].payload.record.totalAmount.value).toBe(62);
|
|
359
|
+
expect(raws[0].payload.record.items[0].unitPrice).toBe(9.9);
|
|
360
|
+
expect(raws[0].payload.record.status).toBe("shipped");
|
|
361
|
+
|
|
362
|
+
const batch = a.normalize(raws[0]);
|
|
363
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
364
|
+
expect(JSON.stringify(batch)).toContain("纸巾100抽");
|
|
365
|
+
expect(JSON.stringify(batch)).toContain("李四");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("invokes signProvider and passes antiToken to fetchFn", async () => {
|
|
369
|
+
let seenAntiToken = null;
|
|
370
|
+
const signProvider = async () => "ANTI-TOKEN-XYZ";
|
|
371
|
+
const fetchFn = async (opts) => {
|
|
372
|
+
seenAntiToken = opts.antiToken;
|
|
373
|
+
return { orders: [] };
|
|
374
|
+
};
|
|
375
|
+
const a = new PinduoduoAdapter({
|
|
376
|
+
account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
|
|
377
|
+
fetchFn,
|
|
378
|
+
signProvider,
|
|
379
|
+
});
|
|
380
|
+
for await (const _r of a.sync({ sinceWatermark: 0 })) { /* drain */ }
|
|
381
|
+
expect(seenAntiToken).toBe("ANTI-TOKEN-XYZ");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("passes antiToken: null when no signProvider configured", async () => {
|
|
385
|
+
let seen = "unset";
|
|
386
|
+
const fetchFn = async (opts) => {
|
|
387
|
+
seen = opts.antiToken;
|
|
388
|
+
return { orders: [] };
|
|
389
|
+
};
|
|
390
|
+
const a = new PinduoduoAdapter({
|
|
391
|
+
account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
|
|
392
|
+
fetchFn,
|
|
393
|
+
});
|
|
394
|
+
for await (const _r of a.sync({ sinceWatermark: 0 })) { /* drain */ }
|
|
395
|
+
expect(seen).toBe(null);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("paginates and stops at sinceWatermark", async () => {
|
|
399
|
+
const pages = {
|
|
400
|
+
1: [
|
|
401
|
+
{ order_sn: "p1-a", mall_name: "m", order_time: 1700000000, order_amount: 100,
|
|
402
|
+
goods_list: [] },
|
|
403
|
+
{ order_sn: "p1-b", mall_name: "m", order_time: 1699000000, order_amount: 100,
|
|
404
|
+
goods_list: [] },
|
|
405
|
+
],
|
|
406
|
+
2: [
|
|
407
|
+
{ order_sn: "p2-a", mall_name: "m", order_time: 1698000000, order_amount: 100,
|
|
408
|
+
goods_list: [] },
|
|
409
|
+
],
|
|
410
|
+
};
|
|
411
|
+
const seenPages = [];
|
|
412
|
+
const fetchFn = async (opts) => {
|
|
413
|
+
seenPages.push(opts.query.pageNumber);
|
|
414
|
+
return { orders: pages[opts.query.pageNumber] || [] };
|
|
415
|
+
};
|
|
416
|
+
const a = new PinduoduoAdapter({
|
|
417
|
+
account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
|
|
418
|
+
fetchFn,
|
|
419
|
+
});
|
|
420
|
+
const raws = [];
|
|
421
|
+
// watermark between 1699000000s and 1700000000s → stops on the older order
|
|
422
|
+
for await (const r of a.sync({ sinceWatermark: 1699500000 * 1000, pageSize: 2 })) {
|
|
423
|
+
raws.push(r);
|
|
424
|
+
}
|
|
425
|
+
expect(raws.map((r) => r.originalId)).toEqual(["p1-a"]);
|
|
426
|
+
// stopped inside page 1 — never fetched page 2
|
|
427
|
+
expect(seenPages).toEqual([1]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("respects per-kind include opt-out in cookie mode", async () => {
|
|
431
|
+
let called = false;
|
|
432
|
+
const fetchFn = async () => {
|
|
433
|
+
called = true;
|
|
434
|
+
return { orders: [] };
|
|
435
|
+
};
|
|
436
|
+
const a = new PinduoduoAdapter({
|
|
437
|
+
account: { uid: "u-1", cookies: "PDDAccessToken=ok" },
|
|
438
|
+
fetchFn,
|
|
439
|
+
});
|
|
440
|
+
const raws = [];
|
|
441
|
+
for await (const r of a.sync({ include: { order: false } })) raws.push(r);
|
|
442
|
+
expect(raws.length).toBe(0);
|
|
443
|
+
expect(called).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("orderToRecord maps snake_case PDD fields with cents conversion", () => {
|
|
447
|
+
const rec = orderToRecord({
|
|
448
|
+
order_sn: "PDD-9",
|
|
449
|
+
mall_name: "店铺B",
|
|
450
|
+
order_status: 4, // numeric → 已完成 → delivered
|
|
451
|
+
order_amount: 1650,
|
|
452
|
+
order_time: 1700000000,
|
|
453
|
+
goods_list: [{ goods_name: "牙刷", goods_number: 2, goods_price: 825 }],
|
|
454
|
+
});
|
|
455
|
+
expect(rec.orderId).toBe("PDD-9");
|
|
456
|
+
expect(rec.merchantName).toBe("店铺B");
|
|
457
|
+
expect(rec.status).toBe("delivered");
|
|
458
|
+
expect(rec.totalAmount.value).toBe(16.5);
|
|
459
|
+
expect(rec.items[0].unitPrice).toBe(8.25);
|
|
460
|
+
expect(rec.extras.capturedBy).toBe("cookie-api");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("extractOrders tolerates nested response shapes", () => {
|
|
464
|
+
expect(extractOrders({ orders: [1] })).toEqual([1]);
|
|
465
|
+
expect(extractOrders({ order_list: [2] })).toEqual([2]);
|
|
466
|
+
expect(extractOrders({ list: [3] })).toEqual([3]);
|
|
467
|
+
expect(extractOrders({ result: { order_list: [4] } })).toEqual([4]);
|
|
468
|
+
expect(extractOrders({ result: { list: [5] } })).toEqual([5]);
|
|
469
|
+
expect(extractOrders({})).toEqual([]);
|
|
470
|
+
expect(extractOrders(null)).toEqual([]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("default fetchFn throws a legible error when cookie mode used without injection", async () => {
|
|
474
|
+
const a = new PinduoduoAdapter({ account: { uid: "u-1", cookies: "PDDAccessToken=ok" } });
|
|
475
|
+
let threw = null;
|
|
476
|
+
try {
|
|
477
|
+
for await (const _r of a.sync({ sinceWatermark: 0 })) { /* drain */ }
|
|
478
|
+
} catch (err) {
|
|
479
|
+
threw = err;
|
|
480
|
+
}
|
|
481
|
+
expect(threw).toBeTruthy();
|
|
482
|
+
expect(String(threw.message)).toMatch(/no fetchFn configured/);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
@@ -1,29 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* §2.4c 购物三联
|
|
2
|
+
* §2.4c 购物三联 — Pinduoduo (拼多多) adapter, dual-mode (snapshot + cookie-api).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* v0.3 brings 拼多多 to parity with shopping-taobao / shopping-jd /
|
|
5
|
+
* shopping-meituan by adding a cookie-api fetch path alongside the existing
|
|
6
|
+
* snapshot ingest. As with the other shopping adapters the actual HTTP call is
|
|
7
|
+
* delegated to an injected `fetchFn` (the Android in-APK cc uses OkHttp; the
|
|
8
|
+
* desktop hub uses an Electron WebView net request) so this module stays a
|
|
9
|
+
* pure-Node parser + orchestrator.
|
|
6
10
|
*
|
|
7
|
-
* 1.
|
|
8
|
-
*
|
|
9
|
-
* JS (similar to 抖音 X-Bogus). No pure-Node implementation survives
|
|
10
|
-
* pinduoduo's monthly anti_token rotation.
|
|
11
|
-
* 2. Pinduoduo Android app has no built-in "export orders" feature, so
|
|
12
|
-
* there's no SAF source-format to parse directly either.
|
|
11
|
+
* 1. snapshot mode (opts.inputPath): ingest a snapshot JSON produced by a
|
|
12
|
+
* browser extension / hand-roll (stateless — account OPTIONAL).
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* 2. cookie-api mode (opts.account.cookies): fetch
|
|
15
|
+
* `mobile.yangkeduo.com/proxy/api/galerie/transaction/transaction_list`
|
|
16
|
+
* via the injected `fetchFn`, paginating with the `pageNumber` cursor and
|
|
17
|
+
* stopping at the `sinceWatermark`. account.uid REQUIRED in this mode.
|
|
16
18
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* ── anti_token signing seam ──────────────────────────────────────────────
|
|
20
|
+
* Pinduoduo's transaction_list requires an `anti_token` (a.k.a.
|
|
21
|
+
* `anti-content`) computed by client-side JS — analogous to 抖音 X-Bogus.
|
|
22
|
+
* No pure-Node implementation survives pinduoduo's anti_token rotation, so
|
|
23
|
+
* the signing itself is injected via `opts.signProvider` (or constructor
|
|
24
|
+
* `signProvider`). On Android the in-APK WebView JS VM produces the token;
|
|
25
|
+
* in tests a stub returns a fixed value. When no signProvider is configured
|
|
26
|
+
* the request is still issued with `antiToken: null` — best-effort, the
|
|
27
|
+
* endpoint may 403, which surfaces as zero events rather than a crash.
|
|
20
28
|
*
|
|
21
|
-
*
|
|
22
|
-
* 推文 §"支付与购物" 大类, with an explicit "v0.2 待用户导出 — 需 web
|
|
23
|
-
* extension 或手抄" banner so user knows the limitation.
|
|
24
|
-
*
|
|
25
|
-
* Snapshot schema (mirrors PinduoduoLocalCollector.SNAPSHOT_SCHEMA_VERSION
|
|
26
|
-
* once the Kotlin collector lands in v0.3+):
|
|
29
|
+
* Snapshot schema (mirrors PinduoduoLocalCollector.SNAPSHOT_SCHEMA_VERSION):
|
|
27
30
|
*
|
|
28
31
|
* {
|
|
29
32
|
* "schemaVersion": 1,
|
|
@@ -45,33 +48,46 @@
|
|
|
45
48
|
* ]
|
|
46
49
|
* }
|
|
47
50
|
*
|
|
48
|
-
* Future v0.
|
|
51
|
+
* Future v0.4: HTML parsing (`Save As Webpage` from `mobile.yangkeduo.com/
|
|
49
52
|
* users/orders.html` — pinduoduo's order list endpoint).
|
|
50
53
|
*/
|
|
51
54
|
|
|
52
55
|
"use strict";
|
|
53
56
|
|
|
54
57
|
const fs = require("node:fs");
|
|
55
|
-
const { normalizeOrderRecord } = require("../shopping-base");
|
|
58
|
+
const { normalizeOrderRecord, CookieAuth } = require("../shopping-base");
|
|
56
59
|
|
|
57
60
|
const NAME = "shopping-pinduoduo";
|
|
58
|
-
const VERSION = "0.
|
|
61
|
+
const VERSION = "0.2.0";
|
|
59
62
|
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
60
63
|
|
|
61
64
|
const KIND_ORDER = "order";
|
|
62
65
|
const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_ORDER]);
|
|
63
66
|
|
|
67
|
+
const PINDUODUO_ORDERS_URL =
|
|
68
|
+
"https://mobile.yangkeduo.com/proxy/api/galerie/transaction/transaction_list";
|
|
69
|
+
|
|
64
70
|
class PinduoduoAdapter {
|
|
65
71
|
constructor(opts = {}) {
|
|
66
|
-
// §2.4c
|
|
67
|
-
//
|
|
72
|
+
// §2.4c: account is OPTIONAL — snapshot mode is stateless. Cookie-api mode
|
|
73
|
+
// activates only when account.cookies is supplied; account.uid is then
|
|
74
|
+
// required (checked at sync time).
|
|
68
75
|
this.account = opts.account || null;
|
|
76
|
+
this._cookieAuth =
|
|
77
|
+
opts.account && opts.account.cookies
|
|
78
|
+
? new CookieAuth({ platform: "pinduoduo", cookies: opts.account.cookies })
|
|
79
|
+
: null;
|
|
80
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
81
|
+
// anti_token signing seam — see file header. Async fn({ url, query,
|
|
82
|
+
// cookies }) → string|null. When absent, requests carry antiToken: null.
|
|
83
|
+
this._signProvider =
|
|
84
|
+
typeof opts.signProvider === "function" ? opts.signProvider : null;
|
|
69
85
|
|
|
70
86
|
this.name = NAME;
|
|
71
87
|
this.version = VERSION;
|
|
72
|
-
this.capabilities = ["sync:snapshot", "parse:pinduoduo-orders"];
|
|
73
|
-
this.extractMode = "
|
|
74
|
-
this.rateLimits = {};
|
|
88
|
+
this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:pinduoduo-orders"];
|
|
89
|
+
this.extractMode = "web-api";
|
|
90
|
+
this.rateLimits = { perMinute: 8, perDay: 200 };
|
|
75
91
|
this.dataDisclosure = {
|
|
76
92
|
fields: [
|
|
77
93
|
"pinduoduo:order_sn / mall_name / goods_list / order_amount / address",
|
|
@@ -99,15 +115,33 @@ class PinduoduoAdapter {
|
|
|
99
115
|
}
|
|
100
116
|
return { ok: true, mode: "snapshot-file" };
|
|
101
117
|
}
|
|
118
|
+
if (this._cookieAuth) {
|
|
119
|
+
const ok = await this._cookieAuth.validate();
|
|
120
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
|
|
121
|
+
if (!this.account || !this.account.uid) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: "NO_ACCOUNT_UID",
|
|
125
|
+
message: "cookie-api mode requires account.uid",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, account: this.account.uid, mode: "cookie" };
|
|
129
|
+
}
|
|
102
130
|
return {
|
|
103
131
|
ok: false,
|
|
104
132
|
reason: "NO_INPUT",
|
|
105
133
|
message:
|
|
106
|
-
"PinduoduoAdapter.authenticate: needs opts.inputPath (snapshot mode
|
|
134
|
+
"PinduoduoAdapter.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode — anti_token signing via signProvider)",
|
|
107
135
|
};
|
|
108
136
|
}
|
|
109
137
|
|
|
110
138
|
async healthCheck() {
|
|
139
|
+
if (this._cookieAuth) {
|
|
140
|
+
const r = await this.authenticate();
|
|
141
|
+
return r.ok
|
|
142
|
+
? { ok: true, lastChecked: Date.now() }
|
|
143
|
+
: { ok: false, reason: r.reason, error: r.error };
|
|
144
|
+
}
|
|
111
145
|
return { ok: true, lastChecked: Date.now() };
|
|
112
146
|
}
|
|
113
147
|
|
|
@@ -116,21 +150,25 @@ class PinduoduoAdapter {
|
|
|
116
150
|
yield* this._syncViaSnapshot(opts);
|
|
117
151
|
return;
|
|
118
152
|
}
|
|
153
|
+
if (this._cookieAuth) {
|
|
154
|
+
yield* this._syncViaCookie(opts);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
119
157
|
throw new Error(
|
|
120
|
-
"PinduoduoAdapter.sync: needs opts.inputPath (snapshot mode
|
|
158
|
+
"PinduoduoAdapter.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode; pinduoduo's web API requires anti_token signing supplied via opts.signProvider)",
|
|
121
159
|
);
|
|
122
160
|
}
|
|
123
161
|
|
|
124
162
|
async *_syncViaSnapshot(opts) {
|
|
125
163
|
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
126
164
|
// v0.2 explicit JSON-only. HTML parsing (SAF-exported webpage from
|
|
127
|
-
// yangkeduo.com order list) is future v0.
|
|
165
|
+
// yangkeduo.com order list) is future v0.4 work.
|
|
128
166
|
let snapshot;
|
|
129
167
|
try {
|
|
130
168
|
snapshot = JSON.parse(raw);
|
|
131
169
|
} catch (err) {
|
|
132
170
|
throw new Error(
|
|
133
|
-
`shopping-pinduoduo.sync: snapshot must be JSON (v0.
|
|
171
|
+
`shopping-pinduoduo.sync: snapshot must be JSON (v0.4 will add HTML parsing). Got parse error: ${err.message}`,
|
|
134
172
|
);
|
|
135
173
|
}
|
|
136
174
|
if (
|
|
@@ -184,11 +222,77 @@ class PinduoduoAdapter {
|
|
|
184
222
|
}
|
|
185
223
|
}
|
|
186
224
|
|
|
225
|
+
async *_syncViaCookie(opts = {}) {
|
|
226
|
+
if (!this.account || !this.account.uid) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"PinduoduoAdapter._syncViaCookie: account.uid required (set via new PinduoduoAdapter({ account: { uid, cookies } }))",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
232
|
+
const sinceMs =
|
|
233
|
+
opts.sinceWatermark != null
|
|
234
|
+
? parseInt(String(opts.sinceWatermark), 10) || 0
|
|
235
|
+
: Date.now() - 365 * 24 * 3600_000; // default last year
|
|
236
|
+
const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : 10;
|
|
237
|
+
const include = opts.include || {};
|
|
238
|
+
if (include[KIND_ORDER] === false) return;
|
|
239
|
+
|
|
240
|
+
let pageNumber = 1;
|
|
241
|
+
while (true) {
|
|
242
|
+
const query = { pageNumber, pageSize, ts: Date.now() };
|
|
243
|
+
// anti_token signing seam — best-effort. null when no signProvider.
|
|
244
|
+
let antiToken = null;
|
|
245
|
+
if (this._signProvider) {
|
|
246
|
+
antiToken = await this._signProvider({
|
|
247
|
+
url: PINDUODUO_ORDERS_URL,
|
|
248
|
+
query,
|
|
249
|
+
cookies: this._cookieAuth.toHeader(),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const resp = await this._fetchFn({
|
|
253
|
+
url: PINDUODUO_ORDERS_URL,
|
|
254
|
+
cookies: this._cookieAuth.toHeader(),
|
|
255
|
+
antiToken,
|
|
256
|
+
query,
|
|
257
|
+
});
|
|
258
|
+
const orders = extractOrders(resp);
|
|
259
|
+
if (!orders.length) break;
|
|
260
|
+
let pageHasNew = false;
|
|
261
|
+
let reachedWatermark = false;
|
|
262
|
+
for (const raw of orders) {
|
|
263
|
+
const rec = orderToRecord(raw);
|
|
264
|
+
if (!rec) continue;
|
|
265
|
+
if (rec.placedAt && rec.placedAt < sinceMs) {
|
|
266
|
+
reachedWatermark = true; // everything from here on is older
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
pageHasNew = true;
|
|
270
|
+
yield {
|
|
271
|
+
adapter: NAME,
|
|
272
|
+
originalId: rec.orderId,
|
|
273
|
+
capturedAt: rec.paidAt || rec.placedAt || Date.now(),
|
|
274
|
+
payload: { record: rec },
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// Stop once we've crossed the watermark, drained the page, or the page
|
|
278
|
+
// came back short (last page).
|
|
279
|
+
if (reachedWatermark || !pageHasNew || orders.length < pageSize) break;
|
|
280
|
+
pageNumber += 1;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
187
284
|
normalize(raw) {
|
|
188
285
|
if (!raw || !raw.payload) {
|
|
189
286
|
throw new Error("PinduoduoAdapter.normalize: payload missing");
|
|
190
287
|
}
|
|
191
|
-
//
|
|
288
|
+
// Cookie-api mode wraps a normalized record under payload.record; snapshot
|
|
289
|
+
// mode carries the raw event fields directly on the payload.
|
|
290
|
+
if (raw.payload.record) {
|
|
291
|
+
return normalizeOrderRecord(raw.payload.record, {
|
|
292
|
+
adapterName: NAME,
|
|
293
|
+
adapterVersion: VERSION,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
192
296
|
const rec = snapshotEventToRecord(raw.payload);
|
|
193
297
|
return normalizeOrderRecord(rec, {
|
|
194
298
|
adapterName: NAME,
|
|
@@ -208,6 +312,104 @@ function stableOriginalId(kind, id) {
|
|
|
208
312
|
return `pinduoduo:${kind}:${safe}`;
|
|
209
313
|
}
|
|
210
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Pull the order array out of a transaction_list response. Pinduoduo nests it
|
|
317
|
+
* under different keys across endpoint versions; the injected fetchFn may also
|
|
318
|
+
* pre-flatten to `{ orders }`. Tolerant of all common shapes.
|
|
319
|
+
*/
|
|
320
|
+
function extractOrders(resp) {
|
|
321
|
+
if (!resp || typeof resp !== "object") return [];
|
|
322
|
+
if (Array.isArray(resp.orders)) return resp.orders;
|
|
323
|
+
if (Array.isArray(resp.order_list)) return resp.order_list;
|
|
324
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
325
|
+
if (resp.result && Array.isArray(resp.result.order_list)) return resp.result.order_list;
|
|
326
|
+
if (resp.result && Array.isArray(resp.result.list)) return resp.result.list;
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Map one pinduoduo transaction_list order object → vendor-neutral OrderRecord.
|
|
332
|
+
* Pinduoduo amounts are in 分 (cents); converted to 元 here. Field names are
|
|
333
|
+
* best-effort across endpoint versions (camelCase + snake_case fallbacks).
|
|
334
|
+
*/
|
|
335
|
+
function orderToRecord(o) {
|
|
336
|
+
if (!o || typeof o !== "object") return null;
|
|
337
|
+
const orderId = o.order_sn || o.orderSn || o.orderId || o.id;
|
|
338
|
+
if (!orderId) return null;
|
|
339
|
+
const merchant = o.mall_name || o.mallName || o.merchantName || o.shop_name || "拼多多";
|
|
340
|
+
|
|
341
|
+
const items = [];
|
|
342
|
+
const rawItems = o.goods_list || o.order_goods || o.goodsList || o.items || [];
|
|
343
|
+
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
|
344
|
+
if (!it) continue;
|
|
345
|
+
items.push({
|
|
346
|
+
name: it.goods_name || it.goodsName || it.name || it.skuName,
|
|
347
|
+
quantity: parseInt(it.goods_number || it.goods_count || it.quantity || 1, 10),
|
|
348
|
+
unitPrice: centsToYuan(it.goods_price || it.goodsPrice || it.unitPrice || 0),
|
|
349
|
+
sku: it.sku_id || it.skuId || it.goods_id || it.sku || null,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
vendorId: "pinduoduo",
|
|
355
|
+
orderId: String(orderId),
|
|
356
|
+
placedAt: parseTime(o.order_time || o.create_at || o.createAt || o.order_create_at),
|
|
357
|
+
paidAt: parseTime(o.pay_time || o.payTime || o.group_order_pay_time),
|
|
358
|
+
status: mapStatus(pickStatusText(o)),
|
|
359
|
+
merchantName: merchant,
|
|
360
|
+
totalAmount: {
|
|
361
|
+
value: centsToYuan(o.order_amount || o.orderAmount || o.pay_amount || o.total_amount || 0),
|
|
362
|
+
currency: "CNY",
|
|
363
|
+
},
|
|
364
|
+
items,
|
|
365
|
+
recipient: o.receive_name || o.receiver || o.recipient || null,
|
|
366
|
+
shippingAddress: o.address || o.receive_address || o.shippingAddress || null,
|
|
367
|
+
trackingNumber: o.tracking_number || o.waybill_no || o.trackingNumber || null,
|
|
368
|
+
extras: { capturedBy: "cookie-api", platform: "pinduoduo" },
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Pinduoduo carries a human-readable status under several keys; prefer text
|
|
374
|
+
* over the numeric `order_status` code so mapStatus's keyword match works.
|
|
375
|
+
*/
|
|
376
|
+
function pickStatusText(o) {
|
|
377
|
+
const text =
|
|
378
|
+
o.order_status_prompt ||
|
|
379
|
+
o.orderStatusPrompt ||
|
|
380
|
+
o.status_prompt ||
|
|
381
|
+
o.statusPrompt ||
|
|
382
|
+
o.status_desc ||
|
|
383
|
+
null;
|
|
384
|
+
if (text) return text;
|
|
385
|
+
// Fall back to the numeric order_status code (best-effort PDD mapping).
|
|
386
|
+
const code = o.order_status != null ? o.order_status : o.orderStatus;
|
|
387
|
+
switch (Number(code)) {
|
|
388
|
+
case 1:
|
|
389
|
+
return "待付款";
|
|
390
|
+
case 2:
|
|
391
|
+
return "待发货";
|
|
392
|
+
case 3:
|
|
393
|
+
return "已发货";
|
|
394
|
+
case 4:
|
|
395
|
+
return "已完成";
|
|
396
|
+
case 5:
|
|
397
|
+
case 6:
|
|
398
|
+
return "已关闭";
|
|
399
|
+
default:
|
|
400
|
+
return o.status != null ? String(o.status) : "";
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function centsToYuan(v) {
|
|
405
|
+
const n = Number(v);
|
|
406
|
+
if (!Number.isFinite(n)) return 0;
|
|
407
|
+
// Snapshot/test inputs may already be 元 with a decimal point; treat any
|
|
408
|
+
// non-integer as 元, integers as 分.
|
|
409
|
+
if (!Number.isInteger(n)) return n;
|
|
410
|
+
return Math.round(n) / 100;
|
|
411
|
+
}
|
|
412
|
+
|
|
211
413
|
function snapshotEventToRecord(ev) {
|
|
212
414
|
const items = [];
|
|
213
415
|
const rawItems = Array.isArray(ev.items) ? ev.items : [];
|
|
@@ -266,8 +468,14 @@ function mapStatus(s) {
|
|
|
266
468
|
return "placed";
|
|
267
469
|
}
|
|
268
470
|
|
|
471
|
+
async function defaultFetch(_opts) {
|
|
472
|
+
throw new Error("PinduoduoAdapter: no fetchFn configured");
|
|
473
|
+
}
|
|
474
|
+
|
|
269
475
|
module.exports = {
|
|
270
476
|
PinduoduoAdapter,
|
|
477
|
+
orderToRecord,
|
|
478
|
+
extractOrders,
|
|
271
479
|
NAME,
|
|
272
480
|
VERSION,
|
|
273
481
|
SNAPSHOT_SCHEMA_VERSION,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* §2.5 v0.
|
|
2
|
+
* §2.5 v0.3 — 12306 (China Railway) ticket adapter, tri-mode.
|
|
3
3
|
*
|
|
4
4
|
* 1. snapshot mode (opts.inputPath): in-APK Android cc reads a snapshot
|
|
5
5
|
* JSON produced by the phone's Kyfw12306LocalCollector. The collector
|
|
@@ -8,7 +8,20 @@
|
|
|
8
8
|
* no signing), parses each ticket into a structured event, writes JSON.
|
|
9
9
|
* Desktop-independent. account is OPTIONAL at construction.
|
|
10
10
|
*
|
|
11
|
-
* 2.
|
|
11
|
+
* 2. cookie-api mode (opts.account.cookies, v0.3): fetch the SAME two
|
|
12
|
+
* kyfw.12306.cn endpoints directly from the hub, so collection no longer
|
|
13
|
+
* requires a phone. 12306's order endpoints are **cookie-only, no signing**
|
|
14
|
+
* (no _signature / X-Bogus / anti-content SDK — unlike 抖音/拼多多), so no
|
|
15
|
+
* signProvider is needed. The actual HTTP call is delegated to an injected
|
|
16
|
+
* `fetchFn` (Android in-APK cc → OkHttp / Kyfw12306ApiClient; desktop hub →
|
|
17
|
+
* Electron WebView net request) so this module stays a pure-Node parser +
|
|
18
|
+
* orchestrator. Completed orders paginate via `pageIndex` (≤50/page, last
|
|
19
|
+
* 90 days by default); pending orders are a single call. account OPTIONAL —
|
|
20
|
+
* the cookie carries identity. Cookie expires after ~30min idle → the
|
|
21
|
+
* endpoint returns an HTML login redirect, surfaced by the fetchFn as a
|
|
22
|
+
* non-object / empty response, which yields zero events rather than crashing.
|
|
23
|
+
*
|
|
24
|
+
* 3. file-import mode (opts.dataPath, legacy v0.5): user-uploaded JSON
|
|
12
25
|
* dump from a 3rd-party 12306 scraper or hand-curated. Preserved for
|
|
13
26
|
* backward compat. account.username REQUIRED.
|
|
14
27
|
*
|
|
@@ -40,14 +53,22 @@
|
|
|
40
53
|
|
|
41
54
|
const fs = require("node:fs");
|
|
42
55
|
const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
|
|
56
|
+
const { CookieAuth } = require("../shopping-base");
|
|
43
57
|
|
|
44
58
|
const NAME = "travel-12306";
|
|
45
|
-
const VERSION = "0.
|
|
59
|
+
const VERSION = "0.7.0"; // §2.5 v0.3 — cookie-api live fetch path
|
|
46
60
|
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
47
61
|
|
|
48
62
|
const KIND_TICKET = "ticket";
|
|
49
63
|
const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_TICKET]);
|
|
50
64
|
|
|
65
|
+
const KYFW_COMPLETED_URL =
|
|
66
|
+
"https://kyfw.12306.cn/otn/queryOrder/queryMyOrder";
|
|
67
|
+
const KYFW_PENDING_URL =
|
|
68
|
+
"https://kyfw.12306.cn/otn/queryOrder/queryMyOrderNoComplete";
|
|
69
|
+
const DAYS_90_MS = 90 * 24 * 3600_000;
|
|
70
|
+
const PAGE_SIZE = 50; // 12306 returns ≤50 completed orders per page
|
|
71
|
+
|
|
51
72
|
class Train12306Adapter {
|
|
52
73
|
constructor(opts = {}) {
|
|
53
74
|
// §2.5 v0.2: account.username OPTIONAL — snapshot mode is stateless and
|
|
@@ -56,10 +77,22 @@ class Train12306Adapter {
|
|
|
56
77
|
this.account = opts.account || null;
|
|
57
78
|
this._dataPath = opts.dataPath || null;
|
|
58
79
|
|
|
80
|
+
// §2.5 v0.3 cookie-api mode — activates when account.cookies is supplied.
|
|
81
|
+
// 12306 order endpoints are cookie-only (no signing), so no signProvider.
|
|
82
|
+
this._cookieAuth =
|
|
83
|
+
opts.account && opts.account.cookies
|
|
84
|
+
? new CookieAuth({ platform: "12306", cookies: opts.account.cookies })
|
|
85
|
+
: null;
|
|
86
|
+
// The actual HTTP call is delegated to an injected fetchFn so this module
|
|
87
|
+
// stays a pure-Node parser/orchestrator (same seam as the shopping
|
|
88
|
+
// adapters). fetchFn({ url, cookies, form }) → parsed JSON response object.
|
|
89
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
90
|
+
|
|
59
91
|
this.name = NAME;
|
|
60
92
|
this.version = VERSION;
|
|
61
93
|
this.capabilities = [
|
|
62
94
|
"sync:snapshot",
|
|
95
|
+
"sync:cookie-api",
|
|
63
96
|
"import:json",
|
|
64
97
|
"parse:12306-orders",
|
|
65
98
|
];
|
|
@@ -95,6 +128,18 @@ class Train12306Adapter {
|
|
|
95
128
|
}
|
|
96
129
|
return { ok: true, mode: "snapshot-file" };
|
|
97
130
|
}
|
|
131
|
+
if (this._cookieAuth) {
|
|
132
|
+
const ok = await this._cookieAuth.validate();
|
|
133
|
+
if (!ok) {
|
|
134
|
+
return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
|
|
135
|
+
}
|
|
136
|
+
// account is OPTIONAL in cookie mode — the 12306 cookie carries identity.
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
account: (this.account && this.account.username) || null,
|
|
140
|
+
mode: "cookie",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
98
143
|
if (this._dataPath || (ctx && typeof ctx.dataPath === "string")) {
|
|
99
144
|
if (!this.account || !this.account.username) {
|
|
100
145
|
return {
|
|
@@ -109,7 +154,7 @@ class Train12306Adapter {
|
|
|
109
154
|
ok: false,
|
|
110
155
|
reason: "NO_INPUT",
|
|
111
156
|
message:
|
|
112
|
-
"travel-12306.authenticate: needs opts.inputPath (snapshot mode) OR opts.dataPath (file-import mode)",
|
|
157
|
+
"travel-12306.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode) OR opts.dataPath (file-import mode)",
|
|
113
158
|
};
|
|
114
159
|
}
|
|
115
160
|
|
|
@@ -122,16 +167,95 @@ class Train12306Adapter {
|
|
|
122
167
|
yield* this._syncViaSnapshot(opts);
|
|
123
168
|
return;
|
|
124
169
|
}
|
|
170
|
+
if (this._cookieAuth) {
|
|
171
|
+
yield* this._syncViaCookie(opts);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
125
174
|
const dataPath = opts.dataPath || this._dataPath;
|
|
126
175
|
if (dataPath) {
|
|
127
176
|
yield* this._syncViaFileImport({ ...opts, dataPath });
|
|
128
177
|
return;
|
|
129
178
|
}
|
|
130
179
|
throw new Error(
|
|
131
|
-
"travel-12306.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) OR opts.dataPath (file-import mode, user-uploaded JSON)",
|
|
180
|
+
"travel-12306.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) OR opts.account.cookies (cookie-api mode, kyfw.12306.cn live fetch) OR opts.dataPath (file-import mode, user-uploaded JSON)",
|
|
132
181
|
);
|
|
133
182
|
}
|
|
134
183
|
|
|
184
|
+
/**
|
|
185
|
+
* §2.5 v0.3 — cookie-api live fetch. Hits the same two kyfw.12306.cn order
|
|
186
|
+
* endpoints the on-device collector uses (cookie-only, no signing). The
|
|
187
|
+
* injected fetchFn performs the HTTP; this method paginates, flattens each
|
|
188
|
+
* order's tickets into snapshot-shaped ticket events, and yields them so the
|
|
189
|
+
* existing snapshot normalize path applies unchanged.
|
|
190
|
+
*/
|
|
191
|
+
async *_syncViaCookie(opts = {}) {
|
|
192
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
193
|
+
const cookies = this._cookieAuth.toHeader();
|
|
194
|
+
const include = opts.include || {};
|
|
195
|
+
if (include[KIND_TICKET] === false) return;
|
|
196
|
+
|
|
197
|
+
const limit =
|
|
198
|
+
Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
199
|
+
const maxPages =
|
|
200
|
+
Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 4;
|
|
201
|
+
const endDateMs = Number.isFinite(opts.endDateMs) ? opts.endDateMs : Date.now();
|
|
202
|
+
// queryMyOrder caps the range at 90 days; honour an explicit start /
|
|
203
|
+
// sinceWatermark but never reach back further than 90d in one query.
|
|
204
|
+
const requestedStart = Number.isFinite(opts.startDateMs)
|
|
205
|
+
? opts.startDateMs
|
|
206
|
+
: opts.sinceWatermark != null
|
|
207
|
+
? parseInt(String(opts.sinceWatermark), 10) || endDateMs - DAYS_90_MS
|
|
208
|
+
: endDateMs - DAYS_90_MS;
|
|
209
|
+
const startDateMs = Math.max(requestedStart, endDateMs - DAYS_90_MS);
|
|
210
|
+
const queryStartDate = fmtDateShanghai(startDateMs);
|
|
211
|
+
const queryEndDate = fmtDateShanghai(endDateMs);
|
|
212
|
+
|
|
213
|
+
let emitted = 0;
|
|
214
|
+
|
|
215
|
+
// ── completed orders (paginated) ──────────────────────────────────────
|
|
216
|
+
let page = 1;
|
|
217
|
+
while (page <= maxPages) {
|
|
218
|
+
const form = {
|
|
219
|
+
come_from_flag: "my_order",
|
|
220
|
+
queryStartDate,
|
|
221
|
+
queryEndDate,
|
|
222
|
+
queryType: "1",
|
|
223
|
+
sequeue_train_name: "",
|
|
224
|
+
pageSize: String(PAGE_SIZE),
|
|
225
|
+
pageIndex: String(page),
|
|
226
|
+
query_where: "G",
|
|
227
|
+
};
|
|
228
|
+
const resp = await this._fetchFn({ url: KYFW_COMPLETED_URL, cookies, form });
|
|
229
|
+
const orders = extractCompletedOrders(resp);
|
|
230
|
+
if (!orders.length) break;
|
|
231
|
+
for (const order of orders) {
|
|
232
|
+
for (const ev of ticketsFromOrder(order, true)) {
|
|
233
|
+
if (emitted >= limit) return;
|
|
234
|
+
yield cookieEventToRecord(ev);
|
|
235
|
+
emitted += 1;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (orders.length < PAGE_SIZE) break; // last page
|
|
239
|
+
page += 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── pending orders (single call, usually empty) ───────────────────────
|
|
243
|
+
if (emitted < limit) {
|
|
244
|
+
const resp = await this._fetchFn({
|
|
245
|
+
url: KYFW_PENDING_URL,
|
|
246
|
+
cookies,
|
|
247
|
+
form: {},
|
|
248
|
+
});
|
|
249
|
+
for (const order of extractPendingOrders(resp)) {
|
|
250
|
+
for (const ev of ticketsFromOrder(order, false)) {
|
|
251
|
+
if (emitted >= limit) return;
|
|
252
|
+
yield cookieEventToRecord(ev);
|
|
253
|
+
emitted += 1;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
135
259
|
async *_syncViaSnapshot(opts) {
|
|
136
260
|
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
137
261
|
const snapshot = JSON.parse(raw);
|
|
@@ -259,6 +383,7 @@ function snapshotEventToRecord(ev) {
|
|
|
259
383
|
isCompleted: ev.isCompleted,
|
|
260
384
|
idLast6: ev.passengerIdLast6 || undefined,
|
|
261
385
|
orderTotalPrice: ev.orderTotalPrice || undefined,
|
|
386
|
+
capturedVia: ev.capturedVia || undefined,
|
|
262
387
|
},
|
|
263
388
|
};
|
|
264
389
|
}
|
|
@@ -327,9 +452,158 @@ function numberOrParse(v) {
|
|
|
327
452
|
return null;
|
|
328
453
|
}
|
|
329
454
|
|
|
455
|
+
// ─── cookie-api (v0.3) helpers ──────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
/** Wrap a cookie-mode ticket event into the same yield shape the snapshot path
|
|
458
|
+
* produces, so normalize() routes it through snapshotEventToRecord unchanged. */
|
|
459
|
+
function cookieEventToRecord(ev) {
|
|
460
|
+
const capturedAt =
|
|
461
|
+
(Number.isFinite(ev.departureMs) && ev.departureMs) ||
|
|
462
|
+
(Number.isFinite(ev.orderDateMs) && ev.orderDateMs) ||
|
|
463
|
+
Date.now();
|
|
464
|
+
return {
|
|
465
|
+
adapter: NAME,
|
|
466
|
+
kind: KIND_TICKET,
|
|
467
|
+
originalId: stableOriginalId(ev.id || ev.orderSequenceNo),
|
|
468
|
+
capturedAt,
|
|
469
|
+
payload: { ...ev, snapshot: true },
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Pull the completed-order array out of a queryMyOrder response. */
|
|
474
|
+
function extractCompletedOrders(resp) {
|
|
475
|
+
const data = resp && typeof resp === "object" ? resp.data : null;
|
|
476
|
+
if (!data || typeof data !== "object") return [];
|
|
477
|
+
const list = data.OrderDTODataList || data.orderDTODataList;
|
|
478
|
+
return Array.isArray(list) ? list : [];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Pull the pending-order array out of a queryMyOrderNoComplete response. */
|
|
482
|
+
function extractPendingOrders(resp) {
|
|
483
|
+
const data = resp && typeof resp === "object" ? resp.data : null;
|
|
484
|
+
if (!data || typeof data !== "object") return [];
|
|
485
|
+
const list = data.orderDBList || data.orderDbList;
|
|
486
|
+
return Array.isArray(list) ? list : [];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Flatten one 12306 order JSON object into per-ticket snapshot events. Mirrors
|
|
491
|
+
* Kyfw12306ApiClient.parseOrderTickets — one order carries 1..N tickets, each
|
|
492
|
+
* flattened so the vault maps 1:1 to "I rode train G123 on date D". Field names
|
|
493
|
+
* are best-effort across endpoint versions (snake_case + camelCase fallbacks).
|
|
494
|
+
*/
|
|
495
|
+
function ticketsFromOrder(order, isCompleted) {
|
|
496
|
+
if (!order || typeof order !== "object") return [];
|
|
497
|
+
const sequenceNo = pickStr(order.sequence_no) || pickStr(order.sequenceNo);
|
|
498
|
+
if (!sequenceNo) return [];
|
|
499
|
+
const orderDateMs = parseYyyymmdd(pickStr(order.order_date) || pickStr(order.orderDate));
|
|
500
|
+
const orderTotalPrice = toNum(order.ticket_total_price || order.ticketTotalPrice);
|
|
501
|
+
const tickets = Array.isArray(order.tickets) ? order.tickets : [];
|
|
502
|
+
const out = [];
|
|
503
|
+
tickets.forEach((t, i) => {
|
|
504
|
+
if (!t || typeof t !== "object") return;
|
|
505
|
+
const passengerName = pickStr(t.passenger_name) || pickStr(t.passengerName);
|
|
506
|
+
if (!passengerName) return;
|
|
507
|
+
const trainNumber = pickStr(t.train_code) || pickStr(t.stationTrainCode);
|
|
508
|
+
if (!trainNumber) return;
|
|
509
|
+
const fromStation =
|
|
510
|
+
pickStr(t.from_station_name) || pickStr(t.from_station_name_page);
|
|
511
|
+
const toStation =
|
|
512
|
+
pickStr(t.to_station_name) || pickStr(t.to_station_name_page);
|
|
513
|
+
if (!fromStation || !toStation) return;
|
|
514
|
+
const idNo = pickStr(t.passenger_id_no) || pickStr(t.passengerIdNo);
|
|
515
|
+
const idLast6 = idNo && idNo.length >= 6 ? idNo.slice(-6) : null;
|
|
516
|
+
out.push({
|
|
517
|
+
kind: KIND_TICKET,
|
|
518
|
+
id: `ticket-${sequenceNo}:${i}`,
|
|
519
|
+
orderSequenceNo: sequenceNo,
|
|
520
|
+
ticketNumber: pickStr(t.ticket_no) || pickStr(t.ticketNo) || null,
|
|
521
|
+
passengerName,
|
|
522
|
+
passengerIdLast6: idLast6 || undefined,
|
|
523
|
+
trainNumber,
|
|
524
|
+
fromStation,
|
|
525
|
+
toStation,
|
|
526
|
+
departureMs: parse12306DateTime(
|
|
527
|
+
pickStr(t.start_train_date_page) || pickStr(t.start_train_date),
|
|
528
|
+
),
|
|
529
|
+
arrivalMs: parse12306DateTime(pickStr(t.arrive_train_date_page)),
|
|
530
|
+
seatTypeName: pickStr(t.seat_type_name) || pickStr(t.seatTypeName) || null,
|
|
531
|
+
coachNo: pickStr(t.coach_no) || pickStr(t.coachNo) || null,
|
|
532
|
+
seatNo: pickStr(t.seat_no) || pickStr(t.seatNo) || null,
|
|
533
|
+
ticketPrice:
|
|
534
|
+
toNum(t.ticket_price) || toNum(t.ticketPrice) || 0,
|
|
535
|
+
orderDateMs,
|
|
536
|
+
orderTotalPrice,
|
|
537
|
+
isCompleted,
|
|
538
|
+
capturedVia: "cookie-api",
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Format epoch-ms as a 12306 `yyyy-MM-dd` query date in Asia/Shanghai. */
|
|
545
|
+
function fmtDateShanghai(ms) {
|
|
546
|
+
const d = new Date(ms + 8 * 3600_000);
|
|
547
|
+
const y = d.getUTCFullYear();
|
|
548
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
549
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
550
|
+
return `${y}-${m}-${day}`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Parse the date shapes 12306 returns into epoch-ms (Asia/Shanghai):
|
|
555
|
+
* "2024-03-20" / "2024-03-20 09:00" / "2024年03月20日 09:00".
|
|
556
|
+
* Returns null on blank/unparseable so capturedAt can fall back.
|
|
557
|
+
*/
|
|
558
|
+
function parse12306DateTime(s) {
|
|
559
|
+
if (!s || typeof s !== "string") return null;
|
|
560
|
+
const str = s.trim();
|
|
561
|
+
if (!str) return null;
|
|
562
|
+
if (str.includes("年")) return parseChineseDateTime(str);
|
|
563
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/.exec(str);
|
|
564
|
+
if (m) {
|
|
565
|
+
const [, y, mo, d, hh, mm] = m;
|
|
566
|
+
// Shanghai is UTC+8 → subtract 8h from the wall-clock to get UTC ms.
|
|
567
|
+
return Date.UTC(+y, +mo - 1, +d, (hh ? +hh : 0) - 8, mm ? +mm : 0);
|
|
568
|
+
}
|
|
569
|
+
const t = Date.parse(str);
|
|
570
|
+
return Number.isFinite(t) ? t : null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Parse a `yyyyMMdd` order date into epoch-ms (Asia/Shanghai). */
|
|
574
|
+
function parseYyyymmdd(s) {
|
|
575
|
+
if (!s || !/^\d{8}$/.test(s)) return null;
|
|
576
|
+
return Date.UTC(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8), -8, 0);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function pickStr(v) {
|
|
580
|
+
if (v == null) return null;
|
|
581
|
+
const s = String(v).trim();
|
|
582
|
+
return s.length > 0 ? s : null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function toNum(v) {
|
|
586
|
+
if (v == null) return 0;
|
|
587
|
+
const n = typeof v === "number" ? v : parseFloat(String(v));
|
|
588
|
+
return Number.isFinite(n) ? n : 0;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function defaultFetch(_opts) {
|
|
592
|
+
// Pure-Node has no HTTP layer; the host (Android cc → Kyfw12306ApiClient /
|
|
593
|
+
// desktop hub → Electron WebView net) injects a real fetchFn. Mirrors the
|
|
594
|
+
// shopping adapters' defaultFetch — a missing fetchFn is a wiring bug, not a
|
|
595
|
+
// runtime data condition, so it throws loudly rather than silently emitting 0.
|
|
596
|
+
throw new Error("travel-12306: no fetchFn configured for cookie-api mode");
|
|
597
|
+
}
|
|
598
|
+
|
|
330
599
|
module.exports = {
|
|
331
600
|
Train12306Adapter,
|
|
332
601
|
parseRecords,
|
|
602
|
+
ticketsFromOrder,
|
|
603
|
+
extractCompletedOrders,
|
|
604
|
+
extractPendingOrders,
|
|
605
|
+
parse12306DateTime,
|
|
606
|
+
parseYyyymmdd,
|
|
333
607
|
NAME,
|
|
334
608
|
VERSION,
|
|
335
609
|
SNAPSHOT_SCHEMA_VERSION,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chainlesschain/personal-data-hub",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "Personal Data Hub — UnifiedSchema + validators + KG ingest helpers for the data-back-to-the-individual middleware",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "lib/index.js",
|