@web-auto/camo 0.1.14 → 0.1.16

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.
@@ -1,934 +0,0 @@
1
- function toTrimmedString(value, fallback = '') {
2
- const text = typeof value === 'string' ? value.trim() : '';
3
- return text || fallback;
4
- }
5
-
6
- function toBoolean(value, fallback) {
7
- if (value === undefined || value === null || value === '') return fallback;
8
- if (typeof value === 'boolean') return value;
9
- const text = String(value).trim().toLowerCase();
10
- if (['1', 'true', 'yes', 'on'].includes(text)) return true;
11
- if (['0', 'false', 'no', 'off'].includes(text)) return false;
12
- return fallback;
13
- }
14
-
15
- function toPositiveInt(value, fallback, min = 1) {
16
- if (value === null || value === undefined || value === '') return fallback;
17
- const num = Number(value);
18
- if (!Number.isFinite(num)) return fallback;
19
- return Math.max(min, Math.floor(num));
20
- }
21
-
22
- function splitCsv(value) {
23
- if (Array.isArray(value)) {
24
- return value
25
- .map((item) => toTrimmedString(item))
26
- .filter(Boolean);
27
- }
28
- return String(value || '')
29
- .split(',')
30
- .map((item) => item.trim())
31
- .filter(Boolean);
32
- }
33
-
34
- function pickCloseDependencies(options) {
35
- const deps = [];
36
- if (options.doLikes) deps.push('comment_like');
37
- if (options.doReply) deps.push('comment_reply');
38
- if (deps.length > 0) return deps;
39
- if (options.matchGateEnabled) return ['comment_match_gate'];
40
- if (options.commentsHarvestEnabled) return ['comments_harvest'];
41
- if (options.detailHarvestEnabled) return ['detail_harvest'];
42
- return ['open_first_detail'];
43
- }
44
-
45
- function buildOpenFirstDetailScript(maxNotes, keyword) {
46
- return `(async () => {
47
- const STATE_KEY = '__camoXhsState';
48
- const loadState = () => {
49
- const inMemory = window.__camoXhsState && typeof window.__camoXhsState === 'object'
50
- ? window.__camoXhsState
51
- : {};
52
- try {
53
- const stored = localStorage.getItem(STATE_KEY);
54
- if (!stored) return { ...inMemory };
55
- const parsed = JSON.parse(stored);
56
- if (!parsed || typeof parsed !== 'object') return { ...inMemory };
57
- return { ...parsed, ...inMemory };
58
- } catch {
59
- return { ...inMemory };
60
- }
61
- };
62
- const saveState = (nextState) => {
63
- window.__camoXhsState = nextState;
64
- try {
65
- localStorage.setItem(STATE_KEY, JSON.stringify(nextState));
66
- } catch {}
67
- };
68
-
69
- const state = loadState();
70
- if (!Array.isArray(state.visitedNoteIds)) state.visitedNoteIds = [];
71
- state.maxNotes = Number(${maxNotes});
72
- state.keyword = ${JSON.stringify(keyword)};
73
-
74
- const nodes = Array.from(document.querySelectorAll('.note-item'))
75
- .map((item, index) => {
76
- const cover = item.querySelector('a.cover');
77
- if (!cover) return null;
78
- const href = String(cover.getAttribute('href') || '').trim();
79
- const noteId = href.split('/').filter(Boolean).pop() || ('idx_' + index);
80
- return { item, cover, href, noteId };
81
- })
82
- .filter(Boolean);
83
-
84
- if (nodes.length === 0) {
85
- throw new Error('NO_SEARCH_RESULT_ITEM');
86
- }
87
-
88
- const next = nodes.find((row) => !state.visitedNoteIds.includes(row.noteId)) || nodes[0];
89
- next.cover.scrollIntoView({ behavior: 'auto', block: 'center' });
90
- await new Promise((resolve) => setTimeout(resolve, 120));
91
- next.cover.click();
92
- if (!state.visitedNoteIds.includes(next.noteId)) state.visitedNoteIds.push(next.noteId);
93
- state.currentNoteId = next.noteId;
94
- state.currentHref = next.href;
95
- saveState(state);
96
- return {
97
- opened: true,
98
- source: 'open_first_detail',
99
- noteId: next.noteId,
100
- visited: state.visitedNoteIds.length,
101
- maxNotes: state.maxNotes,
102
- };
103
- })()`;
104
- }
105
-
106
- function buildOpenNextDetailScript(maxNotes) {
107
- return `(async () => {
108
- const STATE_KEY = '__camoXhsState';
109
- const loadState = () => {
110
- const inMemory = window.__camoXhsState && typeof window.__camoXhsState === 'object'
111
- ? window.__camoXhsState
112
- : {};
113
- try {
114
- const stored = localStorage.getItem(STATE_KEY);
115
- if (!stored) return { ...inMemory };
116
- const parsed = JSON.parse(stored);
117
- if (!parsed || typeof parsed !== 'object') return { ...inMemory };
118
- return { ...parsed, ...inMemory };
119
- } catch {
120
- return { ...inMemory };
121
- }
122
- };
123
- const saveState = (nextState) => {
124
- window.__camoXhsState = nextState;
125
- try {
126
- localStorage.setItem(STATE_KEY, JSON.stringify(nextState));
127
- } catch {}
128
- };
129
-
130
- const state = loadState();
131
- if (!Array.isArray(state.visitedNoteIds)) state.visitedNoteIds = [];
132
- state.maxNotes = Number(${maxNotes});
133
-
134
- if (state.visitedNoteIds.length >= state.maxNotes) {
135
- throw new Error('AUTOSCRIPT_DONE_MAX_NOTES');
136
- }
137
-
138
- const nodes = Array.from(document.querySelectorAll('.note-item'))
139
- .map((item, index) => {
140
- const cover = item.querySelector('a.cover');
141
- if (!cover) return null;
142
- const href = String(cover.getAttribute('href') || '').trim();
143
- const noteId = href.split('/').filter(Boolean).pop() || ('idx_' + index);
144
- return { item, cover, href, noteId };
145
- })
146
- .filter(Boolean);
147
-
148
- const next = nodes.find((row) => !state.visitedNoteIds.includes(row.noteId));
149
- if (!next) {
150
- throw new Error('AUTOSCRIPT_DONE_NO_MORE_NOTES');
151
- }
152
-
153
- next.cover.scrollIntoView({ behavior: 'auto', block: 'center' });
154
- await new Promise((resolve) => setTimeout(resolve, 120));
155
- next.cover.click();
156
- state.visitedNoteIds.push(next.noteId);
157
- state.currentNoteId = next.noteId;
158
- state.currentHref = next.href;
159
- saveState(state);
160
- return {
161
- opened: true,
162
- source: 'open_next_detail',
163
- noteId: next.noteId,
164
- visited: state.visitedNoteIds.length,
165
- maxNotes: state.maxNotes,
166
- };
167
- })()`;
168
- }
169
-
170
- function buildSubmitSearchScript(keyword) {
171
- return `(async () => {
172
- const input = document.querySelector('#search-input, input.search-input');
173
- if (!(input instanceof HTMLInputElement)) {
174
- throw new Error('SEARCH_INPUT_NOT_FOUND');
175
- }
176
-
177
- const targetKeyword = ${JSON.stringify(keyword)};
178
- if (targetKeyword && input.value !== targetKeyword) {
179
- input.focus();
180
- input.value = targetKeyword;
181
- input.dispatchEvent(new Event('input', { bubbles: true }));
182
- input.dispatchEvent(new Event('change', { bubbles: true }));
183
- }
184
-
185
- const enterEvent = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
186
- const beforeUrl = window.location.href;
187
- input.focus();
188
- input.dispatchEvent(new KeyboardEvent('keydown', enterEvent));
189
- input.dispatchEvent(new KeyboardEvent('keypress', enterEvent));
190
- input.dispatchEvent(new KeyboardEvent('keyup', enterEvent));
191
-
192
- const candidates = [
193
- '.input-button .search-icon',
194
- '.input-button',
195
- 'button.min-width-search-icon',
196
- ];
197
- let clickedSelector = null;
198
- for (const selector of candidates) {
199
- const button = document.querySelector(selector);
200
- if (!button) continue;
201
- if (button instanceof HTMLElement) {
202
- button.scrollIntoView({ behavior: 'auto', block: 'center' });
203
- }
204
- await new Promise((resolve) => setTimeout(resolve, 80));
205
- button.click();
206
- clickedSelector = selector;
207
- break;
208
- }
209
-
210
- const form = input.closest('form');
211
- if (form) {
212
- if (typeof form.requestSubmit === 'function') form.requestSubmit();
213
- else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
214
- }
215
-
216
- await new Promise((resolve) => setTimeout(resolve, 320));
217
- return {
218
- submitted: true,
219
- via: clickedSelector || 'enter_or_form_submit',
220
- beforeUrl,
221
- afterUrl: window.location.href,
222
- };
223
- })()`;
224
- }
225
-
226
- function buildDetailHarvestScript() {
227
- return `(async () => {
228
- const state = window.__camoXhsState || (window.__camoXhsState = {});
229
- const scroller = document.querySelector('.note-scroller')
230
- || document.querySelector('.comments-el')
231
- || document.scrollingElement
232
- || document.documentElement;
233
- for (let i = 0; i < 3; i += 1) {
234
- scroller.scrollBy({ top: 360, behavior: 'auto' });
235
- await new Promise((resolve) => setTimeout(resolve, 120));
236
- }
237
- const title = (document.querySelector('.note-title') || {}).textContent || '';
238
- const content = (document.querySelector('.note-content') || {}).textContent || '';
239
- state.lastDetail = {
240
- title: String(title).trim().slice(0, 200),
241
- contentLength: String(content).trim().length,
242
- capturedAt: new Date().toISOString(),
243
- };
244
- return { harvested: true, detail: state.lastDetail };
245
- })()`;
246
- }
247
-
248
- function buildExpandRepliesScript() {
249
- return `(async () => {
250
- const buttons = Array.from(document.querySelectorAll('.show-more, .reply-expand, [class*="expand"]'));
251
- let clicked = 0;
252
- for (const button of buttons.slice(0, 8)) {
253
- if (!(button instanceof HTMLElement)) continue;
254
- const text = (button.textContent || '').trim();
255
- if (!text) continue;
256
- button.scrollIntoView({ behavior: 'auto', block: 'center' });
257
- await new Promise((resolve) => setTimeout(resolve, 60));
258
- button.click();
259
- clicked += 1;
260
- await new Promise((resolve) => setTimeout(resolve, 120));
261
- }
262
- return { expanded: clicked, scanned: buttons.length };
263
- })()`;
264
- }
265
-
266
- function buildCommentsHarvestScript() {
267
- return `(async () => {
268
- const state = window.__camoXhsState || (window.__camoXhsState = {});
269
- const comments = Array.from(document.querySelectorAll('.comment-item'))
270
- .map((item, index) => {
271
- const textNode = item.querySelector('.content, .comment-content, p');
272
- const likeNode = item.querySelector('.like-wrapper');
273
- return {
274
- index,
275
- text: String((textNode && textNode.textContent) || '').trim(),
276
- liked: Boolean(likeNode && /like-active/.test(String(likeNode.className || ''))),
277
- };
278
- })
279
- .filter((row) => row.text);
280
-
281
- state.currentComments = comments;
282
- state.commentsCollectedAt = new Date().toISOString();
283
- return {
284
- collected: comments.length,
285
- firstComment: comments[0] || null,
286
- };
287
- })()`;
288
- }
289
-
290
- function buildCommentMatchScript(matchKeywords, matchMode, matchMinHits) {
291
- return `(async () => {
292
- const state = window.__camoXhsState || (window.__camoXhsState = {});
293
- const rows = Array.isArray(state.currentComments) ? state.currentComments : [];
294
- const keywords = ${JSON.stringify(matchKeywords)};
295
- const mode = ${JSON.stringify(matchMode)};
296
- const minHits = Number(${matchMinHits});
297
-
298
- const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
299
- const tokens = keywords.map((item) => normalize(item)).filter(Boolean);
300
- const matches = [];
301
- for (const row of rows) {
302
- const text = normalize(row.text);
303
- if (!text || tokens.length === 0) continue;
304
- const hits = tokens.filter((token) => text.includes(token));
305
- if (mode === 'all' && hits.length < tokens.length) continue;
306
- if (mode === 'atLeast' && hits.length < Math.max(1, minHits)) continue;
307
- if (mode !== 'all' && mode !== 'atLeast' && hits.length === 0) continue;
308
- matches.push({ index: row.index, hits });
309
- }
310
-
311
- state.matchedComments = matches;
312
- state.matchRule = { tokens, mode, minHits };
313
- return {
314
- matchCount: matches.length,
315
- mode,
316
- minHits: Math.max(1, minHits),
317
- };
318
- })()`;
319
- }
320
-
321
- function buildCommentLikeScript(likeKeywords, maxLikesPerRound) {
322
- return `(async () => {
323
- const state = window.__camoXhsState || (window.__camoXhsState = {});
324
- const keywords = ${JSON.stringify(likeKeywords)};
325
- const maxLikes = Number(${maxLikesPerRound});
326
- const nodes = Array.from(document.querySelectorAll('.comment-item'));
327
- const matches = Array.isArray(state.matchedComments) ? state.matchedComments : [];
328
- const targetIndexes = new Set(matches.map((row) => Number(row.index)));
329
-
330
- let likedCount = 0;
331
- let skippedActive = 0;
332
- for (let idx = 0; idx < nodes.length; idx += 1) {
333
- if (likedCount >= maxLikes) break;
334
- if (targetIndexes.size > 0 && !targetIndexes.has(idx)) continue;
335
- const item = nodes[idx];
336
- const text = String((item.querySelector('.content, .comment-content, p') || {}).textContent || '').trim();
337
- if (!text) continue;
338
- if (keywords.length > 0) {
339
- const lower = text.toLowerCase();
340
- const hit = keywords.some((token) => lower.includes(String(token).toLowerCase()));
341
- if (!hit) continue;
342
- }
343
- const likeWrapper = item.querySelector('.like-wrapper');
344
- if (!likeWrapper) continue;
345
- if (/like-active/.test(String(likeWrapper.className || ''))) {
346
- skippedActive += 1;
347
- continue;
348
- }
349
- likeWrapper.scrollIntoView({ behavior: 'auto', block: 'center' });
350
- await new Promise((resolve) => setTimeout(resolve, 90));
351
- likeWrapper.click();
352
- likedCount += 1;
353
- await new Promise((resolve) => setTimeout(resolve, 150));
354
- }
355
-
356
- state.lastLike = { likedCount, skippedActive, at: new Date().toISOString() };
357
- return state.lastLike;
358
- })()`;
359
- }
360
-
361
- function buildCommentReplyScript(replyText) {
362
- return `(async () => {
363
- const state = window.__camoXhsState || (window.__camoXhsState = {});
364
- const replyText = ${JSON.stringify(replyText)};
365
- const matches = Array.isArray(state.matchedComments) ? state.matchedComments : [];
366
- if (matches.length === 0) {
367
- return { typed: false, reason: 'no_match' };
368
- }
369
-
370
- const index = Number(matches[0].index);
371
- const nodes = Array.from(document.querySelectorAll('.comment-item'));
372
- const target = nodes[index];
373
- if (!target) {
374
- return { typed: false, reason: 'match_not_visible', index };
375
- }
376
-
377
- target.scrollIntoView({ behavior: 'auto', block: 'center' });
378
- await new Promise((resolve) => setTimeout(resolve, 100));
379
- target.click();
380
- await new Promise((resolve) => setTimeout(resolve, 120));
381
-
382
- const input = document.querySelector('textarea, input[placeholder*="说点"], [contenteditable="true"]');
383
- if (!input) {
384
- return { typed: false, reason: 'reply_input_not_found', index };
385
- }
386
-
387
- if (input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement) {
388
- input.focus();
389
- input.value = replyText;
390
- input.dispatchEvent(new Event('input', { bubbles: true }));
391
- } else {
392
- input.focus();
393
- input.textContent = replyText;
394
- input.dispatchEvent(new Event('input', { bubbles: true }));
395
- }
396
-
397
- await new Promise((resolve) => setTimeout(resolve, 120));
398
- const sendButton = Array.from(document.querySelectorAll('button'))
399
- .find((button) => /发送|回复/.test(String(button.textContent || '').trim()));
400
- if (sendButton) {
401
- sendButton.click();
402
- }
403
-
404
- state.lastReply = { typed: true, index, at: new Date().toISOString() };
405
- return state.lastReply;
406
- })()`;
407
- }
408
-
409
- function buildCloseDetailScript() {
410
- return `(async () => {
411
- const modalSelectors = [
412
- '.note-detail-mask',
413
- '.note-detail',
414
- '.detail-container',
415
- '.media-container',
416
- ];
417
- const isModalVisible = () => modalSelectors.some((selector) => {
418
- const node = document.querySelector(selector);
419
- if (!node || !(node instanceof HTMLElement)) return false;
420
- const style = window.getComputedStyle(node);
421
- if (!style) return false;
422
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) {
423
- return false;
424
- }
425
- const rect = node.getBoundingClientRect();
426
- return rect.width > 1 && rect.height > 1;
427
- });
428
- const waitForClosed = async () => {
429
- for (let i = 0; i < 30; i += 1) {
430
- if (!isModalVisible()) return true;
431
- await new Promise((resolve) => setTimeout(resolve, 120));
432
- }
433
- return !isModalVisible();
434
- };
435
-
436
- const selectors = [
437
- '.note-detail-mask .close-box',
438
- '.note-detail-mask .close-circle',
439
- 'a[href*="/explore?channel_id=homefeed_recommend"]',
440
- ];
441
- for (const selector of selectors) {
442
- const target = document.querySelector(selector);
443
- if (!target) continue;
444
- target.scrollIntoView({ behavior: 'auto', block: 'center' });
445
- await new Promise((resolve) => setTimeout(resolve, 80));
446
- target.click();
447
- return { closed: await waitForClosed(), via: selector };
448
- }
449
- if (window.history.length > 1) {
450
- window.history.back();
451
- return { closed: await waitForClosed(), via: 'history.back' };
452
- }
453
- return { closed: false, via: null, modalVisible: isModalVisible() };
454
- })()`;
455
- }
456
-
457
- function buildAbortScript(code) {
458
- return `(async () => {
459
- throw new Error(${JSON.stringify(code)});
460
- })()`;
461
- }
462
-
463
- export function buildXhsUnifiedAutoscript(rawOptions = {}) {
464
- const profileId = toTrimmedString(rawOptions.profileId, 'xiaohongshu-batch-1');
465
- const keyword = toTrimmedString(rawOptions.keyword, '手机膜');
466
- const env = toTrimmedString(rawOptions.env, 'debug');
467
- const outputRoot = toTrimmedString(rawOptions.outputRoot, '');
468
- const throttle = toPositiveInt(rawOptions.throttle, 900, 100);
469
- const tabCount = toPositiveInt(rawOptions.tabCount, 4, 1);
470
- const noteIntervalMs = toPositiveInt(rawOptions.noteIntervalMs, 1200, 200);
471
- const maxNotes = toPositiveInt(rawOptions.maxNotes, 30, 1);
472
- const maxLikesPerRound = toPositiveInt(rawOptions.maxLikesPerRound, 2, 1);
473
- const matchMode = toTrimmedString(rawOptions.matchMode, 'any');
474
- const matchMinHits = toPositiveInt(rawOptions.matchMinHits, 1, 1);
475
- const replyText = toTrimmedString(rawOptions.replyText, '感谢分享,已关注');
476
-
477
- const doHomepage = toBoolean(rawOptions.doHomepage, true);
478
- const doImages = toBoolean(rawOptions.doImages, false);
479
- const doComments = toBoolean(rawOptions.doComments, true);
480
- const doLikes = toBoolean(rawOptions.doLikes, false);
481
- const doReply = toBoolean(rawOptions.doReply, false);
482
- const doOcr = toBoolean(rawOptions.doOcr, false);
483
- const persistComments = toBoolean(rawOptions.persistComments, true);
484
-
485
- const matchKeywords = splitCsv(rawOptions.matchKeywords || keyword);
486
- const likeKeywordsSeed = splitCsv(rawOptions.likeKeywords || '');
487
- const likeKeywords = likeKeywordsSeed.length > 0 ? likeKeywordsSeed : matchKeywords;
488
-
489
- const detailHarvestEnabled = doHomepage || doImages || doComments || doLikes || doReply || doOcr;
490
- const commentsHarvestEnabled = doComments || doLikes || doReply;
491
- const matchGateEnabled = doLikes || doReply;
492
- const closeDependsOn = pickCloseDependencies({
493
- doReply,
494
- doLikes,
495
- matchGateEnabled,
496
- commentsHarvestEnabled,
497
- detailHarvestEnabled,
498
- });
499
-
500
- const recovery = {
501
- attempts: 0,
502
- actions: [],
503
- };
504
-
505
- return {
506
- version: 1,
507
- name: 'xhs-unified-harvest-autoscript',
508
- source: '/Users/fanzhang/Documents/github/webauto/scripts/xiaohongshu/phase-unified-harvest.mjs',
509
- profileId,
510
- throttle,
511
- defaults: {
512
- retry: { attempts: 2, backoffMs: 500 },
513
- impact: 'subscription',
514
- onFailure: 'chain_stop',
515
- validationMode: 'none',
516
- recovery,
517
- pacing: {
518
- operationMinIntervalMs: 700,
519
- eventCooldownMs: 300,
520
- jitterMs: 220,
521
- navigationMinIntervalMs: 1800,
522
- timeoutMs: 180000,
523
- },
524
- timeoutMs: 180000,
525
- },
526
- metadata: {
527
- keyword,
528
- env,
529
- outputRoot,
530
- tabCount,
531
- noteIntervalMs,
532
- maxNotes,
533
- doHomepage,
534
- doImages,
535
- doComments,
536
- doLikes,
537
- doReply,
538
- doOcr,
539
- persistComments,
540
- matchMode,
541
- matchMinHits,
542
- matchKeywords,
543
- likeKeywords,
544
- replyText,
545
- notes: [
546
- 'open_next_detail intentionally stops script by throwing AUTOSCRIPT_DONE_* when exhausted.',
547
- 'dev mode uses deterministic no-recovery policy (checkpoint recovery disabled).',
548
- 'when persistComments=true, xhs_comments_harvest emits full comments in operation result for downstream jsonl/file persistence.',
549
- ],
550
- },
551
- subscriptions: [
552
- { id: 'home_search_input', selector: '#search-input, input.search-input', events: ['appear', 'exist', 'disappear'] },
553
- { id: 'home_search_button', selector: '.input-button, .input-button .search-icon', events: ['exist'] },
554
- { id: 'search_result_item', selector: '.note-item', events: ['appear', 'exist', 'change'] },
555
- { id: 'detail_modal', selector: '.note-detail-mask, .note-detail-page, .note-detail-dialog, .note-detail-mask .detail-container, .note-detail-mask .media-container, .note-detail-mask .note-scroller, .note-detail-mask .note-content, .note-detail-mask .interaction-container, .note-detail-mask .comments-container', events: ['appear', 'exist', 'disappear'] },
556
- { id: 'detail_comment_item', selector: '.comment-item, [class*="comment-item"]', events: ['appear', 'exist', 'change'] },
557
- { id: 'detail_show_more', selector: '.note-detail-mask .show-more, .note-detail-mask .reply-expand, .note-detail-mask [class*="expand"], .note-detail-page .show-more, .note-detail-page .reply-expand, .note-detail-page [class*="expand"]', events: ['appear', 'exist'] },
558
- { id: 'detail_discover_button', selector: 'a[href*="/explore?channel_id=homefeed_recommend"]', events: ['appear', 'exist'] },
559
- { id: 'login_guard', selector: '.login-container, .login-dialog, #login-container', events: ['appear', 'exist'] },
560
- { id: 'risk_guard', selector: '.qrcode-box, .captcha-container, [class*="captcha"]', events: ['appear', 'exist'] },
561
- ],
562
- operations: [
563
- {
564
- id: 'sync_window_viewport',
565
- action: 'sync_window_viewport',
566
- params: { followWindow: true, settleMs: 220, attempts: 2 },
567
- trigger: 'startup',
568
- once: true,
569
- retry: { attempts: 1, backoffMs: 0 },
570
- impact: 'op',
571
- onFailure: 'continue',
572
- },
573
- {
574
- id: 'goto_home',
575
- action: 'goto',
576
- params: { url: 'https://www.xiaohongshu.com/explore' },
577
- trigger: 'startup',
578
- dependsOn: ['sync_window_viewport'],
579
- once: true,
580
- retry: { attempts: 2, backoffMs: 300 },
581
- validation: {
582
- mode: 'post',
583
- post: {
584
- page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['home_ready', 'search_ready'] },
585
- },
586
- },
587
- checkpoint: {
588
- containerId: 'xiaohongshu_home.discover_button',
589
- targetCheckpoint: 'home_ready',
590
- recovery,
591
- },
592
- },
593
- {
594
- id: 'fill_keyword',
595
- action: 'type',
596
- params: { selector: '#search-input', text: keyword },
597
- trigger: 'home_search_input.exist',
598
- dependsOn: ['goto_home'],
599
- once: true,
600
- validation: {
601
- mode: 'both',
602
- pre: { page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['home_ready', 'search_ready'] } },
603
- post: { container: { selector: '#search-input', mustExist: true, minCount: 1 } },
604
- },
605
- checkpoint: {
606
- containerId: 'xiaohongshu_home.search_input',
607
- targetCheckpoint: 'search_ready',
608
- recovery,
609
- },
610
- },
611
- {
612
- id: 'submit_search',
613
- action: 'xhs_submit_search',
614
- params: { keyword },
615
- trigger: 'home_search_input.exist',
616
- dependsOn: ['fill_keyword'],
617
- once: true,
618
- timeoutMs: 120000,
619
- validation: {
620
- mode: 'post',
621
- post: { page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['search_ready', 'home_ready'] } },
622
- },
623
- checkpoint: {
624
- containerId: 'xiaohongshu_home.search_button',
625
- targetCheckpoint: 'search_ready',
626
- recovery,
627
- },
628
- },
629
- {
630
- id: 'open_first_detail',
631
- action: 'xhs_open_detail',
632
- params: { mode: 'first', maxNotes, keyword },
633
- trigger: 'search_result_item.exist',
634
- dependsOn: ['submit_search'],
635
- once: true,
636
- timeoutMs: 90000,
637
- validation: {
638
- mode: 'post',
639
- post: { page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['detail_ready', 'comments_ready', 'search_ready'] } },
640
- },
641
- checkpoint: {
642
- containerId: 'xiaohongshu_search.search_result_item',
643
- targetCheckpoint: 'detail_ready',
644
- recovery,
645
- },
646
- },
647
- {
648
- id: 'detail_harvest',
649
- enabled: detailHarvestEnabled,
650
- action: 'xhs_detail_harvest',
651
- params: {},
652
- trigger: 'detail_modal.exist',
653
- dependsOn: ['open_first_detail'],
654
- once: false,
655
- oncePerAppear: true,
656
- timeoutMs: 90000,
657
- retry: { attempts: 1, backoffMs: 0 },
658
- impact: 'op',
659
- onFailure: 'continue',
660
- pacing: { operationMinIntervalMs: 2000, eventCooldownMs: 1200, jitterMs: 260 },
661
- validation: {
662
- mode: 'both',
663
- pre: {
664
- page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['detail_ready', 'comments_ready'] },
665
- container: { selector: '.note-detail-mask, .note-detail-page, .note-detail-dialog', mustExist: true, minCount: 1 },
666
- },
667
- post: {
668
- page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['detail_ready', 'comments_ready'] },
669
- container: { selector: '.note-content, .note-scroller, .media-container', mustExist: true, minCount: 1 },
670
- },
671
- },
672
- checkpoint: {
673
- containerId: 'xiaohongshu_detail.modal_shell',
674
- targetCheckpoint: 'detail_ready',
675
- recovery,
676
- },
677
- },
678
- {
679
- id: 'expand_replies',
680
- enabled: commentsHarvestEnabled,
681
- action: 'xhs_expand_replies',
682
- params: {},
683
- trigger: 'detail_show_more.exist',
684
- dependsOn: [detailHarvestEnabled ? 'detail_harvest' : 'open_first_detail'],
685
- conditions: [{ type: 'subscription_exist', subscriptionId: 'detail_modal' }],
686
- once: false,
687
- oncePerAppear: true,
688
- retry: { attempts: 1, backoffMs: 0 },
689
- onFailure: 'continue',
690
- impact: 'op',
691
- pacing: { operationMinIntervalMs: 2500, eventCooldownMs: 1500, jitterMs: 220 },
692
- },
693
- {
694
- id: 'comments_harvest',
695
- enabled: commentsHarvestEnabled,
696
- action: 'xhs_comments_harvest',
697
- params: {
698
- env,
699
- keyword,
700
- outputRoot,
701
- persistComments,
702
- maxRounds: 48,
703
- scrollStep: 360,
704
- settleMs: 260,
705
- stallRounds: 8,
706
- recoveryNoProgressRounds: 3,
707
- recoveryStuckRounds: 2,
708
- recoveryUpRounds: 2,
709
- recoveryDownRounds: 3,
710
- maxRecoveries: 3,
711
- adaptiveMaxRounds: true,
712
- adaptiveExpectedPerRound: 6,
713
- adaptiveBufferRounds: 22,
714
- adaptiveMinBoostRounds: 36,
715
- adaptiveMaxRoundsCap: 320,
716
- requireBottom: true,
717
- includeComments: persistComments,
718
- },
719
- trigger: 'detail_modal.exist',
720
- dependsOn: [detailHarvestEnabled ? 'detail_harvest' : 'open_first_detail'],
721
- once: false,
722
- oncePerAppear: true,
723
- timeoutMs: 90000,
724
- retry: { attempts: 1, backoffMs: 0 },
725
- impact: 'op',
726
- onFailure: 'continue',
727
- pacing: { operationMinIntervalMs: 2400, eventCooldownMs: 1500, jitterMs: 280 },
728
- validation: {
729
- mode: 'both',
730
- pre: {
731
- page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['detail_ready', 'comments_ready'] },
732
- container: { selector: '.note-detail-mask, .note-detail-page, .note-detail-dialog', mustExist: true, minCount: 1 },
733
- },
734
- post: {
735
- page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['detail_ready', 'comments_ready'] },
736
- container: { selector: '.comment-item, [class*="comment-item"]', mustExist: false, minCount: 0 },
737
- },
738
- },
739
- checkpoint: {
740
- containerId: 'xiaohongshu_detail.comment_section.comment_item',
741
- targetCheckpoint: 'comments_ready',
742
- recovery,
743
- },
744
- },
745
- {
746
- id: 'comment_match_gate',
747
- enabled: matchGateEnabled,
748
- action: 'xhs_comment_match',
749
- params: { keywords: matchKeywords, mode: matchMode, minHits: matchMinHits },
750
- trigger: 'detail_modal.exist',
751
- dependsOn: [commentsHarvestEnabled ? 'comments_harvest' : (detailHarvestEnabled ? 'detail_harvest' : 'open_first_detail')],
752
- once: false,
753
- oncePerAppear: true,
754
- pacing: { operationMinIntervalMs: 2400, eventCooldownMs: 1200, jitterMs: 160 },
755
- },
756
- {
757
- id: 'comment_like',
758
- enabled: doLikes,
759
- action: 'xhs_comment_like',
760
- params: {
761
- env,
762
- keyword,
763
- outputRoot,
764
- persistLikeState: true,
765
- saveEvidence: true,
766
- keywords: likeKeywords,
767
- maxLikes: maxLikesPerRound,
768
- },
769
- trigger: 'detail_modal.exist',
770
- dependsOn: ['comment_match_gate'],
771
- once: false,
772
- oncePerAppear: true,
773
- timeoutMs: 90000,
774
- retry: { attempts: 1, backoffMs: 0 },
775
- onFailure: 'continue',
776
- impact: 'op',
777
- pacing: { operationMinIntervalMs: 2600, eventCooldownMs: 1500, jitterMs: 300 },
778
- },
779
- {
780
- id: 'comment_reply',
781
- enabled: doReply,
782
- action: 'xhs_comment_reply',
783
- params: { replyText },
784
- trigger: 'detail_modal.exist',
785
- dependsOn: ['comment_match_gate'],
786
- once: false,
787
- oncePerAppear: true,
788
- timeoutMs: 90000,
789
- retry: { attempts: 1, backoffMs: 0 },
790
- onFailure: 'continue',
791
- impact: 'op',
792
- pacing: { operationMinIntervalMs: 2600, eventCooldownMs: 1500, jitterMs: 300 },
793
- },
794
- {
795
- id: 'close_detail',
796
- action: 'xhs_close_detail',
797
- params: {},
798
- trigger: 'detail_modal.exist',
799
- dependsOn: closeDependsOn,
800
- once: false,
801
- oncePerAppear: true,
802
- pacing: { operationMinIntervalMs: 2500, eventCooldownMs: 1300, jitterMs: 180 },
803
- validation: {
804
- mode: 'post',
805
- post: { page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['home_ready', 'search_ready'] } },
806
- },
807
- checkpoint: {
808
- containerId: 'xiaohongshu_detail.discover_button',
809
- targetCheckpoint: 'search_ready',
810
- recovery,
811
- },
812
- },
813
- {
814
- id: 'wait_between_notes',
815
- action: 'wait',
816
- params: { ms: noteIntervalMs },
817
- trigger: 'search_result_item.exist',
818
- dependsOn: ['close_detail'],
819
- once: false,
820
- oncePerAppear: true,
821
- retry: { attempts: 1, backoffMs: 0 },
822
- impact: 'op',
823
- onFailure: 'continue',
824
- pacing: { operationMinIntervalMs: noteIntervalMs, eventCooldownMs: Math.max(400, Math.floor(noteIntervalMs / 2)), jitterMs: 160 },
825
- },
826
- {
827
- id: 'switch_tab_round_robin',
828
- action: 'tab_pool_switch_next',
829
- params: { settleMs: 450 },
830
- trigger: 'search_result_item.exist',
831
- dependsOn: ['wait_between_notes', 'ensure_tab_pool'],
832
- once: false,
833
- oncePerAppear: true,
834
- timeoutMs: 180000,
835
- retry: { attempts: 2, backoffMs: 500 },
836
- impact: 'op',
837
- onFailure: 'continue',
838
- },
839
- {
840
- id: 'open_next_detail',
841
- action: 'xhs_open_detail',
842
- params: { mode: 'next', maxNotes },
843
- trigger: 'search_result_item.exist',
844
- dependsOn: ['switch_tab_round_robin'],
845
- once: false,
846
- oncePerAppear: true,
847
- timeoutMs: 90000,
848
- retry: { attempts: 1, backoffMs: 0 },
849
- impact: 'script',
850
- onFailure: 'stop_all',
851
- checkpoint: {
852
- containerId: 'xiaohongshu_search.search_result_item',
853
- targetCheckpoint: 'detail_ready',
854
- recovery: { attempts: 0, actions: [] },
855
- },
856
- },
857
- {
858
- id: 'abort_on_login_guard',
859
- action: 'raise_error',
860
- params: { code: 'LOGIN_GUARD_DETECTED' },
861
- trigger: 'login_guard.appear',
862
- once: false,
863
- retry: { attempts: 1, backoffMs: 0 },
864
- impact: 'script',
865
- onFailure: 'stop_all',
866
- checkpoint: {
867
- containerId: 'xiaohongshu_login.login_guard',
868
- targetCheckpoint: 'login_guard',
869
- recovery: { attempts: 0, actions: [] },
870
- },
871
- },
872
- {
873
- id: 'abort_on_risk_guard',
874
- action: 'raise_error',
875
- params: { code: 'RISK_CONTROL_DETECTED' },
876
- trigger: 'risk_guard.appear',
877
- once: false,
878
- retry: { attempts: 1, backoffMs: 0 },
879
- impact: 'script',
880
- onFailure: 'stop_all',
881
- checkpoint: {
882
- containerId: 'xiaohongshu_login.qrcode_guard',
883
- targetCheckpoint: 'risk_control',
884
- recovery: { attempts: 0, actions: [] },
885
- },
886
- },
887
- {
888
- id: 'ensure_tab_pool',
889
- action: 'ensure_tab_pool',
890
- params: {
891
- tabCount,
892
- openDelayMs: 1200,
893
- normalizeTabs: false,
894
- },
895
- trigger: 'search_result_item.exist',
896
- dependsOn: ['wait_between_notes'],
897
- once: true,
898
- timeoutMs: 180000,
899
- retry: { attempts: 2, backoffMs: 500 },
900
- impact: 'script',
901
- onFailure: 'stop_all',
902
- validation: {
903
- mode: 'post',
904
- post: { page: { hostIncludes: ['xiaohongshu.com'], checkpointIn: ['search_ready', 'home_ready'] } },
905
- },
906
- checkpoint: {
907
- targetCheckpoint: 'search_ready',
908
- recovery: {
909
- attempts: 0,
910
- actions: [],
911
- },
912
- },
913
- },
914
- {
915
- id: 'verify_subscriptions_all_pages',
916
- action: 'verify_subscriptions',
917
- params: {
918
- acrossPages: true,
919
- settleMs: 320,
920
- selectors: [
921
- { id: 'home_search_input', selector: '#search-input, input.search-input' },
922
- { id: 'search_result_item', selector: '.note-item', visible: false, minCount: 1 },
923
- ],
924
- },
925
- trigger: 'search_result_item.exist',
926
- dependsOn: ['ensure_tab_pool'],
927
- once: true,
928
- timeoutMs: 90000,
929
- impact: 'script',
930
- onFailure: 'stop_all',
931
- },
932
- ],
933
- };
934
- }