@chainlesschain/personal-data-hub 0.3.1 → 0.3.7

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 (79) hide show
  1. package/__tests__/adapters/email-adapter-snapshot.test.js +237 -0
  2. package/__tests__/adapters/email-adapter.test.js +1 -1
  3. package/__tests__/adapters/email-pdf-extractor.test.js +1 -1
  4. package/__tests__/adapters/email-retry-progress.test.js +1 -1
  5. package/__tests__/adapters/email-templates.test.js +1 -1
  6. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +721 -0
  7. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +346 -0
  8. package/__tests__/adapters/social-bilibili-adb-collector.test.js +284 -0
  9. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +343 -0
  10. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +296 -0
  11. package/__tests__/adapters/social-douyin-adb-collector.test.js +254 -0
  12. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +304 -0
  13. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +216 -0
  14. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +432 -0
  15. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +276 -0
  16. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +141 -0
  17. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +178 -0
  18. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +537 -0
  19. package/__tests__/adapters/social-toutiao-adb-collector.test.js +285 -0
  20. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +163 -0
  21. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +196 -0
  22. package/__tests__/adapters/social-weibo-adb-api-client.test.js +362 -0
  23. package/__tests__/adapters/social-weibo-adb-collector.test.js +201 -0
  24. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +189 -0
  25. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +207 -0
  26. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
  27. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +130 -0
  28. package/__tests__/adapters/system-data-android.test.js +32 -1
  29. package/__tests__/longtail-adapters.test.js +15 -2
  30. package/__tests__/shopping-adapters.test.js +96 -0
  31. package/__tests__/sign-providers.test.js +62 -0
  32. package/__tests__/travel-adapters.test.js +66 -0
  33. package/__tests__/whatsapp-adapter.test.js +5 -2
  34. package/lib/adapters/browser-history-chrome/chrome-db-reader.js +11 -1
  35. package/lib/adapters/email-imap/email-adapter.js +224 -17
  36. package/lib/adapters/messaging-telegram/index.js +15 -12
  37. package/lib/adapters/messaging-whatsapp/index.js +15 -12
  38. package/lib/adapters/shopping-taobao/index.js +161 -21
  39. package/lib/adapters/social-bilibili-adb/api-client.js +555 -0
  40. package/lib/adapters/social-bilibili-adb/chromium-cookies-reader.js +296 -0
  41. package/lib/adapters/social-bilibili-adb/collector.js +190 -0
  42. package/lib/adapters/social-bilibili-adb/cookies-extension.js +250 -0
  43. package/lib/adapters/social-bilibili-adb/index.js +51 -0
  44. package/lib/adapters/social-bilibili-adb/snapshot-builder.js +197 -0
  45. package/lib/adapters/social-douyin/index.js +4 -0
  46. package/lib/adapters/social-douyin-adb/collector.js +165 -0
  47. package/lib/adapters/social-douyin-adb/db-extension.js +281 -0
  48. package/lib/adapters/social-douyin-adb/im-db-parser.js +287 -0
  49. package/lib/adapters/social-douyin-adb/index.js +57 -0
  50. package/lib/adapters/social-douyin-adb/snapshot-builder.js +174 -0
  51. package/lib/adapters/social-kuaishou-adb/api-client.js +397 -0
  52. package/lib/adapters/social-kuaishou-adb/collector.js +196 -0
  53. package/lib/adapters/social-kuaishou-adb/cookies-extension.js +261 -0
  54. package/lib/adapters/social-kuaishou-adb/index.js +53 -0
  55. package/lib/adapters/social-kuaishou-adb/snapshot-builder.js +145 -0
  56. package/lib/adapters/social-toutiao-adb/api-client.js +377 -0
  57. package/lib/adapters/social-toutiao-adb/collector.js +200 -0
  58. package/lib/adapters/social-toutiao-adb/cookies-extension.js +266 -0
  59. package/lib/adapters/social-toutiao-adb/index.js +52 -0
  60. package/lib/adapters/social-toutiao-adb/snapshot-builder.js +148 -0
  61. package/lib/adapters/social-weibo-adb/api-client.js +281 -0
  62. package/lib/adapters/social-weibo-adb/collector.js +169 -0
  63. package/lib/adapters/social-weibo-adb/cookies-extension.js +251 -0
  64. package/lib/adapters/social-weibo-adb/index.js +55 -0
  65. package/lib/adapters/social-weibo-adb/snapshot-builder.js +145 -0
  66. package/lib/adapters/social-xiaohongshu-adb/api-client.js +309 -0
  67. package/lib/adapters/social-xiaohongshu-adb/collector.js +209 -0
  68. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +211 -0
  69. package/lib/adapters/social-xiaohongshu-adb/index.js +50 -0
  70. package/lib/adapters/social-xiaohongshu-adb/sign.js +90 -0
  71. package/lib/adapters/social-xiaohongshu-adb/snapshot-builder.js +126 -0
  72. package/lib/adapters/system-data-android/adapter.js +77 -3
  73. package/lib/adapters/travel-amap/index.js +16 -10
  74. package/lib/adapters/travel-ctrip/index.js +25 -9
  75. package/lib/adapters/vscode/vscode-reader.js +7 -1
  76. package/lib/sign-providers/index.js +20 -0
  77. package/lib/sign-providers/interface.js +82 -0
  78. package/lib/sign-providers/null-sign-provider.js +30 -0
  79. package/package.json +10 -1
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * social-weibo-adb — Phase 3 (Weibo C 路径) entry.
5
+ *
6
+ * Phase 3a (this commit) — desktop ADB cookies + m.weibo.cn HTTP path:
7
+ * - weibo.cookies extension (pulls Chromium cookies from Weibo App)
8
+ * - WeiboApiClient (Node port, 4 endpoints, no signing)
9
+ * - buildSnapshot (post / favourite / follow → schemaVersion=1)
10
+ * - collect / collectAndSync
11
+ *
12
+ * Pipeline:
13
+ * bridge.invoke("weibo.cookies")
14
+ * → WeiboApiClient.fetchUid (cookie has no inline UID)
15
+ * → fetchPosts + fetchFavourites + fetchFollows
16
+ * → buildSnapshot + writeSnapshotJson
17
+ * → registry.syncAdapter("social-weibo", { inputPath })
18
+ *
19
+ * Reuses the existing `social-weibo` adapter's snapshot mode — same
20
+ * vault schema / dedup / event types. No 2nd adapter.
21
+ */
22
+
23
+ const {
24
+ createWeiboCookiesExtension,
25
+ WEIBO_COOKIES_REMOTE_PATH,
26
+ WEIBO_COOKIE_HOST_DOMAIN,
27
+ WEIBO_REQUIRED_COOKIE,
28
+ assembleWeiboCookieHeader,
29
+ } = require("./cookies-extension");
30
+ const { WeiboApiClient } = require("./api-client");
31
+ const {
32
+ buildSnapshot,
33
+ writeSnapshotJson,
34
+ cleanupSnapshotJson,
35
+ SNAPSHOT_SCHEMA_VERSION,
36
+ } = require("./snapshot-builder");
37
+ const { collect, collectAndSync } = require("./collector");
38
+
39
+ module.exports = {
40
+ // Extension factory (wiring registers this on the bridge)
41
+ createWeiboCookiesExtension,
42
+ WEIBO_COOKIES_REMOTE_PATH,
43
+ WEIBO_COOKIE_HOST_DOMAIN,
44
+ WEIBO_REQUIRED_COOKIE,
45
+ assembleWeiboCookieHeader,
46
+ // API client + builder
47
+ WeiboApiClient,
48
+ buildSnapshot,
49
+ writeSnapshotJson,
50
+ cleanupSnapshotJson,
51
+ SNAPSHOT_SCHEMA_VERSION,
52
+ // Collector orchestrator
53
+ collect,
54
+ collectAndSync,
55
+ };
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 3a (Weibo C 路径 — 2026-05-25): API responses → snapshot JSON.
5
+ *
6
+ * Matches the existing `social-weibo` adapter's snapshot mode schema:
7
+ *
8
+ * {
9
+ * "schemaVersion": 1,
10
+ * "snapshottedAt": <ms>,
11
+ * "account": { "uid": "<numeric uid as string>", "displayName": "" },
12
+ * "events": [
13
+ * { "kind": "post", "id": "post-<mid>", "capturedAt": <ms>,
14
+ * "text", "mid", "source", "repostsCount", "commentsCount",
15
+ * "likesCount", "picCount" },
16
+ * { "kind": "favourite", "id": "fav-<mid>", "capturedAt": <ms>,
17
+ * "text", "mid", "authorScreenName" },
18
+ * { "kind": "follow", "id": "follow-<uid>", "capturedAt": <ms>,
19
+ * "uid", "screenName", "description", "avatarUrl" }
20
+ * ]
21
+ * }
22
+ *
23
+ * Note: `follow` items don't have an authoritative timestamp from
24
+ * m.weibo.cn's /api/friendships/friends — we use snapshottedAt as
25
+ * fallback so the timestamp is at least monotonic per sync.
26
+ */
27
+
28
+ const fs = require("node:fs");
29
+ const path = require("node:path");
30
+ const os = require("node:os");
31
+ const crypto = require("node:crypto");
32
+
33
+ const SNAPSHOT_SCHEMA_VERSION = 1;
34
+
35
+ function buildSnapshot(input) {
36
+ if (!input || typeof input !== "object") {
37
+ throw new TypeError("buildSnapshot: input must be an object");
38
+ }
39
+ const uid = input.uid;
40
+ if (!Number.isFinite(uid) || uid <= 0) {
41
+ throw new TypeError(
42
+ "buildSnapshot: input.uid must be a positive integer (was " + uid + ")",
43
+ );
44
+ }
45
+ const snapshottedAt =
46
+ Number.isFinite(input.snapshottedAt) && input.snapshottedAt > 0
47
+ ? input.snapshottedAt
48
+ : Date.now();
49
+ const account = {
50
+ uid: String(uid),
51
+ displayName:
52
+ typeof input.displayName === "string" ? input.displayName : "",
53
+ };
54
+ const events = [];
55
+
56
+ // posts
57
+ const posts = Array.isArray(input.posts) ? input.posts : [];
58
+ posts.forEach((p, idx) => {
59
+ if (!p || typeof p !== "object") return;
60
+ events.push({
61
+ kind: "post",
62
+ id: p.mid ? `post-${p.mid}` : `post-${idx}`,
63
+ capturedAt: typeof p.createdAt === "number" && p.createdAt > 0 ? p.createdAt : snapshottedAt,
64
+ text: p.text || null,
65
+ mid: p.mid || null,
66
+ source: p.source || null,
67
+ repostsCount: typeof p.repostsCount === "number" ? p.repostsCount : 0,
68
+ commentsCount:
69
+ typeof p.commentsCount === "number" ? p.commentsCount : 0,
70
+ likesCount: typeof p.likesCount === "number" ? p.likesCount : 0,
71
+ picCount: typeof p.picCount === "number" ? p.picCount : 0,
72
+ });
73
+ });
74
+
75
+ // favourites
76
+ const favs = Array.isArray(input.favourites) ? input.favourites : [];
77
+ favs.forEach((f, idx) => {
78
+ if (!f || typeof f !== "object") return;
79
+ events.push({
80
+ kind: "favourite",
81
+ id: f.mid ? `fav-${f.mid}` : `fav-${idx}`,
82
+ capturedAt: typeof f.favAt === "number" && f.favAt > 0 ? f.favAt : snapshottedAt,
83
+ text: f.text || null,
84
+ mid: f.mid || null,
85
+ authorScreenName: f.authorScreenName || null,
86
+ });
87
+ });
88
+
89
+ // follows
90
+ const fols = Array.isArray(input.follows) ? input.follows : [];
91
+ fols.forEach((fol, idx) => {
92
+ if (!fol || typeof fol !== "object") return;
93
+ const followUid = typeof fol.uid === "number" ? fol.uid : null;
94
+ events.push({
95
+ kind: "follow",
96
+ id: followUid != null ? `follow-${followUid}` : `follow-${idx}`,
97
+ // /api/friendships/friends doesn't return follow time → fall back
98
+ capturedAt:
99
+ typeof fol.followedAt === "number" && fol.followedAt > 0
100
+ ? fol.followedAt
101
+ : snapshottedAt,
102
+ uid: followUid != null ? followUid : null,
103
+ screenName: fol.screenName || null,
104
+ description: fol.description || null,
105
+ avatarUrl: fol.avatarUrl || null,
106
+ });
107
+ });
108
+
109
+ return {
110
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
111
+ snapshottedAt,
112
+ account,
113
+ events,
114
+ };
115
+ }
116
+
117
+ function writeSnapshotJson(snapshot, opts = {}) {
118
+ const dir = opts.dir || os.tmpdir();
119
+ const fileName =
120
+ opts.fileName || `cc-weibo-snapshot-${crypto.randomUUID()}.json`;
121
+ if (fileName.includes("/") || fileName.includes("\\")) {
122
+ throw new Error(
123
+ "writeSnapshotJson: opts.fileName must be a basename, not a path",
124
+ );
125
+ }
126
+ const full = path.join(dir, fileName);
127
+ fs.writeFileSync(full, JSON.stringify(snapshot), "utf-8");
128
+ return full;
129
+ }
130
+
131
+ function cleanupSnapshotJson(filePath) {
132
+ if (!filePath) return;
133
+ try {
134
+ fs.unlinkSync(filePath);
135
+ } catch (_e) {
136
+ // ignore
137
+ }
138
+ }
139
+
140
+ module.exports = {
141
+ buildSnapshot,
142
+ writeSnapshotJson,
143
+ cleanupSnapshotJson,
144
+ SNAPSHOT_SCHEMA_VERSION,
145
+ };
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 3c (Xhs C 路径 — 2026-05-25): Node-side XhsApiClient.
5
+ *
6
+ * Byte-parity port of XhsApiClient.kt 4 endpoints. **Best-effort X-S
7
+ * signing** (~60% GET / <30% POST hit rate) — collector tolerates
8
+ * partial failures.
9
+ *
10
+ * Endpoints:
11
+ * - `/api/sns/web/v1/user/me` — no X-S, cookies-only
12
+ * - `/api/sns/web/v2/user_posted` — needs X-S
13
+ * - `/api/sns/web/v1/note/like/page` — needs X-S
14
+ * - `/api/sns/web/v1/user/follow/list` — needs X-S
15
+ *
16
+ * **Anti-bot signal**: User-Agent must look like desktop Chrome (xhs
17
+ * web is desktop-tuned, NOT mobile like Bilibili/Weibo). Referer +
18
+ * Origin = `https://www.xiaohongshu.com/`.
19
+ */
20
+
21
+ const { computeXsXt } = require("./sign");
22
+ const { NULL_SIGN_PROVIDER } = require("../../sign-providers");
23
+
24
+ const DEFAULT_BASE_URL = "https://edith.xiaohongshu.com/";
25
+
26
+ const BROWSER_UA =
27
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
28
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
29
+
30
+ const BROWSER_HEADERS = Object.freeze({
31
+ "User-Agent": BROWSER_UA,
32
+ Referer: "https://www.xiaohongshu.com/",
33
+ Origin: "https://www.xiaohongshu.com",
34
+ Accept: "application/json, text/plain, */*",
35
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
36
+ });
37
+
38
+ /**
39
+ * Parse xhs's interact_info count strings: "1.2万" / "10w+" / "234" / "亿".
40
+ * Mirrors XhsApiClient.kt:parseCount.
41
+ */
42
+ function parseCount(raw) {
43
+ if (typeof raw !== "string" || raw.length === 0) return 0;
44
+ const trimmed = raw.trim();
45
+ if (trimmed.endsWith("万")) {
46
+ const n = parseFloat(trimmed.slice(0, -1));
47
+ return Number.isFinite(n) ? Math.floor(n * 10000) : 0;
48
+ }
49
+ if (trimmed.endsWith("w+") || trimmed.endsWith("W+")) {
50
+ const n = parseFloat(trimmed.slice(0, -2));
51
+ return Number.isFinite(n) ? Math.floor(n * 10000) : 0;
52
+ }
53
+ if (trimmed.endsWith("w") || trimmed.endsWith("W")) {
54
+ const n = parseFloat(trimmed.slice(0, -1));
55
+ return Number.isFinite(n) ? Math.floor(n * 10000) : 0;
56
+ }
57
+ if (trimmed.endsWith("亿")) {
58
+ const n = parseFloat(trimmed.slice(0, -1));
59
+ return Number.isFinite(n) ? Math.floor(n * 100_000_000) : 0;
60
+ }
61
+ const n = parseInt(trimmed, 10);
62
+ return Number.isFinite(n) ? n : 0;
63
+ }
64
+
65
+ /**
66
+ * Normalize a Xhs timestamp to milliseconds (seconds → ms when < 1e12).
67
+ */
68
+ function normalizeMs(v) {
69
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return 0;
70
+ return v > 1e12 ? v : v * 1000;
71
+ }
72
+
73
+ class XhsApiClient {
74
+ constructor(opts = {}) {
75
+ this.baseUrl = opts.baseUrl || DEFAULT_BASE_URL;
76
+ if (!this.baseUrl.endsWith("/")) this.baseUrl += "/";
77
+ this._fetch = opts.fetch || globalThis.fetch;
78
+ if (typeof this._fetch !== "function") {
79
+ throw new Error(
80
+ "XhsApiClient: fetch not available — pass opts.fetch or run on Node 18+",
81
+ );
82
+ }
83
+ this._now = opts.now || Date.now;
84
+ // Phase 6b: signProvider injectable. Desktop wiring injects
85
+ // XhsSignBridge (Electron WebContentsView running xhs.js, ~100% hit
86
+ // rate). CLI / tests get NULL_SIGN_PROVIDER → falls back to the
87
+ // in-process best-effort computeXsXt (~60% GET / <30% POST hit).
88
+ // Both code paths are present so the client works in either context
89
+ // without the caller having to swap api-client implementations.
90
+ this.signProvider = opts.signProvider || NULL_SIGN_PROVIDER;
91
+ this.lastErrorCode = 0;
92
+ this.lastErrorMessage = null;
93
+ // Diagnostic counters — collector reads these to decide whether to
94
+ // surface "bridge upgrade succeeded" in the report.
95
+ this._bridgeHits = 0;
96
+ this._fallbackHits = 0;
97
+ }
98
+
99
+ async _doGetJson(url, cookie, a1, requireSign) {
100
+ const headers = { ...BROWSER_HEADERS, Cookie: cookie };
101
+ if (requireSign && a1) {
102
+ const pathWithQuery = url.pathname + url.search;
103
+ // Phase 6b: prefer bridge over in-process computeXsXt.
104
+ // signedHeaders is async — bridge does executeJavaScript across
105
+ // Electron IPC. Returns {} on cold bridge / xhs.js rotation / IPC
106
+ // error, in which case we fall back to the best-effort md5.
107
+ const bridgeHeaders = await this.signProvider.signedHeaders(
108
+ url,
109
+ `${pathWithQuery}|`,
110
+ );
111
+ const bridgeKeys = Object.keys(bridgeHeaders);
112
+ if (bridgeKeys.length > 0) {
113
+ // Bridge produced headers — use them verbatim. xhs.js returns
114
+ // X-s / X-t (lowercase t in some builds) / X-s-common; we let
115
+ // the bridge's normalizeXhsHeader handle case.
116
+ Object.assign(headers, bridgeHeaders);
117
+ this._bridgeHits += 1;
118
+ } else {
119
+ // Fallback: in-process best-effort md5 (P3c path).
120
+ const { xs, xt } = computeXsXt(pathWithQuery, null, a1, {
121
+ now: this._now,
122
+ });
123
+ headers["X-S"] = xs;
124
+ headers["X-T"] = xt;
125
+ this._fallbackHits += 1;
126
+ }
127
+ }
128
+ try {
129
+ const resp = await this._fetch(url.toString(), {
130
+ method: "GET",
131
+ headers,
132
+ });
133
+ const body = await resp.text();
134
+ if (!resp.ok) {
135
+ this._setLastError(resp.status, `HTTP ${resp.status}`);
136
+ return null;
137
+ }
138
+ const trimmed = body.trimStart();
139
+ if (!trimmed.startsWith("{")) {
140
+ this._setLastError(-4, "non-json (login redirect / anti-bot HTML)");
141
+ return null;
142
+ }
143
+ let obj;
144
+ try {
145
+ obj = JSON.parse(body);
146
+ } catch (e) {
147
+ this._setLastError(-3, "parse: " + (e.message || String(e)));
148
+ return null;
149
+ }
150
+ // xhs returns either {code: N, msg:..., data:...} or {success:bool, code:N, data:...}
151
+ const success = obj.success === undefined ? true : obj.success;
152
+ if (success === false) {
153
+ this._setLastError(-5, "/success=false (no code)");
154
+ return null;
155
+ }
156
+ const code = typeof obj.code === "number" ? obj.code : 0;
157
+ if (code !== 0) {
158
+ this._setLastError(code, (obj.msg || "").toString());
159
+ return null;
160
+ }
161
+ this._clearLastError();
162
+ return obj;
163
+ } catch (e) {
164
+ this._setLastError(-2, "IO: " + (e.message || String(e)));
165
+ return null;
166
+ }
167
+ }
168
+
169
+ _setLastError(code, message) {
170
+ this.lastErrorCode = code;
171
+ this.lastErrorMessage = message;
172
+ }
173
+ _clearLastError() {
174
+ this.lastErrorCode = 0;
175
+ this.lastErrorMessage = null;
176
+ }
177
+
178
+ /**
179
+ * Fetch /api/sns/web/v1/user/me — cookies-only, no X-S required.
180
+ * Returns `{userId, nickname}` or null on failure.
181
+ */
182
+ async fetchMe(cookie) {
183
+ const url = new URL("api/sns/web/v1/user/me", this.baseUrl);
184
+ const obj = await this._doGetJson(url, cookie, null, false);
185
+ if (!obj) return null;
186
+ const data = obj.data || {};
187
+ const userId = (data.user_id && String(data.user_id)) || null;
188
+ if (!userId) {
189
+ this._setLastError(
190
+ -7,
191
+ "/user/me ok but user_id blank (cookie likely missing web_session)",
192
+ );
193
+ return null;
194
+ }
195
+ return {
196
+ userId,
197
+ nickname: data.nickname || null,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Fetch user's posted notes. Requires X-S signing.
203
+ */
204
+ async fetchNotes(cookie, a1, userId, opts = {}) {
205
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 30;
206
+ const url = new URL("api/sns/web/v2/user_posted", this.baseUrl);
207
+ url.searchParams.set("user_id", userId);
208
+ url.searchParams.set("num", "30");
209
+ url.searchParams.set("cursor", "");
210
+ url.searchParams.set("image_formats", "jpg,webp,avif");
211
+ const obj = await this._doGetJson(url, cookie, a1, true);
212
+ if (!obj) return [];
213
+ const data = obj.data || {};
214
+ const notes = Array.isArray(data.notes) ? data.notes : [];
215
+ const out = [];
216
+ for (let i = 0; i < Math.min(limit, notes.length); i++) {
217
+ const n = notes[i];
218
+ if (!n) continue;
219
+ const noteId =
220
+ (n.note_id && String(n.note_id)) || (n.id && String(n.id));
221
+ if (!noteId) continue;
222
+ const interact = n.interact_info || {};
223
+ out.push({
224
+ noteId,
225
+ title:
226
+ n.display_title ||
227
+ n.title ||
228
+ "(no title)",
229
+ desc: n.desc || null,
230
+ type: n.type || "normal",
231
+ createdAt: normalizeMs(typeof n.time === "number" ? n.time : 0),
232
+ likedCount: parseCount(interact.liked_count),
233
+ collectedCount: parseCount(interact.collected_count),
234
+ commentCount: parseCount(interact.comment_count),
235
+ });
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /**
241
+ * Fetch user's liked notes. Requires X-S.
242
+ */
243
+ async fetchLiked(cookie, a1, opts = {}) {
244
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 30;
245
+ const url = new URL("api/sns/web/v1/note/like/page", this.baseUrl);
246
+ url.searchParams.set("num", "20");
247
+ url.searchParams.set("cursor", "");
248
+ const obj = await this._doGetJson(url, cookie, a1, true);
249
+ if (!obj) return [];
250
+ const data = obj.data || {};
251
+ const notes = Array.isArray(data.notes) ? data.notes : [];
252
+ const out = [];
253
+ for (let i = 0; i < Math.min(limit, notes.length); i++) {
254
+ const n = notes[i];
255
+ if (!n) continue;
256
+ const noteId = n.note_id && String(n.note_id);
257
+ if (!noteId) continue;
258
+ const user = n.user || {};
259
+ out.push({
260
+ noteId,
261
+ title: n.display_title || n.title || "(no title)",
262
+ // xhs doesn't return explicit liked_at — collector fills with snapshotted_at
263
+ likedAt: 0,
264
+ authorNickname: user.nickname || null,
265
+ });
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /**
271
+ * Fetch follow list. Requires X-S.
272
+ */
273
+ async fetchFollows(cookie, a1, userId, opts = {}) {
274
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 100;
275
+ const url = new URL("api/sns/web/v1/user/follow/list", this.baseUrl);
276
+ url.searchParams.set("user_id", userId);
277
+ url.searchParams.set("num", "20");
278
+ url.searchParams.set("cursor", "");
279
+ const obj = await this._doGetJson(url, cookie, a1, true);
280
+ if (!obj) return [];
281
+ const data = obj.data || {};
282
+ const users = Array.isArray(data.users) ? data.users : [];
283
+ const out = [];
284
+ for (let i = 0; i < Math.min(limit, users.length); i++) {
285
+ const u = users[i];
286
+ if (!u) continue;
287
+ const userIdStr = u.user_id && String(u.user_id);
288
+ if (!userIdStr) continue;
289
+ out.push({
290
+ userId: userIdStr,
291
+ nickname: u.nickname || "(unnamed)",
292
+ image: u.image || null,
293
+ // xhs doesn't return explicit follow time
294
+ followedAt: 0,
295
+ });
296
+ }
297
+ return out;
298
+ }
299
+ }
300
+
301
+ module.exports = {
302
+ XhsApiClient,
303
+ _internals: {
304
+ parseCount,
305
+ normalizeMs,
306
+ BROWSER_UA,
307
+ BROWSER_HEADERS,
308
+ },
309
+ };