@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.
Files changed (39) hide show
  1. package/__tests__/adapters/bank-family.test.js +125 -0
  2. package/__tests__/adapters/car-mercedesme.test.js +74 -0
  3. package/__tests__/adapters/finance-dcep.test.js +74 -0
  4. package/__tests__/adapters/fitness-joyrun.test.js +82 -0
  5. package/__tests__/adapters/gov-12123.test.js +103 -0
  6. package/__tests__/adapters/gov-ixiamen.test.js +2 -2
  7. package/__tests__/adapters/music-qq.test.js +112 -0
  8. package/__tests__/adapters/reading-family.test.js +108 -0
  9. package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
  10. package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
  11. package/__tests__/fitness-keep-snapshot.test.js +224 -0
  12. package/__tests__/shopping-eleme-snapshot.test.js +454 -0
  13. package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
  14. package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
  15. package/__tests__/social-douban-snapshot.test.js +351 -0
  16. package/lib/adapter-guide.js +19 -1
  17. package/lib/adapters/_bank-base.js +405 -0
  18. package/lib/adapters/_reading-base.js +315 -0
  19. package/lib/adapters/audio-ximalaya/index.js +414 -0
  20. package/lib/adapters/bank-bankcomm/index.js +27 -0
  21. package/lib/adapters/bank-boc/index.js +26 -0
  22. package/lib/adapters/bank-cmbc/index.js +26 -0
  23. package/lib/adapters/bank-icbc/index.js +27 -0
  24. package/lib/adapters/car-mercedesme/index.js +225 -0
  25. package/lib/adapters/finance-dcep/index.js +302 -0
  26. package/lib/adapters/fitness-joyrun/index.js +295 -0
  27. package/lib/adapters/fitness-keep/index.js +343 -0
  28. package/lib/adapters/gov-12123/index.js +391 -0
  29. package/lib/adapters/gov-ixiamen/index.js +17 -10
  30. package/lib/adapters/music-qq/index.js +372 -0
  31. package/lib/adapters/reading-fanqie/index.js +61 -0
  32. package/lib/adapters/reading-qimao/index.js +61 -0
  33. package/lib/adapters/shopping-eleme/index.js +441 -0
  34. package/lib/adapters/shopping-vipshop/index.js +429 -0
  35. package/lib/adapters/shopping-xianyu/index.js +454 -0
  36. package/lib/adapters/social-douban/index.js +564 -0
  37. package/lib/adapters/travel-didi-consumer/index.js +148 -0
  38. package/lib/index.js +36 -0
  39. package/package.json +1 -1
@@ -0,0 +1,315 @@
1
+ /**
2
+ * _reading-base — shared infrastructure for "novel/reading-history" adapters
3
+ * (番茄小说 / 七猫小说 / etc.), Phase 13+ device-discovered gap (2026-06-15).
4
+ *
5
+ * Reading apps expose the same shape of personal data: books the user read
6
+ * (阅读历史 / 书架, with progress) + optionally favourited (收藏/追更). Same
7
+ * family pattern as _video-base / _document-base: `createReadingAdapter(config)`
8
+ * returns a full adapter (snapshot + cookie-api); each platform supplies only
9
+ * its endpoints + field mapping.
10
+ *
11
+ * 1. snapshot mode (opts.inputPath): JSON schemaVersion 1, stateless.
12
+ * 2. cookie-api mode (opts.account.cookies): fetch read / favourite lists via
13
+ * injected `fetchFn`, paginate; signProvider seam for anti-bot tokens;
14
+ * endpoints overridable via opts.readUrl / opts.favouriteUrl (best-effort,
15
+ * NOT field-verified — FAMILY-23 playbook).
16
+ *
17
+ * normalize() emits, per book: a MEDIA event ("读了 X" / "收藏 X") + a DOCUMENT
18
+ * item (the book entity), mirroring _video-base's event+item dual-emit so the
19
+ * vault can timeline reading and list the book.
20
+ *
21
+ * Snapshot schema (schemaVersion 1):
22
+ * {
23
+ * "schemaVersion": 1, "snapshottedAt": <ms>,
24
+ * "account": { "userId": "...", "name": "..." },
25
+ * "events": [
26
+ * { "kind": "read", "id": "...", "bookId": "...", "title": "...",
27
+ * "author": "...", "category": "...", "chapter": "...",
28
+ * "progress": 0.42, "capturedAt": <s|ms> },
29
+ * { "kind": "favourite", "id": "...", "bookId": "...", "title": "...", "author": "..." }
30
+ * ]
31
+ * }
32
+ */
33
+
34
+ "use strict";
35
+
36
+ const fs = require("node:fs");
37
+ const { newId } = require("../ids");
38
+ const { ENTITY_TYPES, EVENT_SUBTYPES, ITEM_SUBTYPES, CAPTURED_BY } = require("../constants");
39
+
40
+ const SNAPSHOT_SCHEMA_VERSION = 1;
41
+ const KIND_READ = "read";
42
+ const KIND_FAVOURITE = "favourite";
43
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_READ, KIND_FAVOURITE]);
44
+ const PAGE_SIZE = 30;
45
+
46
+ function parseTime(v) {
47
+ if (Number.isFinite(v)) return v > 1e12 ? v : v >= 1e9 ? v * 1000 : v;
48
+ if (typeof v === "string") {
49
+ if (/^\d+$/.test(v)) {
50
+ const n = parseInt(v, 10);
51
+ return n > 1e12 ? n : n >= 1e9 ? n * 1000 : n;
52
+ }
53
+ const t = Date.parse(v);
54
+ return Number.isFinite(t) ? t : null;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * @param {object} config
61
+ * @param {string} config.NAME e.g. "reading-fanqie"
62
+ * @param {string} config.VERSION
63
+ * @param {string} config.platform e.g. "fanqie"
64
+ * @param {string} config.readUrl best-effort read-history endpoint
65
+ * @param {string} config.favouriteUrl best-effort 书架/收藏 endpoint
66
+ * @param {(resp:any)=>any[]} config.extractItems
67
+ * @param {(raw:any)=>object|null} config.mapItem
68
+ * BookRecord = { bookId, title, author, category, chapter, progress, url, occurredAt? }
69
+ */
70
+ function createReadingAdapter(config) {
71
+ const { NAME, VERSION, platform, readUrl, favouriteUrl, extractItems, mapItem } = config;
72
+ const { CookieAuth } = require("./shopping-base");
73
+
74
+ function stableOriginalId(kind, id) {
75
+ const safe =
76
+ (typeof id === "string" && id.length > 0 && id) ||
77
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
78
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
79
+ return `${platform}:${kind}:${safe}`;
80
+ }
81
+
82
+ class ReadingAdapter {
83
+ constructor(opts = {}) {
84
+ this.account = opts.account || null;
85
+ this._cookieAuth =
86
+ opts.account && opts.account.cookies ? new CookieAuth({ platform, cookies: opts.account.cookies }) : null;
87
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
88
+ this._signProvider = typeof opts.signProvider === "function" ? opts.signProvider : null;
89
+ this._urls = {
90
+ read: opts.readUrl || readUrl,
91
+ favourite: opts.favouriteUrl || favouriteUrl,
92
+ };
93
+
94
+ this.name = NAME;
95
+ this.version = VERSION;
96
+ this.capabilities = ["sync:snapshot", "sync:cookie-api", `parse:${platform}-read`, `parse:${platform}-favourite`];
97
+ this.extractMode = "web-api";
98
+ this.rateLimits = {};
99
+ this.dataDisclosure = {
100
+ fields: [`${platform}:read (书名 / 作者 / 分类 / 进度)`, `${platform}:favourite (收藏的书)`],
101
+ sensitivity: "low",
102
+ legalGate: false,
103
+ defaultInclude: { read: true, favourite: true },
104
+ };
105
+ this._deps = { fs };
106
+ }
107
+
108
+ async authenticate(ctx = {}) {
109
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
110
+ try {
111
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
112
+ } catch (err) {
113
+ return { ok: false, reason: "INPUT_PATH_UNREADABLE", message: `snapshot not readable at ${ctx.inputPath}: ${err.message}` };
114
+ }
115
+ return { ok: true, mode: "snapshot-file" };
116
+ }
117
+ if (this._cookieAuth) {
118
+ const ok = await this._cookieAuth.validate();
119
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
120
+ return { ok: true, account: (this.account && this.account.userId) || null, mode: "cookie" };
121
+ }
122
+ return {
123
+ ok: false,
124
+ reason: "NO_INPUT",
125
+ message: `${NAME}.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)`,
126
+ };
127
+ }
128
+
129
+ async healthCheck() {
130
+ if (this._cookieAuth) {
131
+ const r = await this.authenticate();
132
+ return r.ok ? { ok: true, lastChecked: Date.now() } : { ok: false, reason: r.reason, error: r.error };
133
+ }
134
+ return { ok: true, lastChecked: Date.now() };
135
+ }
136
+
137
+ async *sync(opts = {}) {
138
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
139
+ yield* this._syncViaSnapshot(opts);
140
+ return;
141
+ }
142
+ if (this._cookieAuth) {
143
+ yield* this._syncViaCookie(opts);
144
+ return;
145
+ }
146
+ throw new Error(`${NAME}.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)`);
147
+ }
148
+
149
+ async *_syncViaSnapshot(opts) {
150
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
151
+ const snapshot = JSON.parse(raw);
152
+ if (!snapshot || typeof snapshot !== "object" || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
153
+ throw new Error(`${NAME}.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`);
154
+ }
155
+ const fallback =
156
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0 ? Math.floor(snapshot.snapshottedAt) : Date.now();
157
+ const account = snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
158
+ const include = opts.include || {};
159
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
160
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
161
+ let emitted = 0;
162
+ for (const ev of events) {
163
+ if (emitted >= limit) return;
164
+ if (!ev || typeof ev !== "object" || !VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
165
+ if (include[ev.kind] === false) continue;
166
+ const id = (typeof ev.id === "string" && ev.id) || ev.bookId || null;
167
+ yield {
168
+ adapter: NAME,
169
+ kind: ev.kind,
170
+ originalId: stableOriginalId(ev.kind, id),
171
+ capturedAt: parseTime(ev.capturedAt) || fallback,
172
+ payload: { record: snapshotEventToRecord(ev), kind: ev.kind, account },
173
+ };
174
+ emitted += 1;
175
+ }
176
+ }
177
+
178
+ async *_syncViaCookie(opts = {}) {
179
+ if (!(await this._cookieAuth.validate())) return;
180
+ const cookies = this._cookieAuth.toHeader();
181
+ const include = opts.include || {};
182
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
183
+ const maxPages = Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
184
+
185
+ const plan = [
186
+ { kind: KIND_READ, url: this._urls.read },
187
+ { kind: KIND_FAVOURITE, url: this._urls.favourite },
188
+ ];
189
+
190
+ let emitted = 0;
191
+ for (const step of plan) {
192
+ if (include[step.kind] === false) continue;
193
+ if (!step.url) continue;
194
+ let page = 1;
195
+ while (page <= maxPages) {
196
+ const query = { page, pageSize: PAGE_SIZE };
197
+ let sign = null;
198
+ if (this._signProvider) {
199
+ sign = await this._signProvider({ url: step.url, query, cookies });
200
+ }
201
+ const resp = await this._fetchFn({ url: step.url, cookies, query, sign });
202
+ const items = extractItems(resp) || [];
203
+ if (!items.length) break;
204
+ for (const it of items) {
205
+ const rec = mapItem(it);
206
+ if (!rec || !rec.bookId) continue;
207
+ if (emitted >= limit) return;
208
+ yield {
209
+ adapter: NAME,
210
+ kind: step.kind,
211
+ originalId: stableOriginalId(step.kind, rec.bookId),
212
+ capturedAt: rec.occurredAt || Date.now(),
213
+ payload: { record: rec, kind: step.kind },
214
+ };
215
+ emitted += 1;
216
+ }
217
+ if (items.length < PAGE_SIZE) break;
218
+ page += 1;
219
+ }
220
+ }
221
+ }
222
+
223
+ normalize(raw) {
224
+ if (!raw || !raw.payload || !raw.payload.record) {
225
+ throw new Error(`${NAME}.normalize: payload.record missing`);
226
+ }
227
+ const kind = raw.kind || raw.payload.kind;
228
+ const subtype = kind === KIND_FAVOURITE ? EVENT_SUBTYPES.LIKE : EVENT_SUBTYPES.MEDIA;
229
+ const verb = kind === KIND_FAVOURITE ? "收藏" : "读了";
230
+ return normalizeBookRecord(raw.payload.record, raw, platform, NAME, VERSION, subtype, verb);
231
+ }
232
+ }
233
+
234
+ return ReadingAdapter;
235
+ }
236
+
237
+ function snapshotEventToRecord(ev) {
238
+ return {
239
+ bookId: String(ev.bookId || ev.id || "unknown"),
240
+ title: ev.title || "(未知书籍)",
241
+ author: ev.author || null,
242
+ category: ev.category || ev.type || null,
243
+ chapter: ev.chapter || null,
244
+ progress: Number.isFinite(ev.progress) ? ev.progress : null,
245
+ url: ev.url || null,
246
+ occurredAt: parseTime(ev.capturedAt),
247
+ };
248
+ }
249
+
250
+ function normalizeBookRecord(rec, raw, platform, NAME, VERSION, subtype, verb) {
251
+ const ingestedAt = Date.now();
252
+ const occurredAt = rec.occurredAt || raw.capturedAt || ingestedAt;
253
+ const source = {
254
+ adapter: NAME,
255
+ adapterVersion: VERSION,
256
+ originalId: raw.originalId,
257
+ capturedAt: raw.capturedAt || occurredAt,
258
+ capturedBy: CAPTURED_BY.API,
259
+ };
260
+ const title = rec.title || "(未知书籍)";
261
+ const authorSuffix = rec.author ? ` - ${rec.author}` : "";
262
+ const itemId = `item-${platform}-book-${rec.bookId}`;
263
+ return {
264
+ events: [
265
+ {
266
+ id: newId(),
267
+ type: ENTITY_TYPES.EVENT,
268
+ subtype,
269
+ occurredAt,
270
+ actor: "person-self",
271
+ content: { title: `${verb}: ${title}${authorSuffix}`, text: title },
272
+ ingestedAt,
273
+ source,
274
+ extra: {
275
+ platform,
276
+ bookId: rec.bookId,
277
+ author: rec.author || null,
278
+ category: rec.category || null,
279
+ chapter: rec.chapter || null,
280
+ progress: rec.progress != null ? rec.progress : null,
281
+ url: rec.url || null,
282
+ itemRef: itemId,
283
+ },
284
+ },
285
+ ],
286
+ items: [
287
+ {
288
+ id: itemId,
289
+ type: ENTITY_TYPES.ITEM,
290
+ subtype: ITEM_SUBTYPES.DOCUMENT,
291
+ name: rec.author ? `${title} - ${rec.author}` : title,
292
+ ingestedAt,
293
+ source,
294
+ extra: { platform, kind: "book", bookId: rec.bookId, author: rec.author || null, category: rec.category || null },
295
+ },
296
+ ],
297
+ persons: [],
298
+ places: [],
299
+ topics: [],
300
+ };
301
+ }
302
+
303
+ async function defaultFetch(_opts) {
304
+ throw new Error("reading-base: no fetchFn configured for cookie-api mode");
305
+ }
306
+
307
+ module.exports = {
308
+ createReadingAdapter,
309
+ normalizeBookRecord,
310
+ parseTime,
311
+ SNAPSHOT_SCHEMA_VERSION,
312
+ KIND_READ,
313
+ KIND_FAVOURITE,
314
+ VALID_SNAPSHOT_KINDS,
315
+ };