@chainlesschain/personal-data-hub 0.4.7 → 0.4.23

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.
Files changed (45) hide show
  1. package/__tests__/adapters/biz-tianyancha.test.js +159 -0
  2. package/__tests__/adapters/doc-baidu-netdisk.test.js +102 -0
  3. package/__tests__/adapters/doc-camscanner.test.js +147 -0
  4. package/__tests__/adapters/doc-platforms.test.js +177 -0
  5. package/__tests__/adapters/gov-ixiamen.test.js +150 -0
  6. package/__tests__/adapters/gov-tax.test.js +135 -0
  7. package/__tests__/adapters/health-meiyou.test.js +125 -0
  8. package/__tests__/adapters/music-kugou.test.js +187 -0
  9. package/__tests__/adapters/recruit-boss.test.js +180 -0
  10. package/__tests__/adapters/shopping-dianping.test.js +239 -0
  11. package/__tests__/adapters/social-csdn.test.js +175 -0
  12. package/__tests__/adapters/social-dongchedi.test.js +165 -0
  13. package/__tests__/adapters/social-zhihu.test.js +246 -0
  14. package/__tests__/adapters/travel-ctrip.test.js +175 -1
  15. package/__tests__/adapters/travel-didi.test.js +204 -0
  16. package/__tests__/adapters/travel-tongcheng.test.js +289 -0
  17. package/__tests__/adapters/video-platforms.test.js +152 -0
  18. package/__tests__/adapters/video-xigua.test.js +106 -0
  19. package/__tests__/adapters/wework-pc.test.js +124 -0
  20. package/lib/adapter-guide.js +25 -3
  21. package/lib/adapters/_document-base.js +370 -0
  22. package/lib/adapters/_video-base.js +331 -0
  23. package/lib/adapters/biz-tianyancha/index.js +348 -0
  24. package/lib/adapters/doc-baidu-netdisk/index.js +91 -0
  25. package/lib/adapters/doc-camscanner/index.js +102 -0
  26. package/lib/adapters/doc-tencent-docs/index.js +94 -0
  27. package/lib/adapters/doc-wps/index.js +77 -0
  28. package/lib/adapters/gov-ixiamen/index.js +380 -0
  29. package/lib/adapters/gov-tax/index.js +451 -0
  30. package/lib/adapters/health-meiyou/index.js +393 -0
  31. package/lib/adapters/music-kugou/index.js +418 -0
  32. package/lib/adapters/recruit-boss/index.js +442 -0
  33. package/lib/adapters/shopping-dianping/index.js +473 -0
  34. package/lib/adapters/social-csdn/index.js +444 -0
  35. package/lib/adapters/social-dongchedi/index.js +360 -0
  36. package/lib/adapters/social-zhihu/index.js +488 -0
  37. package/lib/adapters/travel-ctrip/index.js +255 -40
  38. package/lib/adapters/travel-didi/index.js +327 -0
  39. package/lib/adapters/travel-tongcheng/index.js +393 -0
  40. package/lib/adapters/video-iqiyi/index.js +75 -0
  41. package/lib/adapters/video-tencent/index.js +78 -0
  42. package/lib/adapters/video-xigua/index.js +68 -0
  43. package/lib/adapters/wework-pc/index.js +31 -0
  44. package/lib/index.js +40 -0
  45. package/package.json +1 -1
@@ -0,0 +1,393 @@
1
+ /**
2
+ * §12.1 Phase 13+ ⭐⭐ — 美柚 (com.lingan.seeyou) adapter, "周期健康".
3
+ *
4
+ * ⚠️ MAXIMALLY SENSITIVE (reproductive / menstrual-cycle health). 美柚 is a
5
+ * period-tracking app; the personal footprint is the user's 经期记录
6
+ * (menstrual cycle entries) + 健康日记 (mood / symptom / weight diary). This is
7
+ * among the most sensitive personal-data categories that exists, so the
8
+ * adapter is gated **sensitivity:"high" + legalGate:true** — the registry
9
+ * REQUIRES explicit legal/consent confirmation before any collection runs, and
10
+ * nothing is fetched until the user opts in.
11
+ *
12
+ * 美柚 has no documented public API and its cloud sync is account-bound, so the
13
+ * reliable path is **snapshot mode** (the app / a manual export produces a JSON
14
+ * of the user's records). The cookie-api path is a best-effort seam only: the
15
+ * endpoint (yunshouyi/seeyou) is a FABRICATED placeholder (overridable via
16
+ * opts.listUrl, NOT field-verified — FAMILY-23 playbook); auth surfaces
17
+ * `unverified:true`.
18
+ *
19
+ * Modelled records → EVENT(subtype OTHER) on the entry date, rich health
20
+ * fields in `extra` (kept local in the vault, never normalized into searchable
21
+ * content beyond the entry label). Two kinds:
22
+ * - "period" 经期记录: { startDate, endDate, cycleLength, periodLength }
23
+ * - "record" 健康日记: { date, recordType(mood|symptom|weight|...), value, note }
24
+ *
25
+ * Snapshot schema (schemaVersion 1):
26
+ * {
27
+ * "schemaVersion": 1, "snapshottedAt": <ms>,
28
+ * "account": { "userId": "...", "name": "..." },
29
+ * "events": [
30
+ * { "kind": "period", "id": "p-<id>", "recordId": "...",
31
+ * "startDate": <s|ms>, "endDate": <s|ms>, "cycleLength": 28, "periodLength": 5 },
32
+ * { "kind": "record", "id": "r-<id>", "recordId": "...", "recordType": "mood",
33
+ * "date": <s|ms>, "value": "开心", "note": "..." }
34
+ * ]
35
+ * }
36
+ */
37
+
38
+ "use strict";
39
+
40
+ const fs = require("node:fs");
41
+ const { newId } = require("../../ids");
42
+ const { ENTITY_TYPES, EVENT_SUBTYPES, CAPTURED_BY } = require("../../constants");
43
+ const { CookieAuth } = require("../shopping-base");
44
+
45
+ const NAME = "health-meiyou";
46
+ const VERSION = "0.1.0";
47
+ const SNAPSHOT_SCHEMA_VERSION = 1;
48
+
49
+ const KIND_PERIOD = "period";
50
+ const KIND_RECORD = "record";
51
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_PERIOD, KIND_RECORD]);
52
+
53
+ // FABRICATED best-effort endpoints — NOT field-verified. Overridable.
54
+ const PERIOD_URL = "https://yunshouyi.seeyouyima.com/v1/calendar/period";
55
+ const RECORD_URL = "https://yunshouyi.seeyouyima.com/v1/calendar/record";
56
+ const PAGE_SIZE = 30;
57
+
58
+ function parseTime(v) {
59
+ if (Number.isFinite(v)) return v > 1e12 ? v : v >= 1e9 ? v * 1000 : v;
60
+ if (typeof v === "string") {
61
+ if (/^\d+$/.test(v)) {
62
+ const n = parseInt(v, 10);
63
+ return n > 1e12 ? n : n >= 1e9 ? n * 1000 : n;
64
+ }
65
+ const t = Date.parse(v);
66
+ return Number.isFinite(t) ? t : null;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function stableOriginalId(kind, id) {
72
+ const safe =
73
+ (typeof id === "string" && id.length > 0 && id) ||
74
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
75
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
76
+ return `meiyou:${kind}:${safe}`;
77
+ }
78
+
79
+ function mapPeriod(raw) {
80
+ if (!raw || typeof raw !== "object") return null;
81
+ const id = raw.recordId || raw.record_id || raw.id || raw.startDate || raw.start_date;
82
+ if (id == null) return null;
83
+ return {
84
+ recordId: String(id),
85
+ startMs: parseTime(raw.startDate || raw.start_date || raw.start),
86
+ endMs: parseTime(raw.endDate || raw.end_date || raw.end),
87
+ cycleLength: raw.cycleLength != null ? raw.cycleLength : raw.cycle_length ?? null,
88
+ periodLength: raw.periodLength != null ? raw.periodLength : raw.period_length ?? null,
89
+ };
90
+ }
91
+
92
+ function mapRecord(raw) {
93
+ if (!raw || typeof raw !== "object") return null;
94
+ const id = raw.recordId || raw.record_id || raw.id;
95
+ if (id == null) return null;
96
+ return {
97
+ recordId: String(id),
98
+ recordType: raw.recordType || raw.record_type || raw.type || "other",
99
+ dateMs: parseTime(raw.date || raw.recordTime || raw.record_time || raw.time),
100
+ value: raw.value != null ? raw.value : raw.content != null ? raw.content : null,
101
+ note: raw.note || raw.remark || null,
102
+ };
103
+ }
104
+
105
+ function extractList(resp) {
106
+ if (!resp || typeof resp !== "object") return [];
107
+ if (Array.isArray(resp.list)) return resp.list;
108
+ if (Array.isArray(resp.data)) return resp.data;
109
+ const d = resp.data;
110
+ if (d && typeof d === "object") {
111
+ if (Array.isArray(d.list)) return d.list;
112
+ if (Array.isArray(d.records)) return d.records;
113
+ if (Array.isArray(d.calendar)) return d.calendar;
114
+ }
115
+ return [];
116
+ }
117
+
118
+ class MeiyouAdapter {
119
+ constructor(opts = {}) {
120
+ this.account = opts.account || null;
121
+ this._cookieAuth =
122
+ opts.account && opts.account.cookies
123
+ ? new CookieAuth({ platform: "meiyou", cookies: opts.account.cookies })
124
+ : null;
125
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
126
+ this._signProvider =
127
+ typeof opts.signProvider === "function" ? opts.signProvider : null;
128
+ this._urls = {
129
+ period: opts.periodUrl || opts.listUrl || PERIOD_URL,
130
+ record: opts.recordUrl || RECORD_URL,
131
+ };
132
+
133
+ this.name = NAME;
134
+ this.version = VERSION;
135
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", "parse:meiyou-period", "parse:meiyou-record"];
136
+ this.extractMode = "web-api";
137
+ this.rateLimits = { perMinute: 6, perDay: 100 };
138
+ this.dataDisclosure = {
139
+ fields: [
140
+ "meiyou:period (startDate / endDate / cycleLength / periodLength)",
141
+ "meiyou:record (recordType / value / note)",
142
+ ],
143
+ // Reproductive / menstrual-cycle health — maximally sensitive.
144
+ sensitivity: "high",
145
+ legalGate: true,
146
+ defaultInclude: { period: true, record: true },
147
+ };
148
+
149
+ this._deps = { fs };
150
+ }
151
+
152
+ async authenticate(ctx = {}) {
153
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
154
+ try {
155
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
156
+ } catch (err) {
157
+ return {
158
+ ok: false,
159
+ reason: "INPUT_PATH_UNREADABLE",
160
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
161
+ };
162
+ }
163
+ return { ok: true, mode: "snapshot-file" };
164
+ }
165
+ if (this._cookieAuth) {
166
+ const ok = await this._cookieAuth.validate();
167
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
168
+ return {
169
+ ok: true,
170
+ account: (this.account && this.account.userId) || null,
171
+ mode: "cookie",
172
+ unverified: true,
173
+ };
174
+ }
175
+ return {
176
+ ok: false,
177
+ reason: "NO_INPUT",
178
+ message:
179
+ "health-meiyou.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode, best-effort/unverified)",
180
+ };
181
+ }
182
+
183
+ async healthCheck() {
184
+ if (this._cookieAuth) {
185
+ const r = await this.authenticate();
186
+ return r.ok
187
+ ? { ok: true, lastChecked: Date.now(), unverified: true }
188
+ : { ok: false, reason: r.reason, error: r.error };
189
+ }
190
+ return { ok: true, lastChecked: Date.now() };
191
+ }
192
+
193
+ async *sync(opts = {}) {
194
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
195
+ yield* this._syncViaSnapshot(opts);
196
+ return;
197
+ }
198
+ if (this._cookieAuth) {
199
+ yield* this._syncViaCookie(opts);
200
+ return;
201
+ }
202
+ throw new Error(
203
+ "health-meiyou.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)",
204
+ );
205
+ }
206
+
207
+ async *_syncViaSnapshot(opts) {
208
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
209
+ const snapshot = JSON.parse(raw);
210
+ if (
211
+ !snapshot ||
212
+ typeof snapshot !== "object" ||
213
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
214
+ ) {
215
+ throw new Error(
216
+ `health-meiyou.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
217
+ );
218
+ }
219
+ const fallbackCapturedAt =
220
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
221
+ ? Math.floor(snapshot.snapshottedAt)
222
+ : Date.now();
223
+ const account =
224
+ snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
225
+ const include = opts.include || {};
226
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
227
+
228
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
229
+ let emitted = 0;
230
+ for (const ev of events) {
231
+ if (emitted >= limit) return;
232
+ if (!ev || typeof ev !== "object") continue;
233
+ if (!VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
234
+ if (include[ev.kind] === false) continue;
235
+
236
+ const rec = ev.kind === KIND_PERIOD ? mapPeriod(ev) : mapRecord(ev);
237
+ if (!rec) continue;
238
+ const recTime = ev.kind === KIND_PERIOD ? rec.startMs : rec.dateMs;
239
+ const capturedAt = parseTime(ev.capturedAt) || recTime || fallbackCapturedAt;
240
+ yield {
241
+ adapter: NAME,
242
+ kind: ev.kind,
243
+ originalId: stableOriginalId(ev.kind, rec.recordId),
244
+ capturedAt,
245
+ payload: { record: rec, kind: ev.kind, account },
246
+ };
247
+ emitted += 1;
248
+ }
249
+ }
250
+
251
+ async *_syncViaCookie(opts = {}) {
252
+ if (!(await this._cookieAuth.validate())) return;
253
+ const cookies = this._cookieAuth.toHeader();
254
+ const include = opts.include || {};
255
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
256
+ const maxPages =
257
+ Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
258
+ const sinceMs =
259
+ opts.sinceWatermark != null ? parseInt(String(opts.sinceWatermark), 10) || 0 : 0;
260
+
261
+ const plan = [
262
+ { kind: KIND_PERIOD, url: this._urls.period, map: mapPeriod, ts: (r) => r.startMs },
263
+ { kind: KIND_RECORD, url: this._urls.record, map: mapRecord, ts: (r) => r.dateMs },
264
+ ];
265
+
266
+ let emitted = 0;
267
+ for (const step of plan) {
268
+ if (include[step.kind] === false) continue;
269
+ let page = 1;
270
+ while (page <= maxPages) {
271
+ const query = { page, size: PAGE_SIZE };
272
+ let sign = null;
273
+ if (this._signProvider) {
274
+ sign = await this._signProvider({ url: step.url, query, cookies });
275
+ }
276
+ const resp = await this._fetchFn({ url: step.url, cookies, query, sign });
277
+ const items = extractList(resp);
278
+ if (!items.length) break;
279
+ let reachedWatermark = false;
280
+ for (const it of items) {
281
+ const rec = step.map(it);
282
+ if (!rec) continue;
283
+ const ts = step.ts(rec) || null;
284
+ if (sinceMs && ts && ts < sinceMs) {
285
+ reachedWatermark = true;
286
+ break;
287
+ }
288
+ if (emitted >= limit) return;
289
+ yield {
290
+ adapter: NAME,
291
+ kind: step.kind,
292
+ originalId: stableOriginalId(step.kind, rec.recordId),
293
+ capturedAt: ts || Date.now(),
294
+ payload: { record: rec, kind: step.kind, cookie: true },
295
+ };
296
+ emitted += 1;
297
+ }
298
+ if (reachedWatermark || items.length < PAGE_SIZE) break;
299
+ page += 1;
300
+ }
301
+ }
302
+ }
303
+
304
+ normalize(raw) {
305
+ if (!raw || !raw.payload || !raw.payload.record) {
306
+ throw new Error("MeiyouAdapter.normalize: payload.record missing");
307
+ }
308
+ const kind = raw.kind || raw.payload.kind;
309
+ const rec = raw.payload.record;
310
+ const ingestedAt = Date.now();
311
+ const source = {
312
+ adapter: NAME,
313
+ adapterVersion: VERSION,
314
+ originalId: raw.originalId,
315
+ capturedAt: raw.capturedAt || ingestedAt,
316
+ capturedBy: CAPTURED_BY.API,
317
+ };
318
+
319
+ if (kind === KIND_PERIOD) {
320
+ const occurredAt = rec.startMs || raw.capturedAt || ingestedAt;
321
+ return {
322
+ events: [
323
+ {
324
+ id: newId(),
325
+ type: ENTITY_TYPES.EVENT,
326
+ subtype: EVENT_SUBTYPES.OTHER,
327
+ occurredAt,
328
+ actor: "person-self",
329
+ content: { title: "经期记录", text: "经期记录" },
330
+ ingestedAt,
331
+ source,
332
+ extra: {
333
+ platform: "meiyou",
334
+ kind: KIND_PERIOD,
335
+ startMs: rec.startMs || null,
336
+ endMs: rec.endMs || null,
337
+ cycleLength: rec.cycleLength,
338
+ periodLength: rec.periodLength,
339
+ },
340
+ },
341
+ ],
342
+ persons: [],
343
+ places: [],
344
+ items: [],
345
+ topics: [],
346
+ };
347
+ }
348
+ // record (health diary)
349
+ const occurredAt = rec.dateMs || raw.capturedAt || ingestedAt;
350
+ const label = `健康记录: ${rec.recordType}`;
351
+ return {
352
+ events: [
353
+ {
354
+ id: newId(),
355
+ type: ENTITY_TYPES.EVENT,
356
+ subtype: EVENT_SUBTYPES.OTHER,
357
+ occurredAt,
358
+ actor: "person-self",
359
+ content: { title: label.slice(0, 80), text: label },
360
+ ingestedAt,
361
+ source,
362
+ extra: {
363
+ platform: "meiyou",
364
+ kind: KIND_RECORD,
365
+ recordType: rec.recordType,
366
+ value: rec.value != null ? rec.value : null,
367
+ note: rec.note || null,
368
+ },
369
+ },
370
+ ],
371
+ persons: [],
372
+ places: [],
373
+ items: [],
374
+ topics: [],
375
+ };
376
+ }
377
+ }
378
+
379
+ async function defaultFetch(_opts) {
380
+ throw new Error("health-meiyou: no fetchFn configured for cookie-api mode");
381
+ }
382
+
383
+ module.exports = {
384
+ MeiyouAdapter,
385
+ mapPeriod,
386
+ mapRecord,
387
+ extractList,
388
+ parseTime,
389
+ NAME,
390
+ VERSION,
391
+ SNAPSHOT_SCHEMA_VERSION,
392
+ VALID_SNAPSHOT_KINDS,
393
+ };