@chainlesschain/personal-data-hub 0.3.6 → 0.3.8

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 (29) hide show
  1. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +432 -0
  2. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +276 -0
  3. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +141 -0
  4. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +178 -0
  5. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +537 -0
  6. package/__tests__/adapters/social-toutiao-adb-collector.test.js +285 -0
  7. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +163 -0
  8. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +196 -0
  9. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
  10. package/__tests__/analysis.test.js +239 -14
  11. package/__tests__/query-parser.test.js +86 -0
  12. package/__tests__/vault.test.js +88 -0
  13. package/lib/adapters/ai-chat-history/health-checker.js +11 -0
  14. package/lib/adapters/social-kuaishou-adb/api-client.js +397 -0
  15. package/lib/adapters/social-kuaishou-adb/collector.js +196 -0
  16. package/lib/adapters/social-kuaishou-adb/cookies-extension.js +261 -0
  17. package/lib/adapters/social-kuaishou-adb/index.js +53 -0
  18. package/lib/adapters/social-kuaishou-adb/snapshot-builder.js +145 -0
  19. package/lib/adapters/social-toutiao-adb/api-client.js +377 -0
  20. package/lib/adapters/social-toutiao-adb/collector.js +200 -0
  21. package/lib/adapters/social-toutiao-adb/cookies-extension.js +266 -0
  22. package/lib/adapters/social-toutiao-adb/index.js +52 -0
  23. package/lib/adapters/social-toutiao-adb/snapshot-builder.js +148 -0
  24. package/lib/adapters/social-xiaohongshu-adb/api-client.js +36 -5
  25. package/lib/adapters/social-xiaohongshu-adb/collector.js +102 -51
  26. package/lib/analysis.js +154 -17
  27. package/lib/query-parser.js +93 -0
  28. package/lib/vault.js +64 -0
  29. package/package.json +5 -1
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 6d (Kuaishou C 路径 — 2026-05-25): end-to-end orchestrator.
5
+ *
6
+ * bridge.invoke("kuaishou.cookies") ← Phase 6d cookies extension
7
+ * │
8
+ * ▼ {cookie, uid, diagnostic}
9
+ * KuaishouApiClient.fetchProfile ← cookie parse (no HTTP)
10
+ * │
11
+ * ▼ ProfileInfo
12
+ * signProvider.warmUp(cookie) ← Phase 6d bridge ready
13
+ * │
14
+ * ▼
15
+ * fetchWatchHistory + fetchProfilePhotos + fetchSearchHistory
16
+ * │ (parallel, GraphQL POST + __NS_sig3 + kpf/kpn)
17
+ * ▼ 3 arrays (partial-failure OK)
18
+ * buildSnapshot + writeSnapshotJson ← schemaVersion=1
19
+ * │
20
+ * ▼
21
+ * registry.syncAdapter("social-kuaishou", { inputPath })
22
+ *
23
+ * Mirror of social-toutiao-adb but with GraphQL POST signing + dual
24
+ * URL/header bridge contract.
25
+ */
26
+
27
+ const { KuaishouApiClient } = require("./api-client");
28
+ const {
29
+ buildSnapshot,
30
+ writeSnapshotJson,
31
+ cleanupSnapshotJson,
32
+ } = require("./snapshot-builder");
33
+
34
+ async function collect(bridge, opts = {}) {
35
+ if (!bridge || typeof bridge.invoke !== "function") {
36
+ throw new TypeError(
37
+ "KuaishouAdbCollector.collect: bridge must expose invoke(method, params)",
38
+ );
39
+ }
40
+ const now = opts.now || Date.now;
41
+ const signProvider = opts.signProvider || undefined;
42
+ const client =
43
+ opts.apiClient || new KuaishouApiClient({ now, signProvider });
44
+ const limits = opts.limits || {};
45
+
46
+ const cookieResult = await bridge.invoke("kuaishou.cookies");
47
+ if (
48
+ !cookieResult ||
49
+ typeof cookieResult.cookie !== "string"
50
+ ) {
51
+ throw new Error(
52
+ "KuaishouAdbCollector.collect: bridge.invoke('kuaishou.cookies') returned malformed payload — got cookie=" +
53
+ typeof cookieResult?.cookie,
54
+ );
55
+ }
56
+ const { cookie, uid: cookieUid, diagnostic: cookieDiagnostic } = cookieResult;
57
+
58
+ if (signProvider && typeof signProvider.warmUp === "function") {
59
+ try {
60
+ await signProvider.warmUp(cookie);
61
+ } catch (e) {
62
+ client._setLastError(
63
+ -98,
64
+ `signProvider warm-up failed: ${e && e.message ? e.message : String(e)}`,
65
+ );
66
+ }
67
+ }
68
+
69
+ try {
70
+ // fetchProfile — pure cookie parse, no HTTP, no _sig.
71
+ const profile = await client.fetchProfile(cookie);
72
+ if (!profile) {
73
+ // Cookie lacks api_ph or parse failed. Emit empty snapshot using
74
+ // cookie-derived uid (or sentinel).
75
+ const uid = cookieUid || "unknown-user";
76
+ const snapshot = buildSnapshot({
77
+ uid,
78
+ displayName: opts.displayName,
79
+ snapshottedAt: now(),
80
+ });
81
+ const snapshotPath = writeSnapshotJson(snapshot, { dir: opts.stagingDir });
82
+ return {
83
+ snapshotPath,
84
+ uid: cookieUid,
85
+ nickname: null,
86
+ eventCounts: { profile: 0, watch: 0, collect: 0, search: 0, total: 0 },
87
+ lastErrorCode: client.lastErrorCode,
88
+ lastErrorMessage: client.lastErrorMessage,
89
+ cookieDiagnostic: cookieDiagnostic || null,
90
+ profileFetchFailed: true,
91
+ signProviderUsed: signProvider
92
+ ? signProvider.constructor.name
93
+ : "none",
94
+ signProviderHits: client._bridgeHits,
95
+ signProviderFallbacks: client._fallbackHits,
96
+ };
97
+ }
98
+
99
+ // Parallel 3 signed endpoints — partial failure tolerated.
100
+ const [watch, collectPosts, search] = await Promise.all([
101
+ client.fetchWatchHistory(cookie, {
102
+ limit: Number.isInteger(limits.watch) ? limits.watch : undefined,
103
+ }),
104
+ client.fetchProfilePhotos(cookie, profile.uid, {
105
+ limit: Number.isInteger(limits.collect)
106
+ ? limits.collect
107
+ : undefined,
108
+ }),
109
+ client.fetchSearchHistory(cookie, {
110
+ limit: Number.isInteger(limits.search) ? limits.search : undefined,
111
+ }),
112
+ ]);
113
+
114
+ const snapshot = buildSnapshot({
115
+ uid: profile.uid,
116
+ displayName: opts.displayName || profile.nickname,
117
+ profile,
118
+ watch,
119
+ collect: collectPosts,
120
+ search,
121
+ snapshottedAt: now(),
122
+ });
123
+ const snapshotPath = writeSnapshotJson(snapshot, { dir: opts.stagingDir });
124
+
125
+ return {
126
+ snapshotPath,
127
+ uid: profile.uid,
128
+ nickname: profile.nickname,
129
+ eventCounts: {
130
+ profile: 1,
131
+ watch: watch.length,
132
+ collect: collectPosts.length,
133
+ search: search.length,
134
+ total: snapshot.events.length,
135
+ },
136
+ lastErrorCode: client.lastErrorCode,
137
+ lastErrorMessage: client.lastErrorMessage,
138
+ cookieDiagnostic: cookieDiagnostic || null,
139
+ profileFetchFailed: false,
140
+ signProviderUsed: signProvider ? signProvider.constructor.name : "none",
141
+ signProviderHits: client._bridgeHits,
142
+ signProviderFallbacks: client._fallbackHits,
143
+ };
144
+ } finally {
145
+ if (signProvider && typeof signProvider.shutdown === "function") {
146
+ try {
147
+ await signProvider.shutdown();
148
+ } catch (_e) {
149
+ // Best-effort
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ async function collectAndSync(bridge, registry, opts = {}) {
156
+ if (!registry || typeof registry.syncAdapter !== "function") {
157
+ throw new TypeError(
158
+ "KuaishouAdbCollector.collectAndSync: registry must expose syncAdapter(name, options)",
159
+ );
160
+ }
161
+ const collectResult = await collect(bridge, opts);
162
+ let syncReport = null;
163
+ let cleanupFailed = false;
164
+ try {
165
+ syncReport = await registry.syncAdapter("social-kuaishou", {
166
+ inputPath: collectResult.snapshotPath,
167
+ });
168
+ } finally {
169
+ try {
170
+ cleanupSnapshotJson(collectResult.snapshotPath);
171
+ } catch (_e) {
172
+ cleanupFailed = true;
173
+ }
174
+ }
175
+ return {
176
+ ...syncReport,
177
+ kuaishou: {
178
+ uid: collectResult.uid,
179
+ nickname: collectResult.nickname,
180
+ eventCounts: collectResult.eventCounts,
181
+ lastErrorCode: collectResult.lastErrorCode,
182
+ lastErrorMessage: collectResult.lastErrorMessage,
183
+ cookieDiagnostic: collectResult.cookieDiagnostic,
184
+ profileFetchFailed: collectResult.profileFetchFailed,
185
+ signProviderUsed: collectResult.signProviderUsed,
186
+ signProviderHits: collectResult.signProviderHits,
187
+ signProviderFallbacks: collectResult.signProviderFallbacks,
188
+ cleanupFailed,
189
+ },
190
+ };
191
+ }
192
+
193
+ module.exports = {
194
+ collect,
195
+ collectAndSync,
196
+ };
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 6d (Kuaishou C 路径 — 2026-05-25): kuaishou.cookies ADB extension factory.
5
+ *
6
+ * Mirror of `social-toutiao-adb/cookies-extension.js` (P6c). Reads the
7
+ * Chromium cookies SQLite from Kuaishou Android app (com.smile.gifmaker)
8
+ * via `su -c base64`. Returns Cookie header + best-effort pre-extracted
9
+ * uid (from userId direct cookie OR kuaishou.web.cp.api_ph nested JSON).
10
+ *
11
+ * **Required cookies**: at least one of `userId` / `kuaishou.web.cp.api_ph`
12
+ * (login state). The api_ph payload also carries nickname / avatar /
13
+ * kuaishou_id, so api-client can extract profile from cookie alone — no
14
+ * HTTP for profile call.
15
+ *
16
+ * Returns:
17
+ * {
18
+ * cookie: string,
19
+ * uid: string|null,
20
+ * extractedAt: number,
21
+ * diagnostic: {
22
+ * cookieCount: number,
23
+ * hadEncrypted: boolean,
24
+ * cookieNames: string[],
25
+ * }
26
+ * }
27
+ */
28
+
29
+ const fs = require("node:fs");
30
+ const path = require("node:path");
31
+ const os = require("node:os");
32
+ const crypto = require("node:crypto");
33
+
34
+ const {
35
+ readChromiumCookies,
36
+ } = require("../social-bilibili-adb/chromium-cookies-reader");
37
+
38
+ const KUAISHOU_COOKIES_REMOTE_PATH =
39
+ "/data/data/com.smile.gifmaker/app_webview/Default/Cookies";
40
+
41
+ const KUAISHOU_COOKIE_HOST_DOMAIN = "kuaishou.com";
42
+
43
+ /**
44
+ * At least one of these must be present for the cookie to be considered
45
+ * "logged in". userId is the simplest signal; api_ph carries richer
46
+ * profile data but isn't always written.
47
+ */
48
+ const KUAISHOU_LOGIN_COOKIES = Object.freeze([
49
+ "userId",
50
+ "kuaishou.web.cp.api_ph",
51
+ ]);
52
+
53
+ async function pullCookiesViaSu(adb, serial, opts) {
54
+ const adbOpts = { serial, timeoutMs: opts?.timeoutMs || 60_000 };
55
+ const lsOut = await adb(
56
+ [
57
+ "shell",
58
+ "su",
59
+ "-c",
60
+ `ls ${KUAISHOU_COOKIES_REMOTE_PATH} 2>/dev/null || echo NOT_FOUND`,
61
+ ],
62
+ adbOpts,
63
+ );
64
+ const lsLine = lsOut.replace(/\r+$/gm, "").trim();
65
+ if (lsLine === "NOT_FOUND" || lsLine === "") {
66
+ throw new Error(
67
+ "KUAISHOU_NOT_INSTALLED: " +
68
+ KUAISHOU_COOKIES_REMOTE_PATH +
69
+ " not found. Install Kuaishou App (快手 com.smile.gifmaker) + log in once, then open any video to populate the WebView cookies, then retry. Note: 极速版 (com.kuaishou.nebula) uses a different package — only the standard app is supported.",
70
+ );
71
+ }
72
+ const idOut = await adb(["shell", "su", "-c", "id -u"], adbOpts);
73
+ const idLine = idOut.replace(/\r+$/gm, "").trim();
74
+ if (idLine !== "0" && !idLine.includes("uid=0")) {
75
+ throw new Error(
76
+ "KUAISHOU_NO_ROOT: this phone isn't rooted (su returned `" +
77
+ idLine.substring(0, 60) +
78
+ "`). Kuaishou release APK isn't debuggable, so root is required to read the Chromium cookies DB.",
79
+ );
80
+ }
81
+ const b64 = await adb(
82
+ [
83
+ "shell",
84
+ "su",
85
+ "-c",
86
+ `base64 ${KUAISHOU_COOKIES_REMOTE_PATH} | tr -d '\\n\\r'`,
87
+ ],
88
+ { ...adbOpts, timeoutMs: opts?.timeoutMs || 60_000 },
89
+ );
90
+ const b64Clean = b64.replace(/[\r\n\t ]+/g, "");
91
+ if (b64Clean.length === 0) {
92
+ throw new Error(
93
+ "KUAISHOU_COOKIES_EMPTY: base64 stream returned 0 bytes (su exec may have silently failed on MIUI / OEM ROM)",
94
+ );
95
+ }
96
+ let buf;
97
+ try {
98
+ buf = Buffer.from(b64Clean, "base64");
99
+ } catch (e) {
100
+ throw new Error(
101
+ "KUAISHOU_BASE64_PARSE: stream wasn't valid base64 (" +
102
+ (e.message || String(e)) +
103
+ ")",
104
+ );
105
+ }
106
+ if (buf.length < 1024) {
107
+ throw new Error(
108
+ "KUAISHOU_COOKIES_TRUNCATED: decoded file is only " +
109
+ buf.length +
110
+ " bytes — expected ≥4KB sqlite",
111
+ );
112
+ }
113
+ const magic = buf.subarray(0, 16).toString("latin1");
114
+ if (!magic.startsWith("SQLite format 3")) {
115
+ throw new Error(
116
+ "KUAISHOU_NOT_SQLITE: decoded file lacks `SQLite format 3` magic header",
117
+ );
118
+ }
119
+ const tmpDir = os.tmpdir();
120
+ const tmpFile = path.join(
121
+ tmpDir,
122
+ `cc-kuaishou-cookies-${crypto.randomUUID()}.db`,
123
+ );
124
+ fs.writeFileSync(tmpFile, buf);
125
+ return tmpFile;
126
+ }
127
+
128
+ /**
129
+ * Extract uid from a chromium-cookies array. Priority:
130
+ * 1. Direct `userId` cookie (numeric, !=0)
131
+ * 2. Nested user_id inside `kuaishou.web.cp.api_ph` URL-encoded JSON
132
+ */
133
+ function pickUidFromCookieMap(byName) {
134
+ const direct = byName.get("userId")?.value;
135
+ if (direct && /^\d+$/.test(direct) && direct !== "0") {
136
+ return direct;
137
+ }
138
+ const cpRaw = byName.get("kuaishou.web.cp.api_ph")?.value;
139
+ if (cpRaw) {
140
+ let decoded;
141
+ try {
142
+ decoded = decodeURIComponent(cpRaw);
143
+ } catch {
144
+ decoded = cpRaw;
145
+ }
146
+ // Try nested user_id / uid / userId regex (don't require strict JSON
147
+ // — api_ph format isn't documented and varies)
148
+ for (const pat of [
149
+ /"?user_id"?\s*:\s*"?(\d+)"?/,
150
+ /"?uid"?\s*:\s*"?(\d+)"?/,
151
+ /"?userId"?\s*:\s*"?(\d+)"?/,
152
+ ]) {
153
+ const m = pat.exec(decoded);
154
+ if (m && m[1] && m[1] !== "0") {
155
+ return m[1];
156
+ }
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function assembleKuaishouCookieHeader(cookies) {
163
+ if (!Array.isArray(cookies)) {
164
+ throw new TypeError(
165
+ "assembleKuaishouCookieHeader: cookies must be an array",
166
+ );
167
+ }
168
+ const byName = new Map();
169
+ for (const c of cookies) {
170
+ if (
171
+ !byName.has(c.name) ||
172
+ c.hostKey.length > (byName.get(c.name).hostKey || "").length
173
+ ) {
174
+ byName.set(c.name, c);
175
+ }
176
+ }
177
+ const hasLogin = KUAISHOU_LOGIN_COOKIES.some((n) => byName.has(n));
178
+ const uid = pickUidFromCookieMap(byName);
179
+ const present = new Set(byName.keys());
180
+ if (!hasLogin) {
181
+ return {
182
+ header: null,
183
+ uid: null,
184
+ present,
185
+ missing: [...KUAISHOU_LOGIN_COOKIES],
186
+ };
187
+ }
188
+ const header = Array.from(byName.values())
189
+ .map((c) => `${c.name}=${c.value}`)
190
+ .join("; ");
191
+ return { header, uid, present, missing: [] };
192
+ }
193
+
194
+ function createKuaishouCookiesExtension(factoryOpts = {}) {
195
+ const timeoutMs = factoryOpts.timeoutMs || 60_000;
196
+ const onCleanupFailed = factoryOpts.onCleanupFailed || (() => {});
197
+
198
+ return async function kuaishouCookiesHandler(_params, ctx) {
199
+ if (
200
+ !ctx ||
201
+ typeof ctx.adb !== "function" ||
202
+ typeof ctx.pickDevice !== "function"
203
+ ) {
204
+ throw new TypeError(
205
+ "kuaishou.cookies extension: ctx must provide {adb, pickDevice}",
206
+ );
207
+ }
208
+ const serial = await ctx.pickDevice();
209
+ let tmpFile = null;
210
+ try {
211
+ tmpFile = await pullCookiesViaSu(ctx.adb, serial, { timeoutMs });
212
+ const cookies = readChromiumCookies(
213
+ tmpFile,
214
+ KUAISHOU_COOKIE_HOST_DOMAIN,
215
+ );
216
+ const cookieCount = cookies.length;
217
+ const hadEncrypted = (cookies._skippedEncryptedCount || 0) > 0;
218
+ const { header, uid, missing, present } =
219
+ assembleKuaishouCookieHeader(cookies);
220
+ if (header === null) {
221
+ throw new Error(
222
+ "KUAISHOU_COOKIES_INCOMPLETE: missing required login cookies " +
223
+ JSON.stringify(missing) +
224
+ ". Likely the user logged out, or has never logged in via the Kuaishou app + browsed (open any video to populate WebView). hadEncrypted=" +
225
+ hadEncrypted +
226
+ ".",
227
+ );
228
+ }
229
+ return {
230
+ cookie: header,
231
+ uid,
232
+ extractedAt: Date.now(),
233
+ diagnostic: {
234
+ cookieCount,
235
+ hadEncrypted,
236
+ cookieNames: Array.from(present),
237
+ },
238
+ };
239
+ } finally {
240
+ if (tmpFile) {
241
+ try {
242
+ fs.unlinkSync(tmpFile);
243
+ } catch (_e) {
244
+ onCleanupFailed(tmpFile);
245
+ }
246
+ }
247
+ }
248
+ };
249
+ }
250
+
251
+ module.exports = {
252
+ createKuaishouCookiesExtension,
253
+ KUAISHOU_COOKIES_REMOTE_PATH,
254
+ KUAISHOU_COOKIE_HOST_DOMAIN,
255
+ KUAISHOU_LOGIN_COOKIES,
256
+ assembleKuaishouCookieHeader,
257
+ _internals: {
258
+ pullCookiesViaSu,
259
+ pickUidFromCookieMap,
260
+ },
261
+ };
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * social-kuaishou-adb — Phase 6d (Kuaishou C 路径) entry.
5
+ *
6
+ * Desktop ADB pulls Chromium cookies from Kuaishou Android app
7
+ * (com.smile.gifmaker) via `su -c base64`, then runs Kuaishou web
8
+ * GraphQL via KuaishouApiClient. Profile comes from cookie's api_ph
9
+ * payload (no HTTP); 3 signed GraphQL endpoints (visionFeedRecommend /
10
+ * visionProfilePhotoList / visionSearchPhoto) need __NS_sig3 query
11
+ * param + kpf/kpn headers. Desktop wiring injects KuaishouSignBridge;
12
+ * CLI / test contexts use NullSignProvider → signed endpoints short-
13
+ * circuit with lastErrorCode=-99.
14
+ *
15
+ * Pipeline:
16
+ * bridge.invoke("kuaishou.cookies") → {cookie, uid}
17
+ * → KuaishouApiClient.fetchProfile (cookie parse, no HTTP)
18
+ * → fetchWatchHistory + fetchProfilePhotos + fetchSearchHistory
19
+ * (signed GraphQL POST, parallel)
20
+ * → buildSnapshot + writeSnapshotJson
21
+ * → registry.syncAdapter("social-kuaishou", { inputPath })
22
+ */
23
+
24
+ const {
25
+ createKuaishouCookiesExtension,
26
+ KUAISHOU_COOKIES_REMOTE_PATH,
27
+ KUAISHOU_COOKIE_HOST_DOMAIN,
28
+ KUAISHOU_LOGIN_COOKIES,
29
+ assembleKuaishouCookieHeader,
30
+ } = require("./cookies-extension");
31
+ const { KuaishouApiClient } = require("./api-client");
32
+ const {
33
+ buildSnapshot,
34
+ writeSnapshotJson,
35
+ cleanupSnapshotJson,
36
+ SNAPSHOT_SCHEMA_VERSION,
37
+ } = require("./snapshot-builder");
38
+ const { collect, collectAndSync } = require("./collector");
39
+
40
+ module.exports = {
41
+ createKuaishouCookiesExtension,
42
+ KUAISHOU_COOKIES_REMOTE_PATH,
43
+ KUAISHOU_COOKIE_HOST_DOMAIN,
44
+ KUAISHOU_LOGIN_COOKIES,
45
+ assembleKuaishouCookieHeader,
46
+ KuaishouApiClient,
47
+ buildSnapshot,
48
+ writeSnapshotJson,
49
+ cleanupSnapshotJson,
50
+ SNAPSHOT_SCHEMA_VERSION,
51
+ collect,
52
+ collectAndSync,
53
+ };
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 6d (Kuaishou C 路径 — 2026-05-25): API responses → snapshot JSON.
5
+ *
6
+ * Matches the existing `social-kuaishou` adapter's snapshot mode schema
7
+ * (schemaVersion=1). Kinds: profile / watch / collect / search.
8
+ *
9
+ * Kuaishou uid is numeric string. account.uid set verbatim.
10
+ */
11
+
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+ const os = require("node:os");
15
+ const crypto = require("node:crypto");
16
+
17
+ const SNAPSHOT_SCHEMA_VERSION = 1;
18
+
19
+ function buildSnapshot(input) {
20
+ if (!input || typeof input !== "object") {
21
+ throw new TypeError("buildSnapshot: input must be an object");
22
+ }
23
+ const uid = input.uid;
24
+ if (typeof uid !== "string" || uid.length === 0) {
25
+ throw new TypeError("buildSnapshot: input.uid must be a non-empty string");
26
+ }
27
+ const snapshottedAt =
28
+ Number.isFinite(input.snapshottedAt) && input.snapshottedAt > 0
29
+ ? input.snapshottedAt
30
+ : Date.now();
31
+ const account = {
32
+ uid,
33
+ displayName: typeof input.displayName === "string" ? input.displayName : "",
34
+ };
35
+ const events = [];
36
+
37
+ // profile (1 event — adapter normalize() upserts the person record)
38
+ if (input.profile && typeof input.profile === "object") {
39
+ const p = input.profile;
40
+ events.push({
41
+ kind: "profile",
42
+ id: `profile-${uid}`,
43
+ capturedAt: snapshottedAt,
44
+ uid,
45
+ nickname: p.nickname || account.displayName,
46
+ kuaishouId: p.kuaishouId || null,
47
+ avatarUrl: p.avatarUrl || null,
48
+ sex: p.sex || null,
49
+ city: p.city || null,
50
+ constellation: p.constellation || null,
51
+ description: p.description || null,
52
+ });
53
+ }
54
+
55
+ // watch history (recommended-feed dwell items)
56
+ const watch = Array.isArray(input.watch) ? input.watch : [];
57
+ watch.forEach((w, idx) => {
58
+ if (!w || typeof w !== "object" || !w.photoId) return;
59
+ events.push({
60
+ kind: "watch",
61
+ id: `photo-${w.photoId}`,
62
+ capturedAt:
63
+ typeof w.viewedAt === "number" && w.viewedAt > 0
64
+ ? w.viewedAt
65
+ : snapshottedAt,
66
+ photoId: w.photoId,
67
+ caption: w.caption || null,
68
+ duration: typeof w.duration === "number" ? w.duration : 0,
69
+ authorId: w.authorId || null,
70
+ authorName: w.authorName || null,
71
+ });
72
+ });
73
+
74
+ // collect — own posted photos (KIND_COLLECT semantically: "videos I'm
75
+ // associated with on Kuaishou"). adapter v0.2.1 mapping intentional.
76
+ const collect = Array.isArray(input.collect) ? input.collect : [];
77
+ collect.forEach((c, idx) => {
78
+ if (!c || typeof c !== "object" || !c.photoId) return;
79
+ events.push({
80
+ kind: "collect",
81
+ id: `collect-${c.photoId}`,
82
+ capturedAt:
83
+ typeof c.postedAt === "number" && c.postedAt > 0
84
+ ? c.postedAt
85
+ : snapshottedAt,
86
+ photoId: c.photoId,
87
+ caption: c.caption || null,
88
+ authorId: uid, // self-posted
89
+ authorName: account.displayName || null,
90
+ });
91
+ });
92
+
93
+ // search history
94
+ const search = Array.isArray(input.search) ? input.search : [];
95
+ search.forEach((s, idx) => {
96
+ if (!s || typeof s !== "object" || !s.keyword) return;
97
+ events.push({
98
+ kind: "search",
99
+ id: `search-${s.keyword}:${s.searchedAt || idx}`,
100
+ capturedAt:
101
+ typeof s.searchedAt === "number" && s.searchedAt > 0
102
+ ? s.searchedAt
103
+ : snapshottedAt,
104
+ keyword: s.keyword,
105
+ searchAt: s.searchedAt || snapshottedAt,
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-kuaishou-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
+ };