@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
@@ -39,433 +39,14 @@ function splitCsv(value) {
39
39
  }
40
40
 
41
41
  function pickCloseDependency(options) {
42
+ if (options.doReply) return 'comment_reply';
43
+ if (options.doLikes) return 'comment_like';
44
+ if (options.matchGateEnabled) return 'comment_match_gate';
45
+ if (options.commentsHarvestEnabled) return 'comments_harvest';
42
46
  if (options.detailHarvestEnabled) return 'detail_harvest';
43
47
  return 'open_first_detail';
44
48
  }
45
49
 
46
- function buildOpenFirstDetailScript(maxNotes, keyword) {
47
- return `(async () => {
48
- const STATE_KEY = '__camoXhsState';
49
- const loadState = () => {
50
- const inMemory = window.__camoXhsState && typeof window.__camoXhsState === 'object'
51
- ? window.__camoXhsState
52
- : {};
53
- try {
54
- const stored = localStorage.getItem(STATE_KEY);
55
- if (!stored) return { ...inMemory };
56
- const parsed = JSON.parse(stored);
57
- if (!parsed || typeof parsed !== 'object') return { ...inMemory };
58
- return { ...parsed, ...inMemory };
59
- } catch {
60
- return { ...inMemory };
61
- }
62
- };
63
- const saveState = (nextState) => {
64
- window.__camoXhsState = nextState;
65
- try {
66
- localStorage.setItem(STATE_KEY, JSON.stringify(nextState));
67
- } catch {}
68
- };
69
-
70
- const state = loadState();
71
- if (!Array.isArray(state.visitedNoteIds)) state.visitedNoteIds = [];
72
- state.maxNotes = Number(${maxNotes});
73
- state.keyword = ${JSON.stringify(keyword)};
74
-
75
- const nodes = Array.from(document.querySelectorAll('.note-item'))
76
- .map((item, index) => {
77
- const cover = item.querySelector('a.cover');
78
- if (!cover) return null;
79
- const href = String(cover.getAttribute('href') || '').trim();
80
- const lastSegment = href.split('/').filter(Boolean).pop() || '';
81
- const normalized = lastSegment.split('?')[0].split('#')[0];
82
- const noteId = normalized || ('idx_' + index);
83
- return { item, cover, href, noteId };
84
- })
85
- .filter(Boolean);
86
-
87
- if (nodes.length === 0) {
88
- throw new Error('NO_SEARCH_RESULT_ITEM');
89
- }
90
-
91
- const next = nodes.find((row) => !state.visitedNoteIds.includes(row.noteId)) || nodes[0];
92
- next.cover.scrollIntoView({ behavior: 'auto', block: 'center' });
93
- await new Promise((resolve) => setTimeout(resolve, 120));
94
- next.cover.click();
95
- if (!state.visitedNoteIds.includes(next.noteId)) state.visitedNoteIds.push(next.noteId);
96
- state.currentNoteId = next.noteId;
97
- state.currentHref = next.href;
98
- saveState(state);
99
- return {
100
- opened: true,
101
- source: 'open_first_detail',
102
- noteId: next.noteId,
103
- visited: state.visitedNoteIds.length,
104
- maxNotes: state.maxNotes,
105
- };
106
- })()`;
107
- }
108
-
109
- function buildOpenNextDetailScript(maxNotes) {
110
- return `(async () => {
111
- const STATE_KEY = '__camoXhsState';
112
- const loadState = () => {
113
- const inMemory = window.__camoXhsState && typeof window.__camoXhsState === 'object'
114
- ? window.__camoXhsState
115
- : {};
116
- try {
117
- const stored = localStorage.getItem(STATE_KEY);
118
- if (!stored) return { ...inMemory };
119
- const parsed = JSON.parse(stored);
120
- if (!parsed || typeof parsed !== 'object') return { ...inMemory };
121
- return { ...parsed, ...inMemory };
122
- } catch {
123
- return { ...inMemory };
124
- }
125
- };
126
- const saveState = (nextState) => {
127
- window.__camoXhsState = nextState;
128
- try {
129
- localStorage.setItem(STATE_KEY, JSON.stringify(nextState));
130
- } catch {}
131
- };
132
-
133
- const state = loadState();
134
- if (!Array.isArray(state.visitedNoteIds)) state.visitedNoteIds = [];
135
- state.maxNotes = Number(${maxNotes});
136
-
137
- if (state.visitedNoteIds.length >= state.maxNotes) {
138
- throw new Error('AUTOSCRIPT_DONE_MAX_NOTES');
139
- }
140
-
141
- const nodes = Array.from(document.querySelectorAll('.note-item'))
142
- .map((item, index) => {
143
- const cover = item.querySelector('a.cover');
144
- if (!cover) return null;
145
- const href = String(cover.getAttribute('href') || '').trim();
146
- const lastSegment = href.split('/').filter(Boolean).pop() || '';
147
- const normalized = lastSegment.split('?')[0].split('#')[0];
148
- const noteId = normalized || ('idx_' + index);
149
- return { item, cover, href, noteId };
150
- })
151
- .filter(Boolean);
152
-
153
- const next = nodes.find((row) => !state.visitedNoteIds.includes(row.noteId));
154
- if (!next) {
155
- throw new Error('AUTOSCRIPT_DONE_NO_MORE_NOTES');
156
- }
157
-
158
- next.cover.scrollIntoView({ behavior: 'auto', block: 'center' });
159
- await new Promise((resolve) => setTimeout(resolve, 120));
160
- next.cover.click();
161
- state.visitedNoteIds.push(next.noteId);
162
- state.currentNoteId = next.noteId;
163
- state.currentHref = next.href;
164
- saveState(state);
165
- return {
166
- opened: true,
167
- source: 'open_next_detail',
168
- noteId: next.noteId,
169
- visited: state.visitedNoteIds.length,
170
- maxNotes: state.maxNotes,
171
- };
172
- })()`;
173
- }
174
-
175
- function buildSubmitSearchScript(keyword) {
176
- return `(async () => {
177
- const input = document.querySelector('#search-input, input.search-input');
178
- if (!(input instanceof HTMLInputElement)) {
179
- throw new Error('SEARCH_INPUT_NOT_FOUND');
180
- }
181
-
182
- const targetKeyword = ${JSON.stringify(keyword)};
183
- if (targetKeyword && input.value !== targetKeyword) {
184
- input.focus();
185
- input.value = targetKeyword;
186
- input.dispatchEvent(new Event('input', { bubbles: true }));
187
- input.dispatchEvent(new Event('change', { bubbles: true }));
188
- }
189
-
190
- const enterEvent = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
191
- const beforeUrl = window.location.href;
192
- input.focus();
193
- input.dispatchEvent(new KeyboardEvent('keydown', enterEvent));
194
- input.dispatchEvent(new KeyboardEvent('keypress', enterEvent));
195
- input.dispatchEvent(new KeyboardEvent('keyup', enterEvent));
196
-
197
- const candidates = [
198
- '.input-button .search-icon',
199
- '.input-button',
200
- 'button.min-width-search-icon',
201
- ];
202
- let clickedSelector = null;
203
- for (const selector of candidates) {
204
- const button = document.querySelector(selector);
205
- if (!button) continue;
206
- if (button instanceof HTMLElement) {
207
- button.scrollIntoView({ behavior: 'auto', block: 'center' });
208
- }
209
- await new Promise((resolve) => setTimeout(resolve, 80));
210
- button.click();
211
- clickedSelector = selector;
212
- break;
213
- }
214
-
215
- const form = input.closest('form');
216
- if (form) {
217
- if (typeof form.requestSubmit === 'function') form.requestSubmit();
218
- else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
219
- }
220
-
221
- await new Promise((resolve) => setTimeout(resolve, 320));
222
- return {
223
- submitted: true,
224
- via: clickedSelector || 'enter_or_form_submit',
225
- beforeUrl,
226
- afterUrl: window.location.href,
227
- };
228
- })()`;
229
- }
230
-
231
- function buildDetailHarvestScript() {
232
- return `(async () => {
233
- const state = window.__camoXhsState || (window.__camoXhsState = {});
234
- const scroller = document.querySelector('.note-scroller')
235
- || document.querySelector('.comments-el')
236
- || document.scrollingElement
237
- || document.documentElement;
238
- for (let i = 0; i < 3; i += 1) {
239
- scroller.scrollBy({ top: 360, behavior: 'auto' });
240
- await new Promise((resolve) => setTimeout(resolve, 120));
241
- }
242
- const title = (document.querySelector('.note-title') || {}).textContent || '';
243
- const content = (document.querySelector('.note-content') || {}).textContent || '';
244
- state.lastDetail = {
245
- title: String(title).trim().slice(0, 200),
246
- contentLength: String(content).trim().length,
247
- capturedAt: new Date().toISOString(),
248
- };
249
- return { harvested: true, detail: state.lastDetail };
250
- })()`;
251
- }
252
-
253
- function buildExpandRepliesScript() {
254
- return `(async () => {
255
- const buttons = Array.from(document.querySelectorAll('.show-more, .reply-expand, [class*="expand"]'));
256
- let clicked = 0;
257
- for (const button of buttons.slice(0, 8)) {
258
- if (!(button instanceof HTMLElement)) continue;
259
- const text = (button.textContent || '').trim();
260
- if (!text) continue;
261
- button.scrollIntoView({ behavior: 'auto', block: 'center' });
262
- await new Promise((resolve) => setTimeout(resolve, 60));
263
- button.click();
264
- clicked += 1;
265
- await new Promise((resolve) => setTimeout(resolve, 120));
266
- }
267
- return { expanded: clicked, scanned: buttons.length };
268
- })()`;
269
- }
270
-
271
- function buildCommentsHarvestScript() {
272
- return `(async () => {
273
- const state = window.__camoXhsState || (window.__camoXhsState = {});
274
- const comments = Array.from(document.querySelectorAll('.comment-item'))
275
- .map((item, index) => {
276
- const textNode = item.querySelector('.content, .comment-content, p');
277
- const likeNode = item.querySelector('.like-wrapper');
278
- return {
279
- index,
280
- text: String((textNode && textNode.textContent) || '').trim(),
281
- liked: Boolean(likeNode && /like-active/.test(String(likeNode.className || ''))),
282
- };
283
- })
284
- .filter((row) => row.text);
285
-
286
- state.currentComments = comments;
287
- state.commentsCollectedAt = new Date().toISOString();
288
- return {
289
- collected: comments.length,
290
- firstComment: comments[0] || null,
291
- };
292
- })()`;
293
- }
294
-
295
- function buildCommentMatchScript(matchKeywords, matchMode, matchMinHits) {
296
- return `(async () => {
297
- const state = window.__camoXhsState || (window.__camoXhsState = {});
298
- const rows = Array.isArray(state.currentComments) ? state.currentComments : [];
299
- const keywords = ${JSON.stringify(matchKeywords)};
300
- const mode = ${JSON.stringify(matchMode)};
301
- const minHits = Number(${matchMinHits});
302
-
303
- const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
304
- const tokens = keywords.map((item) => normalize(item)).filter(Boolean);
305
- const matches = [];
306
- for (const row of rows) {
307
- const text = normalize(row.text);
308
- if (!text || tokens.length === 0) continue;
309
- const hits = tokens.filter((token) => text.includes(token));
310
- if (mode === 'all' && hits.length < tokens.length) continue;
311
- if (mode === 'atLeast' && hits.length < Math.max(1, minHits)) continue;
312
- if (mode !== 'all' && mode !== 'atLeast' && hits.length === 0) continue;
313
- matches.push({ index: row.index, hits });
314
- }
315
-
316
- state.matchedComments = matches;
317
- state.matchRule = { tokens, mode, minHits };
318
- return {
319
- matchCount: matches.length,
320
- mode,
321
- minHits: Math.max(1, minHits),
322
- };
323
- })()`;
324
- }
325
-
326
- function buildCommentLikeScript(likeKeywords, maxLikesPerRound) {
327
- return `(async () => {
328
- const state = window.__camoXhsState || (window.__camoXhsState = {});
329
- const keywords = ${JSON.stringify(likeKeywords)};
330
- const maxLikes = Number(${maxLikesPerRound});
331
- const maxLikesLimit = maxLikes > 0 ? maxLikes : Number.POSITIVE_INFINITY;
332
- const nodes = Array.from(document.querySelectorAll('.comment-item'));
333
- const matches = Array.isArray(state.matchedComments) ? state.matchedComments : [];
334
- const targetIndexes = new Set(matches.map((row) => Number(row.index)));
335
-
336
- let likedCount = 0;
337
- let skippedActive = 0;
338
- for (let idx = 0; idx < nodes.length; idx += 1) {
339
- if (likedCount >= maxLikesLimit) break;
340
- if (targetIndexes.size > 0 && !targetIndexes.has(idx)) continue;
341
- const item = nodes[idx];
342
- const text = String((item.querySelector('.content, .comment-content, p') || {}).textContent || '').trim();
343
- if (!text) continue;
344
- if (keywords.length > 0) {
345
- const lower = text.toLowerCase();
346
- const hit = keywords.some((token) => lower.includes(String(token).toLowerCase()));
347
- if (!hit) continue;
348
- }
349
- const likeWrapper = item.querySelector('.like-wrapper');
350
- if (!likeWrapper) continue;
351
- if (/like-active/.test(String(likeWrapper.className || ''))) {
352
- skippedActive += 1;
353
- continue;
354
- }
355
- likeWrapper.scrollIntoView({ behavior: 'auto', block: 'center' });
356
- await new Promise((resolve) => setTimeout(resolve, 90));
357
- likeWrapper.click();
358
- likedCount += 1;
359
- await new Promise((resolve) => setTimeout(resolve, 150));
360
- }
361
-
362
- state.lastLike = { likedCount, skippedActive, at: new Date().toISOString() };
363
- return state.lastLike;
364
- })()`;
365
- }
366
-
367
- function buildCommentReplyScript(replyText) {
368
- return `(async () => {
369
- const state = window.__camoXhsState || (window.__camoXhsState = {});
370
- const replyText = ${JSON.stringify(replyText)};
371
- const matches = Array.isArray(state.matchedComments) ? state.matchedComments : [];
372
- if (matches.length === 0) {
373
- return { typed: false, reason: 'no_match' };
374
- }
375
-
376
- const index = Number(matches[0].index);
377
- const nodes = Array.from(document.querySelectorAll('.comment-item'));
378
- const target = nodes[index];
379
- if (!target) {
380
- return { typed: false, reason: 'match_not_visible', index };
381
- }
382
-
383
- target.scrollIntoView({ behavior: 'auto', block: 'center' });
384
- await new Promise((resolve) => setTimeout(resolve, 100));
385
- target.click();
386
- await new Promise((resolve) => setTimeout(resolve, 120));
387
-
388
- const input = document.querySelector('textarea, input[placeholder*="说点"], [contenteditable="true"]');
389
- if (!input) {
390
- return { typed: false, reason: 'reply_input_not_found', index };
391
- }
392
-
393
- if (input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement) {
394
- input.focus();
395
- input.value = replyText;
396
- input.dispatchEvent(new Event('input', { bubbles: true }));
397
- } else {
398
- input.focus();
399
- input.textContent = replyText;
400
- input.dispatchEvent(new Event('input', { bubbles: true }));
401
- }
402
-
403
- await new Promise((resolve) => setTimeout(resolve, 120));
404
- const sendButton = Array.from(document.querySelectorAll('button'))
405
- .find((button) => /发送|回复/.test(String(button.textContent || '').trim()));
406
- if (sendButton) {
407
- sendButton.click();
408
- }
409
-
410
- state.lastReply = { typed: true, index, at: new Date().toISOString() };
411
- return state.lastReply;
412
- })()`;
413
- }
414
-
415
- function buildCloseDetailScript() {
416
- return `(async () => {
417
- const modalSelectors = [
418
- '.note-detail-mask',
419
- '.note-detail',
420
- '.detail-container',
421
- '.media-container',
422
- ];
423
- const isModalVisible = () => modalSelectors.some((selector) => {
424
- const node = document.querySelector(selector);
425
- if (!node || !(node instanceof HTMLElement)) return false;
426
- const style = window.getComputedStyle(node);
427
- if (!style) return false;
428
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) {
429
- return false;
430
- }
431
- const rect = node.getBoundingClientRect();
432
- return rect.width > 1 && rect.height > 1;
433
- });
434
- const waitForClosed = async () => {
435
- for (let i = 0; i < 30; i += 1) {
436
- if (!isModalVisible()) return true;
437
- await new Promise((resolve) => setTimeout(resolve, 120));
438
- }
439
- return !isModalVisible();
440
- };
441
-
442
- const selectors = [
443
- '.note-detail-mask .close-box',
444
- '.note-detail-mask .close-circle',
445
- 'a[href*="/explore?channel_id=homefeed_recommend"]',
446
- ];
447
- for (const selector of selectors) {
448
- const target = document.querySelector(selector);
449
- if (!target) continue;
450
- target.scrollIntoView({ behavior: 'auto', block: 'center' });
451
- await new Promise((resolve) => setTimeout(resolve, 80));
452
- target.click();
453
- return { closed: await waitForClosed(), via: selector };
454
- }
455
- if (window.history.length > 1) {
456
- window.history.back();
457
- return { closed: await waitForClosed(), via: 'history.back' };
458
- }
459
- return { closed: false, via: null, modalVisible: isModalVisible() };
460
- })()`;
461
- }
462
-
463
- function buildAbortScript(code) {
464
- return `(async () => {
465
- throw new Error(${JSON.stringify(code)});
466
- })()`;
467
- }
468
-
469
50
  export function buildXhsUnifiedAutoscript(rawOptions = {}) {
470
51
  const profileId = toTrimmedString(rawOptions.profileId, 'xiaohongshu-batch-1');
471
52
  const keyword = toTrimmedString(rawOptions.keyword, '手机膜');
@@ -504,8 +85,11 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
504
85
  const replyText = toTrimmedString(rawOptions.replyText, '感谢分享,已关注');
505
86
  const sharedHarvestPath = toTrimmedString(rawOptions.sharedHarvestPath, '');
506
87
  const searchSerialKey = toTrimmedString(rawOptions.searchSerialKey, `${env}:${keyword}`);
507
- const seedCollectCount = toNonNegativeInt(rawOptions.seedCollectCount, 0);
508
- const seedCollectMaxRounds = toNonNegativeInt(rawOptions.seedCollectMaxRounds, 0);
88
+ const seedCollectCount = toNonNegativeInt(rawOptions.seedCollectCount, maxNotes);
89
+ const seedCollectMaxRounds = toNonNegativeInt(
90
+ rawOptions.seedCollectMaxRounds,
91
+ Math.max(6, Math.ceil(maxNotes / 2)),
92
+ );
509
93
 
510
94
  const doHomepage = toBoolean(rawOptions.doHomepage, true);
511
95
  const doImages = toBoolean(rawOptions.doImages, false);
@@ -514,17 +98,24 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
514
98
  const doReply = toBoolean(rawOptions.doReply, false);
515
99
  const doOcr = toBoolean(rawOptions.doOcr, false);
516
100
  const persistComments = toBoolean(rawOptions.persistComments, true);
101
+ const stage = toTrimmedString(rawOptions.stage, 'full').toLowerCase();
102
+ const stageLinksEnabled = toBoolean(rawOptions.stageLinksEnabled, true);
103
+ const stageContentEnabled = toBoolean(rawOptions.stageContentEnabled, true);
104
+ const stageLikeEnabled = toBoolean(rawOptions.stageLikeEnabled, doLikes);
105
+ const stageReplyEnabled = toBoolean(rawOptions.stageReplyEnabled, doReply);
517
106
 
518
107
  const matchKeywords = splitCsv(rawOptions.matchKeywords || keyword);
519
108
  const likeKeywordsSeed = splitCsv(rawOptions.likeKeywords || '');
520
109
  const likeKeywords = likeKeywordsSeed.length > 0 ? likeKeywordsSeed : matchKeywords;
521
110
 
522
- const detailHarvestEnabled = doHomepage || doImages || doComments || doLikes || doReply || doOcr;
523
- const commentsHarvestEnabled = doComments || doLikes || doReply;
524
- const matchGateEnabled = doLikes || doReply;
111
+ const detailLoopEnabled = stageContentEnabled || stageLikeEnabled || stageReplyEnabled;
112
+ const detailHarvestEnabled = detailLoopEnabled && (doHomepage || doImages || doComments || doOcr);
113
+ const commentsHarvestEnabled = detailLoopEnabled && (doComments || stageLikeEnabled || stageReplyEnabled);
114
+ const matchGateEnabled = stageLikeEnabled || stageReplyEnabled;
115
+ const collectLinksTimeoutMs = Math.max(180000, maxNotes * 6000);
525
116
  const closeDependsOn = pickCloseDependency({
526
- doReply,
527
- doLikes,
117
+ doReply: stageReplyEnabled,
118
+ doLikes: stageLikeEnabled,
528
119
  matchGateEnabled,
529
120
  commentsHarvestEnabled,
530
121
  detailHarvestEnabled,
@@ -591,9 +182,14 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
591
182
  doHomepage,
592
183
  doImages,
593
184
  doComments,
594
- doLikes,
595
- doReply,
185
+ doLikes: stageLikeEnabled,
186
+ doReply: stageReplyEnabled,
596
187
  doOcr,
188
+ stage,
189
+ stageLinksEnabled,
190
+ stageContentEnabled,
191
+ stageLikeEnabled,
192
+ stageReplyEnabled,
597
193
  persistComments,
598
194
  matchMode,
599
195
  matchMinHits,
@@ -604,6 +200,7 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
604
200
  searchSerialKey,
605
201
  seedCollectCount,
606
202
  seedCollectMaxRounds,
203
+ collectOpenLinksOnly: stageLinksEnabled && !detailLoopEnabled,
607
204
  notes: [
608
205
  'open_next_detail intentionally stops script by throwing AUTOSCRIPT_DONE_* when exhausted.',
609
206
  'dev mode uses deterministic no-recovery policy (checkpoint recovery disabled).',
@@ -617,10 +214,7 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
617
214
  { id: 'search_result_item', selector: '.note-item', events: ['appear', 'exist', 'change'] },
618
215
  {
619
216
  id: 'detail_modal',
620
- selector: 'body',
621
- visible: false,
622
- pageUrlIncludes: ['/explore/'],
623
- pageUrlExcludes: ['/search_result'],
217
+ selector: '.note-detail-mask, .note-detail-page, .note-detail-dialog',
624
218
  events: ['appear', 'exist', 'disappear'],
625
219
  },
626
220
  { id: 'detail_comment_item', selector: '.comment-item, [class*="comment-item"]', events: ['appear', 'exist', 'change'] },
@@ -705,8 +299,34 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
705
299
  recovery,
706
300
  },
707
301
  },
302
+ {
303
+ id: 'collect_links',
304
+ enabled: stageLinksEnabled,
305
+ action: 'xhs_open_detail',
306
+ params: {
307
+ mode: 'collect',
308
+ maxNotes,
309
+ env,
310
+ keyword,
311
+ outputRoot,
312
+ resume,
313
+ incrementalMax,
314
+ sharedHarvestPath,
315
+ seedCollectCount,
316
+ seedCollectMaxRounds,
317
+ collectOpenLinksOnly: stageLinksEnabled && !detailLoopEnabled,
318
+ },
319
+ trigger: 'search_result_item.exist',
320
+ dependsOn: ['ensure_tab_pool'],
321
+ once: true,
322
+ timeoutMs: collectLinksTimeoutMs,
323
+ retry: { attempts: 1, backoffMs: 0 },
324
+ impact: 'script',
325
+ onFailure: 'stop_all',
326
+ },
708
327
  {
709
328
  id: 'open_first_detail',
329
+ enabled: detailLoopEnabled,
710
330
  action: 'xhs_open_detail',
711
331
  params: {
712
332
  mode: 'first',
@@ -725,10 +345,11 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
725
345
  postOpenDelayMaxMs: openDetailPostOpenMaxMs,
726
346
  },
727
347
  trigger: 'search_result_item.exist',
728
- dependsOn: ['submit_search'],
348
+ dependsOn: [stageLinksEnabled ? 'collect_links' : 'submit_search'],
729
349
  once: true,
730
350
  timeoutMs: 90000,
731
- onFailure: 'continue',
351
+ onFailure: 'stop_all',
352
+ impact: 'script',
732
353
  validation: { mode: 'none' },
733
354
  checkpoint: {
734
355
  containerId: 'xiaohongshu_search.search_result_item',
@@ -736,6 +357,18 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
736
357
  recovery,
737
358
  },
738
359
  },
360
+ {
361
+ id: 'finish_after_collect_links',
362
+ enabled: stageLinksEnabled && !detailLoopEnabled,
363
+ action: 'raise_error',
364
+ params: { code: 'AUTOSCRIPT_DONE_LINKS_COLLECTED' },
365
+ trigger: 'search_result_item.exist',
366
+ dependsOn: ['collect_links'],
367
+ once: true,
368
+ retry: { attempts: 1, backoffMs: 0 },
369
+ impact: 'script',
370
+ onFailure: 'stop_all',
371
+ },
739
372
  {
740
373
  id: 'detail_harvest',
741
374
  enabled: detailHarvestEnabled,
@@ -835,14 +468,14 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
835
468
  action: 'xhs_comment_match',
836
469
  params: { keywords: matchKeywords, mode: matchMode, minHits: matchMinHits },
837
470
  trigger: 'detail_modal.exist',
838
- dependsOn: [detailHarvestEnabled ? 'detail_harvest' : 'open_first_detail'],
471
+ dependsOn: ['comments_harvest'],
839
472
  once: false,
840
473
  oncePerAppear: true,
841
474
  pacing: { operationMinIntervalMs: 2400, eventCooldownMs: 1200, jitterMs: 160 },
842
475
  },
843
476
  {
844
477
  id: 'comment_like',
845
- enabled: doLikes,
478
+ enabled: stageLikeEnabled,
846
479
  action: 'xhs_comment_like',
847
480
  params: {
848
481
  env,
@@ -865,11 +498,11 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
865
498
  },
866
499
  {
867
500
  id: 'comment_reply',
868
- enabled: doReply,
501
+ enabled: stageReplyEnabled,
869
502
  action: 'xhs_comment_reply',
870
503
  params: { replyText },
871
504
  trigger: 'detail_modal.exist',
872
- dependsOn: ['comment_match_gate'],
505
+ dependsOn: [stageLikeEnabled ? 'comment_like' : 'comment_match_gate'],
873
506
  once: false,
874
507
  oncePerAppear: true,
875
508
  timeoutMs: 90000,
@@ -880,6 +513,7 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
880
513
  },
881
514
  {
882
515
  id: 'close_detail',
516
+ enabled: detailLoopEnabled,
883
517
  action: 'xhs_close_detail',
884
518
  params: {},
885
519
  trigger: 'detail_modal.exist',
@@ -896,6 +530,7 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
896
530
  },
897
531
  {
898
532
  id: 'wait_between_notes',
533
+ enabled: detailLoopEnabled,
899
534
  action: 'wait',
900
535
  params: { ms: noteIntervalMs },
901
536
  trigger: 'search_result_item.exist',
@@ -909,6 +544,7 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
909
544
  },
910
545
  {
911
546
  id: 'switch_tab_round_robin',
547
+ enabled: detailLoopEnabled,
912
548
  action: 'tab_pool_switch_next',
913
549
  params: { settleMs: 450 },
914
550
  trigger: 'search_result_item.exist',
@@ -922,6 +558,7 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
922
558
  },
923
559
  {
924
560
  id: 'open_next_detail',
561
+ enabled: detailLoopEnabled,
925
562
  action: 'xhs_open_detail',
926
563
  params: {
927
564
  mode: 'next',
@@ -989,7 +626,7 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
989
626
  normalizeTabs: false,
990
627
  },
991
628
  trigger: 'search_result_item.exist',
992
- dependsOn: ['wait_between_notes'],
629
+ dependsOn: ['submit_search'],
993
630
  once: true,
994
631
  timeoutMs: 180000,
995
632
  retry: { attempts: 2, backoffMs: 500 },