@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,564 @@
1
+ /**
2
+ * 书影音 — Douban (豆瓣) adapter, dual-mode (snapshot + cookie-api).
3
+ *
4
+ * 豆瓣 (com.douban.frodo) is a Phase 13+ long-tail platform (NOT on the §12.1
5
+ * roadmap nor the reference device, added on user request). It is the highest-
6
+ * value remaining personal-interest source: a user's 书影音游 (book/movie/music/
7
+ * game) marks + ratings + reviews + followed users form a rich taste/interest
8
+ * graph feeding the "interests" analysis skill. Mirrors social-zhihu's two-mode,
9
+ * multi-endpoint, custom-normalize shape, plus the video-base MEDIA event+item
10
+ * pattern for marks (so the vault can both timeline a 标记 AND dedupe the subject).
11
+ *
12
+ * 1. snapshot mode (opts.inputPath): in-APK Android cc / browser-extension /
13
+ * curated JSON. account OPTIONAL — the snapshot carries account.
14
+ *
15
+ * 2. cookie-api mode (opts.account.cookies + account.userId): fetch the
16
+ * logged-in user's interests / reviews / following via the injected
17
+ * `fetchFn`. Frodo (frodo.douban.com /api/v2) endpoints key off the user's
18
+ * numeric id, so account.userId is REQUIRED in cookie mode. Pagination
19
+ * follows Frodo's `{ start, count, total, <collection> }` offset cursor.
20
+ *
21
+ * ── sign seam ──────────────────────────────────────────────────────────
22
+ * Frodo endpoints require an `apikey` + `_sig`/`_ts` signature computed
23
+ * client-side. No pure-Node impl survives the rotation, so signing is
24
+ * injected via `opts.signProvider`. When absent the request is still issued
25
+ * unsigned — best-effort, the endpoint may reject it (zero events, no
26
+ * crash). Endpoint constants are best-effort and overridable via opts.*Url;
27
+ * 豆瓣 rotates these (FAMILY-23 playbook — NOT field-verified here).
28
+ *
29
+ * Snapshot schema (schemaVersion 1):
30
+ *
31
+ * {
32
+ * "schemaVersion": 1, "snapshottedAt": <epoch-ms>,
33
+ * "account": { "userId": "...", "name": "..." },
34
+ * "events": [
35
+ * { "kind": "interest", "id": "interest-<id>", "subjectId": "...",
36
+ * "subjectType": "movie|book|music|game|tv|drama", "title": "...",
37
+ * "status": "mark|doing|done", "myRating": 4, "comment": "...",
38
+ * "createdTime": <s|ms>, "url": "..." },
39
+ * { "kind": "review", "id": "review-<id>", "title": "...", "abstract": "...",
40
+ * "subjectTitle": "...", "rating": 5, "createdTime": <s|ms>, "url": "..." },
41
+ * { "kind": "follow", "id": "follow-<uid>", "memberId": "...",
42
+ * "name": "...", "url": "...", "capturedAt": <ms> }
43
+ * ]
44
+ * }
45
+ */
46
+
47
+ "use strict";
48
+
49
+ const fs = require("node:fs");
50
+ const { newId } = require("../../ids");
51
+ const {
52
+ ENTITY_TYPES,
53
+ PERSON_SUBTYPES,
54
+ ITEM_SUBTYPES,
55
+ EVENT_SUBTYPES,
56
+ CAPTURED_BY,
57
+ } = require("../../constants");
58
+ const { CookieAuth } = require("../shopping-base");
59
+
60
+ const NAME = "social-douban";
61
+ const VERSION = "0.1.0";
62
+ const SNAPSHOT_SCHEMA_VERSION = 1;
63
+
64
+ const KIND_INTEREST = "interest";
65
+ const KIND_REVIEW = "review";
66
+ const KIND_FOLLOW = "follow";
67
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_INTEREST, KIND_REVIEW, KIND_FOLLOW]);
68
+
69
+ // Best-effort Frodo /api/v2 endpoints. `{id}` is replaced with the user id.
70
+ // Overridable via opts.interestsUrl / opts.reviewsUrl / opts.followingUrl.
71
+ const INTERESTS_URL = "https://frodo.douban.com/api/v2/user/{id}/interests";
72
+ const REVIEWS_URL = "https://frodo.douban.com/api/v2/user/{id}/reviews";
73
+ const FOLLOWING_URL = "https://frodo.douban.com/api/v2/user/{id}/following";
74
+ const PAGE_LIMIT = 20;
75
+
76
+ // 豆瓣 mark status → human verb.
77
+ const STATUS_VERB = Object.freeze({
78
+ mark: "想看",
79
+ wish: "想看",
80
+ doing: "在看",
81
+ do: "在看",
82
+ done: "看过",
83
+ collect: "看过",
84
+ });
85
+
86
+ // 豆瓣 subject type → readable category (best-effort; falls back to raw token).
87
+ const SUBJECT_LABEL = Object.freeze({
88
+ movie: "电影",
89
+ tv: "电视剧",
90
+ drama: "舞台剧",
91
+ book: "图书",
92
+ music: "音乐",
93
+ game: "游戏",
94
+ app: "App",
95
+ });
96
+
97
+ function stableOriginalId(kind, id) {
98
+ const stringified =
99
+ (typeof id === "string" && id.length > 0 && id) ||
100
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
101
+ null;
102
+ const safe =
103
+ stringified || `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
104
+ return `douban:${kind}:${safe}`;
105
+ }
106
+
107
+ function parseTime(v) {
108
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
109
+ if (typeof v === "string") {
110
+ if (/^\d+$/.test(v)) {
111
+ const n = parseInt(v, 10);
112
+ return n > 1e12 ? n : n * 1000;
113
+ }
114
+ // 豆瓣 timestamps like "2024-01-02 13:45:00" — Date.parse treats as local.
115
+ const t = Date.parse(v.replace(" ", "T"));
116
+ return Number.isFinite(t) ? t : null;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ class DoubanAdapter {
122
+ constructor(opts = {}) {
123
+ this.account = opts.account || null;
124
+ this._cookieAuth =
125
+ opts.account && opts.account.cookies
126
+ ? new CookieAuth({ platform: "douban", cookies: opts.account.cookies })
127
+ : null;
128
+ this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
129
+ this._signProvider =
130
+ typeof opts.signProvider === "function" ? opts.signProvider : null;
131
+ this._urls = {
132
+ interests: opts.interestsUrl || INTERESTS_URL,
133
+ reviews: opts.reviewsUrl || REVIEWS_URL,
134
+ following: opts.followingUrl || FOLLOWING_URL,
135
+ };
136
+
137
+ this.name = NAME;
138
+ this.version = VERSION;
139
+ this.capabilities = [
140
+ "sync:snapshot",
141
+ "sync:cookie-api",
142
+ "parse:douban-interest",
143
+ "parse:douban-review",
144
+ "parse:douban-follow",
145
+ ];
146
+ this.extractMode = "web-api";
147
+ this.rateLimits = { perMinute: 8, perDay: 200 };
148
+ this.dataDisclosure = {
149
+ fields: [
150
+ "douban:interest (subjectType / title / status / myRating / comment)",
151
+ "douban:review (title / subjectTitle / rating)",
152
+ "douban:follow (memberId / name)",
153
+ ],
154
+ sensitivity: "medium",
155
+ legalGate: false,
156
+ defaultInclude: { interest: true, review: true, follow: true },
157
+ };
158
+
159
+ // _deps injection seam — vi.mock fs doesn't intercept inlined CJS require.
160
+ this._deps = { fs };
161
+ }
162
+
163
+ async authenticate(ctx = {}) {
164
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
165
+ try {
166
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
167
+ } catch (err) {
168
+ return {
169
+ ok: false,
170
+ reason: "INPUT_PATH_UNREADABLE",
171
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
172
+ };
173
+ }
174
+ return { ok: true, mode: "snapshot-file" };
175
+ }
176
+ if (this._cookieAuth) {
177
+ const ok = await this._cookieAuth.validate();
178
+ if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
179
+ if (!this.account || !this.account.userId) {
180
+ return {
181
+ ok: false,
182
+ reason: "NO_ACCOUNT_USER_ID",
183
+ message: "cookie-api mode requires account.userId (douban numeric id)",
184
+ };
185
+ }
186
+ return { ok: true, account: this.account.userId, mode: "cookie" };
187
+ }
188
+ return {
189
+ ok: false,
190
+ reason: "NO_INPUT",
191
+ message:
192
+ "social-douban.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies + userId (cookie-api mode)",
193
+ };
194
+ }
195
+
196
+ async healthCheck() {
197
+ if (this._cookieAuth) {
198
+ const r = await this.authenticate();
199
+ return r.ok
200
+ ? { ok: true, lastChecked: Date.now() }
201
+ : { ok: false, reason: r.reason, error: r.error };
202
+ }
203
+ return { ok: true, lastChecked: Date.now() };
204
+ }
205
+
206
+ async *sync(opts = {}) {
207
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
208
+ yield* this._syncViaSnapshot(opts);
209
+ return;
210
+ }
211
+ if (this._cookieAuth) {
212
+ yield* this._syncViaCookie(opts);
213
+ return;
214
+ }
215
+ throw new Error(
216
+ "social-douban.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies + userId (cookie-api mode; Frodo endpoints need apikey/_sig via opts.signProvider)",
217
+ );
218
+ }
219
+
220
+ async *_syncViaSnapshot(opts) {
221
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
222
+ let snapshot;
223
+ try {
224
+ snapshot = JSON.parse(raw);
225
+ } catch (err) {
226
+ throw new Error(
227
+ `social-douban.sync: snapshot must be JSON. Got parse error: ${err.message}`,
228
+ );
229
+ }
230
+ if (
231
+ !snapshot ||
232
+ typeof snapshot !== "object" ||
233
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
234
+ ) {
235
+ throw new Error(
236
+ `social-douban.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
237
+ );
238
+ }
239
+ const fallbackCapturedAt =
240
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
241
+ ? Math.floor(snapshot.snapshottedAt)
242
+ : Date.now();
243
+ const account =
244
+ snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
245
+ const include = opts.include || {};
246
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
247
+
248
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
249
+ let emitted = 0;
250
+ for (const ev of events) {
251
+ if (emitted >= limit) return;
252
+ if (!ev || typeof ev !== "object") continue;
253
+ const kind = ev.kind;
254
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
255
+ if (include[kind] === false) continue;
256
+
257
+ const capturedAt =
258
+ parseTime(ev.capturedAt) || parseTime(ev.createdTime) || fallbackCapturedAt;
259
+ const id =
260
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
261
+ ev.subjectId ||
262
+ ev.reviewId ||
263
+ ev.memberId ||
264
+ null;
265
+
266
+ yield {
267
+ adapter: NAME,
268
+ kind,
269
+ originalId: stableOriginalId(kind, id),
270
+ capturedAt,
271
+ payload: { ...ev, account },
272
+ };
273
+ emitted += 1;
274
+ }
275
+ }
276
+
277
+ async *_syncViaCookie(opts = {}) {
278
+ if (!this.account || !this.account.userId) {
279
+ throw new Error(
280
+ "social-douban._syncViaCookie: account.userId required (set via new DoubanAdapter({ account: { userId, cookies } }))",
281
+ );
282
+ }
283
+ if (!(await this._cookieAuth.validate())) return;
284
+ const id = encodeURIComponent(this.account.userId);
285
+ const include = opts.include || {};
286
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
287
+ const maxPages =
288
+ Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
289
+
290
+ const plan = [
291
+ { kind: KIND_INTEREST, url: this._urls.interests, mapId: (it) => `interest-${itemId(it)}` },
292
+ { kind: KIND_REVIEW, url: this._urls.reviews, mapId: (it) => `review-${itemId(it)}` },
293
+ { kind: KIND_FOLLOW, url: this._urls.following, mapId: (it) => `follow-${itemId(it)}` },
294
+ ];
295
+
296
+ let emitted = 0;
297
+ for (const step of plan) {
298
+ if (include[step.kind] === false) continue;
299
+ const baseUrl = step.url.replace("{id}", id);
300
+ let start = 0;
301
+ let page = 0;
302
+ while (page < maxPages) {
303
+ const query = { start, count: PAGE_LIMIT };
304
+ let sign = null;
305
+ if (this._signProvider) {
306
+ sign = await this._signProvider({
307
+ url: baseUrl,
308
+ query,
309
+ cookies: this._cookieAuth.toHeader(),
310
+ });
311
+ }
312
+ const resp = await this._fetchFn({
313
+ url: baseUrl,
314
+ cookies: this._cookieAuth.toHeader(),
315
+ query,
316
+ sign,
317
+ });
318
+ const items = extractData(resp, step.kind);
319
+ if (!items.length) break;
320
+ for (const it of items) {
321
+ if (!it || typeof it !== "object") continue;
322
+ if (emitted >= limit) return;
323
+ yield {
324
+ adapter: NAME,
325
+ kind: step.kind,
326
+ originalId: stableOriginalId(step.kind, step.mapId(it)),
327
+ capturedAt: cookieItemTime(step.kind, it),
328
+ payload: { item: it, kind: step.kind, cookie: true },
329
+ };
330
+ emitted += 1;
331
+ }
332
+ if (isEnd(resp, start, items.length) || items.length < PAGE_LIMIT) break;
333
+ start += items.length;
334
+ page += 1;
335
+ }
336
+ }
337
+ }
338
+
339
+ normalize(raw) {
340
+ if (!raw || !raw.payload) {
341
+ throw new Error("DoubanAdapter.normalize: payload missing");
342
+ }
343
+ const ingestedAt = Date.now();
344
+ const kind = raw.kind || raw.payload.kind;
345
+ if (kind === KIND_INTEREST) return normalizeInterest(raw, ingestedAt);
346
+ if (kind === KIND_REVIEW) return normalizeReview(raw, ingestedAt);
347
+ if (kind === KIND_FOLLOW) return normalizeFollow(raw, ingestedAt);
348
+ throw new Error(`DoubanAdapter.normalize: unknown kind ${kind}`);
349
+ }
350
+ }
351
+
352
+ // ─── cookie-api response helpers ─────────────────────────────────────────────
353
+
354
+ /** Pull the collection array from a Frodo paginated response. */
355
+ function extractData(resp, kind) {
356
+ if (!resp || typeof resp !== "object") return [];
357
+ if (Array.isArray(resp)) return resp;
358
+ if (Array.isArray(resp.interests)) return resp.interests;
359
+ if (Array.isArray(resp.reviews)) return resp.reviews;
360
+ if (Array.isArray(resp.users)) return resp.users;
361
+ if (Array.isArray(resp.following)) return resp.following;
362
+ if (Array.isArray(resp.items)) return resp.items;
363
+ if (Array.isArray(resp.data)) return resp.data;
364
+ // Frodo sometimes keys the collection by its kind plural — best-effort.
365
+ if (kind && Array.isArray(resp[`${kind}s`])) return resp[`${kind}s`];
366
+ return [];
367
+ }
368
+
369
+ function isEnd(resp, start, batch) {
370
+ if (resp && Number.isFinite(resp.total)) return start + batch >= resp.total;
371
+ return false;
372
+ }
373
+
374
+ function itemId(it) {
375
+ if (!it || typeof it !== "object") return "unknown";
376
+ // interests nest the subject; reviews/users carry id directly.
377
+ return (
378
+ it.id ||
379
+ (it.subject && it.subject.id) ||
380
+ it.user_id ||
381
+ it.uid ||
382
+ "unknown"
383
+ );
384
+ }
385
+
386
+ function cookieItemTime(kind, it) {
387
+ if (kind === KIND_INTEREST) {
388
+ return parseTime(it.create_time || it.created_time || it.update_time) || Date.now();
389
+ }
390
+ if (kind === KIND_REVIEW) {
391
+ return parseTime(it.create_time || it.created_time) || Date.now();
392
+ }
393
+ return Date.now();
394
+ }
395
+
396
+ // ─── per-kind normalizers (snapshot direct fields OR cookie payload.item) ─────
397
+
398
+ function buildSource(raw, occurredAt) {
399
+ return {
400
+ adapter: NAME,
401
+ adapterVersion: VERSION,
402
+ originalId: raw.originalId,
403
+ capturedAt: raw.capturedAt || occurredAt,
404
+ capturedBy: CAPTURED_BY.API,
405
+ };
406
+ }
407
+
408
+ function normalizeInterest(raw, ingestedAt) {
409
+ const p = raw.payload;
410
+ const it = p.cookie ? p.item : p;
411
+ const subject = it.subject || {};
412
+ const subjectType = (it.subjectType || subject.type || it.type || "").toLowerCase();
413
+ const title = it.title || subject.title || "(未知条目)";
414
+ const status = String(it.status || "done").toLowerCase();
415
+ const verb = STATUS_VERB[status] || "标记";
416
+ const typeLabel = SUBJECT_LABEL[subjectType] || subjectType || "条目";
417
+ const myRating =
418
+ it.myRating != null
419
+ ? it.myRating
420
+ : it.rating && typeof it.rating === "object"
421
+ ? it.rating.value
422
+ : it.rating;
423
+ const comment = it.comment || it.text || "";
424
+ const subjectId = it.subjectId || subject.id || it.id || null;
425
+ const url = it.url || subject.url || (subjectId ? `https://www.douban.com/${subjectType}/${subjectId}/` : null);
426
+ const occurredAt =
427
+ parseTime(it.createdTime || it.create_time || it.created_time || raw.capturedAt) || ingestedAt;
428
+ const source = buildSource(raw, occurredAt);
429
+ const refId = `item-douban-${subjectType || "subject"}-${subjectId || newId()}`;
430
+ return {
431
+ events: [
432
+ {
433
+ id: newId(),
434
+ type: ENTITY_TYPES.EVENT,
435
+ subtype: EVENT_SUBTYPES.MEDIA,
436
+ occurredAt,
437
+ actor: "person-self",
438
+ content: {
439
+ title: `${verb}${typeLabel}: ${title}`,
440
+ text: comment || title,
441
+ },
442
+ ingestedAt,
443
+ source,
444
+ extra: {
445
+ platform: "douban",
446
+ subjectType: subjectType || null,
447
+ subjectId: subjectId != null ? String(subjectId) : null,
448
+ status,
449
+ myRating: Number.isFinite(Number(myRating)) ? Number(myRating) : null,
450
+ comment: comment || null,
451
+ url,
452
+ itemRef: refId,
453
+ },
454
+ },
455
+ ],
456
+ items: [
457
+ {
458
+ id: refId,
459
+ type: ENTITY_TYPES.ITEM,
460
+ subtype: ITEM_SUBTYPES.MEDIA,
461
+ name: title,
462
+ ingestedAt,
463
+ source,
464
+ extra: {
465
+ platform: "douban",
466
+ kind: subjectType || "subject",
467
+ subjectId: subjectId != null ? String(subjectId) : null,
468
+ url,
469
+ },
470
+ },
471
+ ],
472
+ persons: [],
473
+ places: [],
474
+ topics: [],
475
+ };
476
+ }
477
+
478
+ function normalizeReview(raw, ingestedAt) {
479
+ const p = raw.payload;
480
+ const it = p.cookie ? p.item : p;
481
+ const subject = it.subject || {};
482
+ const title = it.title || "(无标题)";
483
+ const abstract = it.abstract || it.content || "";
484
+ const subjectTitle = it.subjectTitle || subject.title || "";
485
+ const reviewId = it.reviewId || it.id || null;
486
+ const rating =
487
+ it.rating && typeof it.rating === "object" ? it.rating.value : it.rating;
488
+ const occurredAt =
489
+ parseTime(it.createdTime || it.create_time || it.created_time || raw.capturedAt) || ingestedAt;
490
+ const source = buildSource(raw, occurredAt);
491
+ return {
492
+ events: [
493
+ {
494
+ id: newId(),
495
+ type: ENTITY_TYPES.EVENT,
496
+ subtype: EVENT_SUBTYPES.POST,
497
+ occurredAt,
498
+ actor: "person-self",
499
+ content: {
500
+ title: (title || subjectTitle || "(无标题)").slice(0, 80),
501
+ text: stripHtml(abstract),
502
+ },
503
+ ingestedAt,
504
+ source,
505
+ extra: {
506
+ platform: "douban",
507
+ doubanReviewId: reviewId != null ? String(reviewId) : null,
508
+ subjectTitle: subjectTitle || null,
509
+ rating: Number.isFinite(Number(rating)) ? Number(rating) : null,
510
+ url: it.url || (reviewId ? `https://www.douban.com/review/${reviewId}/` : null),
511
+ },
512
+ },
513
+ ],
514
+ persons: [],
515
+ places: [],
516
+ items: [],
517
+ topics: [],
518
+ };
519
+ }
520
+
521
+ function normalizeFollow(raw, ingestedAt) {
522
+ const p = raw.payload;
523
+ const it = p.cookie ? p.item : p;
524
+ const memberId = it.memberId || it.id || it.uid || it.user_id || `unknown-${newId()}`;
525
+ const name = it.name || it.nickname || "(unnamed)";
526
+ const occurredAt = parseTime(it.capturedAt || raw.capturedAt) || ingestedAt;
527
+ const source = buildSource(raw, occurredAt);
528
+ const person = {
529
+ id: `person-douban-${memberId}`,
530
+ type: ENTITY_TYPES.PERSON,
531
+ subtype: PERSON_SUBTYPES.CONTACT,
532
+ names: [name],
533
+ ingestedAt,
534
+ source,
535
+ identifiers: {
536
+ "douban-id": [String(memberId)],
537
+ },
538
+ extra: {
539
+ platform: "douban",
540
+ avatarUrl: it.avatarUrl || it.avatar || null,
541
+ url: it.url || (memberId ? `https://www.douban.com/people/${memberId}/` : null),
542
+ followedAt: occurredAt,
543
+ },
544
+ };
545
+ return { events: [], persons: [person], places: [], items: [], topics: [] };
546
+ }
547
+
548
+ function stripHtml(s) {
549
+ if (typeof s !== "string") return "";
550
+ return s.replace(/<[^>]+>/g, "").trim();
551
+ }
552
+
553
+ async function defaultFetch(_opts) {
554
+ throw new Error("social-douban: no fetchFn configured for cookie-api mode");
555
+ }
556
+
557
+ module.exports = {
558
+ DoubanAdapter,
559
+ extractData,
560
+ NAME,
561
+ VERSION,
562
+ SNAPSHOT_SCHEMA_VERSION,
563
+ VALID_SNAPSHOT_KINDS,
564
+ };