@chainlesschain/personal-data-hub 0.4.23 → 0.4.24
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/bank-family.test.js +125 -0
- package/__tests__/adapters/car-mercedesme.test.js +74 -0
- package/__tests__/adapters/finance-dcep.test.js +74 -0
- package/__tests__/adapters/fitness-joyrun.test.js +82 -0
- package/__tests__/adapters/gov-12123.test.js +103 -0
- package/__tests__/adapters/music-qq.test.js +112 -0
- package/__tests__/adapters/reading-family.test.js +108 -0
- package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
- package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
- package/__tests__/fitness-keep-snapshot.test.js +224 -0
- package/__tests__/shopping-eleme-snapshot.test.js +454 -0
- package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
- package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
- package/__tests__/social-douban-snapshot.test.js +351 -0
- package/lib/adapter-guide.js +19 -1
- package/lib/adapters/_bank-base.js +405 -0
- package/lib/adapters/_reading-base.js +315 -0
- package/lib/adapters/audio-ximalaya/index.js +414 -0
- package/lib/adapters/bank-bankcomm/index.js +27 -0
- package/lib/adapters/bank-boc/index.js +26 -0
- package/lib/adapters/bank-cmbc/index.js +26 -0
- package/lib/adapters/bank-icbc/index.js +27 -0
- package/lib/adapters/car-mercedesme/index.js +225 -0
- package/lib/adapters/finance-dcep/index.js +302 -0
- package/lib/adapters/fitness-joyrun/index.js +295 -0
- package/lib/adapters/fitness-keep/index.js +343 -0
- package/lib/adapters/gov-12123/index.js +391 -0
- package/lib/adapters/music-qq/index.js +372 -0
- package/lib/adapters/reading-fanqie/index.js +61 -0
- package/lib/adapters/reading-qimao/index.js +61 -0
- package/lib/adapters/shopping-eleme/index.js +441 -0
- package/lib/adapters/shopping-vipshop/index.js +429 -0
- package/lib/adapters/shopping-xianyu/index.js +454 -0
- package/lib/adapters/social-douban/index.js +564 -0
- package/lib/adapters/travel-didi-consumer/index.js +148 -0
- package/lib/index.js +36 -0
- package/package.json +1 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §12.1 Phase 13+ — 奔驰 Mercedes me (com.daimler.ris.mercedesme.cn.android)
|
|
3
|
+
* adapter, "车辆行程". Device-discovered gap (2026-06-15), new `car-` category
|
|
4
|
+
* (own-vehicle telematics, distinct from ride-hailing travel-didi).
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ BEST-EFFORT SCAFFOLD. Mercedes me uses OAuth (not cookie) over a
|
|
7
|
+
* proprietary OEM API; the endpoint below is a FABRICATED placeholder
|
|
8
|
+
* (overridable via opts.listUrl, NOT field-verified — FAMILY-23 playbook) and
|
|
9
|
+
* the cookie seam stands in for a real bearer token. snapshot/file-import is
|
|
10
|
+
* the reliable path; cookie path surfaces auth.unverified=true. Each trip maps
|
|
11
|
+
* onto the vendor-neutral car TravelRecord (travel-base). sensitivity:"medium"
|
|
12
|
+
* (trip start/end addresses = location history).
|
|
13
|
+
*
|
|
14
|
+
* Snapshot/file shape (JSON array or {trips:[...]}):
|
|
15
|
+
* { "tripId":"...", "startTime":<s|ms>, "endTime":<s|ms>, "startAddress":"...",
|
|
16
|
+
* "endAddress":"...", "distanceKm":12.4, "durationSec":1400, "plate":"..." }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
"use strict";
|
|
20
|
+
|
|
21
|
+
const fs = require("node:fs");
|
|
22
|
+
const { normalizeTravelRecord, parseChineseDateTime } = require("../travel-base");
|
|
23
|
+
const { CookieAuth } = require("../shopping-base");
|
|
24
|
+
|
|
25
|
+
const NAME = "car-mercedesme";
|
|
26
|
+
const VERSION = "0.1.0";
|
|
27
|
+
|
|
28
|
+
const MERCEDESME_TRIPS_URL = "https://api.mercedes-benz.com.cn/vehicledata/v1/trips";
|
|
29
|
+
const DEFAULT_PAGE_SIZE = 30;
|
|
30
|
+
const DEFAULT_MAX_PAGES = 10;
|
|
31
|
+
|
|
32
|
+
function toMs(n) {
|
|
33
|
+
return n >= 1e12 ? n : n >= 1e9 ? n * 1000 : n;
|
|
34
|
+
}
|
|
35
|
+
function numberOrParse(v) {
|
|
36
|
+
if (Number.isFinite(v)) return toMs(v);
|
|
37
|
+
if (typeof v === "string") {
|
|
38
|
+
if (/^\d+$/.test(v)) return toMs(parseInt(v, 10));
|
|
39
|
+
return parseChineseDateTime(v);
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function toNum(v) {
|
|
44
|
+
if (Number.isFinite(v)) return v;
|
|
45
|
+
if (typeof v === "string") {
|
|
46
|
+
const n = parseFloat(v);
|
|
47
|
+
return Number.isFinite(n) ? n : null;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tripToRecord(t, opts = {}) {
|
|
53
|
+
if (!t || typeof t !== "object") return null;
|
|
54
|
+
const recordId = t.tripId || t.trip_id || t.id || t.tripID;
|
|
55
|
+
if (!recordId) return null;
|
|
56
|
+
let distanceKm = toNum(t.distanceKm != null ? t.distanceKm : t.distance_km);
|
|
57
|
+
if (distanceKm == null) {
|
|
58
|
+
const meters = toNum(t.distanceMeters != null ? t.distanceMeters : t.distance_m != null ? t.distance_m : t.distance);
|
|
59
|
+
if (meters != null) distanceKm = meters / 1000;
|
|
60
|
+
}
|
|
61
|
+
const fromAddr = t.startAddress || t.start_address || t.fromAddress || t.startLocation;
|
|
62
|
+
const toAddr = t.endAddress || t.end_address || t.toAddress || t.endLocation;
|
|
63
|
+
return {
|
|
64
|
+
vendorId: "mercedesme",
|
|
65
|
+
recordId: String(recordId),
|
|
66
|
+
vehicleType: "car",
|
|
67
|
+
from: fromAddr ? { name: fromAddr } : null,
|
|
68
|
+
to: toAddr ? { name: toAddr } : null,
|
|
69
|
+
departureMs: numberOrParse(t.startTime || t.start_time || t.beginTime),
|
|
70
|
+
arrivalMs: numberOrParse(t.endTime || t.end_time || t.finishTime),
|
|
71
|
+
carrier: "Mercedes me",
|
|
72
|
+
vehicleNumber: t.plate || t.licensePlate || t.fin || t.vin || null,
|
|
73
|
+
totalCost: null,
|
|
74
|
+
traveler: null,
|
|
75
|
+
confirmationCode: null,
|
|
76
|
+
bookedAt: numberOrParse(t.startTime || t.start_time),
|
|
77
|
+
extras: {
|
|
78
|
+
type: "car",
|
|
79
|
+
...(distanceKm != null ? { distanceKm: Math.round(distanceKm * 100) / 100 } : {}),
|
|
80
|
+
...(t.durationSec != null ? { durationSec: toNum(t.durationSec) } : {}),
|
|
81
|
+
...(opts.capturedVia ? { capturedVia: opts.capturedVia } : {}),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseTrips(text) {
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
raw = JSON.parse(text);
|
|
90
|
+
} catch (_e) {
|
|
91
|
+
raw = text.split(/\r?\n/).filter((l) => l.trim().startsWith("{")).map((l) => JSON.parse(l));
|
|
92
|
+
}
|
|
93
|
+
const trips = Array.isArray(raw) ? raw : raw.trips || raw.list || [];
|
|
94
|
+
return trips.map((t) => tripToRecord(t)).filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractTrips(resp) {
|
|
98
|
+
if (!resp || typeof resp !== "object") return [];
|
|
99
|
+
if (Array.isArray(resp.trips)) return resp.trips;
|
|
100
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
101
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
102
|
+
const d = resp.data;
|
|
103
|
+
if (d && typeof d === "object") {
|
|
104
|
+
if (Array.isArray(d.trips)) return d.trips;
|
|
105
|
+
if (Array.isArray(d.list)) return d.list;
|
|
106
|
+
}
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
class MercedesMeAdapter {
|
|
111
|
+
constructor(opts = {}) {
|
|
112
|
+
this.account = opts.account || null;
|
|
113
|
+
this._dataPath = opts.dataPath || null;
|
|
114
|
+
this._cookieAuth =
|
|
115
|
+
opts.account && opts.account.cookies ? new CookieAuth({ platform: "mercedesme", cookies: opts.account.cookies }) : null;
|
|
116
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
117
|
+
this._signProvider = typeof opts.signProvider === "function" ? opts.signProvider : null;
|
|
118
|
+
this._listUrl = typeof opts.listUrl === "string" && opts.listUrl.length > 0 ? opts.listUrl : MERCEDESME_TRIPS_URL;
|
|
119
|
+
|
|
120
|
+
this.name = NAME;
|
|
121
|
+
this.version = VERSION;
|
|
122
|
+
this.capabilities = ["import:json", "sync:snapshot", "sync:cookie-api", "parse:mercedesme-trips"];
|
|
123
|
+
this.extractMode = "file-import";
|
|
124
|
+
this.rateLimits = {};
|
|
125
|
+
this.dataDisclosure = {
|
|
126
|
+
fields: ["mercedesme:tripId / startAddress / endAddress / startTime / endTime / distanceKm"],
|
|
127
|
+
sensitivity: "medium",
|
|
128
|
+
legalGate: false,
|
|
129
|
+
};
|
|
130
|
+
this._deps = { fs };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async authenticate(ctx = {}) {
|
|
134
|
+
const filePath = (ctx && ctx.inputPath) || ctx.dataPath || this._dataPath;
|
|
135
|
+
if (filePath) {
|
|
136
|
+
try {
|
|
137
|
+
this._deps.fs.accessSync(filePath, this._deps.fs.constants.R_OK);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, reason: "INPUT_PATH_UNREADABLE", message: `not readable at ${filePath}: ${err.message}` };
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, mode: "snapshot-file" };
|
|
142
|
+
}
|
|
143
|
+
if (this._cookieAuth) {
|
|
144
|
+
const ok = await this._cookieAuth.validate();
|
|
145
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "token missing" };
|
|
146
|
+
return { ok: true, account: (this.account && this.account.userId) || null, mode: "cookie", unverified: true };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, account: null, mode: "ready" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async healthCheck() {
|
|
152
|
+
if (this._cookieAuth) {
|
|
153
|
+
const r = await this.authenticate();
|
|
154
|
+
return r.ok ? { ok: true, lastChecked: Date.now(), unverified: true } : { ok: false, reason: r.reason, error: r.error };
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, lastChecked: Date.now() };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async *sync(opts = {}) {
|
|
160
|
+
const dataPath = opts.inputPath || opts.dataPath || this._dataPath;
|
|
161
|
+
if (dataPath) {
|
|
162
|
+
if (!this._deps.fs.existsSync(dataPath)) return;
|
|
163
|
+
const text = this._deps.fs.readFileSync(dataPath, "utf-8");
|
|
164
|
+
let records;
|
|
165
|
+
try {
|
|
166
|
+
records = parseTrips(text);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
throw new Error(`MercedesMeAdapter: parse failed: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
for (const r of records) {
|
|
171
|
+
yield { adapter: NAME, originalId: r.recordId, capturedAt: r.departureMs || r.bookedAt || Date.now(), payload: { record: r } };
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (this._cookieAuth) yield* this._syncViaCookie(opts);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async *_syncViaCookie(opts = {}) {
|
|
179
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
180
|
+
const cookies = this._cookieAuth.toHeader();
|
|
181
|
+
const sinceMs = opts.sinceWatermark != null ? parseInt(String(opts.sinceWatermark), 10) || 0 : 0;
|
|
182
|
+
const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : DEFAULT_PAGE_SIZE;
|
|
183
|
+
const maxPages = Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : DEFAULT_MAX_PAGES;
|
|
184
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
185
|
+
|
|
186
|
+
let emitted = 0;
|
|
187
|
+
let page = 1;
|
|
188
|
+
while (page <= maxPages) {
|
|
189
|
+
const query = { page, pageSize };
|
|
190
|
+
let sign = null;
|
|
191
|
+
if (this._signProvider) sign = await this._signProvider({ url: this._listUrl, query, cookies });
|
|
192
|
+
const resp = await this._fetchFn({ url: this._listUrl, cookies, query, sign });
|
|
193
|
+
const trips = extractTrips(resp);
|
|
194
|
+
if (!trips.length) break;
|
|
195
|
+
let reachedWatermark = false;
|
|
196
|
+
for (const raw of trips) {
|
|
197
|
+
const rec = tripToRecord(raw, { capturedVia: "cookie-api" });
|
|
198
|
+
if (!rec) continue;
|
|
199
|
+
const ts = rec.departureMs || null;
|
|
200
|
+
if (sinceMs && ts && ts < sinceMs) {
|
|
201
|
+
reachedWatermark = true;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
if (emitted >= limit) return;
|
|
205
|
+
yield { adapter: NAME, originalId: rec.recordId, capturedAt: ts || Date.now(), payload: { record: rec } };
|
|
206
|
+
emitted += 1;
|
|
207
|
+
}
|
|
208
|
+
if (reachedWatermark || trips.length < pageSize) break;
|
|
209
|
+
page += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
normalize(raw) {
|
|
214
|
+
if (!raw || !raw.payload || !raw.payload.record) {
|
|
215
|
+
throw new Error("MercedesMeAdapter.normalize: raw.payload.record missing");
|
|
216
|
+
}
|
|
217
|
+
return normalizeTravelRecord(raw.payload.record, { adapterName: NAME, adapterVersion: VERSION });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function defaultFetch(_opts) {
|
|
222
|
+
throw new Error("car-mercedesme: no fetchFn configured for cookie-api mode");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { MercedesMeAdapter, tripToRecord, parseTrips, extractTrips, NAME, VERSION };
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §12.1 Phase 13+ ⭐⭐⭐ — 数字人民币 (DCEP / e-CNY, cn.gov.pbc.dcep) adapter,
|
|
3
|
+
* "DCEP 交易". BEST-EFFORT SCAFFOLD (user-requested).
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ MAXIMALLY SENSITIVE (central-bank digital-currency wallet, real-name +
|
|
6
|
+
* strong-auth). The DCEP app has NO documented public API; the cookie-api
|
|
7
|
+
* endpoint below is a FABRICATED placeholder (overridable via opts.listUrl,
|
|
8
|
+
* NOT field-verified — FAMILY-23 playbook) and cannot authenticate without the
|
|
9
|
+
* app's real login. **snapshot mode is the reliable path**; cookie path
|
|
10
|
+
* surfaces auth.unverified=true. Gated sensitivity:"high" + legalGate:true.
|
|
11
|
+
*
|
|
12
|
+
* One record kind: 钱包交易 (wallet transactions):
|
|
13
|
+
* { txId, time, amount, direction(pay付款/receive收款), counterparty, walletType(子钱包) }
|
|
14
|
+
* → EVENT(PAYMENT).
|
|
15
|
+
*
|
|
16
|
+
* Snapshot schema (schemaVersion 1):
|
|
17
|
+
* {
|
|
18
|
+
* "schemaVersion": 1, "snapshottedAt": <ms>,
|
|
19
|
+
* "account": { "userId": "...", "name": "..." },
|
|
20
|
+
* "events": [
|
|
21
|
+
* { "kind": "transaction", "id": "tx-<id>", "txId": "...", "time": <s|ms>,
|
|
22
|
+
* "amount": 12.5, "direction": "pay", "counterparty": "某商户",
|
|
23
|
+
* "walletType": "中国银行子钱包" }
|
|
24
|
+
* ]
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
"use strict";
|
|
29
|
+
|
|
30
|
+
const fs = require("node:fs");
|
|
31
|
+
const { newId } = require("../../ids");
|
|
32
|
+
const { ENTITY_TYPES, EVENT_SUBTYPES, CAPTURED_BY } = require("../../constants");
|
|
33
|
+
const { CookieAuth } = require("../shopping-base");
|
|
34
|
+
|
|
35
|
+
const NAME = "finance-dcep";
|
|
36
|
+
const VERSION = "0.1.0";
|
|
37
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
38
|
+
const KIND_TX = "transaction";
|
|
39
|
+
const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_TX]);
|
|
40
|
+
const DCEP_LIST_URL = "https://dcep.pbc.gov.cn/api/v1/wallet/transactions";
|
|
41
|
+
const PAGE_SIZE = 30;
|
|
42
|
+
|
|
43
|
+
function parseTime(v) {
|
|
44
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v >= 1e9 ? v * 1000 : v;
|
|
45
|
+
if (typeof v === "string") {
|
|
46
|
+
if (/^\d+$/.test(v)) {
|
|
47
|
+
const n = parseInt(v, 10);
|
|
48
|
+
return n > 1e12 ? n : n >= 1e9 ? n * 1000 : n;
|
|
49
|
+
}
|
|
50
|
+
const t = Date.parse(v);
|
|
51
|
+
return Number.isFinite(t) ? t : null;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toAmount(v) {
|
|
57
|
+
if (Number.isFinite(v)) return v;
|
|
58
|
+
if (typeof v === "string") {
|
|
59
|
+
const n = parseFloat(v.replace(/[,,¥\s]/g, ""));
|
|
60
|
+
return Number.isFinite(n) ? n : null;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normDirection(raw) {
|
|
66
|
+
const d = String(raw.direction || raw.type || raw.dcFlag || "").toLowerCase();
|
|
67
|
+
if (/receive|收款|收入|入账|贷|\+/.test(d)) return "receive";
|
|
68
|
+
if (/pay|付款|支出|出账|借|-/.test(d)) return "pay";
|
|
69
|
+
const amt = toAmount(raw.amount);
|
|
70
|
+
if (Number.isFinite(amt)) return amt < 0 ? "pay" : "receive";
|
|
71
|
+
return "pay";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mapTx(raw) {
|
|
75
|
+
if (!raw || typeof raw !== "object") return null;
|
|
76
|
+
const id = raw.txId || raw.tx_id || raw.id || raw.serialNo || raw.orderNo;
|
|
77
|
+
if (id == null) return null;
|
|
78
|
+
const amt = toAmount(raw.amount != null ? raw.amount : raw.amt);
|
|
79
|
+
return {
|
|
80
|
+
txId: String(id),
|
|
81
|
+
timeMs: parseTime(raw.time || raw.tradeTime || raw.trade_time || raw.date),
|
|
82
|
+
amount: amt != null ? Math.abs(amt) : null,
|
|
83
|
+
direction: normDirection(raw),
|
|
84
|
+
counterparty: raw.counterparty || raw.merchant || raw.payee || raw.oppName || null,
|
|
85
|
+
walletType: raw.walletType || raw.wallet_type || raw.subWallet || raw.bank || null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractList(resp) {
|
|
90
|
+
if (!resp || typeof resp !== "object") return [];
|
|
91
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
92
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
93
|
+
const d = resp.data;
|
|
94
|
+
if (d && typeof d === "object") {
|
|
95
|
+
if (Array.isArray(d.list)) return d.list;
|
|
96
|
+
if (Array.isArray(d.records)) return d.records;
|
|
97
|
+
}
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stableOriginalId(id) {
|
|
102
|
+
const safe =
|
|
103
|
+
(typeof id === "string" && id.length > 0 && id) ||
|
|
104
|
+
(typeof id === "number" && Number.isFinite(id) && String(id)) ||
|
|
105
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
106
|
+
return `dcep:transaction:${safe}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class DcepAdapter {
|
|
110
|
+
constructor(opts = {}) {
|
|
111
|
+
this.account = opts.account || null;
|
|
112
|
+
this._cookieAuth =
|
|
113
|
+
opts.account && opts.account.cookies ? new CookieAuth({ platform: "dcep", cookies: opts.account.cookies }) : null;
|
|
114
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
115
|
+
this._signProvider = typeof opts.signProvider === "function" ? opts.signProvider : null;
|
|
116
|
+
this._listUrl = typeof opts.listUrl === "string" && opts.listUrl.length > 0 ? opts.listUrl : DCEP_LIST_URL;
|
|
117
|
+
|
|
118
|
+
this.name = NAME;
|
|
119
|
+
this.version = VERSION;
|
|
120
|
+
this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:dcep-transaction"];
|
|
121
|
+
this.extractMode = "web-api";
|
|
122
|
+
this.rateLimits = { perMinute: 5, perDay: 60 };
|
|
123
|
+
this.dataDisclosure = {
|
|
124
|
+
fields: ["dcep:transaction (time / amount / direction / counterparty / walletType)"],
|
|
125
|
+
sensitivity: "high",
|
|
126
|
+
legalGate: true,
|
|
127
|
+
defaultInclude: { transaction: true },
|
|
128
|
+
};
|
|
129
|
+
this._deps = { fs };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async authenticate(ctx = {}) {
|
|
133
|
+
if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
|
|
134
|
+
try {
|
|
135
|
+
this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return { ok: false, reason: "INPUT_PATH_UNREADABLE", message: `snapshot not readable at ${ctx.inputPath}: ${err.message}` };
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, mode: "snapshot-file" };
|
|
140
|
+
}
|
|
141
|
+
if (this._cookieAuth) {
|
|
142
|
+
const ok = await this._cookieAuth.validate();
|
|
143
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
|
|
144
|
+
return { ok: true, account: (this.account && this.account.userId) || null, mode: "cookie", unverified: true };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
reason: "NO_INPUT",
|
|
149
|
+
message: "finance-dcep.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode, best-effort/unverified)",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async healthCheck() {
|
|
154
|
+
if (this._cookieAuth) {
|
|
155
|
+
const r = await this.authenticate();
|
|
156
|
+
return r.ok ? { ok: true, lastChecked: Date.now(), unverified: true } : { ok: false, reason: r.reason, error: r.error };
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, lastChecked: Date.now() };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async *sync(opts = {}) {
|
|
162
|
+
if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
|
|
163
|
+
yield* this._syncViaSnapshot(opts);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (this._cookieAuth) {
|
|
167
|
+
yield* this._syncViaCookie(opts);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
throw new Error("finance-dcep.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async *_syncViaSnapshot(opts) {
|
|
174
|
+
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
175
|
+
const snapshot = JSON.parse(raw);
|
|
176
|
+
if (!snapshot || typeof snapshot !== "object" || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
177
|
+
throw new Error(`finance-dcep.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`);
|
|
178
|
+
}
|
|
179
|
+
const fallbackCapturedAt =
|
|
180
|
+
Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0 ? Math.floor(snapshot.snapshottedAt) : Date.now();
|
|
181
|
+
const account = snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
|
|
182
|
+
const include = opts.include || {};
|
|
183
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
184
|
+
|
|
185
|
+
const events = Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
186
|
+
let emitted = 0;
|
|
187
|
+
for (const ev of events) {
|
|
188
|
+
if (emitted >= limit) return;
|
|
189
|
+
if (!ev || typeof ev !== "object") continue;
|
|
190
|
+
if (!VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
|
|
191
|
+
if (include[ev.kind] === false) continue;
|
|
192
|
+
const rec = mapTx(ev);
|
|
193
|
+
if (!rec) continue;
|
|
194
|
+
const capturedAt = parseTime(ev.capturedAt) || rec.timeMs || fallbackCapturedAt;
|
|
195
|
+
yield {
|
|
196
|
+
adapter: NAME,
|
|
197
|
+
kind: KIND_TX,
|
|
198
|
+
originalId: stableOriginalId(rec.txId),
|
|
199
|
+
capturedAt,
|
|
200
|
+
payload: { record: rec, account },
|
|
201
|
+
};
|
|
202
|
+
emitted += 1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async *_syncViaCookie(opts = {}) {
|
|
207
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
208
|
+
const cookies = this._cookieAuth.toHeader();
|
|
209
|
+
const include = opts.include || {};
|
|
210
|
+
if (include[KIND_TX] === false) return;
|
|
211
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
212
|
+
const maxPages = Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 12;
|
|
213
|
+
|
|
214
|
+
let emitted = 0;
|
|
215
|
+
let page = 1;
|
|
216
|
+
while (page <= maxPages) {
|
|
217
|
+
const query = { page, size: PAGE_SIZE };
|
|
218
|
+
let sign = null;
|
|
219
|
+
if (this._signProvider) sign = await this._signProvider({ url: this._listUrl, query, cookies });
|
|
220
|
+
const resp = await this._fetchFn({ url: this._listUrl, cookies, query, sign });
|
|
221
|
+
const items = extractList(resp);
|
|
222
|
+
if (!items.length) break;
|
|
223
|
+
for (const it of items) {
|
|
224
|
+
const rec = mapTx(it);
|
|
225
|
+
if (!rec) continue;
|
|
226
|
+
if (emitted >= limit) return;
|
|
227
|
+
yield {
|
|
228
|
+
adapter: NAME,
|
|
229
|
+
kind: KIND_TX,
|
|
230
|
+
originalId: stableOriginalId(rec.txId),
|
|
231
|
+
capturedAt: rec.timeMs || Date.now(),
|
|
232
|
+
payload: { record: rec, cookie: true },
|
|
233
|
+
};
|
|
234
|
+
emitted += 1;
|
|
235
|
+
}
|
|
236
|
+
if (items.length < PAGE_SIZE) break;
|
|
237
|
+
page += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
normalize(raw) {
|
|
242
|
+
if (!raw || !raw.payload || !raw.payload.record) {
|
|
243
|
+
throw new Error("DcepAdapter.normalize: payload.record missing");
|
|
244
|
+
}
|
|
245
|
+
const rec = raw.payload.record;
|
|
246
|
+
const ingestedAt = Date.now();
|
|
247
|
+
const occurredAt = rec.timeMs || raw.capturedAt || ingestedAt;
|
|
248
|
+
const source = {
|
|
249
|
+
adapter: NAME,
|
|
250
|
+
adapterVersion: VERSION,
|
|
251
|
+
originalId: raw.originalId,
|
|
252
|
+
capturedAt: raw.capturedAt || occurredAt,
|
|
253
|
+
capturedBy: CAPTURED_BY.API,
|
|
254
|
+
};
|
|
255
|
+
const arrow = rec.direction === "receive" ? "收款" : "付款";
|
|
256
|
+
return {
|
|
257
|
+
events: [
|
|
258
|
+
{
|
|
259
|
+
id: newId(),
|
|
260
|
+
type: ENTITY_TYPES.EVENT,
|
|
261
|
+
subtype: EVENT_SUBTYPES.PAYMENT,
|
|
262
|
+
occurredAt,
|
|
263
|
+
actor: "person-self",
|
|
264
|
+
content: {
|
|
265
|
+
title: `数字人民币${arrow}${rec.counterparty ? `: ${rec.counterparty}` : ""}`.slice(0, 80),
|
|
266
|
+
text: rec.counterparty || "数字人民币交易",
|
|
267
|
+
},
|
|
268
|
+
ingestedAt,
|
|
269
|
+
source,
|
|
270
|
+
extra: {
|
|
271
|
+
platform: "dcep",
|
|
272
|
+
kind: KIND_TX,
|
|
273
|
+
amount: rec.amount,
|
|
274
|
+
direction: rec.direction,
|
|
275
|
+
counterparty: rec.counterparty || null,
|
|
276
|
+
walletType: rec.walletType || null,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
persons: [],
|
|
281
|
+
places: [],
|
|
282
|
+
items: [],
|
|
283
|
+
topics: [],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function defaultFetch(_opts) {
|
|
289
|
+
throw new Error("finance-dcep: no fetchFn configured for cookie-api mode");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = {
|
|
293
|
+
DcepAdapter,
|
|
294
|
+
mapTx,
|
|
295
|
+
extractList,
|
|
296
|
+
normDirection,
|
|
297
|
+
parseTime,
|
|
298
|
+
NAME,
|
|
299
|
+
VERSION,
|
|
300
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
301
|
+
VALID_SNAPSHOT_KINDS,
|
|
302
|
+
};
|