@web-auto/webauto 0.1.13 → 0.1.15

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,7 +1,11 @@
1
1
  export function buildCommentsHarvestScript(params = {}) {
2
2
  const maxRounds = Math.max(1, Number(params.maxRounds ?? params.maxScrollRounds ?? 14) || 14);
3
- const scrollStep = Math.max(120, Number(params.scrollStep ?? 420) || 420);
4
- const settleMs = Math.max(80, Number(params.settleMs ?? 180) || 180);
3
+ const scrollStepMin = Math.max(120, Number(params.scrollStepMin ?? params.scrollStep ?? 420) || 420);
4
+ const scrollStepMax = Math.max(scrollStepMin, Number(params.scrollStepMax ?? scrollStepMin) || scrollStepMin);
5
+ const scrollStepBase = Math.max(scrollStepMin, Math.floor((scrollStepMin + scrollStepMax) / 2));
6
+ const settleMinMs = Math.max(80, Number(params.settleMinMs ?? params.settleMs ?? 180) || 180);
7
+ const settleMaxMs = Math.max(settleMinMs, Number(params.settleMaxMs ?? settleMinMs) || settleMinMs);
8
+ const settleMs = settleMinMs;
5
9
  const stallRounds = Math.max(1, Number(params.stallRounds ?? 2) || 2);
6
10
  const requireBottom = params.requireBottom !== false;
7
11
  const includeComments = params.includeComments !== false;
@@ -10,12 +14,12 @@ export function buildCommentsHarvestScript(params = {}) {
10
14
  const recoveryUpRounds = Math.max(1, Number(params.recoveryUpRounds ?? 2) || 2);
11
15
  const recoveryDownRounds = Math.max(1, Number(params.recoveryDownRounds ?? 3) || 3);
12
16
  const maxRecoveries = Math.max(0, Number(params.maxRecoveries ?? 3) || 3);
13
- const recoveryUpStep = Math.max(80, Number(params.recoveryUpStep ?? Math.floor(scrollStep * 0.75)) || Math.floor(scrollStep * 0.75));
14
- const recoveryDownStep = Math.max(120, Number(params.recoveryDownStep ?? Math.floor(scrollStep * 1.3)) || Math.floor(scrollStep * 1.3));
17
+ const recoveryUpStep = Math.max(80, Number(params.recoveryUpStep ?? Math.floor(scrollStepBase * 0.75)) || Math.floor(scrollStepBase * 0.75));
18
+ const recoveryDownStep = Math.max(120, Number(params.recoveryDownStep ?? Math.floor(scrollStepBase * 1.3)) || Math.floor(scrollStepBase * 1.3));
15
19
  const recoveryNoProgressRounds = Math.max(1, Number(params.recoveryNoProgressRounds ?? 3) || 3);
16
20
  const progressDiffThreshold = Math.max(2, Number(
17
- params.progressDiffThreshold ?? Math.max(12, Math.floor(scrollStep * 0.08)),
18
- ) || Math.max(12, Math.floor(scrollStep * 0.08)));
21
+ params.progressDiffThreshold ?? Math.max(12, Math.floor(scrollStepBase * 0.08)),
22
+ ) || Math.max(12, Math.floor(scrollStepBase * 0.08)));
19
23
  const recoveryDownBoostPerAttempt = Math.max(0, Number(params.recoveryDownBoostPerAttempt ?? 1) || 1);
20
24
  const maxRecoveryDownBoost = Math.max(0, Number(params.maxRecoveryDownBoost ?? 2) || 2);
21
25
  const adaptiveMaxRounds = params.adaptiveMaxRounds !== false;
@@ -29,6 +33,13 @@ export function buildCommentsHarvestScript(params = {}) {
29
33
  const metricsState = state.metrics && typeof state.metrics === 'object' ? state.metrics : {};
30
34
  state.metrics = metricsState;
31
35
  metricsState.searchCount = Number(metricsState.searchCount || 0);
36
+ const actionTrace = [];
37
+ const pushTrace = (payload) => {
38
+ actionTrace.push({
39
+ ts: new Date().toISOString(),
40
+ ...payload,
41
+ });
42
+ };
32
43
  const detailSelectors = [
33
44
  '.note-detail-mask',
34
45
  '.note-detail-page',
@@ -158,7 +169,10 @@ export function buildCommentsHarvestScript(params = {}) {
158
169
  };
159
170
 
160
171
  const configuredMaxRounds = Number(${maxRounds});
161
- const scrollStep = Number(${scrollStep});
172
+ const scrollStepMin = Number(${scrollStepMin});
173
+ const scrollStepMax = Number(${scrollStepMax});
174
+ const settleMinMs = Number(${settleMinMs});
175
+ const settleMaxMs = Number(${settleMaxMs});
162
176
  const settleMs = Number(${settleMs});
163
177
  const stallRounds = Number(${stallRounds});
164
178
  const requireBottom = ${requireBottom ? 'true' : 'false'};
@@ -179,6 +193,12 @@ export function buildCommentsHarvestScript(params = {}) {
179
193
  const adaptiveBufferRounds = Number(${adaptiveBufferRounds});
180
194
  const adaptiveMinBoostRounds = Number(${adaptiveMinBoostRounds});
181
195
  const adaptiveMaxRoundsCap = Number(${adaptiveMaxRoundsCap});
196
+ const randomBetween = (min, max) => {
197
+ const lo = Math.max(0, Math.floor(Number(min) || 0));
198
+ const hi = Math.max(lo, Math.floor(Number(max) || 0));
199
+ if (hi <= lo) return lo;
200
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
201
+ };
182
202
  let maxRounds = configuredMaxRounds;
183
203
  let maxRoundsSource = 'configured';
184
204
  let budgetExpectedCommentsCount = null;
@@ -208,13 +228,23 @@ export function buildCommentsHarvestScript(params = {}) {
208
228
  no_effect: 0,
209
229
  no_new_comments: 0,
210
230
  };
211
- const performScroll = async (deltaY, waitMs = settleMs) => {
231
+ const performScroll = async (deltaY, waitMs = settleMs, meta = {}) => {
232
+ const waitFloor = Math.max(settleMinMs, Math.floor(Number(waitMs) || settleMinMs));
233
+ const waitCeil = Math.max(waitFloor, Math.max(settleMaxMs, Math.floor(Number(waitMs) || settleMaxMs)));
234
+ const waitActual = randomBetween(waitFloor, waitCeil);
235
+ pushTrace({
236
+ kind: 'scroll',
237
+ stage: 'xhs_comments_harvest',
238
+ deltaY: Number(deltaY),
239
+ waitMs: waitActual,
240
+ ...meta,
241
+ });
212
242
  if (typeof scroller?.scrollBy === 'function') {
213
243
  scroller.scrollBy({ top: deltaY, behavior: 'auto' });
214
244
  } else {
215
245
  window.scrollBy({ top: deltaY, behavior: 'auto' });
216
246
  }
217
- await new Promise((resolve) => setTimeout(resolve, waitMs));
247
+ await new Promise((resolve) => setTimeout(resolve, waitActual));
218
248
  };
219
249
 
220
250
  for (let round = 1; round <= maxRounds; round += 1) {
@@ -240,18 +270,27 @@ export function buildCommentsHarvestScript(params = {}) {
240
270
  }
241
271
 
242
272
  const prevTop = beforeMetrics.scrollTop;
243
- if (typeof scroller?.scrollBy === 'function') {
244
- scroller.scrollBy({ top: scrollStep, behavior: 'auto' });
245
- } else {
246
- window.scrollBy({ top: scrollStep, behavior: 'auto' });
247
- }
248
- await new Promise((resolve) => setTimeout(resolve, settleMs));
273
+ const roundScrollStep = randomBetween(scrollStepMin, scrollStepMax);
274
+ await performScroll(roundScrollStep, settleMs, {
275
+ round,
276
+ reason: 'main_scroll',
277
+ });
249
278
  collect(round);
250
279
  let afterMetrics = readMetrics();
251
280
  let moved = Math.abs(afterMetrics.scrollTop - prevTop) > 1;
252
281
  if (!moved && typeof window.scrollBy === 'function') {
253
- window.scrollBy({ top: Math.max(120, Math.floor(scrollStep / 2)), behavior: 'auto' });
254
- await new Promise((resolve) => setTimeout(resolve, settleMs));
282
+ const fallbackStep = Math.max(120, Math.floor(roundScrollStep / 2));
283
+ const fallbackWaitMs = randomBetween(settleMinMs, settleMaxMs);
284
+ pushTrace({
285
+ kind: 'scroll',
286
+ stage: 'xhs_comments_harvest',
287
+ round,
288
+ reason: 'fallback_scroll',
289
+ deltaY: Number(fallbackStep),
290
+ waitMs: fallbackWaitMs,
291
+ });
292
+ window.scrollBy({ top: fallbackStep, behavior: 'auto' });
293
+ await new Promise((resolve) => setTimeout(resolve, fallbackWaitMs));
255
294
  collect(round);
256
295
  afterMetrics = readMetrics();
257
296
  moved = Math.abs(afterMetrics.scrollTop - prevTop) > 1;
@@ -298,13 +337,23 @@ export function buildCommentsHarvestScript(params = {}) {
298
337
  recoveries += 1;
299
338
  recoveryReasonCounts[recoveryTrigger] += 1;
300
339
  for (let i = 0; i < recoveryUpRounds; i += 1) {
301
- await performScroll(-recoveryUpStep, settleMs + 120);
340
+ await performScroll(-recoveryUpStep, settleMs + 120, {
341
+ round,
342
+ reason: 'recovery_up',
343
+ recoveryTrigger,
344
+ recoveryStep: i + 1,
345
+ });
302
346
  collect(round);
303
347
  }
304
348
  const downBoost = Math.min(maxRecoveryDownBoost, Math.max(0, recoveries - 1) * recoveryDownBoostPerAttempt);
305
349
  const downRounds = recoveryDownRounds + downBoost;
306
350
  for (let i = 0; i < downRounds; i += 1) {
307
- await performScroll(recoveryDownStep, settleMs + 180);
351
+ await performScroll(recoveryDownStep, settleMs + 180, {
352
+ round,
353
+ reason: 'recovery_down',
354
+ recoveryTrigger,
355
+ recoveryStep: i + 1,
356
+ });
308
357
  collect(round);
309
358
  }
310
359
  const recoveredMetrics = readMetrics();
@@ -405,6 +454,7 @@ export function buildCommentsHarvestScript(params = {}) {
405
454
  budgetExpectedCommentsCount,
406
455
  scroll: metrics,
407
456
  };
457
+ payload.actionTrace = actionTrace;
408
458
  if (includeComments) {
409
459
  const bounded = commentsLimit > 0 ? comments.slice(0, commentsLimit) : comments;
410
460
  payload.comments = bounded;
@@ -165,9 +165,16 @@ function buildCollectLikeTargetsScript() {
165
165
  function buildClickLikeByIndexScript(index, highlight, skipAlreadyCheck = false) {
166
166
  return `(async () => {
167
167
  const idx = Number(${JSON.stringify(index)});
168
+ const actionTrace = [];
169
+ const pushTrace = (payload) => {
170
+ actionTrace.push({
171
+ ts: new Date().toISOString(),
172
+ ...payload,
173
+ });
174
+ };
168
175
  const items = Array.from(document.querySelectorAll('.comment-item'));
169
176
  const item = items[idx];
170
- if (!item) return { clicked: false, reason: 'comment_item_not_found', index: idx };
177
+ if (!item) return { clicked: false, reason: 'comment_item_not_found', index: idx, actionTrace };
171
178
  const findLikeControl = (node) => {
172
179
  const selectors = [
173
180
  '.like-wrapper',
@@ -195,12 +202,14 @@ function buildClickLikeByIndexScript(index, highlight, skipAlreadyCheck = false)
195
202
  };
196
203
 
197
204
  const likeControl = findLikeControl(item);
198
- if (!likeControl) return { clicked: false, reason: 'like_control_not_found', index: idx };
205
+ if (!likeControl) return { clicked: false, reason: 'like_control_not_found', index: idx, actionTrace };
199
206
  const beforeLiked = isAlreadyLiked(likeControl);
200
207
  if (beforeLiked && !${skipAlreadyCheck ? 'true' : 'false'}) {
201
- return { clicked: false, alreadyLiked: true, reason: 'already_liked', index: idx };
208
+ return { clicked: false, alreadyLiked: true, reason: 'already_liked', index: idx, actionTrace };
202
209
  }
210
+ pushTrace({ kind: 'scroll', stage: 'xhs_comment_like', index: idx, target: 'comment_item', via: 'scrollIntoView' });
203
211
  item.scrollIntoView({ behavior: 'auto', block: 'center' });
212
+ pushTrace({ kind: 'scroll', stage: 'xhs_comment_like', index: idx, target: 'like_control', via: 'scrollIntoView' });
204
213
  likeControl.scrollIntoView({ behavior: 'auto', block: 'center' });
205
214
  await new Promise((resolve) => setTimeout(resolve, 120));
206
215
  if (${highlight ? 'true' : 'false'}) {
@@ -208,6 +217,7 @@ function buildClickLikeByIndexScript(index, highlight, skipAlreadyCheck = false)
208
217
  likeControl.style.outline = '2px solid #00d6ff';
209
218
  setTimeout(() => { likeControl.style.outline = prev; }, 450);
210
219
  }
220
+ pushTrace({ kind: 'click', stage: 'xhs_comment_like', index: idx, target: 'like_control' });
211
221
  likeControl.click();
212
222
  await new Promise((resolve) => setTimeout(resolve, 220));
213
223
  return {
@@ -216,6 +226,7 @@ function buildClickLikeByIndexScript(index, highlight, skipAlreadyCheck = false)
216
226
  likedAfter: isAlreadyLiked(likeControl),
217
227
  reason: 'clicked',
218
228
  index: idx,
229
+ actionTrace,
219
230
  };
220
231
  })()`;
221
232
  }
@@ -231,7 +242,28 @@ async function captureScreenshotToFile({ profileId, filePath }) {
231
242
  }
232
243
  }
233
244
 
234
- export async function executeCommentLikeOperation({ profileId, params = {} }) {
245
+ function emitProgress(context, payload = {}) {
246
+ const emit = context?.emitProgress;
247
+ if (typeof emit !== 'function') return;
248
+ emit(payload);
249
+ }
250
+
251
+ function emitActionTrace(context, actionTrace = [], extra = {}) {
252
+ if (!Array.isArray(actionTrace) || actionTrace.length === 0) return;
253
+ for (let i = 0; i < actionTrace.length; i += 1) {
254
+ const row = actionTrace[i];
255
+ if (!row || typeof row !== 'object') continue;
256
+ const kind = String(row.kind || row.action || '').trim().toLowerCase() || 'trace';
257
+ emitProgress(context, {
258
+ kind,
259
+ step: i + 1,
260
+ ...extra,
261
+ ...row,
262
+ });
263
+ }
264
+ }
265
+
266
+ export async function executeCommentLikeOperation({ profileId, params = {}, context = {} }) {
235
267
  const maxLikes = Math.max(1, Number(params.maxLikes ?? params.maxLikesPerRound ?? 1) || 1);
236
268
  const rawKeywords = normalizeArray(params.keywords || params.likeKeywords);
237
269
  const rules = compileLikeRules(rawKeywords);
@@ -353,6 +385,16 @@ export async function executeCommentLikeOperation({ profileId, params = {} }) {
353
385
  highlight: false,
354
386
  });
355
387
  const clickResult = extractEvaluateResultData(clickRaw) || {};
388
+ const clickTrace = Array.isArray(clickResult.actionTrace) ? clickResult.actionTrace : [];
389
+ if (clickTrace.length > 0) {
390
+ emitActionTrace(context, clickTrace, {
391
+ stage: 'xhs_comment_like',
392
+ noteId: output.noteId,
393
+ commentIndex: Number(row.index),
394
+ fallback: false,
395
+ });
396
+ delete clickResult.actionTrace;
397
+ }
356
398
 
357
399
  const afterPath = saveEvidence
358
400
  ? await captureScreenshotToFile({
@@ -454,6 +496,16 @@ export async function executeCommentLikeOperation({ profileId, params = {} }) {
454
496
  highlight: false,
455
497
  });
456
498
  const clickResult = extractEvaluateResultData(clickRaw) || {};
499
+ const clickTrace = Array.isArray(clickResult.actionTrace) ? clickResult.actionTrace : [];
500
+ if (clickTrace.length > 0) {
501
+ emitActionTrace(context, clickTrace, {
502
+ stage: 'xhs_comment_like',
503
+ noteId: output.noteId,
504
+ commentIndex: Number(row.index),
505
+ fallback: true,
506
+ });
507
+ delete clickResult.actionTrace;
508
+ }
457
509
  const afterPath = saveEvidence
458
510
  ? await captureScreenshotToFile({
459
511
  profileId,
@@ -1,15 +1,39 @@
1
1
  export function buildSubmitSearchScript(params = {}) {
2
2
  const keyword = String(params.keyword || '').trim();
3
+ const method = String(params.method || params.submitMethod || 'click').trim().toLowerCase();
4
+ const actionDelayMinMs = Math.max(20, Number(params.actionDelayMinMs ?? 180) || 180);
5
+ const actionDelayMaxMs = Math.max(actionDelayMinMs, Number(params.actionDelayMaxMs ?? 620) || 620);
6
+ const settleMinMs = Math.max(60, Number(params.settleMinMs ?? 1200) || 1200);
7
+ const settleMaxMs = Math.max(settleMinMs, Number(params.settleMaxMs ?? 2600) || 2600);
3
8
  return `(async () => {
4
9
  const state = window.__camoXhsState || (window.__camoXhsState = {});
5
10
  const metrics = state.metrics && typeof state.metrics === 'object' ? state.metrics : {};
6
11
  state.metrics = metrics;
7
12
  metrics.searchCount = Number(metrics.searchCount || 0) + 1;
8
13
  metrics.lastSearchAt = new Date().toISOString();
14
+ const actionTrace = [];
15
+ const pushTrace = (payload) => {
16
+ actionTrace.push({
17
+ ts: new Date().toISOString(),
18
+ ...payload,
19
+ });
20
+ };
9
21
  const input = document.querySelector('#search-input, input.search-input');
10
22
  if (!(input instanceof HTMLInputElement)) {
11
23
  throw new Error('SEARCH_INPUT_NOT_FOUND');
12
24
  }
25
+ const randomBetween = (min, max) => {
26
+ const lo = Math.max(0, Math.floor(Number(min) || 0));
27
+ const hi = Math.max(lo, Math.floor(Number(max) || 0));
28
+ if (hi <= lo) return lo;
29
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
30
+ };
31
+ const waitRandom = async (min, max, stage) => {
32
+ const waitMs = randomBetween(min, max);
33
+ pushTrace({ kind: 'wait', stage, waitMs });
34
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
35
+ return waitMs;
36
+ };
13
37
  const targetKeyword = ${JSON.stringify(keyword)};
14
38
  if (targetKeyword && input.value !== targetKeyword) {
15
39
  input.focus();
@@ -17,35 +41,65 @@ export function buildSubmitSearchScript(params = {}) {
17
41
  input.dispatchEvent(new Event('input', { bubbles: true }));
18
42
  input.dispatchEvent(new Event('change', { bubbles: true }));
19
43
  }
20
- const enterEvent = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
44
+ const requestedMethod = ${JSON.stringify(method)};
45
+ const normalizedMethod = ['click', 'enter', 'form'].includes(requestedMethod) ? requestedMethod : 'click';
21
46
  const beforeUrl = window.location.href;
22
47
  input.focus();
23
- input.dispatchEvent(new KeyboardEvent('keydown', enterEvent));
24
- input.dispatchEvent(new KeyboardEvent('keypress', enterEvent));
25
- input.dispatchEvent(new KeyboardEvent('keyup', enterEvent));
26
48
  const candidates = ['.input-button .search-icon', '.input-button', 'button.min-width-search-icon'];
49
+ let methodUsed = normalizedMethod;
27
50
  let clickedSelector = null;
28
- for (const selector of candidates) {
29
- const button = document.querySelector(selector);
30
- if (!button) continue;
31
- if (button instanceof HTMLElement) button.scrollIntoView({ behavior: 'auto', block: 'center' });
32
- await new Promise((resolve) => setTimeout(resolve, 80));
33
- button.click();
34
- clickedSelector = selector;
35
- break;
36
- }
37
51
  const form = input.closest('form');
38
- if (form) {
39
- if (typeof form.requestSubmit === 'function') form.requestSubmit();
40
- else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
52
+ if (normalizedMethod === 'click') {
53
+ let clicked = false;
54
+ for (const selector of candidates) {
55
+ const button = document.querySelector(selector);
56
+ if (!button) continue;
57
+ if (button instanceof HTMLElement) {
58
+ pushTrace({ kind: 'scroll', stage: 'submit_search', selector, via: 'scrollIntoView' });
59
+ button.scrollIntoView({ behavior: 'auto', block: 'center' });
60
+ }
61
+ await waitRandom(${actionDelayMinMs}, ${actionDelayMaxMs}, 'submit_pre_click');
62
+ pushTrace({ kind: 'click', stage: 'submit_search', selector });
63
+ button.click();
64
+ clickedSelector = selector;
65
+ clicked = true;
66
+ break;
67
+ }
68
+ if (!clicked) {
69
+ methodUsed = 'form';
70
+ }
41
71
  }
42
- await new Promise((resolve) => setTimeout(resolve, 320));
72
+ if (methodUsed === 'enter') {
73
+ await waitRandom(${actionDelayMinMs}, ${actionDelayMaxMs}, 'submit_pre_enter');
74
+ const enterEvent = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
75
+ pushTrace({ kind: 'key', stage: 'submit_search', key: 'Enter' });
76
+ input.dispatchEvent(new KeyboardEvent('keydown', enterEvent));
77
+ input.dispatchEvent(new KeyboardEvent('keypress', enterEvent));
78
+ input.dispatchEvent(new KeyboardEvent('keyup', enterEvent));
79
+ } else if (methodUsed === 'form') {
80
+ await waitRandom(${actionDelayMinMs}, ${actionDelayMaxMs}, 'submit_pre_form');
81
+ if (form) {
82
+ pushTrace({ kind: 'submit', stage: 'submit_search', via: 'form' });
83
+ if (typeof form.requestSubmit === 'function') form.requestSubmit();
84
+ else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
85
+ } else {
86
+ methodUsed = 'enter';
87
+ const enterEvent = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
88
+ pushTrace({ kind: 'key', stage: 'submit_search', key: 'Enter', fallback: true });
89
+ input.dispatchEvent(new KeyboardEvent('keydown', enterEvent));
90
+ input.dispatchEvent(new KeyboardEvent('keypress', enterEvent));
91
+ input.dispatchEvent(new KeyboardEvent('keyup', enterEvent));
92
+ }
93
+ }
94
+ await waitRandom(${settleMinMs}, ${settleMaxMs}, 'submit_settle');
43
95
  return {
44
96
  submitted: true,
45
- via: clickedSelector || 'enter_or_form_submit',
97
+ via: clickedSelector || methodUsed,
46
98
  beforeUrl,
47
99
  afterUrl: window.location.href,
100
+ method: methodUsed,
48
101
  searchCount: metrics.searchCount,
102
+ actionTrace,
49
103
  };
50
104
  })()`;
51
105
  }
@@ -67,9 +121,21 @@ export function buildOpenDetailScript(params = {}) {
67
121
  const nextSeekRounds = Math.max(0, Number(params.nextSeekRounds || 8) || 8);
68
122
  const nextSeekStep = Math.max(0, Number(params.nextSeekStep || 0) || 0);
69
123
  const nextSeekSettleMs = Math.max(120, Number(params.nextSeekSettleMs || 320) || 320);
124
+ const preClickDelayMinMs = Math.max(60, Number(params.preClickDelayMinMs ?? 220) || 220);
125
+ const preClickDelayMaxMs = Math.max(preClickDelayMinMs, Number(params.preClickDelayMaxMs ?? 700) || 700);
126
+ const pollDelayMinMs = Math.max(80, Number(params.pollDelayMinMs ?? 130) || 130);
127
+ const pollDelayMaxMs = Math.max(pollDelayMinMs, Number(params.pollDelayMaxMs ?? 320) || 320);
128
+ const postOpenDelayMinMs = Math.max(120, Number(params.postOpenDelayMinMs ?? 420) || 420);
129
+ const postOpenDelayMaxMs = Math.max(postOpenDelayMinMs, Number(params.postOpenDelayMaxMs ?? 1100) || 1100);
70
130
 
71
131
  return `(async () => {
72
132
  const STATE_KEY = '__camoXhsState';
133
+ const randomBetween = (min, max) => {
134
+ const lo = Math.max(0, Math.floor(Number(min) || 0));
135
+ const hi = Math.max(lo, Math.floor(Number(max) || 0));
136
+ if (hi <= lo) return lo;
137
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
138
+ };
73
139
  const normalizeVisited = (value) => {
74
140
  if (!Array.isArray(value)) return [];
75
141
  return value
@@ -100,6 +166,13 @@ export function buildOpenDetailScript(params = {}) {
100
166
  };
101
167
 
102
168
  const state = loadState();
169
+ const actionTrace = [];
170
+ const pushTrace = (payload) => {
171
+ actionTrace.push({
172
+ ts: new Date().toISOString(),
173
+ ...payload,
174
+ });
175
+ };
103
176
  if (!Array.isArray(state.visitedNoteIds)) state.visitedNoteIds = [];
104
177
  const requestedKeyword = ${JSON.stringify(keyword)};
105
178
  const mode = ${JSON.stringify(mode)};
@@ -156,11 +229,23 @@ export function buildOpenDetailScript(params = {}) {
156
229
  const maxRounds = Number(${seedCollectMaxRounds});
157
230
  const targetCount = Number(${seedCollectCount});
158
231
  for (let round = 0; round < maxRounds && seedCollectedSet.size < targetCount; round += 1) {
232
+ pushTrace({
233
+ kind: 'scroll',
234
+ stage: 'seed_collect',
235
+ round: round + 1,
236
+ deltaY: Number(${seedCollectStep}),
237
+ });
159
238
  window.scrollBy({ top: Number(${seedCollectStep}), left: 0, behavior: 'auto' });
160
239
  await new Promise((resolve) => setTimeout(resolve, Number(${seedCollectSettleMs})));
161
240
  collectVisible();
162
241
  }
163
242
  if (${seedResetToTop ? 'true' : 'false'}) {
243
+ pushTrace({
244
+ kind: 'scroll',
245
+ stage: 'seed_collect',
246
+ round: 'reset_to_top',
247
+ toTop: true,
248
+ });
164
249
  window.scrollTo({ top: 0, behavior: 'auto' });
165
250
  await new Promise((resolve) => setTimeout(resolve, Number(${seedCollectSettleMs})));
166
251
  }
@@ -190,6 +275,12 @@ export function buildOpenDetailScript(params = {}) {
190
275
  let stagnantRounds = 0;
191
276
  for (let round = 0; !next && round < Number(${nextSeekRounds}); round += 1) {
192
277
  const beforeTop = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
278
+ pushTrace({
279
+ kind: 'scroll',
280
+ stage: 'seek_next_detail',
281
+ round: round + 1,
282
+ deltaY: seekStep,
283
+ });
193
284
  window.scrollBy({ top: seekStep, left: 0, behavior: 'auto' });
194
285
  await new Promise((resolve) => setTimeout(resolve, Number(${nextSeekSettleMs})));
195
286
  nodes = mapNodes();
@@ -238,9 +329,23 @@ export function buildOpenDetailScript(params = {}) {
238
329
  return !searchVisible;
239
330
  };
240
331
 
332
+ pushTrace({
333
+ kind: 'scroll',
334
+ stage: 'open_detail',
335
+ noteId: next.noteId,
336
+ via: 'scrollIntoView',
337
+ });
241
338
  next.cover.scrollIntoView({ behavior: 'auto', block: 'center' });
242
- await new Promise((resolve) => setTimeout(resolve, 140));
339
+ const preClickDelay = randomBetween(${preClickDelayMinMs}, ${preClickDelayMaxMs});
340
+ pushTrace({ kind: 'wait', stage: 'open_detail_pre_click', noteId: next.noteId, waitMs: preClickDelay });
341
+ await new Promise((resolve) => setTimeout(resolve, preClickDelay));
243
342
  const beforeUrl = window.location.href;
343
+ pushTrace({
344
+ kind: 'click',
345
+ stage: 'open_detail',
346
+ noteId: next.noteId,
347
+ selector: 'a.cover',
348
+ });
244
349
  next.cover.click();
245
350
 
246
351
  let detailReady = false;
@@ -249,12 +354,15 @@ export function buildOpenDetailScript(params = {}) {
249
354
  detailReady = true;
250
355
  break;
251
356
  }
252
- await new Promise((resolve) => setTimeout(resolve, 120));
357
+ const pollDelay = randomBetween(${pollDelayMinMs}, ${pollDelayMaxMs});
358
+ await new Promise((resolve) => setTimeout(resolve, pollDelay));
253
359
  }
254
360
  if (!detailReady) {
255
361
  throw new Error('DETAIL_OPEN_TIMEOUT');
256
362
  }
257
- await new Promise((resolve) => setTimeout(resolve, 220));
363
+ const postOpenDelay = randomBetween(${postOpenDelayMinMs}, ${postOpenDelayMaxMs});
364
+ pushTrace({ kind: 'wait', stage: 'open_detail_post_open', noteId: next.noteId, waitMs: postOpenDelay });
365
+ await new Promise((resolve) => setTimeout(resolve, postOpenDelay));
258
366
  const afterUrl = window.location.href;
259
367
 
260
368
  if (!state.visitedNoteIds.includes(next.noteId)) state.visitedNoteIds.push(next.noteId);
@@ -274,6 +382,7 @@ export function buildOpenDetailScript(params = {}) {
274
382
  excludedCount: excludedNoteIds.size,
275
383
  seedCollectedCount: seedCollectedSet.size,
276
384
  seedCollectedNoteIds: Array.from(seedCollectedSet),
385
+ actionTrace,
277
386
  };
278
387
  })()`;
279
388
  }