@chainlesschain/personal-data-hub 0.1.0 → 0.2.0
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/ai-chat-history.test.js +395 -0
- package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
- package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
- package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
- package/__tests__/adapters/email-adapter.test.js +138 -1
- package/__tests__/adapters/email-classifier.test.js +347 -0
- package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
- package/__tests__/adapters/email-retry-progress.test.js +294 -0
- package/__tests__/adapters/email-templates.test.js +699 -0
- package/__tests__/adapters/system-data-adapter.test.js +440 -0
- package/__tests__/adapters/system-data-disclosure.test.js +153 -0
- package/__tests__/analysis-skills.test.js +409 -0
- package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
- package/__tests__/entity-resolver-stages.test.js +411 -0
- package/__tests__/entity-resolver-vault.test.js +246 -0
- package/__tests__/entity-resolver.test.js +526 -0
- package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
- package/__tests__/longtail-adapters.test.js +217 -0
- package/__tests__/mobile-extractor.test.js +288 -0
- package/__tests__/shopping-adapters.test.js +296 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
- package/__tests__/sidecar-supervisor.test.js +120 -0
- package/__tests__/social-adapters.test.js +206 -0
- package/__tests__/travel-adapters.test.js +325 -0
- package/__tests__/vault.test.js +3 -3
- package/__tests__/wechat-adapter.test.js +476 -0
- package/__tests__/whatsapp-adapter.test.js +135 -0
- package/lib/adapter-spec.js +12 -0
- package/lib/adapters/_python-sidecar-base.js +207 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
- package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
- package/lib/adapters/ai-chat-history/http-client.js +211 -0
- package/lib/adapters/ai-chat-history/index.js +28 -0
- package/lib/adapters/ai-chat-history/schema-map.js +221 -0
- package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
- package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
- package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
- package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
- package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
- package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
- package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
- package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
- package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
- package/lib/adapters/alipay-bill/counterparty.js +129 -0
- package/lib/adapters/alipay-bill/csv-parser.js +217 -0
- package/lib/adapters/alipay-bill/index.js +41 -0
- package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
- package/lib/adapters/email-imap/classifier.js +495 -0
- package/lib/adapters/email-imap/email-adapter.js +419 -8
- package/lib/adapters/email-imap/index.js +42 -0
- package/lib/adapters/email-imap/pdf-extractor.js +192 -0
- package/lib/adapters/email-imap/templates/bill.js +232 -0
- package/lib/adapters/email-imap/templates/government.js +120 -0
- package/lib/adapters/email-imap/templates/index.js +78 -0
- package/lib/adapters/email-imap/templates/order.js +186 -0
- package/lib/adapters/email-imap/templates/other.js +114 -0
- package/lib/adapters/email-imap/templates/register.js +113 -0
- package/lib/adapters/email-imap/templates/travel.js +157 -0
- package/lib/adapters/email-imap/templates/utils.js +275 -0
- package/lib/adapters/email-imap/transactions.js +234 -0
- package/lib/adapters/messaging-qq/index.js +158 -0
- package/lib/adapters/messaging-telegram/index.js +142 -0
- package/lib/adapters/messaging-whatsapp/index.js +189 -0
- package/lib/adapters/shopping-base/index.js +208 -0
- package/lib/adapters/shopping-jd/index.js +150 -0
- package/lib/adapters/shopping-meituan/index.js +154 -0
- package/lib/adapters/shopping-taobao/index.js +176 -0
- package/lib/adapters/social-bilibili/index.js +171 -0
- package/lib/adapters/social-douyin/index.js +116 -0
- package/lib/adapters/social-weibo/index.js +164 -0
- package/lib/adapters/social-xiaohongshu/index.js +96 -0
- package/lib/adapters/system-data/disclosure.js +166 -0
- package/lib/adapters/system-data/index.js +34 -0
- package/lib/adapters/system-data/system-data-adapter.js +344 -0
- package/lib/adapters/travel-12306/index.js +151 -0
- package/lib/adapters/travel-amap/index.js +164 -0
- package/lib/adapters/travel-baidu-map/index.js +162 -0
- package/lib/adapters/travel-base/index.js +240 -0
- package/lib/adapters/travel-ctrip/index.js +151 -0
- package/lib/adapters/wechat/content-parser.js +326 -0
- package/lib/adapters/wechat/db-reader.js +209 -0
- package/lib/adapters/wechat/index.js +28 -0
- package/lib/adapters/wechat/key-extractor.js +158 -0
- package/lib/adapters/wechat/normalize.js +220 -0
- package/lib/adapters/wechat/wechat-adapter.js +205 -0
- package/lib/analysis-skills/base.js +113 -0
- package/lib/analysis-skills/footprint.js +167 -0
- package/lib/analysis-skills/index.js +58 -0
- package/lib/analysis-skills/interests.js +161 -0
- package/lib/analysis-skills/relations.js +226 -0
- package/lib/analysis-skills/spending.js +216 -0
- package/lib/analysis-skills/timeline.js +167 -0
- package/lib/entity-resolver/embedding-stage.js +198 -0
- package/lib/entity-resolver/entity-resolver.js +384 -0
- package/lib/entity-resolver/index.js +42 -0
- package/lib/entity-resolver/llm-stage.js +191 -0
- package/lib/entity-resolver/rule-stage.js +208 -0
- package/lib/entity-resolver/worker.js +149 -0
- package/lib/index.js +115 -0
- package/lib/migrations.js +73 -0
- package/lib/mobile-extractor/android.js +193 -0
- package/lib/mobile-extractor/index.js +9 -0
- package/lib/mobile-extractor/ios.js +223 -0
- package/lib/registry.js +42 -0
- package/lib/sidecar/index.js +15 -0
- package/lib/sidecar/supervisor.js +359 -0
- package/lib/vault.js +266 -0
- package/package.json +29 -3
- package/scripts/_make-fixture-all.js +126 -0
- package/scripts/_make-fixture-contacts.js +84 -0
- package/scripts/evaluate-entity-resolver.js +213 -0
- package/scripts/smoke-phase-5-5.js +196 -0
- package/scripts/smoke-phase-5-7.js +181 -0
- package/scripts/smoke-system-data-contacts.js +309 -0
- package/scripts/smoke-system-data.js +312 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 9.4b — Baidu Map (百度地图) location history adapter.
|
|
3
|
+
*
|
|
4
|
+
* Parallels travel-amap but uses Baidu's table names. Per
|
|
5
|
+
* sjqz/parsers/baidumap.py the key tables are:
|
|
6
|
+
* - search_history (queries)
|
|
7
|
+
* - route_history (planned routes)
|
|
8
|
+
* - my_favourite (saved places)
|
|
9
|
+
* - offline_map (downloaded offline maps; v2)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
const fs = require("node:fs");
|
|
15
|
+
const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
|
|
16
|
+
|
|
17
|
+
const NAME = "travel-baidu-map";
|
|
18
|
+
const VERSION = "0.5.0";
|
|
19
|
+
|
|
20
|
+
class BaiduMapAdapter {
|
|
21
|
+
constructor(opts = {}) {
|
|
22
|
+
if (!opts.account || !opts.account.deviceId) {
|
|
23
|
+
throw new Error("BaiduMapAdapter: opts.account.deviceId required");
|
|
24
|
+
}
|
|
25
|
+
this.account = opts.account;
|
|
26
|
+
this._dbPath = opts.dbPath || null;
|
|
27
|
+
this._dbDriverFactory = opts.dbDriverFactory || null;
|
|
28
|
+
|
|
29
|
+
this.name = NAME;
|
|
30
|
+
this.version = VERSION;
|
|
31
|
+
this.capabilities = ["sync:sqlite", "parse:baidu-map-history"];
|
|
32
|
+
this.extractMode = "device-pull";
|
|
33
|
+
this.rateLimits = {};
|
|
34
|
+
this.dataDisclosure = {
|
|
35
|
+
fields: [
|
|
36
|
+
"baidu:search_history",
|
|
37
|
+
"baidu:route_history",
|
|
38
|
+
"baidu:my_favourite",
|
|
39
|
+
],
|
|
40
|
+
sensitivity: "medium",
|
|
41
|
+
legalGate: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async authenticate() {
|
|
46
|
+
return { ok: true, account: this.account.deviceId };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async healthCheck() {
|
|
50
|
+
return { ok: true, lastChecked: Date.now() };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async *sync(opts = {}) {
|
|
54
|
+
const dbPath = opts.dbPath || this._dbPath;
|
|
55
|
+
if (!dbPath || !fs.existsSync(dbPath)) return;
|
|
56
|
+
const Database = this._dbDriverFactory || (() => require("better-sqlite3-multiple-ciphers"));
|
|
57
|
+
const Driver = typeof Database === "function" ? Database() : Database;
|
|
58
|
+
const db = new Driver(dbPath, { readonly: true });
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const routes = trySelect(db, "SELECT * FROM route_history LIMIT 5000")
|
|
62
|
+
|| trySelect(db, "SELECT * FROM bd_route_history LIMIT 5000") || [];
|
|
63
|
+
for (const r of routes) {
|
|
64
|
+
const rec = routeRowToRecord(r);
|
|
65
|
+
if (rec) {
|
|
66
|
+
yield {
|
|
67
|
+
adapter: NAME,
|
|
68
|
+
originalId: rec.recordId,
|
|
69
|
+
capturedAt: rec.bookedAt || Date.now(),
|
|
70
|
+
payload: { record: rec, kind: "route" },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const searches = trySelect(db, "SELECT * FROM search_history LIMIT 5000") || [];
|
|
75
|
+
for (const r of searches) {
|
|
76
|
+
const rec = searchRowToRecord(r);
|
|
77
|
+
if (rec) {
|
|
78
|
+
yield {
|
|
79
|
+
adapter: NAME,
|
|
80
|
+
originalId: rec.recordId,
|
|
81
|
+
capturedAt: rec.bookedAt || Date.now(),
|
|
82
|
+
payload: { record: rec, kind: "search" },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
try { db.close(); } catch (_e) {}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
normalize(raw) {
|
|
92
|
+
if (!raw || !raw.payload || !raw.payload.record) {
|
|
93
|
+
throw new Error("BaiduMapAdapter.normalize: raw.payload.record missing");
|
|
94
|
+
}
|
|
95
|
+
return normalizeTravelRecord(raw.payload.record, {
|
|
96
|
+
adapterName: NAME,
|
|
97
|
+
adapterVersion: VERSION,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function trySelect(db, sql) {
|
|
103
|
+
try {
|
|
104
|
+
return db.prepare(sql).all();
|
|
105
|
+
} catch (_e) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function routeRowToRecord(row) {
|
|
111
|
+
if (!row) return null;
|
|
112
|
+
const id = row._id || row.id || row.uid;
|
|
113
|
+
if (!id) return null;
|
|
114
|
+
return {
|
|
115
|
+
vendorId: "baidumap",
|
|
116
|
+
recordId: `route-${id}`,
|
|
117
|
+
vehicleType: detectVehicle(row.type || row.mode),
|
|
118
|
+
from: { name: row.start_name || row.from_name, lat: row.start_lat || null, lng: row.start_lng || null },
|
|
119
|
+
to: { name: row.end_name || row.to_name, lat: row.end_lat || null, lng: row.end_lng || null },
|
|
120
|
+
departureMs: numberOrParse(row.time || row.create_time),
|
|
121
|
+
carrier: "百度地图",
|
|
122
|
+
extras: { mode: row.type || row.mode },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function searchRowToRecord(row) {
|
|
127
|
+
if (!row) return null;
|
|
128
|
+
const id = row._id || row.id;
|
|
129
|
+
if (!id) return null;
|
|
130
|
+
return {
|
|
131
|
+
vendorId: "baidumap",
|
|
132
|
+
recordId: `search-${id}`,
|
|
133
|
+
vehicleType: "visit",
|
|
134
|
+
to: { name: row.key || row.query, lat: row.lat || null, lng: row.lng || null, city: row.city },
|
|
135
|
+
departureMs: numberOrParse(row.time || row.create_time),
|
|
136
|
+
carrier: "百度地图",
|
|
137
|
+
extras: { query: row.key || row.query },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function detectVehicle(v) {
|
|
142
|
+
const s = String(v || "").toLowerCase();
|
|
143
|
+
if (s.includes("drive") || s.includes("car")) return "car";
|
|
144
|
+
if (s.includes("walk")) return "walk";
|
|
145
|
+
if (s.includes("bike") || s.includes("cycle")) return "bike";
|
|
146
|
+
if (s.includes("bus") || s.includes("transit")) return "bus";
|
|
147
|
+
return "trip";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function numberOrParse(v) {
|
|
151
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
|
|
152
|
+
if (typeof v === "string") {
|
|
153
|
+
if (/^\d+$/.test(v)) {
|
|
154
|
+
const n = parseInt(v, 10);
|
|
155
|
+
return n > 1e12 ? n : n * 1000;
|
|
156
|
+
}
|
|
157
|
+
return parseChineseDateTime(v);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { BaiduMapAdapter, NAME, VERSION };
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 9 — shared travel adapter base.
|
|
3
|
+
*
|
|
4
|
+
* Common normalize logic for the 4 travel sources (12306 / Ctrip /
|
|
5
|
+
* Amap / Baidu Map). Each per-vendor adapter parses its source format
|
|
6
|
+
* into a `TravelRecord` then calls `normalizeTravelRecord()` here.
|
|
7
|
+
*
|
|
8
|
+
* TravelRecord shape (vendor-neutral):
|
|
9
|
+
* {
|
|
10
|
+
* vendorId: "12306" | "ctrip" | "amap" | "baidumap"
|
|
11
|
+
* recordId: string (vendor's order/trip id, unique)
|
|
12
|
+
* vehicleType: "flight" | "train" | "hotel" | "bus" | "car" | "visit"
|
|
13
|
+
* from?: { city?, station?, lat?, lng? }
|
|
14
|
+
* to?: { city?, station?, lat?, lng? }
|
|
15
|
+
* departureMs?: number (ms epoch)
|
|
16
|
+
* arrivalMs?: number
|
|
17
|
+
* carrier?: string ("国航" / "12306" / "携程酒店预订" / ...)
|
|
18
|
+
* vehicleNumber?: string ("CA1234" / "G35" / "京A88888")
|
|
19
|
+
* totalCost?: { value, currency }
|
|
20
|
+
* traveler?: string (passenger name)
|
|
21
|
+
* confirmationCode?:string
|
|
22
|
+
* bookedAt?: number
|
|
23
|
+
* extras?: { ... vendor-specific }
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* normalizeTravelRecord() returns a NormalizedBatch with:
|
|
27
|
+
* - 1 Event of subtype `trip`
|
|
28
|
+
* - 0-2 Place entities (from / to)
|
|
29
|
+
* - 0-2 Person entities (traveler if known + carrier as merchant)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
"use strict";
|
|
33
|
+
|
|
34
|
+
const { newId } = require("../../ids");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert a TravelRecord to a NormalizedBatch.
|
|
38
|
+
*
|
|
39
|
+
* @param {TravelRecord} rec
|
|
40
|
+
* @param {object} ctx optional context for cross-source link
|
|
41
|
+
* @param {string} ctx.accountKey typically email or username
|
|
42
|
+
* @param {string} ctx.adapterName the actual adapter.name string
|
|
43
|
+
* @param {string} ctx.adapterVersion
|
|
44
|
+
* @returns {NormalizedBatch}
|
|
45
|
+
*/
|
|
46
|
+
function normalizeTravelRecord(rec, ctx = {}) {
|
|
47
|
+
if (!rec || typeof rec !== "object") {
|
|
48
|
+
throw new Error("normalizeTravelRecord: rec required");
|
|
49
|
+
}
|
|
50
|
+
if (!rec.recordId) throw new Error("normalizeTravelRecord: rec.recordId required");
|
|
51
|
+
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const occurredAt = Number.isFinite(rec.departureMs)
|
|
54
|
+
? rec.departureMs
|
|
55
|
+
: Number.isFinite(rec.bookedAt)
|
|
56
|
+
? rec.bookedAt
|
|
57
|
+
: now;
|
|
58
|
+
|
|
59
|
+
const adapterName = ctx.adapterName || rec.vendorId || "travel";
|
|
60
|
+
const adapterVersion = ctx.adapterVersion || "0.1.0";
|
|
61
|
+
const source = {
|
|
62
|
+
adapter: adapterName,
|
|
63
|
+
adapterVersion,
|
|
64
|
+
originalId: String(rec.recordId),
|
|
65
|
+
capturedAt: occurredAt,
|
|
66
|
+
capturedBy: "export",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Places
|
|
70
|
+
const places = [];
|
|
71
|
+
const fromPlaceId = rec.from ? placeIdFor(rec.from, adapterName) : null;
|
|
72
|
+
const toPlaceId = rec.to ? placeIdFor(rec.to, adapterName) : null;
|
|
73
|
+
if (fromPlaceId) {
|
|
74
|
+
places.push(makePlace(fromPlaceId, rec.from, now, source));
|
|
75
|
+
}
|
|
76
|
+
if (toPlaceId) {
|
|
77
|
+
places.push(makePlace(toPlaceId, rec.to, now, source));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Carrier as merchant Person (so spending skill can attribute)
|
|
81
|
+
const persons = [];
|
|
82
|
+
let carrierPersonId = null;
|
|
83
|
+
if (rec.carrier) {
|
|
84
|
+
carrierPersonId = `person-${adapterName}-carrier-${slug(rec.carrier)}`;
|
|
85
|
+
persons.push({
|
|
86
|
+
id: carrierPersonId,
|
|
87
|
+
type: "person",
|
|
88
|
+
subtype: "merchant",
|
|
89
|
+
names: [rec.carrier],
|
|
90
|
+
identifiers: {},
|
|
91
|
+
ingestedAt: now,
|
|
92
|
+
source,
|
|
93
|
+
extra: { fromAdapter: adapterName, carrier: true },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Traveler (if not self)
|
|
97
|
+
let travelerPersonId = null;
|
|
98
|
+
if (rec.traveler && rec.traveler !== ctx.selfName) {
|
|
99
|
+
travelerPersonId = `person-${adapterName}-traveler-${slug(rec.traveler)}`;
|
|
100
|
+
persons.push({
|
|
101
|
+
id: travelerPersonId,
|
|
102
|
+
type: "person",
|
|
103
|
+
subtype: "contact",
|
|
104
|
+
names: [rec.traveler],
|
|
105
|
+
identifiers: {},
|
|
106
|
+
ingestedAt: now,
|
|
107
|
+
source,
|
|
108
|
+
extra: { fromAdapter: adapterName, role: "traveler" },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Event
|
|
113
|
+
const eventId = newId();
|
|
114
|
+
const event = {
|
|
115
|
+
id: eventId,
|
|
116
|
+
type: "event",
|
|
117
|
+
subtype: "trip",
|
|
118
|
+
occurredAt,
|
|
119
|
+
actor: travelerPersonId || "person-self",
|
|
120
|
+
participants: dedup(["person-self", travelerPersonId, carrierPersonId].filter(Boolean)),
|
|
121
|
+
content: {
|
|
122
|
+
title: buildTitle(rec),
|
|
123
|
+
...(rec.extras && rec.extras.note ? { text: rec.extras.note } : {}),
|
|
124
|
+
...(rec.totalCost && Number.isFinite(rec.totalCost.value)
|
|
125
|
+
? { amount: { value: rec.totalCost.value, currency: rec.totalCost.currency || "CNY", direction: "out" } }
|
|
126
|
+
: {}),
|
|
127
|
+
},
|
|
128
|
+
ingestedAt: now,
|
|
129
|
+
source,
|
|
130
|
+
extra: {
|
|
131
|
+
vehicleType: rec.vehicleType,
|
|
132
|
+
vendorId: rec.vendorId,
|
|
133
|
+
...(rec.from ? { from: rec.from.station || rec.from.city || formatPlace(rec.from) } : {}),
|
|
134
|
+
...(rec.to ? { to: rec.to.station || rec.to.city || formatPlace(rec.to) } : {}),
|
|
135
|
+
...(rec.from ? { fromPlaceId } : {}),
|
|
136
|
+
...(rec.to ? { toPlaceId } : {}),
|
|
137
|
+
...(rec.arrivalMs ? { arrivalMs: rec.arrivalMs } : {}),
|
|
138
|
+
...(rec.vehicleNumber ? { vehicleNumber: rec.vehicleNumber } : {}),
|
|
139
|
+
...(rec.confirmationCode ? { confirmationCode: rec.confirmationCode } : {}),
|
|
140
|
+
...(rec.bookedAt ? { bookedAt: rec.bookedAt } : {}),
|
|
141
|
+
...(carrierPersonId ? { carrier: rec.carrier, carrierPersonId } : {}),
|
|
142
|
+
...(rec.extras ? { vendorExtras: rec.extras } : {}),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return { events: [event], persons, places, items: [], topics: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── helpers ────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function buildTitle(rec) {
|
|
152
|
+
const vt = rec.vehicleType || "trip";
|
|
153
|
+
const from = rec.from ? (rec.from.station || rec.from.city || "?") : "";
|
|
154
|
+
const to = rec.to ? (rec.to.station || rec.to.city || "?") : "";
|
|
155
|
+
if (from && to) return `${vt}: ${from} → ${to}`;
|
|
156
|
+
if (to) return `${vt}: → ${to}`;
|
|
157
|
+
return `${vt}: ${rec.carrier || rec.recordId}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function formatPlace(p) {
|
|
161
|
+
if (!p) return "";
|
|
162
|
+
const parts = [p.city, p.station, p.name].filter(Boolean);
|
|
163
|
+
return parts.join(" ");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function placeIdFor(p, adapterName) {
|
|
167
|
+
// Stable id keyed by station/city/lat-lng so cross-trip places dedup
|
|
168
|
+
if (!p) return null;
|
|
169
|
+
const key = (p.station || p.city || p.name || `${p.lat},${p.lng}` || "").toString().toLowerCase();
|
|
170
|
+
if (!key) return null;
|
|
171
|
+
return `place-${adapterName}-${slug(key)}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function makePlace(id, p, now, source) {
|
|
175
|
+
return {
|
|
176
|
+
id,
|
|
177
|
+
type: "place",
|
|
178
|
+
subtype: "venue",
|
|
179
|
+
name: p.station || p.city || p.name || id,
|
|
180
|
+
address: p.address || undefined,
|
|
181
|
+
aliases: [p.station, p.city, p.name].filter(Boolean),
|
|
182
|
+
coordinates: (Number.isFinite(p.lat) && Number.isFinite(p.lng))
|
|
183
|
+
? { lat: p.lat, lng: p.lng }
|
|
184
|
+
: undefined,
|
|
185
|
+
ingestedAt: now,
|
|
186
|
+
source,
|
|
187
|
+
extra: {},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function slug(s) {
|
|
192
|
+
return String(s || "")
|
|
193
|
+
.toLowerCase()
|
|
194
|
+
.replace(/\s+/g, "-")
|
|
195
|
+
.replace(/[^\w一-鿿-]/g, "")
|
|
196
|
+
.slice(0, 80);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function dedup(arr) {
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const x of arr) {
|
|
203
|
+
if (x == null || seen.has(x)) continue;
|
|
204
|
+
seen.add(x);
|
|
205
|
+
out.push(x);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse a Chinese date string ("2026年4月15日 14:30") to ms epoch.
|
|
212
|
+
* Handles 4 common formats; returns null on failure.
|
|
213
|
+
*/
|
|
214
|
+
function parseChineseDateTime(s) {
|
|
215
|
+
if (typeof s !== "string" || s.length === 0) return null;
|
|
216
|
+
// YYYY-MM-DD HH:MM:SS or YYYY-MM-DDTHH:MM
|
|
217
|
+
let m = /^(\d{4})-(\d{1,2})-(\d{1,2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(s);
|
|
218
|
+
if (m) {
|
|
219
|
+
return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6] || 0).getTime();
|
|
220
|
+
}
|
|
221
|
+
// YYYY/MM/DD HH:MM
|
|
222
|
+
m = /^(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})/.exec(s);
|
|
223
|
+
if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]).getTime();
|
|
224
|
+
// 2026年4月15日 14:30
|
|
225
|
+
m = /^(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})/.exec(s);
|
|
226
|
+
if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]).getTime();
|
|
227
|
+
// 2026年4月15日 (no time)
|
|
228
|
+
m = /^(\d{4})年(\d{1,2})月(\d{1,2})日/.exec(s);
|
|
229
|
+
if (m) return new Date(+m[1], +m[2] - 1, +m[3]).getTime();
|
|
230
|
+
// Fallback: Date.parse
|
|
231
|
+
const t = Date.parse(s);
|
|
232
|
+
return Number.isFinite(t) ? t : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = {
|
|
236
|
+
normalizeTravelRecord,
|
|
237
|
+
parseChineseDateTime,
|
|
238
|
+
placeIdFor,
|
|
239
|
+
slug,
|
|
240
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 9.3 — Ctrip (携程) order adapter.
|
|
3
|
+
*
|
|
4
|
+
* Ctrip has no official user export. Two input paths:
|
|
5
|
+
* 1. JSON dump from a 3rd-party scraper or user-curated file
|
|
6
|
+
* 2. Email order-confirmation events from Phase 5 (vault-side derive)
|
|
7
|
+
*
|
|
8
|
+
* Ctrip orders cover 4 sub-types: flight / hotel / train / cruise.
|
|
9
|
+
* We map each to the appropriate `vehicleType` in TravelRecord:
|
|
10
|
+
* flight → "flight", hotel → "hotel", train → "train", cruise → "cruise"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const fs = require("node:fs");
|
|
16
|
+
const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
|
|
17
|
+
|
|
18
|
+
const NAME = "travel-ctrip";
|
|
19
|
+
const VERSION = "0.5.0";
|
|
20
|
+
|
|
21
|
+
class CtripAdapter {
|
|
22
|
+
constructor(opts = {}) {
|
|
23
|
+
if (!opts.account || !opts.account.email) {
|
|
24
|
+
throw new Error("CtripAdapter: opts.account.email required");
|
|
25
|
+
}
|
|
26
|
+
this.account = opts.account;
|
|
27
|
+
this._dataPath = opts.dataPath || null;
|
|
28
|
+
|
|
29
|
+
this.name = NAME;
|
|
30
|
+
this.version = VERSION;
|
|
31
|
+
this.capabilities = ["import:json", "parse:ctrip-orders"];
|
|
32
|
+
this.extractMode = "file-import";
|
|
33
|
+
this.rateLimits = {};
|
|
34
|
+
this.dataDisclosure = {
|
|
35
|
+
fields: [
|
|
36
|
+
"ctrip:orderId / type / fromCity / toCity / dates / passengerName / price / carrier",
|
|
37
|
+
],
|
|
38
|
+
sensitivity: "medium",
|
|
39
|
+
legalGate: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async authenticate() {
|
|
44
|
+
return { ok: true, account: this.account.email };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async healthCheck() {
|
|
48
|
+
return { ok: true, lastChecked: Date.now() };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async *sync(opts = {}) {
|
|
52
|
+
const dataPath = opts.dataPath || this._dataPath;
|
|
53
|
+
if (!dataPath || !fs.existsSync(dataPath)) return;
|
|
54
|
+
const text = fs.readFileSync(dataPath, "utf-8");
|
|
55
|
+
let records;
|
|
56
|
+
try {
|
|
57
|
+
records = parseRecords(text);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
throw new Error(`CtripAdapter: parse failed: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
for (const r of records) {
|
|
62
|
+
yield {
|
|
63
|
+
adapter: NAME,
|
|
64
|
+
originalId: r.recordId,
|
|
65
|
+
capturedAt: r.bookedAt || r.departureMs || Date.now(),
|
|
66
|
+
payload: { record: r },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
normalize(raw) {
|
|
72
|
+
if (!raw || !raw.payload || !raw.payload.record) {
|
|
73
|
+
throw new Error("CtripAdapter.normalize: raw.payload.record missing");
|
|
74
|
+
}
|
|
75
|
+
return normalizeTravelRecord(raw.payload.record, {
|
|
76
|
+
adapterName: NAME,
|
|
77
|
+
adapterVersion: VERSION,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const TYPE_MAP = {
|
|
83
|
+
flight: "flight",
|
|
84
|
+
airline: "flight",
|
|
85
|
+
hotel: "hotel",
|
|
86
|
+
train: "train",
|
|
87
|
+
cruise: "cruise",
|
|
88
|
+
bus: "bus",
|
|
89
|
+
car: "car",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function parseRecords(text) {
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = JSON.parse(text);
|
|
96
|
+
} catch (_e) {
|
|
97
|
+
// Try JSONL
|
|
98
|
+
raw = text
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
101
|
+
.map((l) => JSON.parse(l));
|
|
102
|
+
}
|
|
103
|
+
const orders = Array.isArray(raw) ? raw : raw.orders || [];
|
|
104
|
+
return orders.map(orderToRecord).filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function orderToRecord(o) {
|
|
108
|
+
if (!o || typeof o !== "object") return null;
|
|
109
|
+
const recordId = o.orderId || o.id || o.order_no;
|
|
110
|
+
if (!recordId) return null;
|
|
111
|
+
const type = (o.type || o.orderType || "").toLowerCase();
|
|
112
|
+
const vehicleType = TYPE_MAP[type] || "trip";
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
vendorId: "ctrip",
|
|
116
|
+
recordId: String(recordId),
|
|
117
|
+
vehicleType,
|
|
118
|
+
from: o.fromCity || o.from_city || o.depCity
|
|
119
|
+
? { city: o.fromCity || o.from_city || o.depCity }
|
|
120
|
+
: null,
|
|
121
|
+
to: o.toCity || o.to_city || o.arrCity || o.hotelCity
|
|
122
|
+
? { city: o.toCity || o.to_city || o.arrCity || o.hotelCity }
|
|
123
|
+
: null,
|
|
124
|
+
departureMs: numberOrParse(o.departureTime || o.dep_time || o.checkIn || o.check_in),
|
|
125
|
+
arrivalMs: numberOrParse(o.arrivalTime || o.arr_time || o.checkOut || o.check_out),
|
|
126
|
+
carrier: o.carrier || o.airline || o.hotelName || o.hotel_name || "携程",
|
|
127
|
+
vehicleNumber: o.flightNumber || o.flight_no || o.trainNumber || o.train_no,
|
|
128
|
+
totalCost: o.price != null
|
|
129
|
+
? { value: parseFloat(o.price), currency: o.currency || "CNY" }
|
|
130
|
+
: null,
|
|
131
|
+
traveler: o.passengerName || o.passenger || o.guestName || o.guest_name,
|
|
132
|
+
confirmationCode: o.confirmationCode || o.pnr || o.confirmation_no,
|
|
133
|
+
bookedAt: numberOrParse(o.bookedAt || o.order_time),
|
|
134
|
+
extras: {
|
|
135
|
+
type,
|
|
136
|
+
...(o.hotel ? { hotel: o.hotel } : {}),
|
|
137
|
+
...(o.nights != null ? { nights: o.nights } : {}),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function numberOrParse(v) {
|
|
143
|
+
if (Number.isFinite(v)) return v;
|
|
144
|
+
if (typeof v === "string") {
|
|
145
|
+
if (/^\d+$/.test(v) && v.length >= 10) return parseInt(v, 10);
|
|
146
|
+
return parseChineseDateTime(v);
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { CtripAdapter, parseRecords, TYPE_MAP, NAME, VERSION };
|