nolo-cli 0.1.41 → 0.1.43

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 (136) hide show
  1. package/README.md +27 -12
  2. package/agent-runtime/agentThread.ts +222 -0
  3. package/agent-runtime/agentThreadAdmission.ts +74 -0
  4. package/agent-runtime/dialogWritePlan.ts +42 -0
  5. package/agent-runtime/hostAdapter.ts +1 -0
  6. package/agent-runtime/index.ts +43 -0
  7. package/agent-runtime/localLoop.ts +31 -2
  8. package/agent-runtime/localToolPolicy.ts +1 -0
  9. package/agent-runtime/localWorkspaceTools.ts +1258 -78
  10. package/agent-runtime/noloWorkspaceTools.ts +20 -1
  11. package/agent-runtime/runtimeToolPolicy.ts +1 -5
  12. package/agent-runtime/runtimeToolSurface.ts +1 -0
  13. package/agentAliases.ts +29 -9
  14. package/agentInternalCommandEntries.ts +11 -3
  15. package/agentMachineCommands.ts +1 -0
  16. package/agentRecordCommands.ts +2 -2
  17. package/agentRecordHelpers.ts +59 -6
  18. package/agentRunCommand.ts +84 -19
  19. package/ai/agent/buildSystemPrompt.ts +14 -0
  20. package/ai/agent/cliExecutor.ts +202 -43
  21. package/ai/agent/createAgentSchema.ts +2 -2
  22. package/ai/agent/liveAudioModel.ts +1 -1
  23. package/ai/agent/streamAgentChatTurn.ts +154 -20
  24. package/ai/agent/streamAgentChatTurnUtils.ts +63 -0
  25. package/ai/ai.locale.ts +70 -0
  26. package/ai/chat/inlineImageUrlsForCustomProvider.ts +7 -2
  27. package/ai/chat/sendOpenAICompletionsRequest.ts +25 -1
  28. package/ai/llm/kimi.ts +0 -1
  29. package/ai/llm/openrouterModels.ts +27 -2
  30. package/ai/memory/policy.ts +49 -0
  31. package/ai/memory/queryShared.ts +10 -4
  32. package/ai/memory/recentRelationshipRecap.ts +4 -0
  33. package/ai/memory/remember.ts +48 -104
  34. package/ai/memory/scope.ts +74 -0
  35. package/ai/memory/types.ts +6 -0
  36. package/ai/memory/understanding.ts +10 -18
  37. package/ai/policy/runtimePolicy.ts +95 -23
  38. package/ai/tools/agent/agentTools.ts +0 -21
  39. package/ai/tools/agent/startAgentDialogTool.ts +2 -1
  40. package/ai/tools/index.ts +2 -20
  41. package/ai/tools/prepareTools.ts +171 -1
  42. package/ai/tools/readXhsProfileTool.ts +80 -69
  43. package/ai/tools/table/createTableTool.ts +11 -0
  44. package/ai/tools/toolPacks.ts +19 -0
  45. package/ai/types.ts +3 -0
  46. package/app/favorite/deletedFavoriteProjection.ts +31 -0
  47. package/app/hooks/deleteDbKey.ts +24 -3
  48. package/app/i18n/translations/interface.locale.ts +48 -16
  49. package/app/types/appSummary.ts +4 -0
  50. package/app/utils/desktopAgentRuntimeTurnClient.ts +2 -0
  51. package/auth/adminPermissions.ts +64 -4
  52. package/auth/routes.ts +5 -0
  53. package/chat/chat.locale.ts +4 -0
  54. package/chat/dialog/actions/handleSendMessageAction.ts +7 -2
  55. package/chat/dialog/dialogAttachmentCleanup.ts +135 -0
  56. package/chat/dialog/dialogSlice.ts +117 -4
  57. package/chat/messages/activityCompletion.ts +97 -0
  58. package/chat/messages/imagePayloadPersistence.ts +22 -0
  59. package/chat/messages/messageContent.ts +20 -16
  60. package/chat/messages/messageSlice.ts +20 -1
  61. package/chat/messages/toolThunks.ts +32 -11
  62. package/chat/messages/types.ts +8 -1
  63. package/chat/messages/web/toolDisplayName.ts +686 -0
  64. package/cli/agentAliases.ts +29 -9
  65. package/cli/agentInternalCommandEntries.ts +11 -3
  66. package/cli/agentMachineCommands.ts +1 -0
  67. package/cli/agentRecordCommands.ts +2 -2
  68. package/cli/agentRecordHelpers.ts +59 -6
  69. package/cli/agentRunCommand.ts +84 -19
  70. package/cli/client/agentRun.ts +65 -253
  71. package/cli/client/localRuntimeAdapter.ts +179 -53
  72. package/cli/commandRegistry.ts +7 -4
  73. package/cli/dialogAttachmentCleanup.ts +151 -0
  74. package/cli/dialogCommands.ts +163 -61
  75. package/cli/dialogInternalCommandEntries.ts +1 -1
  76. package/cli/docCreateCommands.ts +345 -0
  77. package/cli/docInternalCommandEntries.ts +11 -0
  78. package/cli/docPageHelpers.ts +80 -0
  79. package/cli/docSkillHelpers.ts +67 -0
  80. package/cli/docSpaceHelpers.ts +46 -0
  81. package/cli/internalCommandEntries.ts +4 -0
  82. package/cli/machineCommands.ts +18 -34
  83. package/cli/memoryCommands.ts +253 -0
  84. package/cli/memoryInternalCommandEntries.ts +9 -0
  85. package/cli/qoderUsageProbe.ts +252 -0
  86. package/cli/scriptCommandEntries.ts +0 -3
  87. package/cli/secretScan.ts +31 -0
  88. package/cli/tableCommands.ts +248 -13
  89. package/cli/workflowInternalCommandEntries.ts +2 -3
  90. package/client/agentRun.test.ts +68 -84
  91. package/client/agentRun.ts +65 -253
  92. package/client/localRuntimeAdapter.test.ts +505 -74
  93. package/client/localRuntimeAdapter.ts +179 -53
  94. package/commandRegistry.ts +7 -4
  95. package/connector-experimental/capabilities.ts +10 -9
  96. package/create/space/sidebarVisibleTypes.ts +19 -0
  97. package/dialogAttachmentCleanup.ts +151 -0
  98. package/dialogCommands.ts +163 -61
  99. package/dialogInternalCommandEntries.ts +1 -1
  100. package/docCreateCommands.ts +345 -0
  101. package/docInternalCommandEntries.ts +11 -0
  102. package/docPageHelpers.ts +80 -0
  103. package/docSkillHelpers.ts +67 -0
  104. package/docSpaceHelpers.ts +46 -0
  105. package/integrations/google/models.ts +4 -4
  106. package/integrations/openai/generateOpenAIRequestBody.ts +0 -3
  107. package/integrations/xhs-reader/types.ts +36 -3
  108. package/integrations/xhs-reader/url.ts +24 -1
  109. package/internalCommandEntries.ts +4 -0
  110. package/machineCommands.ts +18 -34
  111. package/memoryCommands.ts +253 -0
  112. package/memoryInternalCommandEntries.ts +9 -0
  113. package/package.json +141 -114
  114. package/qoderUsageProbe.ts +252 -0
  115. package/render/contentIcon/types.ts +167 -0
  116. package/render/page/docSlice.ts +30 -3
  117. package/render/page/types.ts +2 -0
  118. package/render/table/createTableAction.ts +4 -0
  119. package/render/table/tableSlice.ts +61 -0
  120. package/render/table/types.ts +10 -0
  121. package/scriptCommandEntries.ts +0 -3
  122. package/secretScan.ts +31 -0
  123. package/server/handlers/agentRun/types.ts +24 -23
  124. package/tableCommands.ts +248 -13
  125. package/workflowInternalCommandEntries.ts +2 -3
  126. package/ai/tools/agent/taskRunTool.ts +0 -112
  127. package/cli/client/taskRunPrompt.ts +0 -27
  128. package/cli/taskRunCommand.ts +0 -265
  129. package/client/taskRunPrompt.ts +0 -27
  130. package/integrations/xhs-reader/analyze/profileAnalyzer.ts +0 -205
  131. package/integrations/xhs-reader/backends/playwrightProfileCollector.ts +0 -624
  132. package/integrations/xhs-reader/bridge/readXhsProfileWithBridge.ts +0 -227
  133. package/integrations/xhs-reader/client/xhsWebApiClient.ts +0 -229
  134. package/integrations/xhs-reader/normalize.ts +0 -390
  135. package/integrations/xhs-reader/orchestrator.ts +0 -178
  136. package/taskRunCommand.ts +0 -265
@@ -1,624 +0,0 @@
1
- /**
2
- * Playwright-based XHS profile collector.
3
- *
4
- * Opens a profile URL in a desktop browser, reads window.__INITIAL_STATE__,
5
- * scrolls to trigger pagination via real user_posted responses,
6
- * and captures + normalizes all note data.
7
- *
8
- * Supports dependency injection of a Page-like object for unit testing.
9
- */
10
-
11
- import { normalizeProfileState, normalizeProfileNote } from "../normalize";
12
- import { redactXhsSensitiveValue } from "../redaction";
13
- import type { XhsProfile, XhsNoteSummary, XhsCollectorDiagnostic, XhsFailureCode } from "../types";
14
-
15
- // Minimal page interface for dependency injection
16
- export interface XhsPageLike {
17
- goto(url: string, opts?: { waitUntil?: string }): Promise<void>;
18
- evaluate<T>(fn: () => T): Promise<T>;
19
- waitForResponse(
20
- fn: (resp: { url: () => string; ok: () => boolean }) => boolean,
21
- opts?: { timeout?: number },
22
- ): Promise<{ json: () => Promise<unknown> }>;
23
- on(event: string, handler: (...args: unknown[]) => void): void;
24
- /** Current page URL. Available on real Playwright pages. */
25
- url?(): string;
26
- /** Page title. Available on real Playwright pages. */
27
- title?(): Promise<string>;
28
- close(): Promise<void>;
29
- }
30
-
31
- export interface CollectProfilePageResult {
32
- profile: XhsProfile;
33
- notes: XhsNoteSummary[];
34
- diagnostic?: XhsCollectorDiagnostic;
35
- }
36
-
37
- interface InternalState {
38
- noteMap: Map<string, XhsNoteSummary>;
39
- profile: XhsProfile | null;
40
- }
41
-
42
- function unwrapReactiveValue(value: unknown): unknown {
43
- if (!value || typeof value !== "object") return value;
44
- const record = value as Record<string, unknown>;
45
- if ("_value" in record || "_rawValue" in record) {
46
- return record._value ?? record._rawValue;
47
- }
48
- return value;
49
- }
50
-
51
- /**
52
- * Detect whether the page shows a login wall or captcha.
53
- * Returns a diagnostic code + evidence, or null if the page looks normal.
54
- */
55
- function detectPageBlockers(
56
- pageUrl: string,
57
- pageTitle: string,
58
- ): { code: XhsFailureCode; reason: string } | null {
59
- const lowerUrl = pageUrl.toLowerCase();
60
- const lowerTitle = pageTitle.toLowerCase();
61
-
62
- // Login redirect detection
63
- if (
64
- lowerUrl.includes("/login") ||
65
- lowerUrl.includes("loginpage") ||
66
- lowerUrl.includes("passport.xiaohongshu.com")
67
- ) {
68
- return { code: "login_required", reason: "page redirected to login URL" };
69
- }
70
-
71
- // Title-based login detection
72
- if (
73
- lowerTitle.includes("登录") ||
74
- lowerTitle.includes("login") ||
75
- lowerTitle.includes("sign in")
76
- ) {
77
- return { code: "login_required", reason: "page title indicates login wall" };
78
- }
79
-
80
- // Captcha / verification detection
81
- if (
82
- lowerTitle.includes("验证") ||
83
- lowerTitle.includes("captcha") ||
84
- lowerTitle.includes("verify") ||
85
- lowerTitle.includes("安全验证")
86
- ) {
87
- return { code: "blocked", reason: "page title indicates captcha/verification wall" };
88
- }
89
-
90
- return null;
91
- }
92
-
93
- /**
94
- * Probe __INITIAL_STATE__ for note-like data to count how many notes
95
- * are visible in the initial page load.
96
- */
97
- function countNotesInState(state: unknown): number {
98
- if (!state || typeof state !== "object") return 0;
99
- const s = state as Record<string, unknown>;
100
- const userContainer = s.user as Record<string, unknown> | undefined;
101
-
102
- // Real XHS shape: state.user.notes[0]
103
- const userNotes = unwrapReactiveValue(userContainer?.notes) as unknown[] | undefined;
104
- if (Array.isArray(userNotes) && userNotes.length > 0) {
105
- const firstPage = userNotes[0];
106
- if (Array.isArray(firstPage)) return firstPage.length;
107
- }
108
-
109
- // Alternate: state.user.userPageData.notes / noteList / choosedNotes
110
- const userPageData = unwrapReactiveValue(userContainer?.userPageData ?? s.userPageData) as
111
- | Record<string, unknown>
112
- | undefined;
113
- if (userPageData) {
114
- const lists = ["notes", "noteList", "choosedNotes"] as const;
115
- for (const key of lists) {
116
- const arr = userPageData[key];
117
- if (Array.isArray(arr) && arr.length > 0) return arr.length;
118
- }
119
- }
120
-
121
- return 0;
122
- }
123
-
124
- /**
125
- * Collect profile and notes from a XHS profile page using Playwright.
126
- *
127
- * @param page - A Playwright Page or compatible object.
128
- * @param profileUrl - The full XHS profile URL.
129
- * @param maxScrollPages - Max number of scroll/pagination pages to collect.
130
- * @returns Normalized profile + notes list + optional diagnostic.
131
- */
132
- export async function collectProfilePage(
133
- page: XhsPageLike,
134
- profileUrl: string,
135
- maxScrollPages: number = 5,
136
- ): Promise<CollectProfilePageResult> {
137
- const collectorState: InternalState = {
138
- noteMap: new Map(),
139
- profile: null,
140
- };
141
-
142
- const diagnostic: XhsCollectorDiagnostic = {
143
- code: "unknown",
144
- message: "",
145
- };
146
-
147
- // Set up response listener before navigation to capture user_posted responses
148
- const capturedResponses: Array<{ url: string; status: number; json: () => Promise<unknown> }> = [];
149
- const responseHandler = (resp: unknown) => {
150
- const r = resp as {
151
- url: () => string;
152
- ok: () => boolean;
153
- status: () => number;
154
- json: () => Promise<unknown>;
155
- };
156
- if (
157
- r.url().includes("/api/sns/web/v1/user_posted") &&
158
- r.ok()
159
- ) {
160
- capturedResponses.push({
161
- url: r.url(),
162
- status: r.status(),
163
- json: r.json.bind(r),
164
- });
165
- }
166
- };
167
-
168
- // Some Page implementations use page.on, others need different setup
169
- try {
170
- page.on("response", responseHandler);
171
- } catch {
172
- // If on() is not available, we rely on __INITIAL_STATE__ only
173
- }
174
-
175
- // Navigate to the profile page
176
- await page.goto(profileUrl, { waitUntil: "networkidle" });
177
-
178
- // Capture page state for diagnostics
179
- const finalUrl = page.url?.() ?? profileUrl;
180
- let pageTitle = "";
181
- try {
182
- pageTitle = page.title ? await page.title() : "";
183
- } catch {
184
- // title() may not be available in all test mocks
185
- }
186
-
187
- diagnostic.finalUrl = redactXhsSensitiveValue(finalUrl) as string;
188
- diagnostic.pageTitle = pageTitle;
189
-
190
- // Check for login/captcha blockers
191
- const blocker = detectPageBlockers(finalUrl, pageTitle);
192
- if (blocker) {
193
- // Try to detect login state more precisely via page content
194
- const loginHint = await page.evaluate(() => {
195
- try {
196
- // Check for common XHS login indicators in the DOM
197
- const body = document.body?.innerText ?? "";
198
- const hasLoginText = /登录|login|sign.?in|手机号|验证码/i.test(body);
199
- // Check for login modal / overlay
200
- const loginModal = document.querySelector('[class*="login"]') ??
201
- document.querySelector('[class*="Login"]');
202
- return { hasLoginText, hasLoginModal: !!loginModal };
203
- } catch {
204
- return { hasLoginText: false, hasLoginModal: false };
205
- }
206
- }).catch(() => ({ hasLoginText: false, hasLoginModal: false }));
207
-
208
- diagnostic.loginDetected = loginHint?.hasLoginText || loginHint?.hasLoginModal;
209
- diagnostic.redirectedToLogin = finalUrl.toLowerCase().includes("login");
210
-
211
- return {
212
- profile: { userId: extractUserId(profileUrl), nickname: "" },
213
- notes: [],
214
- diagnostic: {
215
- ...diagnostic,
216
- code: blocker.code,
217
- message: blocker.reason +
218
- (diagnostic.loginDetected ? " (login UI detected on page)" : ""),
219
- },
220
- };
221
- }
222
-
223
- await waitForInitialProfileState(page, 15_000);
224
-
225
- // Read __INITIAL_STATE__ from the page
226
- const initialState = await readSerializableInitialState(page);
227
-
228
- diagnostic.initialStatePresent = !!initialState;
229
- diagnostic.initialStateNoteCount = countNotesInState(initialState);
230
-
231
- if (initialState) {
232
- const rawState = initialState as Record<string, unknown>;
233
-
234
- // Extract profile info — try real path (state.user.userPageData) first,
235
- // then flat path (state.userPageData) for backward compat with test fakes.
236
- const userContainer = rawState.user as Record<string, unknown> | undefined;
237
- const userPageData = unwrapReactiveValue(
238
- userContainer?.userPageData ?? rawState.userPageData,
239
- ) as Record<string, unknown> | undefined;
240
-
241
- if (userPageData) {
242
- const userId = extractUserId(profileUrl);
243
- collectorState.profile = normalizeProfileState(
244
- { userPageData: userPageData as any },
245
- userId,
246
- );
247
- }
248
-
249
- // Extract initial notes from __INITIAL_STATE__
250
- const noteList = extractNotesFromState(initialState);
251
- for (const note of noteList) {
252
- collectorState.noteMap.set(note.noteId, note);
253
- }
254
- }
255
-
256
- // If no profile from state, create a minimal one from URL
257
- if (!collectorState.profile) {
258
- const userId = extractUserId(profileUrl);
259
- collectorState.profile = { userId, nickname: "" };
260
- }
261
-
262
- // Scroll to trigger pagination
263
- let scrollCount = 0;
264
- let consecutiveEmpty = 0;
265
-
266
- while (scrollCount < maxScrollPages && consecutiveEmpty < 2) {
267
- const countBefore = collectorState.noteMap.size;
268
-
269
- // Scroll to bottom
270
- await page.evaluate(() => {
271
- window.scrollTo(0, document.body.scrollHeight);
272
- });
273
-
274
- // Wait for a user_posted response or timeout
275
- try {
276
- const resp = await page.waitForResponse(
277
- (r) =>
278
- r.url().includes("/api/sns/web/v1/user_posted") && r.ok(),
279
- { timeout: 8000 },
280
- );
281
-
282
- const body = await resp.json();
283
- const pageNotes = extractNotesFromPostedResponse(body);
284
- for (const note of pageNotes) {
285
- collectorState.noteMap.set(note.noteId, note);
286
- }
287
- } catch {
288
- // Timeout or error - may have no more pages
289
- }
290
-
291
- scrollCount++;
292
- if (collectorState.noteMap.size === countBefore) {
293
- consecutiveEmpty++;
294
- } else {
295
- consecutiveEmpty = 0;
296
- }
297
- }
298
-
299
- // Also process any captured responses we haven't handled
300
- for (const resp of capturedResponses) {
301
- try {
302
- const body = await resp.json();
303
- const pageNotes = extractNotesFromPostedResponse(body);
304
- for (const note of pageNotes) {
305
- collectorState.noteMap.set(note.noteId, note);
306
- }
307
- } catch {
308
- // ignore
309
- }
310
- }
311
-
312
- if (!collectorState.profile?.nickname || collectorState.noteMap.size === 0) {
313
- await waitForInitialProfileState(page, 12_000);
314
- const lateState = await readSerializableInitialState(page);
315
- if (lateState) {
316
- const rawState = lateState as Record<string, unknown>;
317
- const userContainer = rawState.user as Record<string, unknown> | undefined;
318
- const userPageData = unwrapReactiveValue(
319
- userContainer?.userPageData ?? rawState.userPageData,
320
- ) as Record<string, unknown> | undefined;
321
- if (userPageData && !collectorState.profile?.nickname) {
322
- const userId = extractUserId(profileUrl);
323
- collectorState.profile = normalizeProfileState(
324
- { userPageData: userPageData as any },
325
- userId,
326
- );
327
- }
328
- const lateNotes = extractNotesFromState(lateState);
329
- for (const note of lateNotes) {
330
- collectorState.noteMap.set(note.noteId, note);
331
- }
332
- diagnostic.initialStatePresent = true;
333
- diagnostic.initialStateNoteCount = Math.max(
334
- diagnostic.initialStateNoteCount ?? 0,
335
- countNotesInState(lateState),
336
- );
337
- }
338
- }
339
-
340
- diagnostic.capturedApiResponseCount = capturedResponses.length;
341
- if (capturedResponses.length > 0) {
342
- diagnostic.firstApiResponseStatus = capturedResponses[0].status;
343
- }
344
-
345
- const notes = Array.from(collectorState.noteMap.values());
346
-
347
- // Build diagnostic for empty results
348
- if (notes.length === 0) {
349
- // Check if the page has login indicators even without URL redirect
350
- const loginCheck = await page.evaluate(() => {
351
- try {
352
- const body = document.body?.innerText ?? "";
353
- return {
354
- hasLoginText: /登录|login|sign.?in|请先登录|未登录/i.test(body),
355
- hasLoginModal: !!(
356
- document.querySelector('[class*="login"]') ??
357
- document.querySelector('[class*="Login"]')
358
- ),
359
- bodyLength: body.length,
360
- };
361
- } catch {
362
- return { hasLoginText: false, hasLoginModal: false, bodyLength: 0 };
363
- }
364
- }).catch(() => ({ hasLoginText: false, hasLoginModal: false, bodyLength: 0 })) as { hasLoginText: boolean; hasLoginModal: boolean; bodyLength: number };
365
- const safeLoginCheck = loginCheck ?? { hasLoginText: false, hasLoginModal: false, bodyLength: 0 };
366
-
367
- diagnostic.loginDetected = safeLoginCheck.hasLoginText || safeLoginCheck.hasLoginModal;
368
-
369
- // Determine the appropriate diagnostic code
370
- let code: XhsFailureCode = "empty_profile_state";
371
- let message = "";
372
-
373
- if (diagnostic.loginDetected) {
374
- code = "login_required";
375
- message =
376
- "页面检测到登录提示,未获取到笔记。" +
377
- "请先用 headless=false + keepOpen=true 手动登录," +
378
- "登录后再次运行即可自动复用登录态。";
379
- } else if (!diagnostic.initialStatePresent) {
380
- code = "empty_profile_state";
381
- message =
382
- "页面未注入 __INITIAL_STATE__,可能页面未完全加载或被反爬拦截。" +
383
- "请尝试增加 timeoutMs 或使用本地持久 profile。";
384
- } else if (
385
- diagnostic.initialStatePresent &&
386
- (diagnostic.initialStateNoteCount ?? 0) === 0 &&
387
- diagnostic.capturedApiResponseCount === 0
388
- ) {
389
- code = "empty_profile_state";
390
- message =
391
- "__INITIAL_STATE__ 存在但未发现笔记数据,且未捕获到 user_posted API 响应。" +
392
- "可能该用户无公开笔记,或页面在 headless 环境下被限流。" +
393
- "建议用本地持久 profile 验证。";
394
- } else {
395
- code = "empty_profile_state";
396
- message = "收集完成但未获取到笔记数据。";
397
- }
398
-
399
- diagnostic.code = code;
400
- diagnostic.message = message;
401
- }
402
-
403
- return {
404
- profile: collectorState.profile,
405
- notes,
406
- diagnostic: notes.length === 0 ? diagnostic : undefined,
407
- };
408
- }
409
-
410
- async function readSerializableInitialState(page: XhsPageLike): Promise<unknown> {
411
- return page.evaluate(() => {
412
- const unwrap = (value: any): any =>
413
- value && typeof value === "object" && ("_value" in value || "_rawValue" in value)
414
- ? (value._value ?? value._rawValue)
415
- : value;
416
- const state = (window as any).__INITIAL_STATE__;
417
- if (!state) return null;
418
-
419
- const userPageData = unwrap(state.user?.userPageData ?? state.userPageData);
420
- const notes = unwrap(state.user?.notes);
421
- const firstPageNotes = Array.isArray(notes?.[0]) ? notes[0] : undefined;
422
-
423
- return {
424
- user: {
425
- userPageData,
426
- notes: firstPageNotes ? [firstPageNotes] : notes,
427
- },
428
- userPageData,
429
- note: unwrap(state.note),
430
- feed: {
431
- feeds: unwrap(state.feed?.feeds),
432
- items: unwrap(state.feed?.items),
433
- },
434
- };
435
- }).catch(() => null);
436
- }
437
-
438
- async function waitForInitialProfileState(page: XhsPageLike, timeoutMs: number): Promise<"ready" | "mock_skip" | "timeout"> {
439
- const started = Date.now();
440
- while (Date.now() - started < timeoutMs) {
441
- const ready = await page.evaluate(() => {
442
- try {
443
- const unwrap = (value: any): any =>
444
- value && typeof value === "object" && ("_value" in value || "_rawValue" in value)
445
- ? (value._value ?? value._rawValue)
446
- : value;
447
- const state = (window as any).__INITIAL_STATE__;
448
- const userPageData = unwrap(state?.user?.userPageData ?? state?.userPageData);
449
- const notes = unwrap(state?.user?.notes);
450
- const firstPage = Array.isArray(notes?.[0]) ? notes[0] : undefined;
451
- const hasProfile = Boolean(userPageData?.basicInfo);
452
- const hasNotes = Array.isArray(firstPage) && firstPage.length > 0;
453
- return hasProfile || hasNotes;
454
- } catch {
455
- return false;
456
- }
457
- }).catch(() => false);
458
- if (ready == null) return "mock_skip";
459
- if (ready) return "ready";
460
- await new Promise((resolve) => setTimeout(resolve, 500));
461
- }
462
- return "timeout";
463
- }
464
-
465
- /**
466
- * Extract notes from __INITIAL_STATE__ object.
467
- * Tries multiple known paths:
468
- * - Real XHS shape: state.user.notes[0] (first page note array)
469
- * - Real XHS shape: state.user.userPageData.notes (alternate)
470
- * - Flat test shape: state.userPageData.notes / noteList / choosedNotes
471
- */
472
- function extractNotesFromState(state: unknown): XhsNoteSummary[] {
473
- const s = state as Record<string, unknown>;
474
- const userContainer = s.user as Record<string, unknown> | undefined;
475
-
476
- // Real XHS shape: state.user.notes[0] is the first-page note array
477
- const userNotes = unwrapReactiveValue(userContainer?.notes) as unknown[] | undefined;
478
- if (Array.isArray(userNotes) && userNotes.length > 0) {
479
- const firstPage = userNotes[0];
480
- if (Array.isArray(firstPage)) {
481
- return normalizeNotesList(firstPage as Array<Record<string, unknown>>);
482
- }
483
- }
484
-
485
- // Real XHS shape: state.user.userPageData.notes
486
- const userPageData = unwrapReactiveValue(userContainer?.userPageData ?? s.userPageData) as
487
- | Record<string, unknown>
488
- | undefined;
489
- if (userPageData) {
490
- const noteList = (userPageData.notes ??
491
- userPageData.noteList ??
492
- userPageData.choosedNotes ??
493
- []) as Array<Record<string, unknown>>;
494
- if (noteList.length > 0) {
495
- return normalizeNotesList(noteList);
496
- }
497
- }
498
-
499
- // Additional: try state.note.noteList (some XHS versions)
500
- const noteContainer = unwrapReactiveValue(s.note) as Record<string, unknown> | undefined;
501
- if (noteContainer) {
502
- const noteList = (noteContainer.noteList ?? noteContainer.notes ?? []) as Array<Record<string, unknown>>;
503
- if (Array.isArray(noteList) && noteList.length > 0) {
504
- return normalizeNotesList(noteList);
505
- }
506
- }
507
-
508
- // Additional: try state.feed.feeds (some XHS SPA versions)
509
- const feedContainer = unwrapReactiveValue(s.feed) as Record<string, unknown> | undefined;
510
- if (feedContainer) {
511
- const feeds = unwrapReactiveValue(feedContainer.feeds ?? feedContainer.items);
512
- if (Array.isArray(feeds) && feeds.length > 0) {
513
- return normalizeNotesList(feeds as Array<Record<string, unknown>>);
514
- }
515
- }
516
-
517
- return [];
518
- }
519
-
520
- /**
521
- * Shared normalization for a raw notes list (works for both INITIAL_STATE
522
- * and paginated user_posted responses).
523
- */
524
- function normalizeNotesList(rawNotes: Array<Record<string, unknown>>): XhsNoteSummary[] {
525
- return rawNotes
526
- .map((raw) => normalizeProfileNote(unwrapNoteWrapper(raw) as any))
527
- .filter((n) => n.noteId);
528
- }
529
-
530
- function unwrapNoteWrapper(raw: Record<string, unknown>): Record<string, unknown> {
531
- const value = unwrapReactiveValue(raw) as Record<string, unknown>;
532
- const noteCard = unwrapReactiveValue(
533
- value.note_card ?? value.noteCard ?? value.note ?? value,
534
- ) as Record<string, unknown>;
535
- const inner = unwrapReactiveValue(
536
- noteCard.note_card ?? noteCard.noteCard ?? noteCard,
537
- ) as Record<string, unknown>;
538
- if (inner === value) return value;
539
- return {
540
- ...inner,
541
- id: inner.id ?? value.id,
542
- noteId: inner.noteId ?? value.noteId,
543
- note_id: inner.note_id ?? value.note_id,
544
- xsecToken: inner.xsecToken ?? value.xsecToken,
545
- xsec_token: inner.xsec_token ?? value.xsec_token,
546
- };
547
- }
548
-
549
- /**
550
- * Extract notes from a user_posted API response body.
551
- * Supports both camelCase and snake_case shapes directly through normalizeProfileNote.
552
- * Also handles nested wrappers like data.items[].note_card.
553
- */
554
- function extractNotesFromPostedResponse(body: unknown): XhsNoteSummary[] {
555
- const b = body as Record<string, unknown>;
556
- const data = (b.data ?? b) as Record<string, unknown>;
557
-
558
- // Direct data.notes path
559
- const notes = data.notes as Array<Record<string, unknown>> | undefined;
560
- if (notes && notes.length > 0) {
561
- return normalizeNotesList(notes);
562
- }
563
-
564
- // data.items path (some API versions wrap notes in items)
565
- const items = data.items as Array<Record<string, unknown>> | undefined;
566
- if (items && items.length > 0) {
567
- // Items may be note objects directly or wrapped in various wrappers:
568
- // { note_card: { ... } } — snake_case (real API)
569
- // { noteCard: { ... } } — camelCase
570
- // { note: { ... } } — older shape
571
- // { note_card: { note_card: … }} — doubly-nested snake_case
572
- // { noteCard: { noteCard: … }} — doubly-nested camelCase
573
- return normalizeNotesList(items);
574
- }
575
-
576
- return [];
577
- }
578
-
579
- /**
580
- * Extract userId from a profile URL.
581
- */
582
- function extractUserId(url: string): string {
583
- const match = url.match(/\/profile\/([a-f0-9]{24})/i);
584
- return match ? match[1] : "";
585
- }
586
-
587
- /**
588
- * Create a Playwright browser context with XHS-compatible desktop viewport
589
- * and user agent.
590
- */
591
- export async function createXhsBrowserContext(
592
- playwright: {
593
- chromium: {
594
- launch: (opts: Record<string, unknown>) => Promise<{
595
- newContext: (opts: Record<string, unknown>) => Promise<{
596
- newPage: () => Promise<XhsPageLike>;
597
- }>;
598
- }>;
599
- };
600
- },
601
- opts: {
602
- headless?: boolean;
603
- userDataDir?: string;
604
- cookies?: Array<{ name: string; value: string; domain: string; path?: string }>;
605
- } = {},
606
- ) {
607
- const browser = await playwright.chromium.launch({
608
- headless: opts.headless ?? true,
609
- args: ["--disable-blink-features=AutomationControlled"],
610
- });
611
-
612
- const context = await browser.newContext({
613
- viewport: { width: 1440, height: 900 },
614
- userAgent:
615
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
616
- locale: "zh-CN",
617
- });
618
-
619
- if (opts.cookies && opts.cookies.length > 0) {
620
- await (context as any).addCookies(opts.cookies);
621
- }
622
-
623
- return context;
624
- }