@web-auto/webauto 0.1.18 → 0.1.19

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 (65) hide show
  1. package/README.md +122 -53
  2. package/apps/desktop-console/dist/main/index.mjs +227 -12
  3. package/apps/desktop-console/dist/renderer/index.js +237 -8
  4. package/apps/desktop-console/entry/ui-cli.mjs +282 -16
  5. package/apps/desktop-console/entry/ui-console.mjs +46 -15
  6. package/apps/webauto/entry/account.mjs +126 -27
  7. package/apps/webauto/entry/lib/account-detect.mjs +399 -9
  8. package/apps/webauto/entry/lib/account-store.mjs +201 -109
  9. package/apps/webauto/entry/lib/iflow-reply.mjs +194 -0
  10. package/apps/webauto/entry/lib/profile-policy.mjs +48 -0
  11. package/apps/webauto/entry/lib/profilepool.mjs +12 -0
  12. package/apps/webauto/entry/lib/schedule-store.mjs +29 -2
  13. package/apps/webauto/entry/lib/session-init.mjs +227 -0
  14. package/apps/webauto/entry/lib/upgrade-check.mjs +269 -0
  15. package/apps/webauto/entry/lib/xhs-unified-blocks.mjs +160 -0
  16. package/apps/webauto/entry/lib/xhs-unified-output-blocks.mjs +83 -0
  17. package/apps/webauto/entry/lib/xhs-unified-plan-blocks.mjs +55 -0
  18. package/apps/webauto/entry/lib/xhs-unified-profile-blocks.mjs +542 -0
  19. package/apps/webauto/entry/lib/xhs-unified-runtime-blocks.mjs +436 -0
  20. package/apps/webauto/entry/profilepool.mjs +56 -9
  21. package/apps/webauto/entry/smart-reply-cli.mjs +267 -0
  22. package/apps/webauto/entry/weibo-unified.mjs +84 -11
  23. package/apps/webauto/entry/xhs-orchestrate.mjs +43 -1
  24. package/apps/webauto/entry/xhs-unified.mjs +92 -997
  25. package/bin/webauto.mjs +22 -4
  26. package/dist/modules/camo-backend/src/index.js +33 -0
  27. package/dist/modules/camo-backend/src/internal/BrowserSession.js +232 -49
  28. package/dist/modules/camo-backend/src/internal/engine-manager.js +14 -13
  29. package/dist/modules/camo-backend/src/internal/ws-server.js +16 -19
  30. package/dist/modules/camo-runtime/src/utils/browser-service.mjs +38 -6
  31. package/dist/modules/workflow/blocks/EnsureSession.js +0 -8
  32. package/dist/modules/workflow/blocks/WeiboCollectFromLinksBlock.js +78 -6
  33. package/dist/modules/workflow/blocks/WeiboCollectSearchLinksBlock.js +266 -192
  34. package/dist/modules/workflow/definitions/weibo-search-workflow-v1.js +2 -0
  35. package/dist/modules/workflow/src/runner.js +2 -0
  36. package/dist/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.js +150 -37
  37. package/dist/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.js +491 -0
  38. package/modules/camo-backend/src/index.ts +31 -0
  39. package/modules/camo-backend/src/internal/BrowserSession.ts +224 -53
  40. package/modules/camo-backend/src/internal/engine-manager.ts +14 -15
  41. package/modules/camo-backend/src/internal/ws-server.ts +17 -17
  42. package/modules/camo-runtime/src/autoscript/action-providers/xhs/common.mjs +12 -2
  43. package/modules/camo-runtime/src/autoscript/action-providers/xhs/persistence.mjs +57 -0
  44. package/modules/camo-runtime/src/autoscript/action-providers/xhs.mjs +2475 -243
  45. package/modules/camo-runtime/src/autoscript/runtime.mjs +35 -30
  46. package/modules/camo-runtime/src/autoscript/xhs-unified-template.mjs +80 -443
  47. package/modules/camo-runtime/src/container/runtime-core/checkpoint.mjs +39 -6
  48. package/modules/camo-runtime/src/container/runtime-core/operations/index.mjs +206 -39
  49. package/modules/camo-runtime/src/container/runtime-core/operations/tab-pool.mjs +0 -79
  50. package/modules/camo-runtime/src/container/runtime-core/operations/viewport.mjs +46 -0
  51. package/modules/camo-runtime/src/utils/browser-service.mjs +41 -6
  52. package/modules/camo-runtime/src/utils/js-policy.mjs +28 -0
  53. package/modules/workflow/blocks/EnsureSession.ts +0 -4
  54. package/modules/workflow/blocks/WeiboCollectFromLinksBlock.ts +81 -6
  55. package/modules/workflow/blocks/WeiboCollectSearchLinksBlock.ts +316 -0
  56. package/modules/workflow/definitions/weibo-search-workflow-v1.ts +2 -0
  57. package/modules/workflow/src/runner.ts +2 -0
  58. package/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.ts +198 -53
  59. package/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.ts +706 -0
  60. package/package.json +2 -2
  61. package/modules/camo-runtime/src/autoscript/action-providers/xhs/comments.mjs +0 -498
  62. package/modules/camo-runtime/src/autoscript/action-providers/xhs/detail.mjs +0 -181
  63. package/modules/camo-runtime/src/autoscript/action-providers/xhs/interaction.mjs +0 -691
  64. package/modules/camo-runtime/src/autoscript/action-providers/xhs/search.mjs +0 -388
  65. package/modules/camo-runtime/src/container/runtime-core/operations/selector-scripts.mjs +0 -135
@@ -1,29 +1,66 @@
1
1
  import path from 'node:path';
2
2
  import fsp from 'node:fs/promises';
3
- import { asErrorPayload } from '../../container/runtime-core/utils.mjs';
3
+ import { asErrorPayload, normalizeArray } from '../../container/runtime-core/utils.mjs';
4
+ import { callAPI } from '../../utils/browser-service.mjs';
4
5
  import {
5
- createEvaluateHandler,
6
6
  extractEvaluateResultData,
7
- evaluateWithScript,
7
+ extractScreenshotBase64,
8
8
  runEvaluateScript,
9
9
  } from './xhs/common.mjs';
10
- import { buildCommentsHarvestScript, buildCommentMatchScript } from './xhs/comments.mjs';
11
10
  import {
12
- buildCloseDetailScript,
13
- buildDetailHarvestScript,
14
- buildExpandRepliesScript,
15
- } from './xhs/detail.mjs';
16
- import {
17
- buildCommentReplyScript,
18
- executeCommentLikeOperation,
19
- } from './xhs/interaction.mjs';
11
+ compileLikeRules,
12
+ matchLikeText,
13
+ normalizeText,
14
+ } from './xhs/like-rules.mjs';
20
15
  import {
16
+ appendLikedSignature,
17
+ ensureDir,
18
+ loadLikedSignatures,
19
+ makeLikeSignature,
20
+ mergeLinksJsonl,
21
21
  mergeCommentsJsonl,
22
22
  resolveXhsOutputContext,
23
+ savePngBase64,
24
+ writeJsonFile,
23
25
  } from './xhs/persistence.mjs';
24
- import { buildOpenDetailScript, buildSubmitSearchScript } from './xhs/search.mjs';
25
26
 
26
27
  const XHS_OPERATION_LOCKS = new Map();
28
+ const XHS_PROFILE_STATE = new Map();
29
+
30
+ function defaultProfileState() {
31
+ return {
32
+ keyword: null,
33
+ currentNoteId: null,
34
+ currentHref: null,
35
+ lastListUrl: null,
36
+ visitedNoteIds: [],
37
+ preCollectedNoteIds: [],
38
+ preCollectedAt: null,
39
+ maxNotes: 0,
40
+ currentComments: [],
41
+ matchedComments: [],
42
+ matchRule: null,
43
+ lastCommentsHarvest: null,
44
+ lastDetail: null,
45
+ lastReply: null,
46
+ metrics: {
47
+ searchCount: 0,
48
+ rollbackCount: 0,
49
+ returnToSearchCount: 0,
50
+ lastSearchAt: null,
51
+ lastRollbackAt: null,
52
+ lastReturnToSearchAt: null,
53
+ },
54
+ };
55
+ }
56
+
57
+ function getProfileState(profileId) {
58
+ const key = String(profileId || '').trim() || 'default';
59
+ if (!XHS_PROFILE_STATE.has(key)) {
60
+ XHS_PROFILE_STATE.set(key, defaultProfileState());
61
+ }
62
+ return XHS_PROFILE_STATE.get(key);
63
+ }
27
64
 
28
65
  function emitOperationProgress(context, payload = {}) {
29
66
  const emit = context?.emitProgress;
@@ -94,6 +131,15 @@ function normalizeNoteIdList(items) {
94
131
  return out;
95
132
  }
96
133
 
134
+ function extractNoteIdFromHref(href) {
135
+ const text = String(href || '').trim();
136
+ if (!text) return '';
137
+ const match = text.match(/\/explore\/([^/?#]+)/);
138
+ if (match && match[1]) return String(match[1]).trim();
139
+ const seg = text.split('/').filter(Boolean).pop() || '';
140
+ return String(seg).split('?')[0].split('#')[0].trim();
141
+ }
142
+
97
143
  function resolveSharedClaimPath(params = {}) {
98
144
  const raw = String(params.sharedHarvestPath || params.sharedClaimPath || '').trim();
99
145
  return raw ? path.resolve(raw) : '';
@@ -137,229 +183,936 @@ async function saveSharedClaimDoc(filePath, doc) {
137
183
  await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
138
184
  }
139
185
 
140
- async function executeSubmitSearchOperation({
141
- profileId,
142
- params = {},
143
- context = {},
144
- }) {
145
- const script = buildSubmitSearchScript(params);
146
- const highlight = params.highlight !== false;
147
- const lockKey = resolveSearchLockKey(params);
148
- return withSerializedLock(lockKey ? `xhs_submit_search:${lockKey}` : '', async () => {
149
- const operationResult = await evaluateWithScript({
150
- profileId,
151
- script,
152
- message: 'xhs_submit_search done',
153
- highlight,
186
+ function clamp(value, min, max) {
187
+ return Math.min(Math.max(value, min), max);
188
+ }
189
+
190
+ function randomBetween(min, max) {
191
+ const lo = Math.max(0, Math.floor(Number(min) || 0));
192
+ const hi = Math.max(lo, Math.floor(Number(max) || 0));
193
+ if (hi <= lo) return lo;
194
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
195
+ }
196
+
197
+ function buildTraceRecorder() {
198
+ const actionTrace = [];
199
+ const pushTrace = (payload) => {
200
+ actionTrace.push({
201
+ ts: new Date().toISOString(),
202
+ ...payload,
154
203
  });
155
- const payload = extractEvaluateResultData(operationResult.data) || {};
156
- const actionTrace = Array.isArray(payload.actionTrace) ? payload.actionTrace : [];
157
- if (actionTrace.length > 0) {
158
- emitActionTrace(context, actionTrace, { stage: 'xhs_submit_search' });
159
- delete payload.actionTrace;
160
- return {
161
- ...operationResult,
162
- data: replaceEvaluateResultData(operationResult.data, payload),
163
- };
164
- }
165
- return operationResult;
166
- });
204
+ };
205
+ return { actionTrace, pushTrace };
167
206
  }
168
207
 
169
- async function executeOpenDetailOperation({
170
- profileId,
171
- params = {},
172
- context = {},
173
- }) {
174
- const highlight = params.highlight !== false;
175
- const claimPath = resolveSharedClaimPath(params);
176
- const lockKey = claimPath ? `xhs_open_detail:${claimPath}` : '';
208
+ function normalizeInlineText(value) {
209
+ return String(value || '').replace(/\s+/g, ' ').trim();
210
+ }
177
211
 
178
- const mapOpenDetailError = (err, paramsRef = {}) => {
179
- const message = String(err?.message || err || '');
180
- const mode = String(paramsRef?.mode || '').trim().toLowerCase();
181
- if (message.includes('AUTOSCRIPT_DONE_NO_MORE_NOTES')) {
182
- return {
183
- ok: true,
184
- code: 'AUTOSCRIPT_DONE_NO_MORE_NOTES',
185
- message: 'no more notes',
186
- data: { stopReason: 'no_more_notes' },
187
- };
188
- }
189
- if (message.includes('NO_SEARCH_RESULT_ITEM')) {
190
- if (mode === 'first') return null;
191
- return {
192
- ok: true,
193
- code: 'OPERATION_SKIPPED_NO_SEARCH_RESULT_ITEM',
194
- message: 'search result item missing',
195
- data: { skipped: true },
196
- };
197
- }
198
- return null;
212
+ function sanitizeAuthorText(raw, commentText = '') {
213
+ const text = normalizeInlineText(raw);
214
+ if (!text) return '';
215
+ if (commentText && text === commentText) return '';
216
+ if (text.length > 40) return '';
217
+ if (/^(回复|展开|收起|查看更多|评论|赞|分享|发送)$/.test(text)) return '';
218
+ return text;
219
+ }
220
+
221
+ function buildElementCollectability(detail = {}, commentsSnapshot = null) {
222
+ const href = String(detail?.href || '').trim();
223
+ const videoUrl = String(detail?.videoUrl || '').trim();
224
+ const videoPresent = detail?.videoPresent === true;
225
+ const commentsContextAvailable = commentsSnapshot?.hasCommentsContext === true || detail?.commentsContextAvailable === true;
226
+ const collectability = {
227
+ canCollectText: detail?.textPresent === true,
228
+ canCollectImages: Number(detail?.imageCount || 0) > 0,
229
+ canCollectComments: commentsContextAvailable,
230
+ canCollectVideo: false,
199
231
  };
200
232
 
201
- const runWithExclude = async (excludeNoteIds) => {
202
- const script = buildOpenDetailScript({
203
- ...params,
204
- excludeNoteIds,
233
+ const skippedElements = [];
234
+ if (videoPresent) {
235
+ skippedElements.push({
236
+ element: 'video',
237
+ reason: 'video_capture_not_supported',
205
238
  });
206
- const operationResult = await evaluateWithScript({
207
- profileId,
208
- script,
209
- message: 'xhs_open_detail done',
210
- highlight,
239
+ }
240
+ if (!commentsContextAvailable) {
241
+ skippedElements.push({
242
+ element: 'comments',
243
+ reason: 'comments_context_missing',
211
244
  });
212
- const payload = extractEvaluateResultData(operationResult.data) || {};
213
- const actionTrace = Array.isArray(payload.actionTrace) ? payload.actionTrace : [];
214
- if (actionTrace.length > 0) {
215
- emitActionTrace(context, actionTrace, { stage: 'xhs_open_detail' });
216
- delete payload.actionTrace;
217
- }
218
- return {
219
- operationResult: actionTrace.length > 0
220
- ? { ...operationResult, data: replaceEvaluateResultData(operationResult.data, payload) }
221
- : operationResult,
222
- payload: payload && typeof payload === 'object' ? payload : {},
223
- };
245
+ }
246
+
247
+ const fallbackCaptured = {};
248
+ if (href) fallbackCaptured.noteUrl = href;
249
+ if (videoPresent) fallbackCaptured.videoUrl = videoUrl || href || null;
250
+
251
+ return {
252
+ collectability,
253
+ skippedElements,
254
+ fallbackCaptured,
224
255
  };
256
+ }
225
257
 
226
- if (!claimPath) {
227
- try {
228
- const { operationResult } = await runWithExclude([]);
229
- return operationResult;
230
- } catch (err) {
231
- const mapped = mapOpenDetailError(err, params);
232
- if (mapped) return mapped;
233
- throw err;
234
- }
258
+ async function sleep(ms) {
259
+ const waitMs = Math.max(0, Number(ms) || 0);
260
+ if (waitMs <= 0) return;
261
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
262
+ }
263
+
264
+ async function sleepRandom(minMs, maxMs, pushTrace, stage, extra = {}) {
265
+ const waitMs = randomBetween(minMs, maxMs);
266
+ if (typeof pushTrace === 'function') {
267
+ pushTrace({ kind: 'wait', stage, waitMs, ...extra });
235
268
  }
269
+ await sleep(waitMs);
270
+ return waitMs;
271
+ }
236
272
 
237
- const runLocked = async () => {
238
- const claimDoc = await loadSharedClaimDoc(claimPath);
239
- const excludeNoteIds = normalizeNoteIdList(claimDoc.noteIds);
240
- const { operationResult, payload } = await runWithExclude(excludeNoteIds);
273
+ async function evaluateReadonly(profileId, script) {
274
+ const payload = await runEvaluateScript({
275
+ profileId,
276
+ script,
277
+ highlight: false,
278
+ });
279
+ return extractEvaluateResultData(payload) || payload?.result || payload?.data || payload || {};
280
+ }
241
281
 
242
- const claimSet = new Set(excludeNoteIds);
243
- const claimAdded = [];
244
- const markClaim = (noteId, source = 'open_detail') => {
245
- const id = String(noteId || '').trim();
246
- if (!id || claimSet.has(id)) return;
247
- claimSet.add(id);
248
- claimAdded.push(id);
249
- claimDoc.byNoteId[id] = {
250
- noteId: id,
251
- profileId,
252
- source,
253
- ts: new Date().toISOString(),
282
+ async function readLocation(profileId) {
283
+ const payload = await evaluateReadonly(profileId, '(() => String(location.href || ""))()');
284
+ return String(payload || '');
285
+ }
286
+
287
+ async function moveMouse(profileId, x, y, steps = 2) {
288
+ await callAPI('mouse:move', {
289
+ profileId,
290
+ x: Math.max(1, Math.round(Number(x) || 1)),
291
+ y: Math.max(1, Math.round(Number(y) || 1)),
292
+ steps: Math.max(1, Number(steps) || 1),
293
+ });
294
+ }
295
+
296
+ async function clickPoint(profileId, point, options = {}) {
297
+ await moveMouse(profileId, point.x, point.y, options.steps ?? 3);
298
+ await callAPI('mouse:click', {
299
+ profileId,
300
+ x: Math.max(1, Math.round(Number(point.x) || 1)),
301
+ y: Math.max(1, Math.round(Number(point.y) || 1)),
302
+ button: String(options.button || 'left').trim() || 'left',
303
+ clicks: Math.max(1, Number(options.clicks ?? 1) || 1),
304
+ });
305
+ }
306
+
307
+ async function wheel(profileId, deltaY) {
308
+ await callAPI('mouse:wheel', {
309
+ profileId,
310
+ deltaX: 0,
311
+ deltaY: clamp(Math.round(Number(deltaY) || 0), -1200, 1200),
312
+ });
313
+ }
314
+
315
+ async function pressKey(profileId, key) {
316
+ await callAPI('keyboard:press', {
317
+ profileId,
318
+ key: String(key || '').trim(),
319
+ });
320
+ }
321
+
322
+ async function clearAndType(profileId, text, keyDelayMs = 60) {
323
+ await pressKey(profileId, process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
324
+ await pressKey(profileId, 'Backspace');
325
+ await callAPI('keyboard:type', {
326
+ profileId,
327
+ text: String(text || ''),
328
+ delay: Math.max(0, Number(keyDelayMs) || 0),
329
+ });
330
+ }
331
+
332
+ async function resolveSelectorTarget(profileId, selectors, options = {}) {
333
+ const normalizedSelectors = normalizeArray(selectors)
334
+ .map((item) => String(item || '').trim())
335
+ .filter(Boolean);
336
+ if (normalizedSelectors.length === 0) return null;
337
+ const script = `(() => {
338
+ const selectors = ${JSON.stringify(normalizedSelectors)};
339
+ const requireViewport = ${options.requireViewport !== false ? 'true' : 'false'};
340
+ const includeText = ${options.includeText === true ? 'true' : 'false'};
341
+ const isVisible = (node) => {
342
+ if (!(node instanceof Element)) return false;
343
+ const rect = node.getBoundingClientRect?.();
344
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
345
+ try {
346
+ const style = window.getComputedStyle(node);
347
+ if (!style) return false;
348
+ if (style.display === 'none') return false;
349
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
350
+ const opacity = Number.parseFloat(String(style.opacity || '1'));
351
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
352
+ } catch {
353
+ return false;
354
+ }
355
+ return true;
356
+ };
357
+ const inViewport = (rect) => {
358
+ const vw = Number(window.innerWidth || 0);
359
+ const vh = Number(window.innerHeight || 0);
360
+ return rect.right > 0 && rect.bottom > 0 && rect.left < vw && rect.top < vh;
361
+ };
362
+ const hitVisible = (node, rect) => {
363
+ if (!(node instanceof Element) || !rect) return false;
364
+ const vw = Number(window.innerWidth || 0);
365
+ const vh = Number(window.innerHeight || 0);
366
+ if (vw <= 0 || vh <= 0) return false;
367
+ const x = Math.max(0, Math.min(vw - 1, rect.left + rect.width / 2));
368
+ const y = Math.max(0, Math.min(vh - 1, rect.top + rect.height / 2));
369
+ const top = document.elementFromPoint(x, y);
370
+ if (!top) return false;
371
+ return top === node || node.contains(top) || top.contains(node);
372
+ };
373
+ const toPayload = (selector, node) => {
374
+ const rect = node.getBoundingClientRect();
375
+ const vw = Number(window.innerWidth || 0);
376
+ const vh = Number(window.innerHeight || 0);
377
+ const center = {
378
+ x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
379
+ y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
254
380
  };
381
+ const payload = {
382
+ selector,
383
+ center,
384
+ rect: {
385
+ left: Number(rect.left || 0),
386
+ top: Number(rect.top || 0),
387
+ width: Number(rect.width || 0),
388
+ height: Number(rect.height || 0),
389
+ },
390
+ viewport: { width: vw, height: vh },
391
+ };
392
+ if (includeText) payload.text = String(node.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 180);
393
+ return payload;
255
394
  };
256
-
257
- const seeded = normalizeNoteIdList(payload.seedCollectedNoteIds);
258
- for (const noteId of seeded) markClaim(noteId, 'seed_collect');
259
- if (payload.opened === true) markClaim(payload.noteId, 'open_detail');
260
- claimDoc.noteIds = Array.from(claimSet);
261
- if (claimAdded.length > 0) {
262
- await saveSharedClaimDoc(claimPath, claimDoc);
395
+ for (const selector of selectors) {
396
+ const nodes = Array.from(document.querySelectorAll(selector));
397
+ for (const node of nodes) {
398
+ if (!isVisible(node)) continue;
399
+ const rect = node.getBoundingClientRect();
400
+ if (requireViewport && !inViewport(rect)) continue;
401
+ if (requireViewport && !hitVisible(node, rect)) continue;
402
+ return { ok: true, target: toPayload(selector, node) };
403
+ }
404
+ for (const node of nodes) {
405
+ if (!isVisible(node)) continue;
406
+ return { ok: true, target: toPayload(selector, node) };
407
+ }
263
408
  }
409
+ return { ok: false };
410
+ })()`;
411
+ const payload = await evaluateReadonly(profileId, script);
412
+ if (!payload || payload.ok !== true || !payload.target?.center) return null;
413
+ return payload.target;
414
+ }
264
415
 
265
- const mergedPayload = {
266
- ...payload,
267
- sharedClaimPath: claimPath,
268
- sharedClaimCount: claimDoc.noteIds.length,
269
- sharedClaimAdded: claimAdded,
270
- dedupExcluded: excludeNoteIds.length,
416
+ async function isDetailVisible(profileId) {
417
+ const script = `(() => {
418
+ const detailSelectors = [
419
+ '.note-detail-mask',
420
+ '.note-detail-page',
421
+ '.note-detail-dialog',
422
+ '.note-detail-mask .detail-container',
423
+ '.note-detail-mask .media-container',
424
+ '.note-detail-mask .note-scroller',
425
+ '.note-detail-mask .note-content',
426
+ '.note-detail-mask .interaction-container',
427
+ '.note-detail-mask .comments-container',
428
+ ];
429
+ const searchSelectors = ['.note-item', '.search-result-list', '#search-input', '.feeds-page'];
430
+ const isVisible = (node) => {
431
+ if (!(node instanceof Element)) return false;
432
+ const rect = node.getBoundingClientRect?.();
433
+ if (!rect || rect.width <= 1 || rect.height <= 1) return false;
434
+ try {
435
+ const style = window.getComputedStyle(node);
436
+ if (!style) return false;
437
+ if (style.display === 'none') return false;
438
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
439
+ const opacity = Number.parseFloat(String(style.opacity || '1'));
440
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
441
+ } catch {
442
+ return false;
443
+ }
444
+ const sampleX = Math.max(0, Math.min((window.innerWidth || 1) - 1, rect.left + rect.width / 2));
445
+ const sampleY = Math.max(0, Math.min((window.innerHeight || 1) - 1, rect.top + rect.height / 2));
446
+ const top = document.elementFromPoint(sampleX, sampleY);
447
+ if (!top) return false;
448
+ return top === node || node.contains(top) || top.contains(node);
271
449
  };
272
- const mergedData = operationResult.data && typeof operationResult.data === 'object'
273
- ? { ...operationResult.data, result: mergedPayload }
274
- : { result: mergedPayload };
275
-
450
+ const hasVisible = (selectors) => selectors.some((selector) => isVisible(document.querySelector(selector)));
451
+ const detailVisible = hasVisible(detailSelectors);
452
+ const searchVisible = hasVisible(searchSelectors);
453
+ const href = String(location.href || '');
276
454
  return {
277
- ...operationResult,
278
- data: mergedData,
455
+ detailVisible,
456
+ searchVisible,
457
+ detailReady: detailVisible,
458
+ href,
279
459
  };
460
+ })()`;
461
+ return evaluateReadonly(profileId, script);
462
+ }
463
+
464
+ async function closeDetailToSearch(profileId, pushTrace = null) {
465
+ const waitForCloseAnimation = async () => {
466
+ for (let i = 0; i < 45; i += 1) {
467
+ const s = await isDetailVisible(profileId);
468
+ if (s?.detailVisible !== true && s?.searchVisible === true) return true;
469
+ await sleep(120);
470
+ }
471
+ const s = await isDetailVisible(profileId);
472
+ return s?.detailVisible !== true && s?.searchVisible === true;
280
473
  };
281
474
 
282
- try {
283
- return await withSerializedLock(lockKey, runLocked);
284
- } catch (err) {
285
- const mapped = mapOpenDetailError(err, params);
286
- if (mapped) return mapped;
287
- throw err;
475
+ for (let attempt = 1; attempt <= 4; attempt += 1) {
476
+ await pressKey(profileId, 'Escape');
477
+ if (typeof pushTrace === 'function') {
478
+ pushTrace({ kind: 'key', stage: 'collect_links_close', key: 'Escape', attempt });
479
+ }
480
+ await sleep(randomBetween(220, 480));
481
+ if (await waitForCloseAnimation()) return true;
288
482
  }
483
+
484
+ const snapshot = await isDetailVisible(profileId);
485
+ return snapshot?.detailVisible !== true && snapshot?.searchVisible === true;
289
486
  }
290
487
 
291
- function buildReadStateScript() {
292
- return `(() => {
293
- const state = window.__camoXhsState || {};
488
+ async function readSearchInput(profileId) {
489
+ const script = `(() => {
490
+ const input = document.querySelector('#search-input, input.search-input');
491
+ if (!(input instanceof HTMLInputElement)) return { ok: false };
492
+ const rect = input.getBoundingClientRect();
493
+ const vw = Number(window.innerWidth || 0);
494
+ const vh = Number(window.innerHeight || 0);
495
+ const center = {
496
+ x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
497
+ y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
498
+ };
294
499
  return {
295
- keyword: state.keyword || null,
296
- currentNoteId: state.currentNoteId || null,
297
- lastCommentsHarvest: state.lastCommentsHarvest && typeof state.lastCommentsHarvest === 'object'
298
- ? state.lastCommentsHarvest
299
- : null,
500
+ ok: true,
501
+ value: String(input.value || ''),
502
+ center,
503
+ viewport: { width: vw, height: vh },
300
504
  };
301
505
  })()`;
506
+ return evaluateReadonly(profileId, script);
302
507
  }
303
508
 
304
- async function readXhsRuntimeState(profileId) {
305
- try {
306
- const payload = await runEvaluateScript({
307
- profileId,
308
- script: buildReadStateScript(),
309
- highlight: false,
310
- });
311
- return extractEvaluateResultData(payload) || {};
312
- } catch {
313
- return {};
314
- }
509
+ async function readSearchCandidates(profileId) {
510
+ const script = `(() => {
511
+ const nodes = Array.from(document.querySelectorAll('.note-item'));
512
+ const isVisible = (node) => {
513
+ if (!(node instanceof Element)) return false;
514
+ const rect = node.getBoundingClientRect?.();
515
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
516
+ try {
517
+ const style = window.getComputedStyle(node);
518
+ if (!style) return false;
519
+ if (style.display === 'none') return false;
520
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
521
+ const opacity = Number.parseFloat(String(style.opacity || '1'));
522
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
523
+ } catch {
524
+ return false;
525
+ }
526
+ return true;
527
+ };
528
+ const rows = [];
529
+ for (let index = 0; index < nodes.length; index += 1) {
530
+ const item = nodes[index];
531
+ const cover = item.querySelector('a.cover');
532
+ if (!(cover instanceof Element)) continue;
533
+ if (!isVisible(cover)) continue;
534
+ const href = String(cover.getAttribute('href') || '').trim();
535
+ const seg = href.split('/').filter(Boolean).pop() || '';
536
+ const noteId = (seg.split('?')[0].split('#')[0] || ('idx_' + index)).trim();
537
+ const rect = cover.getBoundingClientRect();
538
+ const vw = Number(window.innerWidth || 0);
539
+ const vh = Number(window.innerHeight || 0);
540
+ const inViewport = rect.right > 0 && rect.bottom > 0 && rect.left < vw && rect.top < vh;
541
+ rows.push({
542
+ index,
543
+ noteId,
544
+ href,
545
+ inViewport,
546
+ center: {
547
+ x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
548
+ y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
549
+ },
550
+ rect: {
551
+ left: Number(rect.left || 0),
552
+ top: Number(rect.top || 0),
553
+ width: Number(rect.width || 0),
554
+ height: Number(rect.height || 0),
555
+ },
556
+ });
557
+ }
558
+ return {
559
+ rows,
560
+ page: {
561
+ href: String(location.href || ''),
562
+ innerHeight: Number(window.innerHeight || 0),
563
+ },
564
+ };
565
+ })()`;
566
+ return evaluateReadonly(profileId, script);
315
567
  }
316
568
 
317
- function buildAssertLoggedInScript(params = {}) {
318
- const selectors = Array.isArray(params.loginSelectors) && params.loginSelectors.length > 0
319
- ? params.loginSelectors.map((item) => String(item || '').trim()).filter(Boolean)
320
- : [
321
- '.login-container',
322
- '.login-dialog',
323
- '#login-container',
324
- ];
325
- const loginPattern = String(
326
- params.loginPattern || '登录|扫码|验证码|手机号|请先登录|注册|sign\\s*in',
327
- ).trim();
328
-
329
- return `(() => {
330
- const guardSelectors = ${JSON.stringify(selectors)};
331
- const loginPattern = new RegExp(${JSON.stringify(loginPattern || '登录|扫码|验证码|手机号|请先登录|注册|sign\\\\s*in')}, 'i');
569
+ async function paintSearchCandidates(profileId, {
570
+ candidateNoteIds = [],
571
+ selectedNoteId = '',
572
+ processedNoteIds = [],
573
+ } = {}) {
574
+ const script = `(() => {
575
+ const candidateColor = '#3b82f6';
576
+ const selectedColor = '#facc15';
577
+ const processedColor = '#8b5cf6';
578
+ const candidate = new Set(${JSON.stringify(normalizeNoteIdList(candidateNoteIds))});
579
+ const processed = new Set(${JSON.stringify(normalizeNoteIdList(processedNoteIds))});
580
+ const selected = ${JSON.stringify(String(selectedNoteId || '').trim())};
581
+ const parseNoteId = (item, index) => {
582
+ const cover = item?.querySelector?.('a.cover');
583
+ const href = String(cover?.getAttribute?.('href') || '').trim();
584
+ if (!href) return 'idx_' + index;
585
+ const match = href.match(/\\/explore\\/([^/?#]+)/);
586
+ if (match && match[1]) return String(match[1]).trim();
587
+ const seg = href.split('/').filter(Boolean).pop() || '';
588
+ return String(seg).split('?')[0].split('#')[0].trim() || ('idx_' + index);
589
+ };
590
+ const clearMark = (node) => {
591
+ if (!(node instanceof HTMLElement)) return;
592
+ if (node.dataset.webautoXhsMark !== '1') return;
593
+ node.style.outline = '';
594
+ node.style.outlineOffset = '';
595
+ node.style.boxShadow = '';
596
+ node.dataset.webautoXhsMark = '0';
597
+ };
598
+ const applyMark = (node, color) => {
599
+ if (!(node instanceof HTMLElement)) return;
600
+ node.style.outline = '2px solid ' + color;
601
+ node.style.outlineOffset = '2px';
602
+ node.style.boxShadow = 'inset 0 0 0 2px ' + color;
603
+ node.dataset.webautoXhsMark = '1';
604
+ };
605
+ const rows = Array.from(document.querySelectorAll('.note-item'));
606
+ for (let i = 0; i < rows.length; i += 1) {
607
+ const row = rows[i];
608
+ const noteId = parseNoteId(row, i);
609
+ if (noteId && processed.has(noteId)) {
610
+ applyMark(row, processedColor);
611
+ } else if (noteId && selected && noteId === selected) {
612
+ applyMark(row, selectedColor);
613
+ } else if (noteId && candidate.has(noteId)) {
614
+ applyMark(row, candidateColor);
615
+ } else {
616
+ clearMark(row);
617
+ }
618
+ }
619
+ return { ok: true, count: rows.length };
620
+ })()`;
621
+ return evaluateReadonly(profileId, script).catch(() => ({ ok: false }));
622
+ }
332
623
 
624
+ async function readDetailSnapshot(profileId) {
625
+ const script = `(() => {
333
626
  const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
334
627
  const isVisible = (node) => {
335
- if (!(node instanceof HTMLElement)) return false;
336
- const style = window.getComputedStyle(node);
337
- if (!style) return false;
338
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) return false;
628
+ if (!(node instanceof Element)) return false;
629
+ const rect = node.getBoundingClientRect?.();
630
+ if (!rect || rect.width <= 1 || rect.height <= 1) return false;
631
+ try {
632
+ const style = window.getComputedStyle(node);
633
+ if (!style) return false;
634
+ if (style.display === 'none') return false;
635
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
636
+ } catch {
637
+ return false;
638
+ }
639
+ return true;
640
+ };
641
+ const detailRoot = document.querySelector('.note-detail-mask')
642
+ || document.querySelector('.note-detail-page')
643
+ || document.querySelector('.note-detail-dialog')
644
+ || document.body;
645
+ const text = (selector) => normalize(detailRoot?.querySelector(selector)?.textContent || '');
646
+ const title = text('.note-title').slice(0, 200);
647
+ const content = text('.note-content');
648
+ const href = String(location.href || '');
649
+ const noteMatch = href.match(/\\/explore\\/([^/?#]+)/);
650
+ const imageNodes = Array.from(detailRoot?.querySelectorAll?.('.note-content img, .swiper-wrapper img, .media-container img, img') || []);
651
+ const imageSet = new Set();
652
+ for (const node of imageNodes) {
653
+ if (!(node instanceof HTMLImageElement)) continue;
654
+ if (!isVisible(node)) continue;
655
+ const src = normalize(node.currentSrc || node.src || node.getAttribute('src') || '');
656
+ if (!src) continue;
657
+ imageSet.add(src);
658
+ }
659
+ const videoNodes = Array.from(detailRoot?.querySelectorAll?.('video, .player video, [class*="video"] video') || []);
660
+ let videoUrl = '';
661
+ let videoPresent = false;
662
+ for (const node of videoNodes) {
663
+ if (!(node instanceof HTMLVideoElement)) continue;
664
+ if (!isVisible(node)) continue;
665
+ videoPresent = true;
666
+ const src = normalize(node.currentSrc || node.src || node.getAttribute('src') || '');
667
+ if (!videoUrl && src) videoUrl = src;
668
+ }
669
+ const commentsContextAvailable = Boolean(
670
+ detailRoot?.querySelector?.('.comments-container')
671
+ || detailRoot?.querySelector?.('.comment-list')
672
+ || detailRoot?.querySelector?.('.comment-item')
673
+ || detailRoot?.querySelector?.('[class*="comment-item"]')
674
+ || detailRoot?.querySelector?.('.note-scroller')
675
+ );
676
+ return {
677
+ title,
678
+ contentLength: content.length,
679
+ contentPreview: content.slice(0, 500),
680
+ noteIdFromUrl: noteMatch && noteMatch[1] ? String(noteMatch[1]) : null,
681
+ href,
682
+ textPresent: Boolean(title || content),
683
+ imageCount: imageSet.size,
684
+ imageUrls: Array.from(imageSet).slice(0, 24),
685
+ videoPresent,
686
+ videoUrl: videoUrl || null,
687
+ commentsContextAvailable,
688
+ capturedAt: new Date().toISOString(),
689
+ };
690
+ })()`;
691
+ return evaluateReadonly(profileId, script);
692
+ }
693
+
694
+ async function readExpandButtons(profileId) {
695
+ const script = `(() => {
696
+ const selectors = [
697
+ '.note-detail-mask .show-more',
698
+ '.note-detail-mask .reply-expand',
699
+ '.note-detail-mask [class*="expand"]',
700
+ '.note-detail-page .show-more',
701
+ '.note-detail-page .reply-expand',
702
+ '.note-detail-page [class*="expand"]',
703
+ ];
704
+ const nodes = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
705
+ const isVisible = (node) => {
706
+ if (!(node instanceof Element)) return false;
707
+ const rect = node.getBoundingClientRect?.();
708
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
709
+ try {
710
+ const style = window.getComputedStyle(node);
711
+ if (!style) return false;
712
+ if (style.display === 'none') return false;
713
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
714
+ } catch {
715
+ return false;
716
+ }
717
+ return true;
718
+ };
719
+ const out = [];
720
+ const vw = Number(window.innerWidth || 0);
721
+ const vh = Number(window.innerHeight || 0);
722
+ for (const node of nodes) {
723
+ if (!isVisible(node)) continue;
724
+ const text = String(node.textContent || '').replace(/\s+/g, ' ').trim();
725
+ if (!text) continue;
339
726
  const rect = node.getBoundingClientRect();
340
- return rect.width > 0 && rect.height > 0;
727
+ out.push({
728
+ text,
729
+ signature: String(text)
730
+ + '::' + String(Math.round(rect.left))
731
+ + '::' + String(Math.round(rect.top))
732
+ + '::' + String(Math.round(rect.width))
733
+ + '::' + String(Math.round(rect.height)),
734
+ center: {
735
+ x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
736
+ y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
737
+ },
738
+ });
739
+ }
740
+ return { rows: out };
741
+ })()`;
742
+ return evaluateReadonly(profileId, script);
743
+ }
744
+
745
+ async function readCommentsSnapshot(profileId) {
746
+ const script = `(() => {
747
+ const parseCountToken = (raw) => {
748
+ const token = String(raw || '').trim();
749
+ const matched = token.match(/^([0-9]+(?:\\.[0-9]+)?)(万|w|W)?$/);
750
+ if (!matched) return null;
751
+ const base = Number(matched[1]);
752
+ if (!Number.isFinite(base)) return null;
753
+ if (!matched[2]) return Math.round(base);
754
+ return Math.round(base * 10000);
341
755
  };
756
+ const isVisible = (node) => {
757
+ if (!(node instanceof Element)) return false;
758
+ const rect = node.getBoundingClientRect?.();
759
+ if (!rect || rect.width <= 1 || rect.height <= 1) return false;
760
+ try {
761
+ const style = window.getComputedStyle(node);
762
+ if (!style) return false;
763
+ if (style.display === 'none') return false;
764
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
765
+ } catch {
766
+ return false;
767
+ }
768
+ return true;
769
+ };
770
+ const detailSelectors = [
771
+ '.note-detail-mask',
772
+ '.note-detail-page',
773
+ '.note-detail-dialog',
774
+ '.note-detail-mask .detail-container',
775
+ '.note-detail-mask .media-container',
776
+ '.note-detail-mask .note-scroller',
777
+ '.note-detail-mask .note-content',
778
+ '.note-detail-mask .interaction-container',
779
+ '.note-detail-mask .comments-container',
780
+ ];
781
+ const detailVisible = detailSelectors.some((selector) => isVisible(document.querySelector(selector)));
782
+ const hasCommentsContext = Boolean(
783
+ document.querySelector('.comments-container')
784
+ || document.querySelector('.comment-list')
785
+ || document.querySelector('.comment-item')
786
+ || document.querySelector('[class*="comment-item"]')
787
+ || document.querySelector('.note-scroller')
788
+ );
789
+ const scopeSelectors = [
790
+ '.note-detail-mask .interaction-container',
791
+ '.note-detail-mask .comments-container',
792
+ '.note-detail-page .interaction-container',
793
+ '.note-detail-page .comments-container',
794
+ '.note-detail-mask',
795
+ '.note-detail-page',
796
+ ];
797
+ const patterns = [
798
+ /([0-9]+(?:\\.[0-9]+)?(?:万|w|W)?)\\s*条?评论/,
799
+ /评论\\s*([0-9]+(?:\\.[0-9]+)?(?:万|w|W)?)/,
800
+ /共\\s*([0-9]+(?:\\.[0-9]+)?(?:万|w|W)?)\\s*条/,
801
+ ];
802
+ let expectedCommentsCount = null;
803
+ for (const selector of scopeSelectors) {
804
+ const root = document.querySelector(selector);
805
+ if (!root) continue;
806
+ const text = String(root.textContent || '').replace(/\\s+/g, ' ').trim();
807
+ if (!text) continue;
808
+ for (const re of patterns) {
809
+ const matched = text.match(re);
810
+ if (!matched || !matched[1]) continue;
811
+ const parsed = parseCountToken(matched[1]);
812
+ if (Number.isFinite(parsed) && parsed >= 0) {
813
+ expectedCommentsCount = parsed;
814
+ break;
815
+ }
816
+ }
817
+ if (expectedCommentsCount !== null) break;
818
+ }
342
819
 
343
- const guardNodes = guardSelectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
344
- const visibleGuardNodes = guardNodes.filter((node) => isVisible(node));
345
- const guardTexts = visibleGuardNodes
346
- .slice(0, 10)
347
- .map((node) => normalize(node.textContent || ''))
348
- .filter(Boolean);
349
- const mergedGuardText = guardTexts.join(' ');
350
- const hasLoginText = loginPattern.test(mergedGuardText);
351
- const loginUrl = /\\/login|signin|passport|account\\/login/i.test(String(location.href || ''));
820
+ const scroller = document.querySelector('.note-scroller')
821
+ || document.querySelector('.comments-el')
822
+ || document.querySelector('.comments-container')
823
+ || document.scrollingElement
824
+ || document.documentElement;
825
+ const scrollerRect = scroller?.getBoundingClientRect?.() || { left: 0, top: 0, width: 1, height: 1 };
826
+ const vw = Number(window.innerWidth || 0);
827
+ const vh = Number(window.innerHeight || 0);
828
+ const scrollerCenter = {
829
+ x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(scrollerRect.left + scrollerRect.width / 2))),
830
+ y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(scrollerRect.top + Math.min(scrollerRect.height * 0.6, Math.max(80, scrollerRect.height - 60))))),
831
+ };
352
832
 
353
- let accountId = '';
354
- try {
355
- const initialState = (typeof window !== 'undefined' && window.__INITIAL_STATE__) || null;
356
- const rawUserInfo = initialState && initialState.user && initialState.user.userInfo
357
- ? (
358
- (initialState.user.userInfo._rawValue && typeof initialState.user.userInfo._rawValue === 'object' && initialState.user.userInfo._rawValue)
359
- || (initialState.user.userInfo._value && typeof initialState.user.userInfo._value === 'object' && initialState.user.userInfo._value)
360
- || (typeof initialState.user.userInfo === 'object' ? initialState.user.userInfo : null)
361
- )
362
- : null;
833
+ const readText = (item, selectors) => {
834
+ for (const selector of selectors) {
835
+ const node = item.querySelector(selector);
836
+ const text = String(node?.textContent || '').replace(/\\s+/g, ' ').trim();
837
+ if (text) return text;
838
+ }
839
+ return '';
840
+ };
841
+ const readAttr = (item, attrs) => {
842
+ for (const attr of attrs) {
843
+ const value = String(item.getAttribute?.(attr) || '').trim();
844
+ if (value) return value;
845
+ }
846
+ return '';
847
+ };
848
+ const readUserName = (item, commentText) => {
849
+ const attrNames = ['data-user-name', 'data-username', 'data-user_nickname', 'data-nickname'];
850
+ for (const attr of attrNames) {
851
+ const value = String(item.getAttribute?.(attr) || '').replace(/\\s+/g, ' ').trim();
852
+ if (value && value !== commentText && value.length <= 40) return value;
853
+ }
854
+ const selectors = [
855
+ '.comment-user .name',
856
+ '.comment-user .username',
857
+ '.comment-user .user-name',
858
+ '.author .name',
859
+ '.author',
860
+ '.user-name',
861
+ '.username',
862
+ '.name',
863
+ 'a[href*="/user/profile/"]',
864
+ 'a[href*="/user/"]',
865
+ ];
866
+ for (const selector of selectors) {
867
+ const node = item.querySelector(selector);
868
+ if (!node) continue;
869
+ const title = String(node.getAttribute?.('title') || '').replace(/\\s+/g, ' ').trim();
870
+ if (title && title !== commentText && title.length <= 40) return title;
871
+ const text = String(node.textContent || '').replace(/\\s+/g, ' ').trim();
872
+ if (text && text !== commentText && text.length <= 40) return text;
873
+ }
874
+ return '';
875
+ };
876
+ const readUserId = (item) => {
877
+ const value = readAttr(item, ['data-user-id', 'data-userid', 'data-user_id']);
878
+ if (value) return value;
879
+ const anchor = item.querySelector('a[href*="/user/profile/"], a[href*="/user/"]');
880
+ const href = String(anchor?.getAttribute?.('href') || '').trim();
881
+ const matched = href.match(/\\/user\\/(?:profile\\/)?([a-zA-Z0-9_-]+)/);
882
+ return matched && matched[1] ? matched[1] : '';
883
+ };
884
+ const findLikeControl = (item) => {
885
+ const selectors = [
886
+ '.like-wrapper',
887
+ '.comment-like',
888
+ '.interactions .like-wrapper',
889
+ '.interactions [class*="like"]',
890
+ 'button[class*="like"]',
891
+ '[aria-label*="赞"]',
892
+ ];
893
+ for (const selector of selectors) {
894
+ const node = item.querySelector(selector);
895
+ if (node instanceof Element) return node;
896
+ }
897
+ return null;
898
+ };
899
+ const isAlreadyLiked = (node) => {
900
+ if (!node) return false;
901
+ const className = String(node.className || '').toLowerCase();
902
+ const ariaPressed = String(node.getAttribute?.('aria-pressed') || '').toLowerCase();
903
+ const text = String(node.textContent || '');
904
+ return /(?:^|\\s)like-active(?:\\s|$)/.test(className) || ariaPressed === 'true' || /已赞|取消赞/.test(text);
905
+ };
906
+
907
+ const rows = [];
908
+ const commentNodes = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
909
+ for (let index = 0; index < commentNodes.length; index += 1) {
910
+ const item = commentNodes[index];
911
+ const text = readText(item, ['.content', '.comment-content', 'p']);
912
+ if (!text) continue;
913
+ const userName = readUserName(item, text);
914
+ const userId = readUserId(item);
915
+ const timestamp = readText(item, ['.date', '.time', '.timestamp', '[class*="time"]']);
916
+ const likeControl = findLikeControl(item);
917
+ rows.push({
918
+ index,
919
+ text,
920
+ userName,
921
+ userId,
922
+ timestamp,
923
+ hasLikeControl: Boolean(likeControl),
924
+ alreadyLiked: isAlreadyLiked(likeControl),
925
+ });
926
+ }
927
+
928
+ const href = String(location.href || '');
929
+ const noteMatch = href.match(/\\/explore\\/([^/?#]+)/);
930
+ return {
931
+ detailVisible,
932
+ hasCommentsContext,
933
+ noteIdFromUrl: noteMatch && noteMatch[1] ? String(noteMatch[1]) : null,
934
+ metrics: {
935
+ scrollTop: Number(scroller?.scrollTop || 0),
936
+ scrollHeight: Number(scroller?.scrollHeight || 0),
937
+ clientHeight: Number(scroller?.clientHeight || window.innerHeight || 0),
938
+ },
939
+ expectedCommentsCount,
940
+ rows,
941
+ scrollerCenter,
942
+ };
943
+ })()`;
944
+ return evaluateReadonly(profileId, script);
945
+ }
946
+
947
+ async function readLikeTargetByIndex(profileId, index) {
948
+ const idx = Math.max(0, Number(index) || 0);
949
+ const script = `(() => {
950
+ const index = Number(${JSON.stringify(idx)});
951
+ const findLikeControl = (item) => {
952
+ const selectors = [
953
+ '.like-wrapper',
954
+ '.comment-like',
955
+ '.interactions .like-wrapper',
956
+ '.interactions [class*="like"]',
957
+ 'button[class*="like"]',
958
+ '[aria-label*="赞"]',
959
+ ];
960
+ for (const selector of selectors) {
961
+ const node = item.querySelector(selector);
962
+ if (node instanceof Element) return node;
963
+ }
964
+ return null;
965
+ };
966
+ const isAlreadyLiked = (node) => {
967
+ if (!node) return false;
968
+ const className = String(node.className || '').toLowerCase();
969
+ const ariaPressed = String(node.getAttribute?.('aria-pressed') || '').toLowerCase();
970
+ const text = String(node.textContent || '');
971
+ return /(?:^|\\s)like-active(?:\\s|$)/.test(className) || ariaPressed === 'true' || /已赞|取消赞/.test(text);
972
+ };
973
+ const nodes = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
974
+ const target = nodes[index];
975
+ if (!(target instanceof Element)) return { ok: false, reason: 'comment_item_not_found' };
976
+ const likeNode = findLikeControl(target);
977
+ if (!(likeNode instanceof Element)) return { ok: false, reason: 'like_control_not_found' };
978
+ const rect = likeNode.getBoundingClientRect();
979
+ const vw = Number(window.innerWidth || 0);
980
+ const vh = Number(window.innerHeight || 0);
981
+ return {
982
+ ok: true,
983
+ index,
984
+ alreadyLiked: isAlreadyLiked(likeNode),
985
+ center: {
986
+ x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
987
+ y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
988
+ },
989
+ };
990
+ })()`;
991
+ return evaluateReadonly(profileId, script);
992
+ }
993
+
994
+ async function readReplyTargetByIndex(profileId, index) {
995
+ const idx = Math.max(0, Number(index) || 0);
996
+ const script = `(() => {
997
+ const index = Number(${JSON.stringify(idx)});
998
+ const rows = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
999
+ const target = rows[index];
1000
+ if (!(target instanceof Element)) return { ok: false, reason: 'match_not_visible' };
1001
+ const targetRect = target.getBoundingClientRect();
1002
+ const vw = Number(window.innerWidth || 0);
1003
+ const vh = Number(window.innerHeight || 0);
1004
+ const center = {
1005
+ x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(targetRect.left + targetRect.width / 2))),
1006
+ y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(targetRect.top + Math.min(32, Math.max(12, targetRect.height / 3))))),
1007
+ };
1008
+ return { ok: true, center };
1009
+ })()`;
1010
+ return evaluateReadonly(profileId, script);
1011
+ }
1012
+
1013
+ async function readReplyInputTarget(profileId) {
1014
+ return resolveSelectorTarget(profileId, [
1015
+ 'textarea',
1016
+ 'input[placeholder*="说点"]',
1017
+ '[contenteditable="true"]',
1018
+ ]);
1019
+ }
1020
+
1021
+ async function readReplySendButtonTarget(profileId) {
1022
+ const script = `(() => {
1023
+ const buttons = Array.from(document.querySelectorAll('button'));
1024
+ const vw = Number(window.innerWidth || 0);
1025
+ const vh = Number(window.innerHeight || 0);
1026
+ const isVisible = (node) => {
1027
+ if (!(node instanceof Element)) return false;
1028
+ const rect = node.getBoundingClientRect?.();
1029
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
1030
+ try {
1031
+ const style = window.getComputedStyle(node);
1032
+ if (!style) return false;
1033
+ if (style.display === 'none') return false;
1034
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
1035
+ } catch {
1036
+ return false;
1037
+ }
1038
+ return true;
1039
+ };
1040
+ const target = buttons.find((button) => {
1041
+ if (!isVisible(button)) return false;
1042
+ const text = String(button.textContent || '').replace(/\s+/g, ' ').trim();
1043
+ return /发送|回复/.test(text);
1044
+ }) || null;
1045
+ if (!target) return { ok: false };
1046
+ const rect = target.getBoundingClientRect();
1047
+ return {
1048
+ ok: true,
1049
+ center: {
1050
+ x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
1051
+ y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
1052
+ },
1053
+ };
1054
+ })()`;
1055
+ const payload = await evaluateReadonly(profileId, script);
1056
+ return payload?.ok === true && payload.center ? payload.center : null;
1057
+ }
1058
+
1059
+ async function captureScreenshotToFile({ profileId, filePath }) {
1060
+ try {
1061
+ const payload = await callAPI('screenshot', { profileId, fullPage: false });
1062
+ const base64 = extractScreenshotBase64(payload);
1063
+ if (!base64) return null;
1064
+ return savePngBase64(filePath, base64);
1065
+ } catch {
1066
+ return null;
1067
+ }
1068
+ }
1069
+
1070
+ function buildAssertLoggedInScript(params = {}) {
1071
+ const selectors = Array.isArray(params.loginSelectors) && params.loginSelectors.length > 0
1072
+ ? params.loginSelectors.map((item) => String(item || '').trim()).filter(Boolean)
1073
+ : [
1074
+ '.login-container',
1075
+ '.login-dialog',
1076
+ '#login-container',
1077
+ ];
1078
+ const loginPattern = String(
1079
+ params.loginPattern || '登录|扫码|验证码|手机号|请先登录|注册|sign\\s*in',
1080
+ ).trim();
1081
+
1082
+ return `(() => {
1083
+ const guardSelectors = ${JSON.stringify(selectors)};
1084
+ const loginPattern = new RegExp(${JSON.stringify(loginPattern || '登录|扫码|验证码|手机号|请先登录|注册|sign\\\\s*in')}, 'i');
1085
+
1086
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
1087
+ const isVisible = (node) => {
1088
+ if (!(node instanceof HTMLElement)) return false;
1089
+ const style = window.getComputedStyle(node);
1090
+ if (!style) return false;
1091
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) return false;
1092
+ const rect = node.getBoundingClientRect();
1093
+ return rect.width > 0 && rect.height > 0;
1094
+ };
1095
+
1096
+ const guardNodes = guardSelectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
1097
+ const visibleGuardNodes = guardNodes.filter((node) => isVisible(node));
1098
+ const guardTexts = visibleGuardNodes
1099
+ .slice(0, 10)
1100
+ .map((node) => normalize(node.textContent || ''))
1101
+ .filter(Boolean);
1102
+ const mergedGuardText = guardTexts.join(' ');
1103
+ const hasLoginText = loginPattern.test(mergedGuardText);
1104
+ const loginUrl = /\\/login|signin|passport|account\\/login/i.test(String(location.href || ''));
1105
+
1106
+ let accountId = '';
1107
+ try {
1108
+ const initialState = (typeof window !== 'undefined' && window.__INITIAL_STATE__) || null;
1109
+ const rawUserInfo = initialState && initialState.user && initialState.user.userInfo
1110
+ ? (
1111
+ (initialState.user.userInfo._rawValue && typeof initialState.user.userInfo._rawValue === 'object' && initialState.user.userInfo._rawValue)
1112
+ || (initialState.user.userInfo._value && typeof initialState.user.userInfo._value === 'object' && initialState.user.userInfo._value)
1113
+ || (typeof initialState.user.userInfo === 'object' ? initialState.user.userInfo : null)
1114
+ )
1115
+ : null;
363
1116
  accountId = normalize(rawUserInfo?.user_id || rawUserInfo?.userId || '');
364
1117
  } catch {}
365
1118
 
@@ -394,32 +1147,696 @@ function buildAssertLoggedInScript(params = {}) {
394
1147
  hasLoginText,
395
1148
  guardSelectors,
396
1149
  };
397
- })()`;
1150
+ })()`;
1151
+ }
1152
+
1153
+ async function executeAssertLoggedInOperation({ profileId, params = {} }) {
1154
+ const payload = await runEvaluateScript({
1155
+ profileId,
1156
+ script: buildAssertLoggedInScript(params),
1157
+ highlight: false,
1158
+ });
1159
+ const data = extractEvaluateResultData(payload) || {};
1160
+ if (data?.hasLoginGuard === true) {
1161
+ const code = String(params.code || 'LOGIN_GUARD_DETECTED').trim() || 'LOGIN_GUARD_DETECTED';
1162
+ return asErrorPayload('OPERATION_FAILED', code, { guard: data });
1163
+ }
1164
+ return {
1165
+ ok: true,
1166
+ code: 'OPERATION_DONE',
1167
+ message: 'xhs_assert_logged_in done',
1168
+ data,
1169
+ };
1170
+ }
1171
+
1172
+ async function executeSubmitSearchOperation({
1173
+ profileId,
1174
+ params = {},
1175
+ context = {},
1176
+ }) {
1177
+ const lockKey = resolveSearchLockKey(params);
1178
+ return withSerializedLock(lockKey ? `xhs_submit_search:${lockKey}` : '', async () => {
1179
+ const profileState = getProfileState(profileId);
1180
+ const metrics = profileState.metrics || (profileState.metrics = {});
1181
+ const { actionTrace, pushTrace } = buildTraceRecorder();
1182
+
1183
+ const methodRequested = String(params.method || params.submitMethod || 'click').trim().toLowerCase();
1184
+ const method = ['click', 'enter', 'form'].includes(methodRequested) ? methodRequested : 'click';
1185
+ const keyword = String(params.keyword || '').trim();
1186
+ const actionDelayMinMs = Math.max(300, Number(params.actionDelayMinMs ?? 500) || 500);
1187
+ const actionDelayMaxMs = Math.max(actionDelayMinMs, Number(params.actionDelayMaxMs ?? 1600) || 1600);
1188
+ const settleMinMs = Math.max(500, Number(params.settleMinMs ?? 1200) || 1200);
1189
+ const settleMaxMs = Math.max(settleMinMs, Number(params.settleMaxMs ?? 2800) || 2800);
1190
+
1191
+ const input = await readSearchInput(profileId);
1192
+ if (!input || input.ok !== true || !input.center) {
1193
+ throw new Error('SEARCH_INPUT_NOT_FOUND');
1194
+ }
1195
+
1196
+ await clickPoint(profileId, input.center, { steps: 3 });
1197
+ pushTrace({ kind: 'click', stage: 'submit_search', target: 'search_input' });
1198
+ await sleepRandom(actionDelayMinMs, actionDelayMaxMs, pushTrace, 'submit_pre_type');
1199
+
1200
+ if (keyword && String(input.value || '') !== keyword) {
1201
+ await clearAndType(profileId, keyword, Number(params.keyDelayMs ?? 65) || 65);
1202
+ pushTrace({ kind: 'type', stage: 'submit_search', target: 'search_input', length: keyword.length });
1203
+ await sleepRandom(actionDelayMinMs, actionDelayMaxMs, pushTrace, 'submit_after_type');
1204
+ }
1205
+
1206
+ const beforeUrl = await readLocation(profileId);
1207
+ let via = method;
1208
+
1209
+ if (method === 'click') {
1210
+ const target = await resolveSelectorTarget(profileId, [
1211
+ '.input-button .search-icon',
1212
+ '.input-button',
1213
+ 'button.min-width-search-icon',
1214
+ ], { requireViewport: true });
1215
+ if (target && target.center) {
1216
+ await clickPoint(profileId, target.center, { steps: 3 });
1217
+ via = target.selector || 'click';
1218
+ pushTrace({ kind: 'click', stage: 'submit_search', selector: via });
1219
+ } else {
1220
+ await pressKey(profileId, 'Enter');
1221
+ via = 'enter_fallback';
1222
+ pushTrace({ kind: 'key', stage: 'submit_search', key: 'Enter', fallback: true });
1223
+ }
1224
+ } else {
1225
+ await pressKey(profileId, 'Enter');
1226
+ via = 'Enter';
1227
+ pushTrace({ kind: 'key', stage: 'submit_search', key: 'Enter' });
1228
+ }
1229
+
1230
+ await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'submit_settle');
1231
+ const afterUrl = await readLocation(profileId);
1232
+
1233
+ metrics.searchCount = Number(metrics.searchCount || 0) + 1;
1234
+ metrics.lastSearchAt = new Date().toISOString();
1235
+ if (keyword) profileState.keyword = keyword;
1236
+
1237
+ emitActionTrace(context, actionTrace, { stage: 'xhs_submit_search' });
1238
+
1239
+ return {
1240
+ ok: true,
1241
+ code: 'OPERATION_DONE',
1242
+ message: 'xhs_submit_search done',
1243
+ data: {
1244
+ submitted: true,
1245
+ via,
1246
+ beforeUrl,
1247
+ afterUrl,
1248
+ method,
1249
+ searchCount: Number(metrics.searchCount || 0),
1250
+ },
1251
+ };
1252
+ });
1253
+ }
1254
+
1255
+ async function executeOpenDetailOperation({
1256
+ profileId,
1257
+ params = {},
1258
+ context = {},
1259
+ }) {
1260
+ const claimPath = resolveSharedClaimPath(params);
1261
+ const lockKey = claimPath ? `xhs_open_detail:${claimPath}` : '';
1262
+
1263
+ const mapOpenDetailError = (err, paramsRef = {}) => {
1264
+ const message = String(err?.message || err || '');
1265
+ const mode = String(paramsRef?.mode || '').trim().toLowerCase();
1266
+ if (message.includes('AUTOSCRIPT_DONE_NO_MORE_NOTES') || message.includes('AUTOSCRIPT_DONE_MAX_NOTES')) {
1267
+ return {
1268
+ ok: true,
1269
+ code: 'AUTOSCRIPT_DONE_NO_MORE_NOTES',
1270
+ message: 'no more notes',
1271
+ data: { stopReason: 'no_more_notes' },
1272
+ };
1273
+ }
1274
+ if (message.includes('NO_SEARCH_RESULT_ITEM')) {
1275
+ if (mode === 'collect') {
1276
+ return {
1277
+ ok: true,
1278
+ code: 'AUTOSCRIPT_DONE_NO_MORE_NOTES',
1279
+ message: 'no notes collected',
1280
+ data: { stopReason: 'no_more_notes' },
1281
+ };
1282
+ }
1283
+ if (mode === 'first') return null;
1284
+ return {
1285
+ ok: true,
1286
+ code: 'OPERATION_SKIPPED_NO_SEARCH_RESULT_ITEM',
1287
+ message: 'search result item missing',
1288
+ data: { skipped: true },
1289
+ };
1290
+ }
1291
+ return null;
1292
+ };
1293
+
1294
+ const runWithExclude = async (excludeNoteIds) => {
1295
+ const profileState = getProfileState(profileId);
1296
+ const { actionTrace, pushTrace } = buildTraceRecorder();
1297
+ const mode = String(params.mode || 'first').trim().toLowerCase();
1298
+ const maxNotes = Math.max(1, Number(params.maxNotes ?? params.limit ?? 20) || 20);
1299
+ const keyword = String(params.keyword || '').trim();
1300
+ const resume = params.resume !== false;
1301
+ const incrementalMax = params.incrementalMax !== false;
1302
+ const excluded = new Set(normalizeNoteIdList(excludeNoteIds));
1303
+
1304
+ const previousKeyword = String(profileState.keyword || '').trim();
1305
+ const keywordChanged = Boolean(keyword && previousKeyword && keyword !== previousKeyword);
1306
+ if (mode === 'first' || mode === 'collect') {
1307
+ if (!resume || keywordChanged) {
1308
+ profileState.visitedNoteIds = [];
1309
+ profileState.preCollectedNoteIds = [];
1310
+ profileState.preCollectedAt = null;
1311
+ }
1312
+ if (incrementalMax && resume && !keywordChanged) {
1313
+ profileState.maxNotes = Number(profileState.visitedNoteIds.length || 0) + maxNotes;
1314
+ } else {
1315
+ profileState.maxNotes = maxNotes;
1316
+ }
1317
+ } else if (!Number.isFinite(Number(profileState.maxNotes)) || Number(profileState.maxNotes) <= 0) {
1318
+ profileState.maxNotes = maxNotes;
1319
+ }
1320
+ if (keyword) profileState.keyword = keyword;
1321
+
1322
+ if (mode === 'next' && normalizeNoteIdList(profileState.visitedNoteIds).length >= Number(profileState.maxNotes || maxNotes)) {
1323
+ throw new Error('AUTOSCRIPT_DONE_MAX_NOTES');
1324
+ }
1325
+
1326
+ const seedCollectCount = Math.max(0, Number(params.seedCollectCount || 0) || 0);
1327
+ const seedCollectMaxRounds = Math.max(0, Number(params.seedCollectMaxRounds || 0) || 0);
1328
+ const seedCollectStep = Math.max(120, Number(params.seedCollectStep || 420) || 420);
1329
+ const seedCollectSettleMs = Math.max(200, Number(params.seedCollectSettleMs || 480) || 480);
1330
+ const seedResetToTop = params.seedResetToTop !== false;
1331
+ const targetSeedCollectCount = Math.max(1, seedCollectCount || Number(profileState.maxNotes || maxNotes) || maxNotes);
1332
+ const targetSeedCollectMaxRounds = Math.max(
1333
+ 1,
1334
+ seedCollectMaxRounds || Math.max(6, Math.ceil(targetSeedCollectCount / 2)),
1335
+ );
1336
+
1337
+ const seekRounds = Math.max(0, Number(params.nextSeekRounds || 8) || 8);
1338
+ const seekStep = Math.max(240, Number(params.nextSeekStep || 0) || 0) || 0;
1339
+ const seekSettleMs = Math.max(280, Number(params.nextSeekSettleMs || 620) || 620);
1340
+
1341
+ const preClickDelayMinMs = Math.max(500, Number(params.preClickDelayMinMs ?? 600) || 600);
1342
+ const preClickDelayMaxMs = Math.max(preClickDelayMinMs, Number(params.preClickDelayMaxMs ?? 1800) || 1800);
1343
+ const pollDelayMinMs = Math.max(200, Number(params.pollDelayMinMs ?? 260) || 260);
1344
+ const pollDelayMaxMs = Math.max(pollDelayMinMs, Number(params.pollDelayMaxMs ?? 600) || 600);
1345
+ const postOpenDelayMinMs = Math.max(500, Number(params.postOpenDelayMinMs ?? 700) || 700);
1346
+ const postOpenDelayMaxMs = Math.max(postOpenDelayMinMs, Number(params.postOpenDelayMaxMs ?? 1800) || 1800);
1347
+ const collectOpenLinksOnly = params.collectOpenLinksOnly === true;
1348
+
1349
+ const waitDetailReady = async () => {
1350
+ for (let i = 0; i < 60; i += 1) {
1351
+ const snapshot = await isDetailVisible(profileId);
1352
+ if (snapshot?.detailReady === true) return true;
1353
+ await sleep(randomBetween(pollDelayMinMs, pollDelayMaxMs));
1354
+ }
1355
+ return false;
1356
+ };
1357
+
1358
+ const collectVisibleRows = async () => {
1359
+ const snapshot = await readSearchCandidates(profileId);
1360
+ const rows = Array.isArray(snapshot?.rows) ? snapshot.rows : [];
1361
+ return rows;
1362
+ };
1363
+
1364
+ const collectLinksFirst = async () => {
1365
+ const seedCollectedSet = new Set();
1366
+ const collectVisible = async () => {
1367
+ const rows = await collectVisibleRows();
1368
+ for (const row of rows) {
1369
+ if (row?.noteId) seedCollectedSet.add(String(row.noteId));
1370
+ }
1371
+ return rows;
1372
+ };
1373
+
1374
+ let rows = await collectVisible();
1375
+ if (rows.length === 0) throw new Error('NO_SEARCH_RESULT_ITEM');
1376
+
1377
+ for (let round = 0; round < targetSeedCollectMaxRounds && seedCollectedSet.size < targetSeedCollectCount; round += 1) {
1378
+ const center = await resolveSelectorTarget(profileId, ['.search-result-list', '.feeds-page', 'body'], { requireViewport: true });
1379
+ if (center?.center) {
1380
+ await moveMouse(profileId, center.center.x, center.center.y, 2);
1381
+ }
1382
+ pushTrace({ kind: 'scroll', stage: 'collect_links', round: round + 1, deltaY: seedCollectStep });
1383
+ await wheel(profileId, seedCollectStep);
1384
+ await sleep(seedCollectSettleMs);
1385
+ rows = await collectVisible();
1386
+ }
1387
+ if (seedResetToTop) {
1388
+ for (let i = 0; i < 6; i += 1) {
1389
+ pushTrace({ kind: 'scroll', stage: 'collect_links_reset', round: i + 1, deltaY: -900 });
1390
+ await wheel(profileId, -900);
1391
+ await sleep(Math.max(140, Math.floor(seedCollectSettleMs / 2)));
1392
+ }
1393
+ rows = await collectVisible();
1394
+ }
1395
+ const collectedNoteIds = normalizeNoteIdList(Array.from(seedCollectedSet));
1396
+ profileState.preCollectedNoteIds = collectedNoteIds;
1397
+ profileState.preCollectedAt = new Date().toISOString();
1398
+ return { rows, collectedNoteIds };
1399
+ };
1400
+
1401
+ const collectLinksByOpening = async () => {
1402
+ const output = resolveXhsOutputContext({
1403
+ params,
1404
+ state: profileState,
1405
+ noteId: 'links',
1406
+ });
1407
+ const visitedSet = new Set(normalizeNoteIdList(profileState.visitedNoteIds));
1408
+ const collectedSet = new Set();
1409
+ const targetCount = Number(profileState.maxNotes || maxNotes);
1410
+ let stagnantRounds = 0;
1411
+ const maxStagnantRounds = Math.max(8, targetSeedCollectMaxRounds);
1412
+ const collectStallTimeoutMs = Math.max(30_000, Number(params.collectStallTimeoutMs || 180_000) || 180_000);
1413
+ let lastProgressAt = Date.now();
1414
+ let lastVisitedCount = visitedSet.size;
1415
+
1416
+ while (visitedSet.size < targetCount) {
1417
+ emitOperationProgress(context, {
1418
+ kind: 'loop',
1419
+ stage: 'collect_links',
1420
+ visitedCount: visitedSet.size,
1421
+ targetCount,
1422
+ stagnantRounds,
1423
+ stallTimeoutMs: collectStallTimeoutMs,
1424
+ elapsedSinceProgressMs: Math.max(0, Date.now() - lastProgressAt),
1425
+ });
1426
+ if ((Date.now() - lastProgressAt) > collectStallTimeoutMs) {
1427
+ throw new Error(`COLLECT_LINKS_STALL:${visitedSet.size}/${targetCount}`);
1428
+ }
1429
+
1430
+ const rows = await collectVisibleRows();
1431
+ if (rows.length === 0) {
1432
+ if (visitedSet.size === 0) throw new Error('NO_SEARCH_RESULT_ITEM');
1433
+ break;
1434
+ }
1435
+
1436
+ const eligibleInViewport = rows.filter((row) => (
1437
+ row
1438
+ && row.inViewport === true
1439
+ && row.center
1440
+ && !excluded.has(String(row.noteId || '').trim())
1441
+ && !visitedSet.has(String(row.noteId || '').trim())
1442
+ ));
1443
+ const candidateIds = normalizeNoteIdList(eligibleInViewport.map((row) => row.noteId));
1444
+ const processedIds = normalizeNoteIdList(Array.from(visitedSet));
1445
+ await paintSearchCandidates(profileId, {
1446
+ candidateNoteIds: candidateIds,
1447
+ selectedNoteId: '',
1448
+ processedNoteIds: processedIds,
1449
+ });
1450
+
1451
+ if (eligibleInViewport.length === 0) {
1452
+ stagnantRounds += 1;
1453
+ if (stagnantRounds > maxStagnantRounds) break;
1454
+ pushTrace({ kind: 'scroll', stage: 'collect_links_seek_next_page', round: stagnantRounds, deltaY: seedCollectStep });
1455
+ await wheel(profileId, seedCollectStep);
1456
+ await sleep(seedCollectSettleMs);
1457
+ continue;
1458
+ }
1459
+
1460
+ stagnantRounds = 0;
1461
+ const next = eligibleInViewport[Math.floor(Math.random() * eligibleInViewport.length)] || null;
1462
+ if (!next?.center) continue;
1463
+ await paintSearchCandidates(profileId, {
1464
+ candidateNoteIds: candidateIds,
1465
+ selectedNoteId: String(next.noteId || '').trim(),
1466
+ processedNoteIds: processedIds,
1467
+ });
1468
+
1469
+ const beforeUrl = await readLocation(profileId);
1470
+ await sleepRandom(preClickDelayMinMs, preClickDelayMaxMs, pushTrace, 'open_detail_pre_click', { noteId: next.noteId, mode: 'collect' });
1471
+ pushTrace({ kind: 'click', stage: 'open_detail', noteId: next.noteId, selector: 'a.cover', mode: 'collect' });
1472
+ await clickPoint(profileId, next.center, { steps: 4 });
1473
+
1474
+ const detailReady = await waitDetailReady();
1475
+ if (!detailReady) throw new Error('DETAIL_OPEN_TIMEOUT');
1476
+ await sleepRandom(postOpenDelayMinMs, postOpenDelayMaxMs, pushTrace, 'open_detail_post_open', { noteId: next.noteId, mode: 'collect' });
1477
+
1478
+ const afterUrl = await readLocation(profileId);
1479
+ const resolvedNoteId = extractNoteIdFromHref(afterUrl) || String(next.noteId || '').trim();
1480
+ if (!resolvedNoteId) throw new Error('LINK_NOTE_ID_MISSING');
1481
+ if (!String(afterUrl || '').includes('xsec_token=')) {
1482
+ throw new Error(`LINK_WITHOUT_XSEC_TOKEN:${resolvedNoteId}`);
1483
+ }
1484
+
1485
+ visitedSet.add(resolvedNoteId);
1486
+ collectedSet.add(resolvedNoteId);
1487
+ profileState.visitedNoteIds = Array.from(visitedSet);
1488
+ profileState.currentNoteId = resolvedNoteId;
1489
+ profileState.currentHref = afterUrl || null;
1490
+ profileState.lastListUrl = beforeUrl || null;
1491
+ if (visitedSet.size > lastVisitedCount) {
1492
+ lastVisitedCount = visitedSet.size;
1493
+ lastProgressAt = Date.now();
1494
+ }
1495
+
1496
+ await mergeLinksJsonl({
1497
+ filePath: output.linksPath,
1498
+ links: [{
1499
+ noteId: resolvedNoteId,
1500
+ noteUrl: afterUrl,
1501
+ listUrl: beforeUrl,
1502
+ }],
1503
+ });
1504
+
1505
+ await paintSearchCandidates(profileId, {
1506
+ candidateNoteIds: candidateIds,
1507
+ selectedNoteId: '',
1508
+ processedNoteIds: normalizeNoteIdList(Array.from(visitedSet)),
1509
+ });
1510
+
1511
+ const closed = await closeDetailToSearch(profileId, pushTrace);
1512
+ if (!closed) throw new Error(`DETAIL_CLOSE_FAILED:${resolvedNoteId}`);
1513
+ }
1514
+
1515
+ const collectedNoteIds = normalizeNoteIdList(Array.from(collectedSet));
1516
+ profileState.preCollectedNoteIds = collectedNoteIds;
1517
+ profileState.preCollectedAt = new Date().toISOString();
1518
+ await paintSearchCandidates(profileId, {
1519
+ candidateNoteIds: [],
1520
+ selectedNoteId: '',
1521
+ processedNoteIds: normalizeNoteIdList(Array.from(visitedSet)),
1522
+ });
1523
+ return {
1524
+ collectedNoteIds,
1525
+ linksPath: output.linksPath,
1526
+ };
1527
+ };
1528
+
1529
+ let nodes = await collectVisibleRows();
1530
+ if (nodes.length === 0) throw new Error('NO_SEARCH_RESULT_ITEM');
1531
+
1532
+ if (mode === 'collect') {
1533
+ const collected = collectOpenLinksOnly
1534
+ ? await collectLinksByOpening()
1535
+ : await collectLinksFirst();
1536
+ const collectedNoteIds = normalizeNoteIdList(collected?.collectedNoteIds);
1537
+ emitActionTrace(context, actionTrace, { stage: 'xhs_collect_links' });
1538
+ return {
1539
+ operationResult: {
1540
+ ok: true,
1541
+ code: 'OPERATION_DONE',
1542
+ message: 'xhs_collect_links done',
1543
+ data: {
1544
+ collected: collectedNoteIds.length,
1545
+ target: targetSeedCollectCount,
1546
+ maxRounds: targetSeedCollectMaxRounds,
1547
+ noteIds: collectedNoteIds,
1548
+ seedCollectedCount: collectedNoteIds.length,
1549
+ seedCollectedNoteIds: collectedNoteIds,
1550
+ linksPath: collected?.linksPath || null,
1551
+ linksWithXsecToken: collectOpenLinksOnly ? collectedNoteIds.length : 0,
1552
+ searchOnly: true,
1553
+ },
1554
+ },
1555
+ payload: {
1556
+ opened: false,
1557
+ source: 'collect_links',
1558
+ collected: collectedNoteIds.length,
1559
+ target: targetSeedCollectCount,
1560
+ maxRounds: targetSeedCollectMaxRounds,
1561
+ noteIds: collectedNoteIds,
1562
+ seedCollectedCount: collectedNoteIds.length,
1563
+ seedCollectedNoteIds: collectedNoteIds,
1564
+ linksPath: collected?.linksPath || null,
1565
+ linksWithXsecToken: collectOpenLinksOnly ? collectedNoteIds.length : 0,
1566
+ searchOnly: true,
1567
+ },
1568
+ };
1569
+ }
1570
+
1571
+ let preCollectedSet = new Set(normalizeNoteIdList(profileState.preCollectedNoteIds));
1572
+ if (preCollectedSet.size === 0) {
1573
+ const collected = await collectLinksFirst();
1574
+ preCollectedSet = new Set(collected.collectedNoteIds);
1575
+ nodes = collected.rows;
1576
+ }
1577
+
1578
+ const visitedSet = new Set(normalizeNoteIdList(profileState.visitedNoteIds));
1579
+ if (preCollectedSet.size > 0 && mode === 'next') {
1580
+ const pending = Array.from(preCollectedSet).filter((noteId) => !visitedSet.has(noteId) && !excluded.has(noteId));
1581
+ if (pending.length === 0) {
1582
+ throw new Error('AUTOSCRIPT_DONE_NO_MORE_NOTES');
1583
+ }
1584
+ }
1585
+ const isEligible = (row) => {
1586
+ if (!row || typeof row !== 'object') return false;
1587
+ const noteId = String(row.noteId || '').trim();
1588
+ if (!noteId) return false;
1589
+ if (excluded.has(noteId)) return false;
1590
+ if (visitedSet.has(noteId)) return false;
1591
+ if (preCollectedSet.size > 0 && !preCollectedSet.has(noteId)) return false;
1592
+ return true;
1593
+ };
1594
+
1595
+ const pickRandom = (rows) => {
1596
+ if (!Array.isArray(rows) || rows.length === 0) return null;
1597
+ return rows[Math.floor(Math.random() * rows.length)] || null;
1598
+ };
1599
+ const pickNode = (rows) => {
1600
+ const inViewport = rows.filter((row) => isEligible(row) && row.inViewport);
1601
+ if (inViewport.length > 0) return pickRandom(inViewport);
1602
+ const fallback = rows.filter((row) => isEligible(row));
1603
+ return pickRandom(fallback);
1604
+ };
1605
+
1606
+ let next = pickNode(nodes);
1607
+ const dynamicSeekStep = seekStep || Math.max(260, Math.floor((Number(nodes?.[0]?.viewport?.height || 900) || 900) * 0.9));
1608
+ if (!next) {
1609
+ for (let round = 0; round < seekRounds; round += 1) {
1610
+ pushTrace({ kind: 'scroll', stage: 'seek_next_detail', round: round + 1, deltaY: dynamicSeekStep });
1611
+ await wheel(profileId, dynamicSeekStep);
1612
+ await sleep(seekSettleMs);
1613
+ nodes = await collectVisibleRows();
1614
+ next = pickNode(nodes);
1615
+ if (next) break;
1616
+ }
1617
+ }
1618
+
1619
+ if (!next) {
1620
+ throw new Error('AUTOSCRIPT_DONE_NO_MORE_NOTES');
1621
+ }
1622
+
1623
+ const beforeUrl = await readLocation(profileId);
1624
+ await sleepRandom(preClickDelayMinMs, preClickDelayMaxMs, pushTrace, 'open_detail_pre_click', { noteId: next.noteId });
1625
+ pushTrace({ kind: 'click', stage: 'open_detail', noteId: next.noteId, selector: 'a.cover' });
1626
+ await clickPoint(profileId, next.center, { steps: 4 });
1627
+
1628
+ let detailReady = false;
1629
+ for (let i = 0; i < 60; i += 1) {
1630
+ const snapshot = await isDetailVisible(profileId);
1631
+ if (snapshot?.detailReady === true) {
1632
+ detailReady = true;
1633
+ break;
1634
+ }
1635
+ await sleep(randomBetween(pollDelayMinMs, pollDelayMaxMs));
1636
+ }
1637
+ if (!detailReady) {
1638
+ throw new Error('DETAIL_OPEN_TIMEOUT');
1639
+ }
1640
+
1641
+ await sleepRandom(postOpenDelayMinMs, postOpenDelayMaxMs, pushTrace, 'open_detail_post_open', { noteId: next.noteId });
1642
+ const afterUrl = await readLocation(profileId);
1643
+
1644
+ if (!visitedSet.has(next.noteId)) {
1645
+ visitedSet.add(next.noteId);
1646
+ profileState.visitedNoteIds = Array.from(visitedSet);
1647
+ }
1648
+ profileState.currentNoteId = next.noteId;
1649
+ profileState.currentHref = next.href || null;
1650
+ profileState.lastListUrl = beforeUrl || null;
1651
+
1652
+ emitActionTrace(context, actionTrace, { stage: 'xhs_open_detail' });
1653
+
1654
+ return {
1655
+ operationResult: {
1656
+ ok: true,
1657
+ code: 'OPERATION_DONE',
1658
+ message: 'xhs_open_detail done',
1659
+ data: {
1660
+ opened: true,
1661
+ source: mode === 'next' ? 'open_next_detail' : 'open_first_detail',
1662
+ noteId: next.noteId,
1663
+ visited: profileState.visitedNoteIds.length,
1664
+ maxNotes: Number(profileState.maxNotes || maxNotes),
1665
+ openByClick: true,
1666
+ beforeUrl,
1667
+ afterUrl,
1668
+ excludedCount: excluded.size,
1669
+ seedCollectedCount: preCollectedSet.size,
1670
+ seedCollectedNoteIds: Array.from(preCollectedSet),
1671
+ },
1672
+ },
1673
+ payload: {
1674
+ opened: true,
1675
+ source: mode === 'next' ? 'open_next_detail' : 'open_first_detail',
1676
+ noteId: next.noteId,
1677
+ visited: profileState.visitedNoteIds.length,
1678
+ maxNotes: Number(profileState.maxNotes || maxNotes),
1679
+ openByClick: true,
1680
+ beforeUrl,
1681
+ afterUrl,
1682
+ excludedCount: excluded.size,
1683
+ seedCollectedCount: preCollectedSet.size,
1684
+ seedCollectedNoteIds: Array.from(preCollectedSet),
1685
+ },
1686
+ };
1687
+ };
1688
+
1689
+ if (!claimPath) {
1690
+ try {
1691
+ const { operationResult } = await runWithExclude([]);
1692
+ return operationResult;
1693
+ } catch (err) {
1694
+ const mapped = mapOpenDetailError(err, params);
1695
+ if (mapped) return mapped;
1696
+ throw err;
1697
+ }
1698
+ }
1699
+
1700
+ const runLocked = async () => {
1701
+ const claimDoc = await loadSharedClaimDoc(claimPath);
1702
+ const excludeNoteIds = normalizeNoteIdList(claimDoc.noteIds);
1703
+ const { operationResult, payload } = await runWithExclude(excludeNoteIds);
1704
+
1705
+ const claimSet = new Set(excludeNoteIds);
1706
+ const claimAdded = [];
1707
+ const markClaim = (noteId, source = 'open_detail') => {
1708
+ const id = String(noteId || '').trim();
1709
+ if (!id || claimSet.has(id)) return;
1710
+ claimSet.add(id);
1711
+ claimAdded.push(id);
1712
+ claimDoc.byNoteId[id] = {
1713
+ noteId: id,
1714
+ profileId,
1715
+ source,
1716
+ ts: new Date().toISOString(),
1717
+ };
1718
+ };
1719
+
1720
+ const seeded = normalizeNoteIdList(payload.seedCollectedNoteIds);
1721
+ for (const noteId of seeded) markClaim(noteId, 'seed_collect');
1722
+ if (payload.opened === true) markClaim(payload.noteId, 'open_detail');
1723
+ claimDoc.noteIds = Array.from(claimSet);
1724
+ if (claimAdded.length > 0) {
1725
+ await saveSharedClaimDoc(claimPath, claimDoc);
1726
+ }
1727
+
1728
+ const mergedPayload = {
1729
+ ...payload,
1730
+ sharedClaimPath: claimPath,
1731
+ sharedClaimCount: claimDoc.noteIds.length,
1732
+ sharedClaimAdded: claimAdded,
1733
+ dedupExcluded: excludeNoteIds.length,
1734
+ };
1735
+ const mergedData = operationResult.data && typeof operationResult.data === 'object'
1736
+ ? { ...operationResult.data, result: mergedPayload }
1737
+ : { result: mergedPayload };
1738
+
1739
+ return {
1740
+ ...operationResult,
1741
+ data: mergedData,
1742
+ };
1743
+ };
1744
+
1745
+ try {
1746
+ return await withSerializedLock(lockKey, runLocked);
1747
+ } catch (err) {
1748
+ const mapped = mapOpenDetailError(err, params);
1749
+ if (mapped) return mapped;
1750
+ throw err;
1751
+ }
398
1752
  }
399
1753
 
400
- async function executeAssertLoggedInOperation({ profileId, params = {} }) {
401
- const highlight = params.highlight !== false;
402
- const payload = await runEvaluateScript({
403
- profileId,
404
- script: buildAssertLoggedInScript(params),
405
- highlight,
406
- });
407
- const data = extractEvaluateResultData(payload) || {};
408
- if (data?.hasLoginGuard === true) {
409
- const code = String(params.code || 'LOGIN_GUARD_DETECTED').trim() || 'LOGIN_GUARD_DETECTED';
410
- return asErrorPayload('OPERATION_FAILED', code, { guard: data });
1754
+ async function readXhsRuntimeState(profileId) {
1755
+ const state = getProfileState(profileId);
1756
+ return {
1757
+ keyword: state.keyword || null,
1758
+ currentNoteId: state.currentNoteId || null,
1759
+ lastCommentsHarvest: state.lastCommentsHarvest && typeof state.lastCommentsHarvest === 'object'
1760
+ ? state.lastCommentsHarvest
1761
+ : null,
1762
+ };
1763
+ }
1764
+
1765
+ async function executeDetailHarvestOperation({ profileId, context = {} }) {
1766
+ const state = getProfileState(profileId);
1767
+ const { actionTrace } = buildTraceRecorder();
1768
+
1769
+ const detail = await readDetailSnapshot(profileId);
1770
+ const commentsSnapshot = await readCommentsSnapshot(profileId);
1771
+ const elementMeta = buildElementCollectability(detail, commentsSnapshot);
1772
+ if (detail?.noteIdFromUrl) {
1773
+ state.currentNoteId = String(detail.noteIdFromUrl);
411
1774
  }
1775
+ state.lastDetail = {
1776
+ title: String(detail?.title || '').trim().slice(0, 200),
1777
+ contentLength: Number(detail?.contentLength || 0),
1778
+ href: String(detail?.href || '').trim() || null,
1779
+ textPresent: detail?.textPresent === true,
1780
+ imageCount: Number(detail?.imageCount || 0),
1781
+ imageUrls: Array.isArray(detail?.imageUrls) ? detail.imageUrls : [],
1782
+ videoPresent: detail?.videoPresent === true,
1783
+ videoUrl: String(detail?.videoUrl || '').trim() || null,
1784
+ commentsContextAvailable: commentsSnapshot?.hasCommentsContext === true || detail?.commentsContextAvailable === true,
1785
+ collectability: elementMeta.collectability,
1786
+ skippedElements: elementMeta.skippedElements,
1787
+ fallbackCaptured: elementMeta.fallbackCaptured,
1788
+ capturedAt: detail?.capturedAt || new Date().toISOString(),
1789
+ };
1790
+
1791
+ emitActionTrace(context, actionTrace, { stage: 'xhs_detail_harvest' });
1792
+
412
1793
  return {
413
1794
  ok: true,
414
1795
  code: 'OPERATION_DONE',
415
- message: 'xhs_assert_logged_in done',
416
- data,
1796
+ message: 'xhs_detail_harvest done',
1797
+ data: {
1798
+ harvested: true,
1799
+ detail: state.lastDetail,
1800
+ collectability: elementMeta.collectability,
1801
+ skippedElements: elementMeta.skippedElements,
1802
+ fallbackCaptured: elementMeta.fallbackCaptured,
1803
+ },
417
1804
  };
418
1805
  }
419
1806
 
420
- async function handleRaiseError({ params }) {
421
- const code = String(params.code || params.message || 'AUTOSCRIPT_ABORT').trim();
422
- return asErrorPayload('OPERATION_FAILED', code || 'AUTOSCRIPT_ABORT');
1807
+ async function executeExpandRepliesOperation({ profileId, context = {} }) {
1808
+ const { actionTrace, pushTrace } = buildTraceRecorder();
1809
+ const seen = new Set();
1810
+ let expanded = 0;
1811
+ let scanned = 0;
1812
+
1813
+ for (let round = 0; round < 8; round += 1) {
1814
+ const snapshot = await readExpandButtons(profileId);
1815
+ const rows = Array.isArray(snapshot?.rows) ? snapshot.rows : [];
1816
+ scanned = Math.max(scanned, rows.length);
1817
+ const next = rows.find((row) => row && row.center && !seen.has(row.signature));
1818
+ if (!next) break;
1819
+ seen.add(next.signature);
1820
+
1821
+ await moveMouse(profileId, next.center.x, next.center.y, 2);
1822
+ await sleepRandom(500, 1200, pushTrace, 'expand_pre_click', { round: round + 1, text: String(next.text || '').slice(0, 40) });
1823
+ await clickPoint(profileId, next.center, { steps: 3 });
1824
+ pushTrace({ kind: 'click', stage: 'xhs_expand_replies', round: round + 1, text: String(next.text || '').slice(0, 40) });
1825
+ expanded += 1;
1826
+ await sleepRandom(700, 1800, pushTrace, 'expand_post_click', { round: round + 1 });
1827
+ }
1828
+
1829
+ emitActionTrace(context, actionTrace, { stage: 'xhs_expand_replies' });
1830
+
1831
+ return {
1832
+ ok: true,
1833
+ code: 'OPERATION_DONE',
1834
+ message: 'xhs_expand_replies done',
1835
+ data: {
1836
+ expanded,
1837
+ scanned,
1838
+ },
1839
+ };
423
1840
  }
424
1841
 
425
1842
  async function executeCommentsHarvestOperation({
@@ -427,48 +1844,375 @@ async function executeCommentsHarvestOperation({
427
1844
  params = {},
428
1845
  context = {},
429
1846
  }) {
430
- const script = buildCommentsHarvestScript(params);
431
- const highlight = params.highlight !== false;
432
- const operationResult = await evaluateWithScript({
433
- profileId,
434
- script,
435
- message: 'xhs_comments_harvest done',
436
- highlight,
437
- });
1847
+ const state = getProfileState(profileId);
1848
+ const metricsState = state.metrics || (state.metrics = {});
1849
+ metricsState.searchCount = Number(metricsState.searchCount || 0);
1850
+
1851
+ const { actionTrace, pushTrace } = buildTraceRecorder();
438
1852
 
439
- const payload = extractEvaluateResultData(operationResult.data) || {};
440
- const actionTrace = Array.isArray(payload.actionTrace) ? payload.actionTrace : [];
441
- if (actionTrace.length > 0) {
1853
+ const maxRounds = Math.max(1, Number(params.maxRounds ?? params.maxScrollRounds ?? 24) || 24);
1854
+ const scrollStepMin = Math.max(120, Number(params.scrollStepMin ?? params.scrollStep ?? 420) || 420);
1855
+ const scrollStepMax = Math.max(scrollStepMin, Number(params.scrollStepMax ?? scrollStepMin) || scrollStepMin);
1856
+ const settleMinMs = Math.max(500, Number(params.settleMinMs ?? params.settleMs ?? 900) || 900);
1857
+ const settleMaxMs = Math.max(settleMinMs, Number(params.settleMaxMs ?? 2200) || 2200);
1858
+ const stallRounds = Math.max(2, Number(params.stallRounds ?? 5) || 5);
1859
+ const requireBottom = params.requireBottom !== false;
1860
+ const includeComments = params.includeComments !== false;
1861
+ const commentsLimit = Math.max(0, Number(params.commentsLimit ?? 0) || 0);
1862
+ const detailSnapshot = await readDetailSnapshot(profileId).catch(() => ({}));
1863
+
1864
+ let detailVisible = false;
1865
+ let commentsReady = false;
1866
+ let precheckSnapshot = null;
1867
+ for (let probe = 0; probe < 40; probe += 1) {
1868
+ const snapshot = await readCommentsSnapshot(profileId);
1869
+ precheckSnapshot = snapshot;
1870
+ if (snapshot?.detailVisible === true) {
1871
+ detailVisible = true;
1872
+ }
1873
+ if (snapshot?.detailVisible === true && snapshot?.hasCommentsContext === true) {
1874
+ commentsReady = true;
1875
+ break;
1876
+ }
1877
+ await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'comments_precheck', { probe: probe + 1 });
1878
+ }
1879
+ const elementMeta = buildElementCollectability(detailSnapshot, precheckSnapshot);
1880
+ const returnSkippedComments = (commentsSkippedReason) => {
1881
+ const now = new Date().toISOString();
1882
+ state.currentComments = [];
1883
+ state.commentsCollectedAt = now;
1884
+ state.lastCommentsHarvest = {
1885
+ noteId: state.currentNoteId || detailSnapshot?.noteIdFromUrl || null,
1886
+ searchCount: Number(metricsState.searchCount || 0),
1887
+ collected: 0,
1888
+ expectedCommentsCount: Number.isFinite(Number(precheckSnapshot?.expectedCommentsCount))
1889
+ ? Number(precheckSnapshot.expectedCommentsCount)
1890
+ : null,
1891
+ commentCoverageRate: null,
1892
+ recoveries: 0,
1893
+ maxRecoveries: Math.max(0, Number(params.maxRecoveries ?? 2) || 2),
1894
+ reachedBottom: false,
1895
+ exitReason: commentsSkippedReason,
1896
+ rounds: 0,
1897
+ configuredMaxRounds: maxRounds,
1898
+ maxRounds,
1899
+ maxRoundsSource: 'configured',
1900
+ budgetExpectedCommentsCount: null,
1901
+ scroll: precheckSnapshot?.metrics && typeof precheckSnapshot.metrics === 'object'
1902
+ ? {
1903
+ scrollTop: Number(precheckSnapshot.metrics.scrollTop || 0),
1904
+ scrollHeight: Number(precheckSnapshot.metrics.scrollHeight || 0),
1905
+ clientHeight: Number(precheckSnapshot.metrics.clientHeight || 0),
1906
+ }
1907
+ : { scrollTop: 0, scrollHeight: 0, clientHeight: 0 },
1908
+ collectability: elementMeta.collectability,
1909
+ skippedElements: elementMeta.skippedElements,
1910
+ fallbackCaptured: elementMeta.fallbackCaptured,
1911
+ commentsSkippedReason,
1912
+ at: now,
1913
+ };
1914
+ let payload = {
1915
+ noteId: state.currentNoteId || detailSnapshot?.noteIdFromUrl || null,
1916
+ searchCount: Number(metricsState.searchCount || 0),
1917
+ collected: 0,
1918
+ expectedCommentsCount: state.lastCommentsHarvest.expectedCommentsCount,
1919
+ commentCoverageRate: null,
1920
+ recoveries: 0,
1921
+ maxRecoveries: state.lastCommentsHarvest.maxRecoveries,
1922
+ firstComment: null,
1923
+ reachedBottom: false,
1924
+ exitReason: commentsSkippedReason,
1925
+ commentsSkippedReason,
1926
+ rounds: 0,
1927
+ configuredMaxRounds: maxRounds,
1928
+ maxRounds,
1929
+ maxRoundsSource: 'configured',
1930
+ budgetExpectedCommentsCount: null,
1931
+ scroll: state.lastCommentsHarvest.scroll,
1932
+ collectability: elementMeta.collectability,
1933
+ skippedElements: elementMeta.skippedElements,
1934
+ fallbackCaptured: elementMeta.fallbackCaptured,
1935
+ actionTrace,
1936
+ };
1937
+ if (includeComments) {
1938
+ payload = {
1939
+ ...payload,
1940
+ comments: [],
1941
+ commentsTruncated: false,
1942
+ };
1943
+ }
442
1944
  emitActionTrace(context, actionTrace, { stage: 'xhs_comments_harvest' });
443
- delete payload.actionTrace;
1945
+ return {
1946
+ ok: true,
1947
+ code: 'OPERATION_DONE',
1948
+ message: 'xhs_comments_harvest done',
1949
+ data: {
1950
+ ...payload,
1951
+ commentsPath: null,
1952
+ commentsAdded: 0,
1953
+ commentsTotal: 0,
1954
+ },
1955
+ };
1956
+ };
1957
+ if (!detailVisible) {
1958
+ return returnSkippedComments('detail_not_ready_before_scroll');
1959
+ }
1960
+ if (!commentsReady) {
1961
+ return returnSkippedComments('comments_context_missing');
1962
+ }
1963
+
1964
+ const commentMap = new Map();
1965
+ let rounds = 0;
1966
+ let reachedBottom = false;
1967
+ let exitReason = 'max_rounds_reached';
1968
+ let noProgressRounds = 0;
1969
+ let recoveries = 0;
1970
+ const maxRecoveries = Math.max(0, Number(params.maxRecoveries ?? 2) || 2);
1971
+
1972
+ let expectedCommentsCount = null;
1973
+ let lastMetrics = { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
1974
+ let lastScrollerCenter = null;
1975
+
1976
+ for (let round = 1; round <= maxRounds; round += 1) {
1977
+ rounds = round;
1978
+ const beforeSnapshot = await readCommentsSnapshot(profileId);
1979
+ if (beforeSnapshot?.detailVisible !== true) {
1980
+ exitReason = 'detail_hidden';
1981
+ break;
1982
+ }
1983
+
1984
+ if (Number.isFinite(Number(beforeSnapshot?.expectedCommentsCount)) && Number(beforeSnapshot.expectedCommentsCount) >= 0) {
1985
+ expectedCommentsCount = Number(beforeSnapshot.expectedCommentsCount);
1986
+ }
1987
+
1988
+ const beforeCount = commentMap.size;
1989
+ const rows = Array.isArray(beforeSnapshot?.rows) ? beforeSnapshot.rows : [];
1990
+ for (const row of rows) {
1991
+ if (!row || typeof row !== 'object') continue;
1992
+ const text = normalizeInlineText(row.text);
1993
+ if (!text) continue;
1994
+ const author = sanitizeAuthorText(row.userName || '', text);
1995
+ const key = `${author}::${text}`;
1996
+ if (commentMap.has(key)) continue;
1997
+ commentMap.set(key, {
1998
+ index: Number(row.index),
1999
+ userName: author,
2000
+ userId: String(row.userId || ''),
2001
+ text,
2002
+ timestamp: String(row.timestamp || ''),
2003
+ liked: row.alreadyLiked === true,
2004
+ firstSeenRound: round,
2005
+ });
2006
+ }
2007
+
2008
+ lastMetrics = beforeSnapshot?.metrics && typeof beforeSnapshot.metrics === 'object'
2009
+ ? {
2010
+ scrollTop: Number(beforeSnapshot.metrics.scrollTop || 0),
2011
+ scrollHeight: Number(beforeSnapshot.metrics.scrollHeight || 0),
2012
+ clientHeight: Number(beforeSnapshot.metrics.clientHeight || 0),
2013
+ }
2014
+ : lastMetrics;
2015
+ lastScrollerCenter = beforeSnapshot?.scrollerCenter && typeof beforeSnapshot.scrollerCenter === 'object'
2016
+ ? beforeSnapshot.scrollerCenter
2017
+ : lastScrollerCenter;
2018
+
2019
+ const beforeDiff = Number(lastMetrics.scrollHeight - (lastMetrics.scrollTop + lastMetrics.clientHeight));
2020
+ if (Number.isFinite(beforeDiff) && beforeDiff <= 6) {
2021
+ reachedBottom = true;
2022
+ exitReason = 'bottom_reached';
2023
+ break;
2024
+ }
2025
+
2026
+ if (commentsLimit > 0 && commentMap.size >= commentsLimit && !requireBottom) {
2027
+ exitReason = 'comments_limit_reached';
2028
+ break;
2029
+ }
2030
+
2031
+ if (lastScrollerCenter?.x && lastScrollerCenter?.y) {
2032
+ await moveMouse(profileId, lastScrollerCenter.x, lastScrollerCenter.y, 2);
2033
+ }
2034
+ const roundStep = randomBetween(scrollStepMin, scrollStepMax);
2035
+ pushTrace({ kind: 'scroll', stage: 'xhs_comments_harvest', round, deltaY: roundStep });
2036
+ await wheel(profileId, roundStep);
2037
+ await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'comments_settle', { round });
2038
+
2039
+ const afterSnapshot = await readCommentsSnapshot(profileId);
2040
+ const afterRows = Array.isArray(afterSnapshot?.rows) ? afterSnapshot.rows : [];
2041
+ for (const row of afterRows) {
2042
+ if (!row || typeof row !== 'object') continue;
2043
+ const text = normalizeInlineText(row.text);
2044
+ if (!text) continue;
2045
+ const author = sanitizeAuthorText(row.userName || '', text);
2046
+ const key = `${author}::${text}`;
2047
+ if (commentMap.has(key)) continue;
2048
+ commentMap.set(key, {
2049
+ index: Number(row.index),
2050
+ userName: author,
2051
+ userId: String(row.userId || ''),
2052
+ text,
2053
+ timestamp: String(row.timestamp || ''),
2054
+ liked: row.alreadyLiked === true,
2055
+ firstSeenRound: round,
2056
+ });
2057
+ }
2058
+
2059
+ const afterMetrics = afterSnapshot?.metrics && typeof afterSnapshot.metrics === 'object'
2060
+ ? {
2061
+ scrollTop: Number(afterSnapshot.metrics.scrollTop || 0),
2062
+ scrollHeight: Number(afterSnapshot.metrics.scrollHeight || 0),
2063
+ clientHeight: Number(afterSnapshot.metrics.clientHeight || 0),
2064
+ }
2065
+ : lastMetrics;
2066
+ lastMetrics = afterMetrics;
2067
+
2068
+ const moved = Math.abs(Number(afterMetrics.scrollTop || 0) - Number(beforeSnapshot?.metrics?.scrollTop || 0)) > 1;
2069
+ const increased = commentMap.size > beforeCount;
2070
+ if (!moved && !increased) {
2071
+ noProgressRounds += 1;
2072
+ } else {
2073
+ noProgressRounds = 0;
2074
+ }
2075
+
2076
+ const afterDiff = Number(afterMetrics.scrollHeight - (afterMetrics.scrollTop + afterMetrics.clientHeight));
2077
+ if (Number.isFinite(afterDiff) && afterDiff <= 6) {
2078
+ reachedBottom = true;
2079
+ exitReason = 'bottom_reached';
2080
+ break;
2081
+ }
2082
+
2083
+ if (commentsLimit > 0 && commentMap.size >= commentsLimit && !requireBottom) {
2084
+ exitReason = 'comments_limit_reached';
2085
+ break;
2086
+ }
2087
+
2088
+ if (noProgressRounds >= stallRounds) {
2089
+ if (recoveries < maxRecoveries) {
2090
+ recoveries += 1;
2091
+ noProgressRounds = 0;
2092
+ pushTrace({ kind: 'scroll', stage: 'xhs_comments_harvest_recovery', round, recovery: recoveries, deltaY: -420 });
2093
+ await wheel(profileId, -420);
2094
+ await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'comments_recovery_settle', { round, recovery: recoveries });
2095
+ pushTrace({ kind: 'scroll', stage: 'xhs_comments_harvest_recovery', round, recovery: recoveries, deltaY: 760 });
2096
+ await wheel(profileId, 760);
2097
+ await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'comments_recovery_settle', { round, recovery: recoveries, pass: 'down' });
2098
+ } else if (!requireBottom) {
2099
+ exitReason = 'no_new_comments';
2100
+ break;
2101
+ } else {
2102
+ exitReason = 'scroll_stalled_after_recovery';
2103
+ break;
2104
+ }
2105
+ }
2106
+
2107
+ if (round === maxRounds) {
2108
+ exitReason = 'max_rounds_reached';
2109
+ }
2110
+ }
2111
+
2112
+ const comments = Array.from(commentMap.values())
2113
+ .sort((a, b) => Number(a.firstSeenRound || 0) - Number(b.firstSeenRound || 0))
2114
+ .map((item, index) => ({
2115
+ index,
2116
+ author: item.userName,
2117
+ userName: item.userName,
2118
+ userId: item.userId,
2119
+ text: item.text,
2120
+ liked: item.liked,
2121
+ timestamp: item.timestamp,
2122
+ }));
2123
+
2124
+ const commentCoverageRate = Number.isFinite(Number(expectedCommentsCount)) && Number(expectedCommentsCount) > 0
2125
+ ? Number(Math.min(1, comments.length / Number(expectedCommentsCount)).toFixed(4))
2126
+ : null;
2127
+
2128
+ state.currentComments = comments;
2129
+ state.commentsCollectedAt = new Date().toISOString();
2130
+ state.lastCommentsHarvest = {
2131
+ noteId: state.currentNoteId || null,
2132
+ searchCount: Number(metricsState.searchCount || 0),
2133
+ collected: comments.length,
2134
+ expectedCommentsCount,
2135
+ commentCoverageRate,
2136
+ recoveries,
2137
+ maxRecoveries,
2138
+ reachedBottom,
2139
+ exitReason,
2140
+ rounds,
2141
+ configuredMaxRounds: maxRounds,
2142
+ maxRounds,
2143
+ maxRoundsSource: 'configured',
2144
+ budgetExpectedCommentsCount: expectedCommentsCount,
2145
+ scroll: lastMetrics,
2146
+ collectability: elementMeta.collectability,
2147
+ skippedElements: elementMeta.skippedElements,
2148
+ fallbackCaptured: elementMeta.fallbackCaptured,
2149
+ commentsSkippedReason: null,
2150
+ at: state.commentsCollectedAt,
2151
+ };
2152
+
2153
+ let payload = {
2154
+ noteId: state.currentNoteId || null,
2155
+ searchCount: Number(metricsState.searchCount || 0),
2156
+ collected: comments.length,
2157
+ expectedCommentsCount,
2158
+ commentCoverageRate,
2159
+ recoveries,
2160
+ maxRecoveries,
2161
+ firstComment: comments[0] || null,
2162
+ reachedBottom,
2163
+ exitReason,
2164
+ rounds,
2165
+ configuredMaxRounds: maxRounds,
2166
+ maxRounds,
2167
+ maxRoundsSource: 'configured',
2168
+ budgetExpectedCommentsCount: expectedCommentsCount,
2169
+ scroll: lastMetrics,
2170
+ collectability: elementMeta.collectability,
2171
+ skippedElements: elementMeta.skippedElements,
2172
+ fallbackCaptured: elementMeta.fallbackCaptured,
2173
+ commentsSkippedReason: null,
2174
+ actionTrace,
2175
+ };
2176
+ if (includeComments) {
2177
+ const bounded = commentsLimit > 0 ? comments.slice(0, commentsLimit) : comments;
2178
+ payload = {
2179
+ ...payload,
2180
+ comments: bounded,
2181
+ commentsTruncated: commentsLimit > 0 && comments.length > commentsLimit,
2182
+ };
444
2183
  }
2184
+
2185
+ emitActionTrace(context, actionTrace, { stage: 'xhs_comments_harvest' });
2186
+
445
2187
  const shouldPersistComments = params.persistComments === true || params.persistCollectedComments === true;
446
- const includeComments = params.includeComments !== false;
447
- const comments = Array.isArray(payload.comments) ? payload.comments : [];
2188
+ const includePayloadComments = params.includeComments !== false;
2189
+ const payloadComments = Array.isArray(payload.comments) ? payload.comments : [];
448
2190
 
449
- if (!shouldPersistComments || !includeComments || comments.length === 0) {
2191
+ if (!shouldPersistComments || !includePayloadComments || payloadComments.length === 0) {
450
2192
  return {
451
- ...operationResult,
452
- data: replaceEvaluateResultData(operationResult.data, {
2193
+ ok: true,
2194
+ code: 'OPERATION_DONE',
2195
+ message: 'xhs_comments_harvest done',
2196
+ data: {
453
2197
  ...payload,
454
2198
  commentsPath: null,
455
2199
  commentsAdded: 0,
456
- commentsTotal: Number(payload.collected || comments.length || 0),
457
- }),
2200
+ commentsTotal: Number(payload.collected || payloadComments.length || 0),
2201
+ },
458
2202
  };
459
2203
  }
460
2204
 
461
- const state = await readXhsRuntimeState(profileId);
2205
+ const runtimeState = await readXhsRuntimeState(profileId);
462
2206
  const output = resolveXhsOutputContext({
463
2207
  params,
464
- state,
465
- noteId: payload.noteId || state.currentNoteId || params.noteId,
2208
+ state: runtimeState,
2209
+ noteId: payload.noteId || runtimeState.currentNoteId || params.noteId,
466
2210
  });
467
2211
 
468
2212
  const merged = await mergeCommentsJsonl({
469
2213
  filePath: output.commentsPath,
470
2214
  noteId: output.noteId,
471
- comments,
2215
+ comments: payloadComments,
472
2216
  });
473
2217
 
474
2218
  return {
@@ -485,18 +2229,494 @@ async function executeCommentsHarvestOperation({
485
2229
  };
486
2230
  }
487
2231
 
2232
+ async function executeCommentMatchOperation({ profileId, params = {} }) {
2233
+ const state = getProfileState(profileId);
2234
+ const rows = Array.isArray(state.currentComments) ? state.currentComments : [];
2235
+ const keywords = normalizeArray(params.keywords || params.matchKeywords)
2236
+ .map((item) => String(item || '').trim())
2237
+ .filter(Boolean);
2238
+ if (keywords.length === 0) {
2239
+ const text = String(params.keywords || params.matchKeywords || '').trim();
2240
+ if (text) {
2241
+ for (const token of text.split(',')) {
2242
+ const normalized = String(token || '').trim();
2243
+ if (normalized) keywords.push(normalized);
2244
+ }
2245
+ }
2246
+ }
2247
+
2248
+ const mode = String(params.mode || params.matchMode || 'any').trim();
2249
+ const minHits = Math.max(1, Number(params.minHits ?? params.matchMinHits ?? 1) || 1);
2250
+ const tokens = keywords
2251
+ .map((item) => String(item || '').toLowerCase().replace(/\s+/g, ' ').trim())
2252
+ .filter(Boolean);
2253
+
2254
+ const matches = [];
2255
+ for (const row of rows) {
2256
+ const text = String(row?.text || row?.content || '').toLowerCase().replace(/\s+/g, ' ').trim();
2257
+ if (!text || tokens.length === 0) continue;
2258
+ const hits = tokens.filter((token) => text.includes(token));
2259
+ if (mode === 'all' && hits.length < tokens.length) continue;
2260
+ if (mode === 'atLeast' && hits.length < minHits) continue;
2261
+ if (mode !== 'all' && mode !== 'atLeast' && hits.length === 0) continue;
2262
+ matches.push({ index: Number(row?.index || 0), hits });
2263
+ }
2264
+
2265
+ state.matchedComments = matches;
2266
+ state.matchRule = { tokens, mode, minHits };
2267
+
2268
+ return {
2269
+ ok: true,
2270
+ code: 'OPERATION_DONE',
2271
+ message: 'xhs_comment_match done',
2272
+ data: {
2273
+ matchCount: matches.length,
2274
+ mode,
2275
+ minHits,
2276
+ },
2277
+ };
2278
+ }
2279
+
2280
+ async function executeCommentLikeOperation({ profileId, params = {}, context = {} }) {
2281
+ const state = getProfileState(profileId);
2282
+ const maxLikes = Math.max(1, Number(params.maxLikes ?? params.maxLikesPerRound ?? 1) || 1);
2283
+ const rawKeywords = normalizeArray(params.keywords || params.likeKeywords);
2284
+ const rules = compileLikeRules(rawKeywords);
2285
+ const dryRun = params.dryRun === true;
2286
+ const saveEvidence = params.saveEvidence !== false;
2287
+ const persistLikeState = params.persistLikeState !== false;
2288
+ const persistComments = params.persistComments === true || params.persistCollectedComments === true;
2289
+ const fallbackPickOne = params.pickOneIfNoNew !== false;
2290
+
2291
+ const snapshot = await readCommentsSnapshot(profileId);
2292
+ const rows = Array.isArray(snapshot?.rows) ? snapshot.rows : [];
2293
+ if (rows.length > 0) {
2294
+ state.currentComments = rows.map((row, idx) => ({
2295
+ index: Number(row.index ?? idx),
2296
+ userName: String(row.userName || ''),
2297
+ userId: String(row.userId || ''),
2298
+ text: String(row.text || ''),
2299
+ timestamp: String(row.timestamp || ''),
2300
+ liked: row.alreadyLiked === true,
2301
+ }));
2302
+ }
2303
+
2304
+ const runtimeState = await readXhsRuntimeState(profileId);
2305
+ const output = resolveXhsOutputContext({
2306
+ params,
2307
+ state: runtimeState,
2308
+ noteId: snapshot?.noteIdFromUrl || runtimeState.currentNoteId || params.noteId,
2309
+ });
2310
+ const evidenceDir = dryRun ? output.virtualLikeEvidenceDir : output.likeEvidenceDir;
2311
+ if (saveEvidence) {
2312
+ await ensureDir(evidenceDir);
2313
+ }
2314
+
2315
+ const likedSignatures = persistLikeState ? await loadLikedSignatures(output.likeStatePath) : new Set();
2316
+ const likedComments = [];
2317
+
2318
+ let hitCount = 0;
2319
+ let likedCount = 0;
2320
+ let dedupSkipped = 0;
2321
+ let alreadyLikedSkipped = 0;
2322
+ let missingLikeControl = 0;
2323
+ let clickFailed = 0;
2324
+ let verifyFailed = 0;
2325
+
2326
+ if (persistComments && rows.length > 0) {
2327
+ await mergeCommentsJsonl({
2328
+ filePath: output.commentsPath,
2329
+ noteId: output.noteId,
2330
+ comments: rows,
2331
+ }).catch(() => null);
2332
+ }
2333
+
2334
+ const { actionTrace, pushTrace } = buildTraceRecorder();
2335
+
2336
+ const candidates = rows.map((row) => ({
2337
+ ...row,
2338
+ text: normalizeText(row.text),
2339
+ })).filter((row) => row.text);
2340
+
2341
+ const tryLikeRow = async (row, matchedRule = 'fallback') => {
2342
+ const signature = makeLikeSignature({
2343
+ noteId: output.noteId,
2344
+ userId: String(row.userId || ''),
2345
+ userName: String(row.userName || ''),
2346
+ text: row.text,
2347
+ });
2348
+
2349
+ if (signature && likedSignatures.has(signature)) {
2350
+ dedupSkipped += 1;
2351
+ return false;
2352
+ }
2353
+
2354
+ if (!row.hasLikeControl) {
2355
+ missingLikeControl += 1;
2356
+ return false;
2357
+ }
2358
+
2359
+ if (row.alreadyLiked) {
2360
+ alreadyLikedSkipped += 1;
2361
+ if (persistLikeState && signature) {
2362
+ likedSignatures.add(signature);
2363
+ await appendLikedSignature(output.likeStatePath, signature, {
2364
+ noteId: output.noteId,
2365
+ userId: String(row.userId || ''),
2366
+ userName: String(row.userName || ''),
2367
+ reason: 'already_liked',
2368
+ }).catch(() => null);
2369
+ }
2370
+ return false;
2371
+ }
2372
+
2373
+ if (dryRun) return false;
2374
+
2375
+ const beforePath = saveEvidence
2376
+ ? await captureScreenshotToFile({
2377
+ profileId,
2378
+ filePath: path.join(evidenceDir, `like-before-idx-${String(row.index).padStart(3, '0')}-${Date.now()}.png`),
2379
+ })
2380
+ : null;
2381
+
2382
+ const targetBefore = await readLikeTargetByIndex(profileId, row.index);
2383
+ if (!targetBefore || targetBefore.ok !== true || !targetBefore.center) {
2384
+ clickFailed += 1;
2385
+ return false;
2386
+ }
2387
+ if (targetBefore.alreadyLiked) {
2388
+ alreadyLikedSkipped += 1;
2389
+ return false;
2390
+ }
2391
+
2392
+ await clickPoint(profileId, targetBefore.center, { steps: 4 });
2393
+ pushTrace({ kind: 'click', stage: 'xhs_comment_like', commentIndex: Number(row.index) });
2394
+ await sleepRandom(500, 1600, pushTrace, 'like_post_click', { commentIndex: Number(row.index) });
2395
+
2396
+ const targetAfter = await readLikeTargetByIndex(profileId, row.index);
2397
+ const afterPath = saveEvidence
2398
+ ? await captureScreenshotToFile({
2399
+ profileId,
2400
+ filePath: path.join(evidenceDir, `like-after-idx-${String(row.index).padStart(3, '0')}-${Date.now()}.png`),
2401
+ })
2402
+ : null;
2403
+
2404
+ if (!targetAfter || targetAfter.ok !== true) {
2405
+ verifyFailed += 1;
2406
+ return false;
2407
+ }
2408
+ if (!targetAfter.alreadyLiked) {
2409
+ verifyFailed += 1;
2410
+ return false;
2411
+ }
2412
+
2413
+ likedCount += 1;
2414
+ if (persistLikeState && signature) {
2415
+ likedSignatures.add(signature);
2416
+ await appendLikedSignature(output.likeStatePath, signature, {
2417
+ noteId: output.noteId,
2418
+ userId: String(row.userId || ''),
2419
+ userName: String(row.userName || ''),
2420
+ reason: 'liked',
2421
+ }).catch(() => null);
2422
+ }
2423
+ likedComments.push({
2424
+ index: Number(row.index),
2425
+ userId: String(row.userId || ''),
2426
+ userName: String(row.userName || ''),
2427
+ content: row.text,
2428
+ timestamp: String(row.timestamp || ''),
2429
+ matchedRule,
2430
+ screenshots: {
2431
+ before: beforePath,
2432
+ after: afterPath,
2433
+ },
2434
+ });
2435
+ return true;
2436
+ };
2437
+
2438
+ for (const row of candidates) {
2439
+ if (likedCount >= maxLikes) break;
2440
+ let match = null;
2441
+ if (rules.length === 0) {
2442
+ const matchedByState = Array.isArray(state.matchedComments)
2443
+ && state.matchedComments.some((item) => Number(item?.index) === Number(row.index));
2444
+ if (!matchedByState) continue;
2445
+ match = { ok: true, reason: 'state_match', matchedRule: 'state_match' };
2446
+ } else {
2447
+ match = matchLikeText(row.text, rules);
2448
+ if (!match.ok) continue;
2449
+ }
2450
+ hitCount += 1;
2451
+ await tryLikeRow(row, match.matchedRule || match.reason || 'match');
2452
+ }
2453
+
2454
+ if (!dryRun && fallbackPickOne && likedCount < maxLikes) {
2455
+ for (const row of candidates) {
2456
+ if (likedCount >= maxLikes) break;
2457
+ hitCount += 1;
2458
+ const ok = await tryLikeRow(row, 'fallback_first_available');
2459
+ if (ok) break;
2460
+ }
2461
+ }
2462
+
2463
+ emitActionTrace(context, actionTrace, { stage: 'xhs_comment_like', noteId: output.noteId });
2464
+
2465
+ const skippedCount = missingLikeControl + clickFailed + verifyFailed;
2466
+ const likedTotal = likedCount + dedupSkipped + alreadyLikedSkipped;
2467
+ const hitCheckOk = likedTotal + skippedCount === hitCount;
2468
+ const summary = {
2469
+ noteId: output.noteId,
2470
+ keyword: output.keyword,
2471
+ env: output.env,
2472
+ likeKeywords: rawKeywords,
2473
+ maxLikes,
2474
+ scannedCount: rows.length,
2475
+ hitCount,
2476
+ likedCount,
2477
+ skippedCount,
2478
+ likedTotal,
2479
+ hitCheckOk,
2480
+ skippedBreakdown: {
2481
+ missingLikeControl,
2482
+ clickFailed,
2483
+ verifyFailed,
2484
+ },
2485
+ likedBreakdown: {
2486
+ newLikes: likedCount,
2487
+ alreadyLiked: alreadyLikedSkipped,
2488
+ dedup: dedupSkipped,
2489
+ },
2490
+ reachedBottom: snapshot?.metrics
2491
+ ? Number(snapshot.metrics.scrollHeight || 0) - (Number(snapshot.metrics.scrollTop || 0) + Number(snapshot.metrics.clientHeight || 0)) <= 6
2492
+ : false,
2493
+ stopReason: String(state.lastCommentsHarvest?.exitReason || '').trim() || null,
2494
+ likedComments,
2495
+ ts: new Date().toISOString(),
2496
+ };
2497
+
2498
+ let summaryPath = null;
2499
+ if (saveEvidence) {
2500
+ summaryPath = await writeJsonFile(path.join(evidenceDir, `summary-${Date.now()}.json`), summary).catch(() => null);
2501
+ }
2502
+
2503
+ return {
2504
+ ok: true,
2505
+ code: 'OPERATION_DONE',
2506
+ message: 'xhs_comment_like done',
2507
+ data: {
2508
+ noteId: output.noteId,
2509
+ scannedCount: rows.length,
2510
+ hitCount,
2511
+ likedCount,
2512
+ skippedCount,
2513
+ likedTotal,
2514
+ hitCheckOk,
2515
+ dedupSkipped,
2516
+ alreadyLikedSkipped,
2517
+ missingLikeControl,
2518
+ clickFailed,
2519
+ verifyFailed,
2520
+ likedComments,
2521
+ commentsPath: persistComments ? output.commentsPath : null,
2522
+ likeStatePath: persistLikeState ? output.likeStatePath : null,
2523
+ evidenceDir: saveEvidence ? evidenceDir : null,
2524
+ summaryPath,
2525
+ reachedBottom: summary.reachedBottom,
2526
+ stopReason: summary.stopReason,
2527
+ },
2528
+ };
2529
+ }
2530
+
2531
+ async function executeCommentReplyOperation({ profileId, params = {}, context = {} }) {
2532
+ const state = getProfileState(profileId);
2533
+ const replyText = String(params.replyText || '').trim();
2534
+ if (!replyText) {
2535
+ return asErrorPayload('OPERATION_FAILED', 'replyText is required');
2536
+ }
2537
+
2538
+ const matches = Array.isArray(state.matchedComments) ? state.matchedComments : [];
2539
+ if (matches.length === 0) {
2540
+ return {
2541
+ ok: true,
2542
+ code: 'OPERATION_DONE',
2543
+ message: 'xhs_comment_reply done',
2544
+ data: { typed: false, reason: 'no_match' },
2545
+ };
2546
+ }
2547
+
2548
+ const index = Number(matches[0]?.index || 0);
2549
+ const { actionTrace, pushTrace } = buildTraceRecorder();
2550
+
2551
+ const target = await readReplyTargetByIndex(profileId, index);
2552
+ if (!target || target.ok !== true || !target.center) {
2553
+ return {
2554
+ ok: true,
2555
+ code: 'OPERATION_DONE',
2556
+ message: 'xhs_comment_reply done',
2557
+ data: { typed: false, reason: 'match_not_visible', index },
2558
+ };
2559
+ }
2560
+
2561
+ await clickPoint(profileId, target.center, { steps: 3 });
2562
+ pushTrace({ kind: 'click', stage: 'xhs_comment_reply', target: 'comment', index });
2563
+ await sleepRandom(500, 1200, pushTrace, 'reply_after_comment_click', { index });
2564
+
2565
+ const inputTarget = await readReplyInputTarget(profileId);
2566
+ if (!inputTarget || !inputTarget.center) {
2567
+ return {
2568
+ ok: true,
2569
+ code: 'OPERATION_DONE',
2570
+ message: 'xhs_comment_reply done',
2571
+ data: { typed: false, reason: 'reply_input_not_found', index },
2572
+ };
2573
+ }
2574
+
2575
+ await clickPoint(profileId, inputTarget.center, { steps: 2 });
2576
+ pushTrace({ kind: 'click', stage: 'xhs_comment_reply', target: 'reply_input', index });
2577
+ await sleepRandom(500, 1100, pushTrace, 'reply_pre_type', { index });
2578
+ await clearAndType(profileId, replyText, Number(params.keyDelayMs ?? 65) || 65);
2579
+ pushTrace({ kind: 'type', stage: 'xhs_comment_reply', target: 'reply_input', length: replyText.length, index });
2580
+ await sleepRandom(500, 1400, pushTrace, 'reply_post_type', { index });
2581
+
2582
+ const sendCenter = await readReplySendButtonTarget(profileId);
2583
+ if (sendCenter) {
2584
+ await clickPoint(profileId, sendCenter, { steps: 2 });
2585
+ pushTrace({ kind: 'click', stage: 'xhs_comment_reply', target: 'reply_send', index });
2586
+ }
2587
+
2588
+ state.lastReply = { typed: true, index, at: new Date().toISOString() };
2589
+ emitActionTrace(context, actionTrace, { stage: 'xhs_comment_reply', index });
2590
+
2591
+ return {
2592
+ ok: true,
2593
+ code: 'OPERATION_DONE',
2594
+ message: 'xhs_comment_reply done',
2595
+ data: state.lastReply,
2596
+ };
2597
+ }
2598
+
2599
+ async function executeCloseDetailOperation({ profileId, context = {} }) {
2600
+ const state = getProfileState(profileId);
2601
+ const metrics = state.metrics || (state.metrics = {});
2602
+ metrics.searchCount = Number(metrics.searchCount || 0);
2603
+ metrics.rollbackCount = Number(metrics.rollbackCount || 0);
2604
+ metrics.returnToSearchCount = Number(metrics.returnToSearchCount || 0);
2605
+
2606
+ const harvest = state.lastCommentsHarvest && typeof state.lastCommentsHarvest === 'object'
2607
+ ? state.lastCommentsHarvest
2608
+ : null;
2609
+ const exitMeta = {
2610
+ pageExitReason: String(harvest?.exitReason || 'close_without_harvest').trim(),
2611
+ reachedBottom: typeof harvest?.reachedBottom === 'boolean' ? harvest.reachedBottom : null,
2612
+ commentsCollected: Number.isFinite(Number(harvest?.collected)) ? Number(harvest.collected) : null,
2613
+ expectedCommentsCount: Number.isFinite(Number(harvest?.expectedCommentsCount)) ? Number(harvest.expectedCommentsCount) : null,
2614
+ commentCoverageRate: Number.isFinite(Number(harvest?.commentCoverageRate)) ? Number(harvest.commentCoverageRate) : null,
2615
+ scrollRecoveries: Number.isFinite(Number(harvest?.recoveries)) ? Number(harvest.recoveries) : 0,
2616
+ harvestRounds: Number.isFinite(Number(harvest?.rounds)) ? Number(harvest.rounds) : null,
2617
+ };
2618
+
2619
+ const { actionTrace, pushTrace } = buildTraceRecorder();
2620
+ const firstSnapshot = await isDetailVisible(profileId);
2621
+ if (firstSnapshot?.detailVisible !== true) {
2622
+ return {
2623
+ ok: true,
2624
+ code: 'OPERATION_DONE',
2625
+ message: 'xhs_close_detail done',
2626
+ data: {
2627
+ closed: true,
2628
+ via: 'already_closed',
2629
+ searchVisible: firstSnapshot?.searchVisible === true,
2630
+ searchCount: Number(metrics.searchCount || 0),
2631
+ rollbackCount: Number(metrics.rollbackCount || 0),
2632
+ returnToSearchCount: Number(metrics.returnToSearchCount || 0),
2633
+ returnedToSearch: false,
2634
+ ...exitMeta,
2635
+ },
2636
+ };
2637
+ }
2638
+
2639
+ metrics.rollbackCount += 1;
2640
+ metrics.lastRollbackAt = new Date().toISOString();
2641
+
2642
+ const waitForCloseAnimation = async () => {
2643
+ for (let i = 0; i < 45; i += 1) {
2644
+ const s = await isDetailVisible(profileId);
2645
+ if (s?.detailVisible !== true && s?.searchVisible === true) return true;
2646
+ await sleep(120);
2647
+ }
2648
+ const s = await isDetailVisible(profileId);
2649
+ return s?.detailVisible !== true && s?.searchVisible === true;
2650
+ };
2651
+
2652
+ for (let attempt = 1; attempt <= 4; attempt += 1) {
2653
+ await pressKey(profileId, 'Escape');
2654
+ pushTrace({ kind: 'key', stage: 'xhs_close_detail', key: 'Escape', attempt });
2655
+ await sleep(randomBetween(220, 480));
2656
+ if (await waitForCloseAnimation()) {
2657
+ const s = await isDetailVisible(profileId);
2658
+ const searchVisible = s?.searchVisible === true;
2659
+ if (searchVisible) {
2660
+ metrics.returnToSearchCount += 1;
2661
+ metrics.lastReturnToSearchAt = new Date().toISOString();
2662
+ }
2663
+ emitActionTrace(context, actionTrace, { stage: 'xhs_close_detail' });
2664
+ return {
2665
+ ok: true,
2666
+ code: 'OPERATION_DONE',
2667
+ message: 'xhs_close_detail done',
2668
+ data: {
2669
+ closed: true,
2670
+ via: 'escape',
2671
+ attempts: attempt,
2672
+ searchVisible,
2673
+ searchCount: Number(metrics.searchCount || 0),
2674
+ rollbackCount: Number(metrics.rollbackCount || 0),
2675
+ returnToSearchCount: Number(metrics.returnToSearchCount || 0),
2676
+ returnedToSearch: searchVisible,
2677
+ ...exitMeta,
2678
+ },
2679
+ };
2680
+ }
2681
+ }
2682
+
2683
+ const finalSnapshot = await isDetailVisible(profileId);
2684
+ emitActionTrace(context, actionTrace, { stage: 'xhs_close_detail' });
2685
+ return {
2686
+ ok: true,
2687
+ code: 'OPERATION_DONE',
2688
+ message: 'xhs_close_detail done',
2689
+ data: {
2690
+ closed: false,
2691
+ via: 'escape_failed',
2692
+ detailVisible: finalSnapshot?.detailVisible === true,
2693
+ searchVisible: finalSnapshot?.searchVisible === true,
2694
+ searchCount: Number(metrics.searchCount || 0),
2695
+ rollbackCount: Number(metrics.rollbackCount || 0),
2696
+ returnToSearchCount: Number(metrics.returnToSearchCount || 0),
2697
+ returnedToSearch: false,
2698
+ ...exitMeta,
2699
+ },
2700
+ };
2701
+ }
2702
+
2703
+ async function handleRaiseError({ params }) {
2704
+ const code = String(params.code || params.message || 'AUTOSCRIPT_ABORT').trim();
2705
+ return asErrorPayload('OPERATION_FAILED', code || 'AUTOSCRIPT_ABORT');
2706
+ }
2707
+
488
2708
  const XHS_ACTION_HANDLERS = {
489
2709
  raise_error: handleRaiseError,
490
2710
  xhs_assert_logged_in: executeAssertLoggedInOperation,
491
2711
  xhs_submit_search: executeSubmitSearchOperation,
492
2712
  xhs_open_detail: executeOpenDetailOperation,
493
- xhs_detail_harvest: createEvaluateHandler('xhs_detail_harvest done', buildDetailHarvestScript),
494
- xhs_expand_replies: createEvaluateHandler('xhs_expand_replies done', buildExpandRepliesScript),
2713
+ xhs_detail_harvest: executeDetailHarvestOperation,
2714
+ xhs_expand_replies: executeExpandRepliesOperation,
495
2715
  xhs_comments_harvest: executeCommentsHarvestOperation,
496
- xhs_comment_match: createEvaluateHandler('xhs_comment_match done', buildCommentMatchScript),
2716
+ xhs_comment_match: executeCommentMatchOperation,
497
2717
  xhs_comment_like: executeCommentLikeOperation,
498
- xhs_comment_reply: createEvaluateHandler('xhs_comment_reply done', buildCommentReplyScript),
499
- xhs_close_detail: createEvaluateHandler('xhs_close_detail done', buildCloseDetailScript),
2718
+ xhs_comment_reply: executeCommentReplyOperation,
2719
+ xhs_close_detail: executeCloseDetailOperation,
500
2720
  };
501
2721
 
502
2722
  export function isXhsAutoscriptAction(action) {
@@ -515,5 +2735,17 @@ export async function executeXhsAutoscriptOperation({
515
2735
  if (!handler) {
516
2736
  return asErrorPayload('UNSUPPORTED_OPERATION', `Unsupported xhs operation: ${action}`);
517
2737
  }
518
- return handler({ profileId, params, operation, context });
2738
+ try {
2739
+ return await handler({ profileId, params, operation, context });
2740
+ } catch (err) {
2741
+ const message = String(err?.message || err || '');
2742
+ if (message.includes('forbidden_js_action')) {
2743
+ return asErrorPayload('JS_DISABLED', message);
2744
+ }
2745
+ return asErrorPayload('OPERATION_FAILED', message);
2746
+ }
2747
+ }
2748
+
2749
+ export function __unsafe_getProfileStateForTests(profileId) {
2750
+ return getProfileState(profileId);
519
2751
  }