@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,209 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 3c (Xhs C 路径 — 2026-05-25): end-to-end orchestrator.
5
+ *
6
+ * bridge.invoke("xhs.cookies") ← Phase 3c cookies extension
7
+ * │
8
+ * ▼ {cookie, a1, diagnostic}
9
+ * XhsApiClient.fetchMe ← /user/me 拿 user_id (无 X-S)
10
+ * │
11
+ * ▼ {userId, nickname}
12
+ * fetchNotes + fetchLiked + fetchFollows (parallel, X-S 需 a1)
13
+ * │
14
+ * ▼ 3 arrays (partial-failure OK; ~60% GET hit rate)
15
+ * buildSnapshot + writeSnapshotJson ← schemaVersion=1
16
+ * │
17
+ * ▼
18
+ * registry.syncAdapter("social-xiaohongshu", { inputPath })
19
+ *
20
+ * Mirror of social-weibo-adb/collector.js. Key diff: 3 endpoints need
21
+ * X-S signing (best-effort md5 approximation hits ~60% GET, <30% POST;
22
+ * collector tolerates partial failures via lastErrorCode propagation).
23
+ */
24
+
25
+ const { XhsApiClient } = require("./api-client");
26
+ const {
27
+ buildSnapshot,
28
+ writeSnapshotJson,
29
+ cleanupSnapshotJson,
30
+ } = require("./snapshot-builder");
31
+
32
+ async function collect(bridge, opts = {}) {
33
+ if (!bridge || typeof bridge.invoke !== "function") {
34
+ throw new TypeError(
35
+ "XhsAdbCollector.collect: bridge must expose invoke(method, params)",
36
+ );
37
+ }
38
+ const now = opts.now || Date.now;
39
+ // Phase 6b: signProvider opt — desktop wiring injects XhsSignBridge for
40
+ // ~100% X-S hit rate; cli wiring leaves undefined → client falls back
41
+ // to in-process best-effort md5 (~60% GET / <30% POST).
42
+ const signProvider = opts.signProvider || undefined;
43
+ const client =
44
+ opts.apiClient || new XhsApiClient({ now, signProvider });
45
+ const limits = opts.limits || {};
46
+
47
+ const cookieResult = await bridge.invoke("xhs.cookies");
48
+ if (
49
+ !cookieResult ||
50
+ typeof cookieResult.cookie !== "string" ||
51
+ typeof cookieResult.a1 !== "string"
52
+ ) {
53
+ throw new Error(
54
+ "XhsAdbCollector.collect: bridge.invoke('xhs.cookies') returned malformed payload — got cookie=" +
55
+ typeof cookieResult?.cookie +
56
+ " a1=" +
57
+ typeof cookieResult?.a1,
58
+ );
59
+ }
60
+ const { cookie, a1, diagnostic: cookieDiagnostic } = cookieResult;
61
+
62
+ // Phase 6b: warm up the sign bridge with the captured cookie BEFORE
63
+ // calling any X-S endpoint. warmUp is idempotent (no-op when already
64
+ // warm). NullSignProvider.warmUp doesn't exist (only on the abstract
65
+ // base + ElectronWebSignBridge), so we feature-detect.
66
+ if (signProvider && typeof signProvider.warmUp === "function") {
67
+ try {
68
+ await signProvider.warmUp(cookie);
69
+ } catch (e) {
70
+ // Bridge warm-up failed (timeout / xhs.com 403 / IPC error).
71
+ // Fall through — api-client will use in-process fallback. Surface
72
+ // the reason via lastErrorMessage so UI can hint "Electron bridge
73
+ // unavailable, command-line precision degraded".
74
+ client._setLastError(
75
+ -98,
76
+ `signProvider warm-up failed: ${e && e.message ? e.message : String(e)}`,
77
+ );
78
+ }
79
+ }
80
+
81
+ try {
82
+ // fetchMe — no X-S required
83
+ const me = await client.fetchMe(cookie);
84
+ if (!me) {
85
+ // Cookie expired or web_session missing — write empty snapshot
86
+ // (build requires userId, use sentinel "0" + emit 0 events).
87
+ const snapshot = buildSnapshot({
88
+ userId: "unknown-user",
89
+ nickname: opts.displayName,
90
+ snapshottedAt: now(),
91
+ });
92
+ const snapshotPath = writeSnapshotJson(snapshot, { dir: opts.stagingDir });
93
+ return {
94
+ snapshotPath,
95
+ userId: null,
96
+ nickname: null,
97
+ eventCounts: { note: 0, liked: 0, follow: 0, total: 0 },
98
+ lastErrorCode: client.lastErrorCode,
99
+ lastErrorMessage: client.lastErrorMessage,
100
+ cookieDiagnostic: cookieDiagnostic || null,
101
+ meFetchFailed: true,
102
+ signProviderUsed: signProvider
103
+ ? signProvider.constructor.name
104
+ : "none",
105
+ signProviderHits: client._bridgeHits,
106
+ signProviderFallbacks: client._fallbackHits,
107
+ };
108
+ }
109
+
110
+ // Parallel 3 endpoints — partial failure tolerated; bridge-signed
111
+ // requests should hit ~100% while fallback hits ~60% GET / <30% POST.
112
+ const [notes, liked, follows] = await Promise.all([
113
+ client.fetchNotes(cookie, a1, me.userId, {
114
+ limit: Number.isInteger(limits.note) ? limits.note : undefined,
115
+ }),
116
+ client.fetchLiked(cookie, a1, {
117
+ limit: Number.isInteger(limits.liked) ? limits.liked : undefined,
118
+ }),
119
+ client.fetchFollows(cookie, a1, me.userId, {
120
+ limit: Number.isInteger(limits.follow) ? limits.follow : undefined,
121
+ }),
122
+ ]);
123
+
124
+ const snapshot = buildSnapshot({
125
+ userId: me.userId,
126
+ nickname: opts.displayName || me.nickname,
127
+ notes,
128
+ liked,
129
+ follows,
130
+ snapshottedAt: now(),
131
+ });
132
+ const snapshotPath = writeSnapshotJson(snapshot, { dir: opts.stagingDir });
133
+
134
+ return {
135
+ snapshotPath,
136
+ userId: me.userId,
137
+ nickname: me.nickname,
138
+ eventCounts: {
139
+ note: notes.length,
140
+ liked: liked.length,
141
+ follow: follows.length,
142
+ total: snapshot.events.length,
143
+ },
144
+ lastErrorCode: client.lastErrorCode,
145
+ lastErrorMessage: client.lastErrorMessage,
146
+ cookieDiagnostic: cookieDiagnostic || null,
147
+ meFetchFailed: false,
148
+ signProviderUsed: signProvider ? signProvider.constructor.name : "none",
149
+ signProviderHits: client._bridgeHits,
150
+ signProviderFallbacks: client._fallbackHits,
151
+ };
152
+ } finally {
153
+ // Always release the WebContentsView heap (~30-50MB) — even on
154
+ // throw. shutdown is idempotent so collectAndSync's outer cleanup
155
+ // calling it again is safe.
156
+ if (signProvider && typeof signProvider.shutdown === "function") {
157
+ try {
158
+ await signProvider.shutdown();
159
+ } catch (_e) {
160
+ // Best-effort — shutdown errors don't block sync result.
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ async function collectAndSync(bridge, registry, opts = {}) {
167
+ if (!registry || typeof registry.syncAdapter !== "function") {
168
+ throw new TypeError(
169
+ "XhsAdbCollector.collectAndSync: registry must expose syncAdapter(name, options)",
170
+ );
171
+ }
172
+ const collectResult = await collect(bridge, opts);
173
+ let syncReport = null;
174
+ let cleanupFailed = false;
175
+ try {
176
+ syncReport = await registry.syncAdapter("social-xiaohongshu", {
177
+ inputPath: collectResult.snapshotPath,
178
+ });
179
+ } finally {
180
+ try {
181
+ cleanupSnapshotJson(collectResult.snapshotPath);
182
+ } catch (_e) {
183
+ cleanupFailed = true;
184
+ }
185
+ }
186
+ return {
187
+ ...syncReport,
188
+ xhs: {
189
+ userId: collectResult.userId,
190
+ nickname: collectResult.nickname,
191
+ eventCounts: collectResult.eventCounts,
192
+ lastErrorCode: collectResult.lastErrorCode,
193
+ lastErrorMessage: collectResult.lastErrorMessage,
194
+ cookieDiagnostic: collectResult.cookieDiagnostic,
195
+ meFetchFailed: collectResult.meFetchFailed,
196
+ // Phase 6b diagnostic — UI can highlight when bridge upgraded
197
+ // X-S signing from ~60% best-effort to ~100% bridge.
198
+ signProviderUsed: collectResult.signProviderUsed,
199
+ signProviderHits: collectResult.signProviderHits,
200
+ signProviderFallbacks: collectResult.signProviderFallbacks,
201
+ cleanupFailed,
202
+ },
203
+ };
204
+ }
205
+
206
+ module.exports = {
207
+ collect,
208
+ collectAndSync,
209
+ };
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 3c (Xhs C 路径 — 2026-05-25): xhs.cookies ADB extension factory.
5
+ *
6
+ * Mirror of `social-weibo-adb/cookies-extension.js` (P3a) but for
7
+ * com.xingin.xhs (Xiaohongshu) — same chromium-cookies-reader reuse,
8
+ * different package + cookie name requirements.
9
+ *
10
+ * Required cookies (without these, X-S signing or auth fails):
11
+ * - `a1` — anti-bot fingerprint, REQUIRED for X-S sig input
12
+ * - `web_session` — login session token
13
+ *
14
+ * Either of the two missing → WEIBO_COOKIES_INCOMPLETE-style error code
15
+ * (XHS_COOKIES_INCOMPLETE) so UI surfaces a "relog on phone" banner.
16
+ *
17
+ * Returns:
18
+ * {
19
+ * cookie: string, // full Cookie header value
20
+ * a1: string, // pre-extracted a1 (saves caller parsing)
21
+ * extractedAt: number,
22
+ * diagnostic: {
23
+ * cookieCount: number,
24
+ * hadEncrypted: boolean,
25
+ * cookieNames: string[],
26
+ * }
27
+ * }
28
+ */
29
+
30
+ const fs = require("node:fs");
31
+ const path = require("node:path");
32
+ const os = require("node:os");
33
+ const crypto = require("node:crypto");
34
+
35
+ const {
36
+ readChromiumCookies,
37
+ } = require("../social-bilibili-adb/chromium-cookies-reader");
38
+
39
+ const XHS_COOKIES_REMOTE_PATH =
40
+ "/data/data/com.xingin.xhs/app_webview/Default/Cookies";
41
+
42
+ const XHS_COOKIE_HOST_DOMAIN = "xiaohongshu.com";
43
+
44
+ const XHS_REQUIRED_COOKIES = Object.freeze(["a1", "web_session"]);
45
+
46
+ async function pullCookiesViaSu(adb, serial, opts) {
47
+ const adbOpts = { serial, timeoutMs: opts?.timeoutMs || 60_000 };
48
+ const lsOut = await adb(
49
+ [
50
+ "shell",
51
+ "su",
52
+ "-c",
53
+ `ls ${XHS_COOKIES_REMOTE_PATH} 2>/dev/null || echo NOT_FOUND`,
54
+ ],
55
+ adbOpts,
56
+ );
57
+ const lsLine = lsOut.replace(/\r+$/gm, "").trim();
58
+ if (lsLine === "NOT_FOUND" || lsLine === "") {
59
+ throw new Error(
60
+ "XHS_NOT_INSTALLED: " +
61
+ XHS_COOKIES_REMOTE_PATH +
62
+ " not found. Install Xiaohongshu App + log in once on the phone, then retry.",
63
+ );
64
+ }
65
+ const idOut = await adb(["shell", "su", "-c", "id -u"], adbOpts);
66
+ const idLine = idOut.replace(/\r+$/gm, "").trim();
67
+ if (idLine !== "0" && !idLine.includes("uid=0")) {
68
+ throw new Error(
69
+ "XHS_NO_ROOT: this phone isn't rooted (su returned `" +
70
+ idLine.substring(0, 60) +
71
+ "`). Xiaohongshu release APK isn't debuggable, so root is required.",
72
+ );
73
+ }
74
+ const b64 = await adb(
75
+ [
76
+ "shell",
77
+ "su",
78
+ "-c",
79
+ `base64 ${XHS_COOKIES_REMOTE_PATH} | tr -d '\\n\\r'`,
80
+ ],
81
+ { ...adbOpts, timeoutMs: opts?.timeoutMs || 60_000 },
82
+ );
83
+ const b64Clean = b64.replace(/[\r\n\t ]+/g, "");
84
+ if (b64Clean.length === 0) {
85
+ throw new Error(
86
+ "XHS_COOKIES_EMPTY: base64 stream returned 0 bytes (su exec may have silently failed on MIUI / OEM ROM)",
87
+ );
88
+ }
89
+ let buf;
90
+ try {
91
+ buf = Buffer.from(b64Clean, "base64");
92
+ } catch (e) {
93
+ throw new Error(
94
+ "XHS_BASE64_PARSE: stream wasn't valid base64 (" +
95
+ (e.message || String(e)) +
96
+ ")",
97
+ );
98
+ }
99
+ if (buf.length < 1024) {
100
+ throw new Error(
101
+ "XHS_COOKIES_TRUNCATED: decoded file is only " +
102
+ buf.length +
103
+ " bytes — expected ≥4KB sqlite",
104
+ );
105
+ }
106
+ const magic = buf.subarray(0, 16).toString("latin1");
107
+ if (!magic.startsWith("SQLite format 3")) {
108
+ throw new Error(
109
+ "XHS_NOT_SQLITE: decoded file lacks `SQLite format 3` magic header",
110
+ );
111
+ }
112
+ const tmpDir = os.tmpdir();
113
+ const tmpFile = path.join(tmpDir, `cc-xhs-cookies-${crypto.randomUUID()}.db`);
114
+ fs.writeFileSync(tmpFile, buf);
115
+ return tmpFile;
116
+ }
117
+
118
+ /**
119
+ * Build a Cookie header + extract a1 from a chromium-cookies array.
120
+ *
121
+ * Xhs requires BOTH a1 and web_session — without either, X-S signing
122
+ * fails (a1) or auth fails (web_session).
123
+ */
124
+ function assembleXhsCookieHeader(cookies) {
125
+ if (!Array.isArray(cookies)) {
126
+ throw new TypeError("assembleXhsCookieHeader: cookies must be an array");
127
+ }
128
+ const byName = new Map();
129
+ for (const c of cookies) {
130
+ if (
131
+ !byName.has(c.name) ||
132
+ c.hostKey.length > (byName.get(c.name).hostKey || "").length
133
+ ) {
134
+ byName.set(c.name, c);
135
+ }
136
+ }
137
+ const missing = XHS_REQUIRED_COOKIES.filter((n) => !byName.has(n));
138
+ const present = new Set(byName.keys());
139
+ if (missing.length > 0) {
140
+ return { header: null, a1: null, present, missing };
141
+ }
142
+ const header = Array.from(byName.values())
143
+ .map((c) => `${c.name}=${c.value}`)
144
+ .join("; ");
145
+ const a1 = byName.get("a1")?.value || null;
146
+ return { header, a1, present, missing: [] };
147
+ }
148
+
149
+ function createXhsCookiesExtension(factoryOpts = {}) {
150
+ const timeoutMs = factoryOpts.timeoutMs || 60_000;
151
+ const onCleanupFailed = factoryOpts.onCleanupFailed || (() => {});
152
+
153
+ return async function xhsCookiesHandler(_params, ctx) {
154
+ if (
155
+ !ctx ||
156
+ typeof ctx.adb !== "function" ||
157
+ typeof ctx.pickDevice !== "function"
158
+ ) {
159
+ throw new TypeError(
160
+ "xhs.cookies extension: ctx must provide {adb, pickDevice}",
161
+ );
162
+ }
163
+ const serial = await ctx.pickDevice();
164
+ let tmpFile = null;
165
+ try {
166
+ tmpFile = await pullCookiesViaSu(ctx.adb, serial, { timeoutMs });
167
+ const cookies = readChromiumCookies(tmpFile, XHS_COOKIE_HOST_DOMAIN);
168
+ const cookieCount = cookies.length;
169
+ const hadEncrypted = (cookies._skippedEncryptedCount || 0) > 0;
170
+ const { header, a1, missing, present } = assembleXhsCookieHeader(cookies);
171
+ if (header === null) {
172
+ throw new Error(
173
+ "XHS_COOKIES_INCOMPLETE: missing required cookies " +
174
+ JSON.stringify(missing) +
175
+ ". User probably logged out (relog on phone) or Xhs version uses non-default WebView storage path (hadEncrypted=" +
176
+ hadEncrypted +
177
+ ").",
178
+ );
179
+ }
180
+ return {
181
+ cookie: header,
182
+ a1,
183
+ extractedAt: Date.now(),
184
+ diagnostic: {
185
+ cookieCount,
186
+ hadEncrypted,
187
+ cookieNames: Array.from(present),
188
+ },
189
+ };
190
+ } finally {
191
+ if (tmpFile) {
192
+ try {
193
+ fs.unlinkSync(tmpFile);
194
+ } catch (_e) {
195
+ onCleanupFailed(tmpFile);
196
+ }
197
+ }
198
+ }
199
+ };
200
+ }
201
+
202
+ module.exports = {
203
+ createXhsCookiesExtension,
204
+ XHS_COOKIES_REMOTE_PATH,
205
+ XHS_COOKIE_HOST_DOMAIN,
206
+ XHS_REQUIRED_COOKIES,
207
+ assembleXhsCookieHeader,
208
+ _internals: {
209
+ pullCookiesViaSu,
210
+ },
211
+ };
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * social-xiaohongshu-adb — Phase 3c (Xhs C 路径) entry.
5
+ *
6
+ * Phase 3c — desktop ADB Chromium cookies + edith.xiaohongshu.com HTTP
7
+ * with best-effort X-S signing (md5 approximation, ~60% GET hit rate).
8
+ *
9
+ * Pipeline:
10
+ * bridge.invoke("xhs.cookies") → {cookie, a1}
11
+ * → XhsApiClient.fetchMe (no X-S, cookies-only)
12
+ * → fetchNotes + fetchLiked + fetchFollows (X-S signed, parallel)
13
+ * → buildSnapshot + writeSnapshotJson
14
+ * → registry.syncAdapter("social-xiaohongshu", { inputPath })
15
+ */
16
+
17
+ const {
18
+ createXhsCookiesExtension,
19
+ XHS_COOKIES_REMOTE_PATH,
20
+ XHS_COOKIE_HOST_DOMAIN,
21
+ XHS_REQUIRED_COOKIES,
22
+ assembleXhsCookieHeader,
23
+ } = require("./cookies-extension");
24
+ const { computeXsXt, extractA1, XS_PREFIX } = require("./sign");
25
+ const { XhsApiClient } = require("./api-client");
26
+ const {
27
+ buildSnapshot,
28
+ writeSnapshotJson,
29
+ cleanupSnapshotJson,
30
+ SNAPSHOT_SCHEMA_VERSION,
31
+ } = require("./snapshot-builder");
32
+ const { collect, collectAndSync } = require("./collector");
33
+
34
+ module.exports = {
35
+ createXhsCookiesExtension,
36
+ XHS_COOKIES_REMOTE_PATH,
37
+ XHS_COOKIE_HOST_DOMAIN,
38
+ XHS_REQUIRED_COOKIES,
39
+ assembleXhsCookieHeader,
40
+ computeXsXt,
41
+ extractA1,
42
+ XS_PREFIX,
43
+ XhsApiClient,
44
+ buildSnapshot,
45
+ writeSnapshotJson,
46
+ cleanupSnapshotJson,
47
+ SNAPSHOT_SCHEMA_VERSION,
48
+ collect,
49
+ collectAndSync,
50
+ };
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 3c (Xhs C 路径 — 2026-05-25): X-S signature generator (Node port).
5
+ *
6
+ * Byte-parity port of
7
+ * `android-app/.../pdh/social/xiaohongshu/XhsApiClient.kt`:computeXsXt.
8
+ *
9
+ * **Real xhs.js algorithm (open-source reverse-engineered, best-effort)**:
10
+ * 1. payload = "url=" + url_path_with_query + ("" or body_json)
11
+ * 2. raw = ts_ms + payload + a1_cookie
12
+ * 3. md5_hex = MD5(raw).hex() — hex STRING (not bytes)
13
+ * 4. X-S = "XYW_" + base64(utf8_bytes(md5_hex))
14
+ * Critical: base64 encodes the UTF-8 bytes of the hex STRING, not
15
+ * the raw 16 MD5 bytes. This is what xhs.js does — it stringifies
16
+ * the digest before base64-ing it.
17
+ * 5. X-T = ts_ms (as decimal string)
18
+ *
19
+ * **Real xhs.js does one more step after step 3** — XOR-rotate with a
20
+ * key derived from b1 cookie, then base64 with `=` padding. v0.2 we
21
+ * skip that step → ~60% GET hit rate, <30% POST hit rate. UI banner
22
+ * surfaces lastErrorCode=461 when xhs rejects our X-S; collector
23
+ * gracefully degrades to emptyList() per endpoint.
24
+ *
25
+ * Future Phase 3c-v0.3: a WebView-based bridge (see Android-side
26
+ * XhsSignBridge.kt — runs xhs's own JS in a hidden Electron BrowserView)
27
+ * would push the hit rate to ~100%. Out of scope for v0.2 Node port.
28
+ */
29
+
30
+ const crypto = require("node:crypto");
31
+
32
+ /** "XYW_" prefix — matches xhs.js output. */
33
+ const XS_PREFIX = "XYW_";
34
+
35
+ /**
36
+ * Compute X-S + X-T headers for a GET request.
37
+ *
38
+ * @param {string} urlPathWithQuery url.pathname + url.search (path + "?" + query, encoded)
39
+ * @param {string|null} body POST body as a JSON string, or null/empty for GET
40
+ * @param {string} a1 a1 cookie value (anti-bot fingerprint)
41
+ * @param {{now?: () => number}} [opts] test seam — inject `now: () => 1716383021000`
42
+ * @returns {{xs: string, xt: string}}
43
+ */
44
+ function computeXsXt(urlPathWithQuery, body, a1, opts = {}) {
45
+ if (typeof urlPathWithQuery !== "string" || urlPathWithQuery.length === 0) {
46
+ throw new TypeError("computeXsXt: urlPathWithQuery must be non-empty string");
47
+ }
48
+ if (typeof a1 !== "string" || a1.length === 0) {
49
+ throw new TypeError("computeXsXt: a1 must be non-empty string");
50
+ }
51
+ const ts = (opts.now || Date.now)();
52
+ const bodyStr = typeof body === "string" ? body : "";
53
+ const payload = "url=" + urlPathWithQuery + bodyStr;
54
+ const raw = `${ts}${payload}${a1}`;
55
+ const md5Hex = crypto.createHash("md5").update(raw, "utf8").digest("hex");
56
+ // base64 encode the UTF-8 bytes of the hex STRING (32 chars → 32 bytes
57
+ // → 44-char base64 with padding). xhs.js NO_WRAP NO_PADDING flags
58
+ // mirror: replace = padding with "", remove newlines (default in
59
+ // Buffer.toString("base64") already no-newlines, only padding to strip).
60
+ const b64NoPad = Buffer.from(md5Hex, "utf8").toString("base64").replace(/=+$/, "");
61
+ return {
62
+ xs: XS_PREFIX + b64NoPad,
63
+ xt: String(ts),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Extract a1 cookie value from a Cookie header string.
69
+ *
70
+ * "web_session=abc; a1=18d6e123abc; xsec_token=xxx" → "18d6e123abc"
71
+ *
72
+ * Returns null when a1 not present.
73
+ */
74
+ function extractA1(cookie) {
75
+ if (typeof cookie !== "string") return null;
76
+ for (const part of cookie.split(";")) {
77
+ const trimmed = part.trim();
78
+ if (trimmed.startsWith("a1=")) {
79
+ const v = trimmed.substring(3);
80
+ return v.length > 0 ? v : null;
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+
86
+ module.exports = {
87
+ computeXsXt,
88
+ extractA1,
89
+ XS_PREFIX,
90
+ };
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 3c (Xhs C 路径 — 2026-05-25): API responses → snapshot JSON.
5
+ *
6
+ * Matches the existing `social-xiaohongshu` adapter's snapshot mode
7
+ * schema (schemaVersion=1). Kinds: note / liked / follow.
8
+ *
9
+ * Note: xhs userId is a Base64-ish string (e.g. "5e8c8f7e..."), not a
10
+ * numeric Long. The account.uid in the snapshot is set to userId
11
+ * verbatim (string passthrough); consumers shouldn't expect numeric uid.
12
+ */
13
+
14
+ const fs = require("node:fs");
15
+ const path = require("node:path");
16
+ const os = require("node:os");
17
+ const crypto = require("node:crypto");
18
+
19
+ const SNAPSHOT_SCHEMA_VERSION = 1;
20
+
21
+ function buildSnapshot(input) {
22
+ if (!input || typeof input !== "object") {
23
+ throw new TypeError("buildSnapshot: input must be an object");
24
+ }
25
+ const userId = input.userId;
26
+ if (typeof userId !== "string" || userId.length === 0) {
27
+ throw new TypeError("buildSnapshot: input.userId must be a non-empty string");
28
+ }
29
+ const snapshottedAt =
30
+ Number.isFinite(input.snapshottedAt) && input.snapshottedAt > 0
31
+ ? input.snapshottedAt
32
+ : Date.now();
33
+ const account = {
34
+ userId, // xhs userId is a string, not numeric
35
+ nickname: typeof input.nickname === "string" ? input.nickname : "",
36
+ };
37
+ const events = [];
38
+
39
+ // notes
40
+ const notes = Array.isArray(input.notes) ? input.notes : [];
41
+ notes.forEach((n, idx) => {
42
+ if (!n || typeof n !== "object") return;
43
+ events.push({
44
+ kind: "note",
45
+ id: n.noteId ? `note-${n.noteId}` : `note-${idx}`,
46
+ capturedAt:
47
+ typeof n.createdAt === "number" && n.createdAt > 0
48
+ ? n.createdAt
49
+ : snapshottedAt,
50
+ noteId: n.noteId || null,
51
+ title: n.title || null,
52
+ desc: n.desc || null,
53
+ type: n.type || "normal",
54
+ likedCount: typeof n.likedCount === "number" ? n.likedCount : 0,
55
+ collectedCount:
56
+ typeof n.collectedCount === "number" ? n.collectedCount : 0,
57
+ commentCount: typeof n.commentCount === "number" ? n.commentCount : 0,
58
+ });
59
+ });
60
+
61
+ // liked
62
+ const liked = Array.isArray(input.liked) ? input.liked : [];
63
+ liked.forEach((l, idx) => {
64
+ if (!l || typeof l !== "object") return;
65
+ events.push({
66
+ kind: "liked",
67
+ id: l.noteId ? `liked-${l.noteId}` : `liked-${idx}`,
68
+ // xhs doesn't return liked_at — use snapshottedAt
69
+ capturedAt: snapshottedAt,
70
+ noteId: l.noteId || null,
71
+ title: l.title || null,
72
+ authorNickname: l.authorNickname || null,
73
+ });
74
+ });
75
+
76
+ // follows
77
+ const follows = Array.isArray(input.follows) ? input.follows : [];
78
+ follows.forEach((f, idx) => {
79
+ if (!f || typeof f !== "object") return;
80
+ events.push({
81
+ kind: "follow",
82
+ id: f.userId ? `follow-${f.userId}` : `follow-${idx}`,
83
+ capturedAt: snapshottedAt,
84
+ userId: f.userId || null,
85
+ nickname: f.nickname || null,
86
+ image: f.image || null,
87
+ });
88
+ });
89
+
90
+ return {
91
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
92
+ snapshottedAt,
93
+ account,
94
+ events,
95
+ };
96
+ }
97
+
98
+ function writeSnapshotJson(snapshot, opts = {}) {
99
+ const dir = opts.dir || os.tmpdir();
100
+ const fileName =
101
+ opts.fileName || `cc-xhs-snapshot-${crypto.randomUUID()}.json`;
102
+ if (fileName.includes("/") || fileName.includes("\\")) {
103
+ throw new Error(
104
+ "writeSnapshotJson: opts.fileName must be a basename, not a path",
105
+ );
106
+ }
107
+ const full = path.join(dir, fileName);
108
+ fs.writeFileSync(full, JSON.stringify(snapshot), "utf-8");
109
+ return full;
110
+ }
111
+
112
+ function cleanupSnapshotJson(filePath) {
113
+ if (!filePath) return;
114
+ try {
115
+ fs.unlinkSync(filePath);
116
+ } catch (_e) {
117
+ // ignore
118
+ }
119
+ }
120
+
121
+ module.exports = {
122
+ buildSnapshot,
123
+ writeSnapshotJson,
124
+ cleanupSnapshotJson,
125
+ SNAPSHOT_SCHEMA_VERSION,
126
+ };