@chainlesschain/personal-data-hub 0.4.23 → 0.4.25
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/gov-ixiamen.test.js +2 -2
- 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/gov-ixiamen/index.js +17 -10
- 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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §12.1 Phase 13+ — 悦跑圈 (Joyrun, co.runner.app) adapter, "跑步记录".
|
|
3
|
+
* Device-discovered gap (2026-06-15), new `fitness-` category.
|
|
4
|
+
*
|
|
5
|
+
* BEST-EFFORT SCAFFOLD: thejoyrun.com endpoints are FABRICATED placeholders
|
|
6
|
+
* (overridable via opts.listUrl, NOT field-verified — FAMILY-23 playbook);
|
|
7
|
+
* snapshot mode is the reliable path; cookie path surfaces auth.unverified=true.
|
|
8
|
+
* Running records carry GPS/route info → sensitivity:"medium" (legalGate off,
|
|
9
|
+
* like the travel adapters).
|
|
10
|
+
*
|
|
11
|
+
* One record kind: 跑步记录 (runs):
|
|
12
|
+
* { runId, time, distanceMeters, durationSec, paceSecPerKm, calories, steps }
|
|
13
|
+
* → EVENT(OTHER) "跑步 X.XX km".
|
|
14
|
+
*
|
|
15
|
+
* Snapshot schema (schemaVersion 1):
|
|
16
|
+
* {
|
|
17
|
+
* "schemaVersion": 1, "snapshottedAt": <ms>,
|
|
18
|
+
* "account": { "userId": "...", "name": "..." },
|
|
19
|
+
* "events": [
|
|
20
|
+
* { "kind": "run", "id": "r-<id>", "runId": "...", "time": <s|ms>,
|
|
21
|
+
* "distanceMeters": 5230, "durationSec": 1800, "paceSecPerKm": 344,
|
|
22
|
+
* "calories": 320, "steps": 6400 }
|
|
23
|
+
* ]
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
const fs = require("node:fs");
|
|
30
|
+
const { newId } = require("../../ids");
|
|
31
|
+
const { ENTITY_TYPES, EVENT_SUBTYPES, CAPTURED_BY } = require("../../constants");
|
|
32
|
+
const { CookieAuth } = require("../shopping-base");
|
|
33
|
+
|
|
34
|
+
const NAME = "fitness-joyrun";
|
|
35
|
+
const VERSION = "0.1.0";
|
|
36
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
37
|
+
const KIND_RUN = "run";
|
|
38
|
+
const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_RUN]);
|
|
39
|
+
const RUNS_URL = "https://api.thejoyrun.com/v1/user/runs";
|
|
40
|
+
const PAGE_SIZE = 30;
|
|
41
|
+
|
|
42
|
+
function parseTime(v) {
|
|
43
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v >= 1e9 ? v * 1000 : v;
|
|
44
|
+
if (typeof v === "string") {
|
|
45
|
+
if (/^\d+$/.test(v)) {
|
|
46
|
+
const n = parseInt(v, 10);
|
|
47
|
+
return n > 1e12 ? n : n >= 1e9 ? n * 1000 : n;
|
|
48
|
+
}
|
|
49
|
+
const t = Date.parse(v);
|
|
50
|
+
return Number.isFinite(t) ? t : null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toNum(v) {
|
|
56
|
+
if (Number.isFinite(v)) return v;
|
|
57
|
+
if (typeof v === "string") {
|
|
58
|
+
const n = parseFloat(v);
|
|
59
|
+
return Number.isFinite(n) ? n : null;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mapRun(raw) {
|
|
65
|
+
if (!raw || typeof raw !== "object") return null;
|
|
66
|
+
const id = raw.runId || raw.run_id || raw.id || raw.fid || raw.postRunId;
|
|
67
|
+
if (id == null) return null;
|
|
68
|
+
// distance may arrive in meters or kilometers; meter field wins, else km*1000.
|
|
69
|
+
let meters = toNum(raw.distanceMeters != null ? raw.distanceMeters : raw.meter != null ? raw.meter : raw.distance);
|
|
70
|
+
if (meters != null && meters < 1000 && raw.distanceMeters == null && raw.meter == null) {
|
|
71
|
+
// looked like kilometers
|
|
72
|
+
meters = meters * 1000;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
runId: String(id),
|
|
76
|
+
timeMs: parseTime(raw.time || raw.starttime || raw.start_time || raw.date || raw.utc),
|
|
77
|
+
distanceMeters: meters,
|
|
78
|
+
durationSec: toNum(raw.durationSec != null ? raw.durationSec : raw.second != null ? raw.second : raw.totaltime),
|
|
79
|
+
paceSecPerKm: toNum(raw.paceSecPerKm != null ? raw.paceSecPerKm : raw.pace),
|
|
80
|
+
calories: toNum(raw.calories != null ? raw.calories : raw.cal != null ? raw.cal : raw.dohas),
|
|
81
|
+
steps: toNum(raw.steps != null ? raw.steps : raw.stepcount),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extractList(resp) {
|
|
86
|
+
if (!resp || typeof resp !== "object") return [];
|
|
87
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
88
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
89
|
+
const d = resp.data;
|
|
90
|
+
if (d && typeof d === "object") {
|
|
91
|
+
if (Array.isArray(d.list)) return d.list;
|
|
92
|
+
if (Array.isArray(d.runs)) return d.runs;
|
|
93
|
+
if (Array.isArray(d.records)) return d.records;
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stableOriginalId(id) {
|
|
99
|
+
const safe =
|
|
100
|
+
(typeof id === "string" && id.length > 0 && id) ||
|
|
101
|
+
(typeof id === "number" && Number.isFinite(id) && String(id)) ||
|
|
102
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
103
|
+
return `joyrun:run:${safe}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
class JoyrunAdapter {
|
|
107
|
+
constructor(opts = {}) {
|
|
108
|
+
this.account = opts.account || null;
|
|
109
|
+
this._cookieAuth =
|
|
110
|
+
opts.account && opts.account.cookies ? new CookieAuth({ platform: "joyrun", cookies: opts.account.cookies }) : null;
|
|
111
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
112
|
+
this._signProvider = typeof opts.signProvider === "function" ? opts.signProvider : null;
|
|
113
|
+
this._listUrl = typeof opts.listUrl === "string" && opts.listUrl.length > 0 ? opts.listUrl : RUNS_URL;
|
|
114
|
+
|
|
115
|
+
this.name = NAME;
|
|
116
|
+
this.version = VERSION;
|
|
117
|
+
this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:joyrun-run"];
|
|
118
|
+
this.extractMode = "web-api";
|
|
119
|
+
this.rateLimits = {};
|
|
120
|
+
this.dataDisclosure = {
|
|
121
|
+
fields: ["joyrun:run (distance / duration / pace / calories / steps — carries GPS route)"],
|
|
122
|
+
sensitivity: "medium",
|
|
123
|
+
legalGate: false,
|
|
124
|
+
defaultInclude: { run: true },
|
|
125
|
+
};
|
|
126
|
+
this._deps = { fs };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async authenticate(ctx = {}) {
|
|
130
|
+
if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
|
|
131
|
+
try {
|
|
132
|
+
this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return { ok: false, reason: "INPUT_PATH_UNREADABLE", message: `snapshot not readable at ${ctx.inputPath}: ${err.message}` };
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, mode: "snapshot-file" };
|
|
137
|
+
}
|
|
138
|
+
if (this._cookieAuth) {
|
|
139
|
+
const ok = await this._cookieAuth.validate();
|
|
140
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
|
|
141
|
+
return { ok: true, account: (this.account && this.account.userId) || null, mode: "cookie", unverified: true };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason: "NO_INPUT",
|
|
146
|
+
message: "fitness-joyrun.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode, best-effort/unverified)",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async healthCheck() {
|
|
151
|
+
if (this._cookieAuth) {
|
|
152
|
+
const r = await this.authenticate();
|
|
153
|
+
return r.ok ? { ok: true, lastChecked: Date.now(), unverified: true } : { ok: false, reason: r.reason, error: r.error };
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, lastChecked: Date.now() };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async *sync(opts = {}) {
|
|
159
|
+
if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
|
|
160
|
+
yield* this._syncViaSnapshot(opts);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (this._cookieAuth) {
|
|
164
|
+
yield* this._syncViaCookie(opts);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
throw new Error("fitness-joyrun.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async *_syncViaSnapshot(opts) {
|
|
171
|
+
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
172
|
+
const snapshot = JSON.parse(raw);
|
|
173
|
+
if (!snapshot || typeof snapshot !== "object" || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
174
|
+
throw new Error(`fitness-joyrun.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`);
|
|
175
|
+
}
|
|
176
|
+
const fallback =
|
|
177
|
+
Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0 ? Math.floor(snapshot.snapshottedAt) : Date.now();
|
|
178
|
+
const account = snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
|
|
179
|
+
const include = opts.include || {};
|
|
180
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
181
|
+
const events = Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
182
|
+
let emitted = 0;
|
|
183
|
+
for (const ev of events) {
|
|
184
|
+
if (emitted >= limit) return;
|
|
185
|
+
if (!ev || typeof ev !== "object" || !VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
|
|
186
|
+
if (include[ev.kind] === false) continue;
|
|
187
|
+
const rec = mapRun(ev);
|
|
188
|
+
if (!rec) continue;
|
|
189
|
+
const capturedAt = parseTime(ev.capturedAt) || rec.timeMs || fallback;
|
|
190
|
+
yield {
|
|
191
|
+
adapter: NAME,
|
|
192
|
+
kind: KIND_RUN,
|
|
193
|
+
originalId: stableOriginalId(rec.runId),
|
|
194
|
+
capturedAt,
|
|
195
|
+
payload: { record: rec, account },
|
|
196
|
+
};
|
|
197
|
+
emitted += 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async *_syncViaCookie(opts = {}) {
|
|
202
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
203
|
+
const cookies = this._cookieAuth.toHeader();
|
|
204
|
+
const include = opts.include || {};
|
|
205
|
+
if (include[KIND_RUN] === false) return;
|
|
206
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
207
|
+
const maxPages = Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
|
|
208
|
+
|
|
209
|
+
let emitted = 0;
|
|
210
|
+
let page = 1;
|
|
211
|
+
while (page <= maxPages) {
|
|
212
|
+
const query = { page, pageSize: PAGE_SIZE };
|
|
213
|
+
let sign = null;
|
|
214
|
+
if (this._signProvider) sign = await this._signProvider({ url: this._listUrl, query, cookies });
|
|
215
|
+
const resp = await this._fetchFn({ url: this._listUrl, cookies, query, sign });
|
|
216
|
+
const items = extractList(resp);
|
|
217
|
+
if (!items.length) break;
|
|
218
|
+
for (const it of items) {
|
|
219
|
+
const rec = mapRun(it);
|
|
220
|
+
if (!rec) continue;
|
|
221
|
+
if (emitted >= limit) return;
|
|
222
|
+
yield {
|
|
223
|
+
adapter: NAME,
|
|
224
|
+
kind: KIND_RUN,
|
|
225
|
+
originalId: stableOriginalId(rec.runId),
|
|
226
|
+
capturedAt: rec.timeMs || Date.now(),
|
|
227
|
+
payload: { record: rec, cookie: true },
|
|
228
|
+
};
|
|
229
|
+
emitted += 1;
|
|
230
|
+
}
|
|
231
|
+
if (items.length < PAGE_SIZE) break;
|
|
232
|
+
page += 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
normalize(raw) {
|
|
237
|
+
if (!raw || !raw.payload || !raw.payload.record) {
|
|
238
|
+
throw new Error("JoyrunAdapter.normalize: payload.record missing");
|
|
239
|
+
}
|
|
240
|
+
const rec = raw.payload.record;
|
|
241
|
+
const ingestedAt = Date.now();
|
|
242
|
+
const occurredAt = rec.timeMs || raw.capturedAt || ingestedAt;
|
|
243
|
+
const source = {
|
|
244
|
+
adapter: NAME,
|
|
245
|
+
adapterVersion: VERSION,
|
|
246
|
+
originalId: raw.originalId,
|
|
247
|
+
capturedAt: raw.capturedAt || occurredAt,
|
|
248
|
+
capturedBy: CAPTURED_BY.API,
|
|
249
|
+
};
|
|
250
|
+
const km = rec.distanceMeters != null ? (rec.distanceMeters / 1000).toFixed(2) : null;
|
|
251
|
+
return {
|
|
252
|
+
events: [
|
|
253
|
+
{
|
|
254
|
+
id: newId(),
|
|
255
|
+
type: ENTITY_TYPES.EVENT,
|
|
256
|
+
subtype: EVENT_SUBTYPES.OTHER,
|
|
257
|
+
occurredAt,
|
|
258
|
+
actor: "person-self",
|
|
259
|
+
content: { title: km != null ? `跑步 ${km} km` : "跑步记录", text: "跑步记录" },
|
|
260
|
+
ingestedAt,
|
|
261
|
+
source,
|
|
262
|
+
extra: {
|
|
263
|
+
platform: "joyrun",
|
|
264
|
+
kind: KIND_RUN,
|
|
265
|
+
distanceMeters: rec.distanceMeters,
|
|
266
|
+
durationSec: rec.durationSec,
|
|
267
|
+
paceSecPerKm: rec.paceSecPerKm,
|
|
268
|
+
calories: rec.calories,
|
|
269
|
+
steps: rec.steps,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
persons: [],
|
|
274
|
+
places: [],
|
|
275
|
+
items: [],
|
|
276
|
+
topics: [],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function defaultFetch(_opts) {
|
|
282
|
+
throw new Error("fitness-joyrun: no fetchFn configured for cookie-api mode");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
JoyrunAdapter,
|
|
287
|
+
mapRun,
|
|
288
|
+
extractList,
|
|
289
|
+
toNum,
|
|
290
|
+
parseTime,
|
|
291
|
+
NAME,
|
|
292
|
+
VERSION,
|
|
293
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
294
|
+
VALID_SNAPSHOT_KINDS,
|
|
295
|
+
};
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 健身 — Keep (com.gotokeep.keep) adapter, "运动训练记录". Phase 13+ long-tail
|
|
3
|
+
* (user-requested), new entry under the `fitness-` category (sibling of
|
|
4
|
+
* fitness-joyrun).
|
|
5
|
+
*
|
|
6
|
+
* Keep is China's largest fitness app — 跑步/骑行/健走/瑜伽/力量训练/课程. Unlike
|
|
7
|
+
* 悦跑圈 (running-only), Keep logs MANY workout types, so this carries a
|
|
8
|
+
* `workoutType` per record (vs joyrun's single run kind).
|
|
9
|
+
*
|
|
10
|
+
* BEST-EFFORT SCAFFOLD: api.gotokeep.com endpoints are FABRICATED placeholders
|
|
11
|
+
* (overridable via opts.listUrl, NOT field-verified — FAMILY-23 playbook);
|
|
12
|
+
* snapshot mode is the reliable path; cookie path surfaces auth.unverified=true.
|
|
13
|
+
* Outdoor workouts carry GPS/route → sensitivity:"medium" (legalGate off).
|
|
14
|
+
*
|
|
15
|
+
* One record kind: 训练记录 (workouts):
|
|
16
|
+
* { workoutId, type, name, time, distanceMeters, durationSec, calories, steps }
|
|
17
|
+
* → EVENT(OTHER) "运动: <type> X.XX km" / "运动: <type> N 分钟".
|
|
18
|
+
*
|
|
19
|
+
* Snapshot schema (schemaVersion 1):
|
|
20
|
+
* {
|
|
21
|
+
* "schemaVersion": 1, "snapshottedAt": <ms>,
|
|
22
|
+
* "account": { "userId": "...", "name": "..." },
|
|
23
|
+
* "events": [
|
|
24
|
+
* { "kind": "workout", "id": "w-<id>", "workoutId": "...",
|
|
25
|
+
* "type": "running|cycling|yoga|strength|hiking|...", "name": "...",
|
|
26
|
+
* "time": <s|ms>, "distanceMeters": 5230, "durationSec": 1800,
|
|
27
|
+
* "calories": 320, "steps": 6400 }
|
|
28
|
+
* ]
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
"use strict";
|
|
33
|
+
|
|
34
|
+
const fs = require("node:fs");
|
|
35
|
+
const { newId } = require("../../ids");
|
|
36
|
+
const { ENTITY_TYPES, EVENT_SUBTYPES, CAPTURED_BY } = require("../../constants");
|
|
37
|
+
const { CookieAuth } = require("../shopping-base");
|
|
38
|
+
|
|
39
|
+
const NAME = "fitness-keep";
|
|
40
|
+
const VERSION = "0.1.0";
|
|
41
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
42
|
+
const KIND_WORKOUT = "workout";
|
|
43
|
+
const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_WORKOUT]);
|
|
44
|
+
const WORKOUTS_URL = "https://api.gotokeep.com/pd/v3/stats/detail";
|
|
45
|
+
const PAGE_SIZE = 30;
|
|
46
|
+
|
|
47
|
+
// Keep workout type token → readable Chinese label (best-effort; falls back to
|
|
48
|
+
// the raw token so unknown types still surface).
|
|
49
|
+
const TYPE_LABEL = Object.freeze({
|
|
50
|
+
running: "跑步",
|
|
51
|
+
run: "跑步",
|
|
52
|
+
jogging: "慢跑",
|
|
53
|
+
cycling: "骑行",
|
|
54
|
+
riding: "骑行",
|
|
55
|
+
walking: "健走",
|
|
56
|
+
hiking: "徒步",
|
|
57
|
+
yoga: "瑜伽",
|
|
58
|
+
strength: "力量训练",
|
|
59
|
+
training: "训练",
|
|
60
|
+
workout: "训练",
|
|
61
|
+
swimming: "游泳",
|
|
62
|
+
rope_skipping: "跳绳",
|
|
63
|
+
elliptical: "椭圆机",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function parseTime(v) {
|
|
67
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v >= 1e9 ? v * 1000 : v;
|
|
68
|
+
if (typeof v === "string") {
|
|
69
|
+
if (/^\d+$/.test(v)) {
|
|
70
|
+
const n = parseInt(v, 10);
|
|
71
|
+
return n > 1e12 ? n : n >= 1e9 ? n * 1000 : n;
|
|
72
|
+
}
|
|
73
|
+
const t = Date.parse(v);
|
|
74
|
+
return Number.isFinite(t) ? t : null;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toNum(v) {
|
|
80
|
+
if (Number.isFinite(v)) return v;
|
|
81
|
+
if (typeof v === "string") {
|
|
82
|
+
const n = parseFloat(v);
|
|
83
|
+
return Number.isFinite(n) ? n : null;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function typeLabel(type) {
|
|
89
|
+
if (!type) return "运动";
|
|
90
|
+
const key = String(type).toLowerCase();
|
|
91
|
+
return TYPE_LABEL[key] || type;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mapWorkout(raw) {
|
|
95
|
+
if (!raw || typeof raw !== "object") return null;
|
|
96
|
+
const id = raw.workoutId || raw.workout_id || raw.id || raw.logId || raw._id;
|
|
97
|
+
if (id == null) return null;
|
|
98
|
+
// distance may arrive in meters or kilometers; meter field wins, else km*1000.
|
|
99
|
+
let meters = toNum(
|
|
100
|
+
raw.distanceMeters != null ? raw.distanceMeters
|
|
101
|
+
: raw.meter != null ? raw.meter
|
|
102
|
+
: raw.distance,
|
|
103
|
+
);
|
|
104
|
+
if (meters != null && meters > 0 && meters < 1000 && raw.distanceMeters == null && raw.meter == null) {
|
|
105
|
+
meters = meters * 1000; // looked like kilometers
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
workoutId: String(id),
|
|
109
|
+
type: raw.type || raw.workoutType || raw.subtype || raw.trainingType || null,
|
|
110
|
+
name: raw.name || raw.workoutName || raw.title || raw.planName || null,
|
|
111
|
+
timeMs: parseTime(raw.time || raw.doneDate || raw.endTime || raw.startTime || raw.createTime || raw.date),
|
|
112
|
+
distanceMeters: meters,
|
|
113
|
+
durationSec: toNum(
|
|
114
|
+
raw.durationSec != null ? raw.durationSec
|
|
115
|
+
: raw.duration != null ? raw.duration
|
|
116
|
+
: raw.trainingDuration != null ? raw.trainingDuration
|
|
117
|
+
: raw.second,
|
|
118
|
+
),
|
|
119
|
+
calories: toNum(raw.calories != null ? raw.calories : raw.kcal != null ? raw.kcal : raw.calorie),
|
|
120
|
+
steps: toNum(raw.steps != null ? raw.steps : raw.stepCount),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractList(resp) {
|
|
125
|
+
if (!resp || typeof resp !== "object") return [];
|
|
126
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
127
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
128
|
+
const d = resp.data;
|
|
129
|
+
if (d && typeof d === "object") {
|
|
130
|
+
if (Array.isArray(d.list)) return d.list;
|
|
131
|
+
if (Array.isArray(d.records)) return d.records;
|
|
132
|
+
if (Array.isArray(d.logs)) return d.logs;
|
|
133
|
+
if (Array.isArray(d.workouts)) return d.workouts;
|
|
134
|
+
}
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stableOriginalId(id) {
|
|
139
|
+
const safe =
|
|
140
|
+
(typeof id === "string" && id.length > 0 && id) ||
|
|
141
|
+
(typeof id === "number" && Number.isFinite(id) && String(id)) ||
|
|
142
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
143
|
+
return `keep:workout:${safe}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
class KeepAdapter {
|
|
147
|
+
constructor(opts = {}) {
|
|
148
|
+
this.account = opts.account || null;
|
|
149
|
+
this._cookieAuth =
|
|
150
|
+
opts.account && opts.account.cookies ? new CookieAuth({ platform: "keep", cookies: opts.account.cookies }) : null;
|
|
151
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
152
|
+
this._signProvider = typeof opts.signProvider === "function" ? opts.signProvider : null;
|
|
153
|
+
this._listUrl = typeof opts.listUrl === "string" && opts.listUrl.length > 0 ? opts.listUrl : WORKOUTS_URL;
|
|
154
|
+
|
|
155
|
+
this.name = NAME;
|
|
156
|
+
this.version = VERSION;
|
|
157
|
+
this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:keep-workout"];
|
|
158
|
+
this.extractMode = "web-api";
|
|
159
|
+
this.rateLimits = {};
|
|
160
|
+
this.dataDisclosure = {
|
|
161
|
+
fields: ["keep:workout (type / distance / duration / calories / steps — outdoor carries GPS route)"],
|
|
162
|
+
sensitivity: "medium",
|
|
163
|
+
legalGate: false,
|
|
164
|
+
defaultInclude: { workout: true },
|
|
165
|
+
};
|
|
166
|
+
this._deps = { fs };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async authenticate(ctx = {}) {
|
|
170
|
+
if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
|
|
171
|
+
try {
|
|
172
|
+
this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return { ok: false, reason: "INPUT_PATH_UNREADABLE", message: `snapshot not readable at ${ctx.inputPath}: ${err.message}` };
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, mode: "snapshot-file" };
|
|
177
|
+
}
|
|
178
|
+
if (this._cookieAuth) {
|
|
179
|
+
const ok = await this._cookieAuth.validate();
|
|
180
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
|
|
181
|
+
return { ok: true, account: (this.account && this.account.userId) || null, mode: "cookie", unverified: true };
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
reason: "NO_INPUT",
|
|
186
|
+
message: "fitness-keep.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode, best-effort/unverified)",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async healthCheck() {
|
|
191
|
+
if (this._cookieAuth) {
|
|
192
|
+
const r = await this.authenticate();
|
|
193
|
+
return r.ok ? { ok: true, lastChecked: Date.now(), unverified: true } : { ok: false, reason: r.reason, error: r.error };
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, lastChecked: Date.now() };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async *sync(opts = {}) {
|
|
199
|
+
if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
|
|
200
|
+
yield* this._syncViaSnapshot(opts);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (this._cookieAuth) {
|
|
204
|
+
yield* this._syncViaCookie(opts);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
throw new Error("fitness-keep.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async *_syncViaSnapshot(opts) {
|
|
211
|
+
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
212
|
+
const snapshot = JSON.parse(raw);
|
|
213
|
+
if (!snapshot || typeof snapshot !== "object" || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
214
|
+
throw new Error(`fitness-keep.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`);
|
|
215
|
+
}
|
|
216
|
+
const fallback =
|
|
217
|
+
Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0 ? Math.floor(snapshot.snapshottedAt) : Date.now();
|
|
218
|
+
const account = snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
|
|
219
|
+
const include = opts.include || {};
|
|
220
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
221
|
+
const events = Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
222
|
+
let emitted = 0;
|
|
223
|
+
for (const ev of events) {
|
|
224
|
+
if (emitted >= limit) return;
|
|
225
|
+
if (!ev || typeof ev !== "object" || !VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
|
|
226
|
+
if (include[ev.kind] === false) continue;
|
|
227
|
+
const rec = mapWorkout(ev);
|
|
228
|
+
if (!rec) continue;
|
|
229
|
+
const capturedAt = parseTime(ev.capturedAt) || rec.timeMs || fallback;
|
|
230
|
+
yield {
|
|
231
|
+
adapter: NAME,
|
|
232
|
+
kind: KIND_WORKOUT,
|
|
233
|
+
originalId: stableOriginalId(rec.workoutId),
|
|
234
|
+
capturedAt,
|
|
235
|
+
payload: { record: rec, account },
|
|
236
|
+
};
|
|
237
|
+
emitted += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async *_syncViaCookie(opts = {}) {
|
|
242
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
243
|
+
const cookies = this._cookieAuth.toHeader();
|
|
244
|
+
const include = opts.include || {};
|
|
245
|
+
if (include[KIND_WORKOUT] === false) return;
|
|
246
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
247
|
+
const maxPages = Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
|
|
248
|
+
|
|
249
|
+
let emitted = 0;
|
|
250
|
+
let page = 1;
|
|
251
|
+
while (page <= maxPages) {
|
|
252
|
+
const query = { page, pageSize: PAGE_SIZE };
|
|
253
|
+
let sign = null;
|
|
254
|
+
if (this._signProvider) sign = await this._signProvider({ url: this._listUrl, query, cookies });
|
|
255
|
+
const resp = await this._fetchFn({ url: this._listUrl, cookies, query, sign });
|
|
256
|
+
const items = extractList(resp);
|
|
257
|
+
if (!items.length) break;
|
|
258
|
+
for (const it of items) {
|
|
259
|
+
const rec = mapWorkout(it);
|
|
260
|
+
if (!rec) continue;
|
|
261
|
+
if (emitted >= limit) return;
|
|
262
|
+
yield {
|
|
263
|
+
adapter: NAME,
|
|
264
|
+
kind: KIND_WORKOUT,
|
|
265
|
+
originalId: stableOriginalId(rec.workoutId),
|
|
266
|
+
capturedAt: rec.timeMs || Date.now(),
|
|
267
|
+
payload: { record: rec, cookie: true },
|
|
268
|
+
};
|
|
269
|
+
emitted += 1;
|
|
270
|
+
}
|
|
271
|
+
if (items.length < PAGE_SIZE) break;
|
|
272
|
+
page += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
normalize(raw) {
|
|
277
|
+
if (!raw || !raw.payload || !raw.payload.record) {
|
|
278
|
+
throw new Error("KeepAdapter.normalize: payload.record missing");
|
|
279
|
+
}
|
|
280
|
+
const rec = raw.payload.record;
|
|
281
|
+
const ingestedAt = Date.now();
|
|
282
|
+
const occurredAt = rec.timeMs || raw.capturedAt || ingestedAt;
|
|
283
|
+
const source = {
|
|
284
|
+
adapter: NAME,
|
|
285
|
+
adapterVersion: VERSION,
|
|
286
|
+
originalId: raw.originalId,
|
|
287
|
+
capturedAt: raw.capturedAt || occurredAt,
|
|
288
|
+
capturedBy: CAPTURED_BY.API,
|
|
289
|
+
};
|
|
290
|
+
const label = typeLabel(rec.type);
|
|
291
|
+
const km = rec.distanceMeters != null && rec.distanceMeters > 0 ? (rec.distanceMeters / 1000).toFixed(2) : null;
|
|
292
|
+
const minutes = rec.durationSec != null && rec.durationSec > 0 ? Math.round(rec.durationSec / 60) : null;
|
|
293
|
+
let title;
|
|
294
|
+
if (km != null) title = `运动: ${label} ${km} km`;
|
|
295
|
+
else if (minutes != null) title = `运动: ${label} ${minutes} 分钟`;
|
|
296
|
+
else title = `运动: ${label}`;
|
|
297
|
+
return {
|
|
298
|
+
events: [
|
|
299
|
+
{
|
|
300
|
+
id: newId(),
|
|
301
|
+
type: ENTITY_TYPES.EVENT,
|
|
302
|
+
subtype: EVENT_SUBTYPES.OTHER,
|
|
303
|
+
occurredAt,
|
|
304
|
+
actor: "person-self",
|
|
305
|
+
content: { title, text: rec.name || label },
|
|
306
|
+
ingestedAt,
|
|
307
|
+
source,
|
|
308
|
+
extra: {
|
|
309
|
+
platform: "keep",
|
|
310
|
+
kind: KIND_WORKOUT,
|
|
311
|
+
workoutType: rec.type || null,
|
|
312
|
+
workoutName: rec.name || null,
|
|
313
|
+
distanceMeters: rec.distanceMeters,
|
|
314
|
+
durationSec: rec.durationSec,
|
|
315
|
+
calories: rec.calories,
|
|
316
|
+
steps: rec.steps,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
persons: [],
|
|
321
|
+
places: [],
|
|
322
|
+
items: [],
|
|
323
|
+
topics: [],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function defaultFetch(_opts) {
|
|
329
|
+
throw new Error("fitness-keep: no fetchFn configured for cookie-api mode");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
KeepAdapter,
|
|
334
|
+
mapWorkout,
|
|
335
|
+
extractList,
|
|
336
|
+
toNum,
|
|
337
|
+
typeLabel,
|
|
338
|
+
parseTime,
|
|
339
|
+
NAME,
|
|
340
|
+
VERSION,
|
|
341
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
342
|
+
VALID_SNAPSHOT_KINDS,
|
|
343
|
+
};
|