@reconcrap/boss-recommend-mcp 2.0.11 → 2.0.13

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,10 +1,68 @@
1
1
  import crypto from "node:crypto";
2
2
  import {
3
3
  getNodeBox,
4
+ getOuterHTML,
5
+ querySelectorAll,
4
6
  scrollNodeIntoView,
5
7
  sleep
6
8
  } from "../browser/index.js";
7
9
 
10
+ export const DEFAULT_BOTTOM_HINT_KEYWORDS = Object.freeze([
11
+ "没有更多",
12
+ "已显示全部",
13
+ "已经到底",
14
+ "暂无更多",
15
+ "推荐完了",
16
+ "没有更多人选",
17
+ "没有更多了",
18
+ "已到底"
19
+ ]);
20
+
21
+ export const DEFAULT_LOAD_MORE_HINT_KEYWORDS = Object.freeze([
22
+ "滚动加载更多",
23
+ "下滑加载更多",
24
+ "继续下滑",
25
+ "继续滑动",
26
+ "滑动加载",
27
+ "正在加载",
28
+ "加载中"
29
+ ]);
30
+
31
+ export const DEFAULT_BOTTOM_MARKER_SELECTORS = Object.freeze([
32
+ ".finished-wrap",
33
+ ".load-tips",
34
+ "div[role=\"tfoot\"] .load-tips",
35
+ ".no-data-refresh",
36
+ ".empty-tip",
37
+ ".empty-text",
38
+ ".no-data",
39
+ ".tip-nodata",
40
+ "[class*=\"finished\"]",
41
+ "[class*=\"load-tips\"]",
42
+ "[class*=\"no-more\"]",
43
+ "[class*=\"no_more\"]"
44
+ ]);
45
+
46
+ export const DEFAULT_BOTTOM_TEXT_SCAN_SELECTORS = Object.freeze([
47
+ "div",
48
+ "span",
49
+ "p",
50
+ "li",
51
+ "button",
52
+ "a"
53
+ ]);
54
+
55
+ export const DEFAULT_BOTTOM_REFRESH_SELECTORS = Object.freeze([
56
+ ".finished-wrap .btn-refresh",
57
+ ".finished-wrap .btn",
58
+ ".no-data-refresh .btn-refresh",
59
+ ".no-data-refresh .btn",
60
+ "[class*=\"refresh\"]",
61
+ "[ka*=\"refresh\"]",
62
+ "button",
63
+ "a"
64
+ ]);
65
+
8
66
  function nowIso() {
9
67
  return new Date().toISOString();
10
68
  }
@@ -13,6 +71,31 @@ function normalizeText(value) {
13
71
  return String(value || "").replace(/\s+/g, " ").trim();
14
72
  }
15
73
 
74
+ function uniqueValues(values = []) {
75
+ return Array.from(new Set(values.filter(Boolean)));
76
+ }
77
+
78
+ function decodeBasicHtmlEntities(value = "") {
79
+ return String(value || "")
80
+ .replace(/ | /gi, " ")
81
+ .replace(/&/gi, "&")
82
+ .replace(/&lt;/gi, "<")
83
+ .replace(/&gt;/gi, ">")
84
+ .replace(/&quot;/gi, "\"")
85
+ .replace(/&#39;|&apos;/gi, "'");
86
+ }
87
+
88
+ function plainTextFromHtml(html = "") {
89
+ return normalizeText(decodeBasicHtmlEntities(String(html || "")
90
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
91
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
92
+ .replace(/<[^>]+>/g, " ")));
93
+ }
94
+
95
+ function isUsableBox(box) {
96
+ return Number(box?.rect?.width || 0) > 2 && Number(box?.rect?.height || 0) > 2;
97
+ }
98
+
16
99
  function shortHash(value) {
17
100
  return crypto.createHash("sha256").update(String(value || "")).digest("hex").slice(0, 16);
18
101
  }
@@ -174,6 +257,220 @@ export function resetInfiniteListForRefreshRound(state, {
174
257
  return compactInfiniteListState(state);
175
258
  }
176
259
 
260
+ export function classifyInfiniteListBottomMarker({
261
+ text = "",
262
+ refreshButtonVisible = false,
263
+ bottomKeywords = DEFAULT_BOTTOM_HINT_KEYWORDS,
264
+ loadMoreKeywords = DEFAULT_LOAD_MORE_HINT_KEYWORDS
265
+ } = {}) {
266
+ const normalizedText = normalizeText(text);
267
+ const matchedBottomKeyword = bottomKeywords.find((keyword) => normalizedText.includes(keyword)) || null;
268
+ if (matchedBottomKeyword) {
269
+ return {
270
+ is_bottom: true,
271
+ reason: matchedBottomKeyword,
272
+ matched_bottom_keyword: matchedBottomKeyword,
273
+ matched_load_more_keyword: null
274
+ };
275
+ }
276
+
277
+ const matchedLoadMoreKeyword = loadMoreKeywords.find((keyword) => normalizedText.includes(keyword)) || null;
278
+ if (matchedLoadMoreKeyword) {
279
+ return {
280
+ is_bottom: false,
281
+ reason: null,
282
+ matched_bottom_keyword: null,
283
+ matched_load_more_keyword: matchedLoadMoreKeyword
284
+ };
285
+ }
286
+
287
+ if (refreshButtonVisible) {
288
+ return {
289
+ is_bottom: true,
290
+ reason: "refresh_button_visible",
291
+ matched_bottom_keyword: null,
292
+ matched_load_more_keyword: null
293
+ };
294
+ }
295
+
296
+ return {
297
+ is_bottom: false,
298
+ reason: null,
299
+ matched_bottom_keyword: null,
300
+ matched_load_more_keyword: null
301
+ };
302
+ }
303
+
304
+ async function safeQuerySelectorAll(client, rootNodeId, selector) {
305
+ try {
306
+ return await querySelectorAll(client, rootNodeId, selector);
307
+ } catch {
308
+ return [];
309
+ }
310
+ }
311
+
312
+ async function readVisibleMarkerNode(client, nodeId) {
313
+ let box = null;
314
+ try {
315
+ box = await getNodeBox(client, nodeId);
316
+ } catch {
317
+ return null;
318
+ }
319
+ if (!isUsableBox(box)) return null;
320
+ let outerHTML = "";
321
+ try {
322
+ outerHTML = await getOuterHTML(client, nodeId);
323
+ } catch {
324
+ return null;
325
+ }
326
+ return {
327
+ node_id: nodeId,
328
+ text: plainTextFromHtml(outerHTML),
329
+ box
330
+ };
331
+ }
332
+
333
+ function looksLikeRefreshLabel(text = "") {
334
+ const normalized = normalizeText(text).replace(/\s+/g, "");
335
+ return Boolean(normalized) && normalized.length <= 80 && /刷新|refresh/i.test(normalized);
336
+ }
337
+
338
+ export async function detectInfiniteListBottomMarker(client, {
339
+ rootNodeId,
340
+ markerSelectors = DEFAULT_BOTTOM_MARKER_SELECTORS,
341
+ textScanSelectors = DEFAULT_BOTTOM_TEXT_SCAN_SELECTORS,
342
+ refreshSelectors = DEFAULT_BOTTOM_REFRESH_SELECTORS,
343
+ bottomKeywords = DEFAULT_BOTTOM_HINT_KEYWORDS,
344
+ loadMoreKeywords = DEFAULT_LOAD_MORE_HINT_KEYWORDS,
345
+ maxMarkerNodes = 300,
346
+ maxTextScanNodes = 800,
347
+ textMaxLength = 80
348
+ } = {}) {
349
+ if (!client || !rootNodeId) {
350
+ return {
351
+ found: false,
352
+ reason: "missing_client_or_root"
353
+ };
354
+ }
355
+
356
+ const selectorCounts = {};
357
+ const markerNodeIds = [];
358
+ for (const selector of markerSelectors || []) {
359
+ const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
360
+ selectorCounts[selector] = nodeIds.length;
361
+ markerNodeIds.push(...nodeIds);
362
+ }
363
+
364
+ const visibleMarkers = [];
365
+ const markerIds = uniqueValues(markerNodeIds).slice(0, Math.max(0, Number(maxMarkerNodes) || 0));
366
+ for (const nodeId of markerIds) {
367
+ const marker = await readVisibleMarkerNode(client, nodeId);
368
+ if (!marker?.text) continue;
369
+ const classified = classifyInfiniteListBottomMarker({
370
+ text: marker.text,
371
+ bottomKeywords,
372
+ loadMoreKeywords
373
+ });
374
+ const summary = {
375
+ node_id: marker.node_id,
376
+ text: marker.text.slice(0, 160),
377
+ y: marker.box?.rect?.y || null,
378
+ matched_bottom_keyword: classified.matched_bottom_keyword,
379
+ matched_load_more_keyword: classified.matched_load_more_keyword
380
+ };
381
+ visibleMarkers.push(summary);
382
+ if (classified.is_bottom) {
383
+ return {
384
+ found: true,
385
+ reason: classified.reason,
386
+ source: "marker_selector",
387
+ marker: summary,
388
+ selector_counts: selectorCounts,
389
+ visible_marker_count: visibleMarkers.length,
390
+ refresh_button_visible: false
391
+ };
392
+ }
393
+ }
394
+
395
+ const hasLoadMoreMarker = visibleMarkers.some((marker) => marker.matched_load_more_keyword);
396
+
397
+ const refreshNodeIds = [];
398
+ for (const selector of refreshSelectors || []) {
399
+ const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
400
+ selectorCounts[selector] = (selectorCounts[selector] || 0) + nodeIds.length;
401
+ refreshNodeIds.push(...nodeIds);
402
+ }
403
+ const refreshButtons = [];
404
+ for (const nodeId of uniqueValues(refreshNodeIds).slice(0, 300)) {
405
+ const marker = await readVisibleMarkerNode(client, nodeId);
406
+ if (!marker?.text || !looksLikeRefreshLabel(marker.text)) continue;
407
+ refreshButtons.push({
408
+ node_id: marker.node_id,
409
+ text: marker.text.slice(0, 120),
410
+ y: marker.box?.rect?.y || null
411
+ });
412
+ }
413
+ if (refreshButtons.length && !hasLoadMoreMarker) {
414
+ return {
415
+ found: true,
416
+ reason: "refresh_button_visible",
417
+ source: "refresh_button",
418
+ marker: refreshButtons[0],
419
+ selector_counts: selectorCounts,
420
+ visible_marker_count: visibleMarkers.length,
421
+ refresh_button_visible: true,
422
+ refresh_button_count: refreshButtons.length
423
+ };
424
+ }
425
+
426
+ const scanNodeIds = [];
427
+ for (const selector of textScanSelectors || []) {
428
+ const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
429
+ selectorCounts[selector] = (selectorCounts[selector] || 0) + nodeIds.length;
430
+ scanNodeIds.push(...nodeIds);
431
+ }
432
+ let checkedTextNodeCount = 0;
433
+ for (const nodeId of uniqueValues(scanNodeIds).slice(0, Math.max(0, Number(maxTextScanNodes) || 0))) {
434
+ const marker = await readVisibleMarkerNode(client, nodeId);
435
+ if (!marker?.text || marker.text.length > textMaxLength) continue;
436
+ checkedTextNodeCount += 1;
437
+ const classified = classifyInfiniteListBottomMarker({
438
+ text: marker.text,
439
+ bottomKeywords,
440
+ loadMoreKeywords
441
+ });
442
+ if (classified.is_bottom) {
443
+ return {
444
+ found: true,
445
+ reason: classified.reason,
446
+ source: "text_scan",
447
+ marker: {
448
+ node_id: marker.node_id,
449
+ text: marker.text.slice(0, 160),
450
+ y: marker.box?.rect?.y || null,
451
+ matched_bottom_keyword: classified.matched_bottom_keyword
452
+ },
453
+ selector_counts: selectorCounts,
454
+ visible_marker_count: visibleMarkers.length,
455
+ checked_text_node_count: checkedTextNodeCount,
456
+ refresh_button_visible: refreshButtons.length > 0,
457
+ refresh_button_count: refreshButtons.length
458
+ };
459
+ }
460
+ }
461
+
462
+ return {
463
+ found: false,
464
+ reason: hasLoadMoreMarker ? "load_more_marker_visible" : "bottom_marker_not_found",
465
+ selector_counts: selectorCounts,
466
+ visible_markers: visibleMarkers.slice(0, 20),
467
+ visible_marker_count: visibleMarkers.length,
468
+ checked_text_node_count: checkedTextNodeCount,
469
+ refresh_button_visible: refreshButtons.length > 0,
470
+ refresh_button_count: refreshButtons.length
471
+ };
472
+ }
473
+
177
474
  export async function readVisibleInfiniteListItems({
178
475
  nodeIds = [],
179
476
  readCandidate,
@@ -333,9 +630,11 @@ export async function getNextInfiniteListCandidate({
333
630
  state,
334
631
  findNodeIds,
335
632
  readCandidate,
633
+ detectBottomMarker = null,
336
634
  keyForCandidate = candidateKeyFromProfile,
337
635
  maxScrolls = 20,
338
636
  stableSignatureLimit = 2,
637
+ minScrollsBeforeEnd = 3,
339
638
  wheelDeltaY = 850,
340
639
  settleMs = 1200,
341
640
  fallbackPoint = null
@@ -383,6 +682,54 @@ export async function getNextInfiniteListCandidate({
383
682
  return result;
384
683
  }
385
684
 
685
+ if (typeof detectBottomMarker === "function") {
686
+ let bottomMarker = null;
687
+ try {
688
+ bottomMarker = await detectBottomMarker({
689
+ scrollAttempt,
690
+ items,
691
+ signature,
692
+ state: compactInfiniteListState(state)
693
+ });
694
+ } catch (error) {
695
+ bottomMarker = {
696
+ found: false,
697
+ reason: "bottom_marker_probe_failed",
698
+ error: error?.message || String(error)
699
+ };
700
+ }
701
+ attempts[attempts.length - 1].bottom_marker = bottomMarker;
702
+ if (bottomMarker?.found) {
703
+ state.ledger?.push({
704
+ at: nowIso(),
705
+ event: "bottom_marker_detected",
706
+ reason: bottomMarker.reason || "bottom_marker",
707
+ source: bottomMarker.source || "",
708
+ marker: bottomMarker.marker || null
709
+ });
710
+ const result = {
711
+ ok: false,
712
+ end_reached: true,
713
+ reason: "bottom_marker",
714
+ bottom_marker: bottomMarker,
715
+ attempts,
716
+ state: compactInfiniteListState(state)
717
+ };
718
+ state.last_result = {
719
+ at: nowIso(),
720
+ ok: false,
721
+ end_reached: true,
722
+ reason: result.reason,
723
+ bottom_marker: {
724
+ reason: bottomMarker.reason || null,
725
+ source: bottomMarker.source || null,
726
+ marker: bottomMarker.marker || null
727
+ }
728
+ };
729
+ return result;
730
+ }
731
+ }
732
+
386
733
  if (!items.length) {
387
734
  const result = {
388
735
  ok: false,
@@ -400,7 +747,9 @@ export async function getNextInfiniteListCandidate({
400
747
  return result;
401
748
  }
402
749
 
403
- if (signature.stable_signature_count >= Math.max(1, Number(stableSignatureLimit) || 1)) {
750
+ const stableLimit = Math.max(1, Number(stableSignatureLimit) || 1);
751
+ const minStableScrolls = Math.max(0, Number(minScrollsBeforeEnd) || 0);
752
+ if (signature.stable_signature_count >= stableLimit && scrollAttempt >= minStableScrolls) {
404
753
  const result = {
405
754
  ok: false,
406
755
  end_reached: true,
@@ -416,6 +765,10 @@ export async function getNextInfiniteListCandidate({
416
765
  };
417
766
  return result;
418
767
  }
768
+ if (signature.stable_signature_count >= stableLimit) {
769
+ attempts[attempts.length - 1].stable_end_deferred = true;
770
+ attempts[attempts.length - 1].min_scrolls_before_end = minStableScrolls;
771
+ }
419
772
 
420
773
  const scrollResult = await scrollInfiniteListByVisibleItems(client, items, {
421
774
  wheelDeltaY,
@@ -8,6 +8,17 @@ export const CHAT_CARD_SELECTORS = Object.freeze([
8
8
  "div[role=\"listitem\"]"
9
9
  ]);
10
10
 
11
+ export const CHAT_BOTTOM_MARKER_SELECTORS = Object.freeze([
12
+ "div[role=\"tfoot\"] .load-tips",
13
+ "p.load-tips",
14
+ ".load-tips",
15
+ ".empty-tip",
16
+ ".empty-text",
17
+ ".no-data",
18
+ "[class*=\"load-tips\"]",
19
+ "[class*=\"empty\"]"
20
+ ]);
21
+
11
22
  export const CHAT_JOB_LABEL_SELECTORS = Object.freeze([
12
23
  ".chat-job .chat-select-job",
13
24
  ".chat-job .dropmenu-label",
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  compactInfiniteListState,
21
21
  createInfiniteListState,
22
+ detectInfiniteListBottomMarker,
22
23
  getNextInfiniteListCandidate,
23
24
  markInfiniteListCandidateProcessed
24
25
  } from "../../core/infinite-list/index.js";
@@ -34,7 +35,10 @@ import {
34
35
  normalizeText,
35
36
  screenCandidate
36
37
  } from "../../core/screening/index.js";
37
- import { CHAT_TARGET_URL } from "./constants.js";
38
+ import {
39
+ CHAT_BOTTOM_MARKER_SELECTORS,
40
+ CHAT_TARGET_URL
41
+ } from "./constants.js";
38
42
  import {
39
43
  chatCandidateKeyFromProfile,
40
44
  findChatCandidateNodeIdById,
@@ -372,9 +376,9 @@ export async function runChatWorkflow({
372
376
  llmImageDetail = "high",
373
377
  screeningMode = "llm",
374
378
  listMaxScrolls = 20,
375
- listStableSignatureLimit = 2,
379
+ listStableSignatureLimit = 5,
376
380
  listWheelDeltaY = 850,
377
- listSettleMs = 1200,
381
+ listSettleMs = 2200,
378
382
  listFallbackPoint = null,
379
383
  imageOutputDir = ""
380
384
  } = {}, runControl) {
@@ -631,6 +635,13 @@ export async function runChatWorkflow({
631
635
  run_candidate_index: results.length,
632
636
  visible_index: visibleIndex
633
637
  }
638
+ }),
639
+ detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
640
+ rootNodeId: rootState?.rootNodes?.top,
641
+ markerSelectors: CHAT_BOTTOM_MARKER_SELECTORS,
642
+ refreshSelectors: [],
643
+ textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
644
+ maxTextScanNodes: 500
634
645
  })
635
646
  }));
636
647
  if (!nextCandidateResult.ok) {
@@ -827,6 +838,9 @@ export async function runChatWorkflow({
827
838
  maxScreenshots: maxImagePages,
828
839
  wheelDeltaY: imageWheelDeltaY,
829
840
  settleMs: 350,
841
+ scrollMethod: "dom-anchor-fallback-input",
842
+ stepTimeoutMs: 45000,
843
+ totalTimeoutMs: 90000,
830
844
  duplicateStopCount: 1,
831
845
  skipDuplicateScreenshots: true,
832
846
  composeForLlm: true,
@@ -1137,9 +1151,9 @@ export function createChatRunService({
1137
1151
  llmImageDetail = "high",
1138
1152
  screeningMode = "llm",
1139
1153
  listMaxScrolls = 20,
1140
- listStableSignatureLimit = 2,
1154
+ listStableSignatureLimit = 5,
1141
1155
  listWheelDeltaY = 850,
1142
- listSettleMs = 1200,
1156
+ listSettleMs = 2200,
1143
1157
  listFallbackPoint = null,
1144
1158
  imageOutputDir = "",
1145
1159
  name = "chat-domain-run"
@@ -1163,6 +1177,8 @@ export function createChatRunService({
1163
1177
  detail_limit: normalizedDetailLimit,
1164
1178
  detail_source: normalizedDetailSource,
1165
1179
  close_resume: closeResume,
1180
+ request_resume_for_passed: Boolean(requestResumeForPassed),
1181
+ dry_run_request_cv: Boolean(dryRunRequestCv),
1166
1182
  cv_acquisition_mode: cvAcquisitionMode,
1167
1183
  call_llm_on_image: Boolean(callLlmOnImage),
1168
1184
  screening_mode: normalizedScreeningMode,
@@ -67,6 +67,17 @@ export const RECOMMEND_END_REFRESH_SELECTOR = [
67
67
  "a"
68
68
  ].join(", ");
69
69
 
70
+ export const RECOMMEND_BOTTOM_MARKER_SELECTORS = Object.freeze([
71
+ ".finished-wrap",
72
+ ".no-data-refresh",
73
+ ".load-tips",
74
+ ".empty-tip",
75
+ ".empty-text",
76
+ ".no-data",
77
+ "[class*=\"finished\"]",
78
+ "[class*=\"load-tips\"]"
79
+ ]);
80
+
70
81
  export const DETAIL_POPUP_SELECTORS = Object.freeze([
71
82
  ".dialog-wrap.active",
72
83
  ".boss-popup__wrapper",
@@ -21,6 +21,7 @@ import {
21
21
  import {
22
22
  compactInfiniteListState,
23
23
  createInfiniteListState,
24
+ detectInfiniteListBottomMarker,
24
25
  getNextInfiniteListCandidate,
25
26
  markInfiniteListCandidateProcessed,
26
27
  resetInfiniteListForRefreshRound
@@ -54,6 +55,10 @@ import {
54
55
  normalizeRecommendPageScope,
55
56
  selectRecommendPageScope
56
57
  } from "./scopes.js";
58
+ import {
59
+ RECOMMEND_BOTTOM_MARKER_SELECTORS,
60
+ RECOMMEND_END_REFRESH_SELECTOR
61
+ } from "./constants.js";
57
62
  import {
58
63
  clickRecommendActionControl,
59
64
  normalizeRecommendPostAction,
@@ -66,6 +71,15 @@ function normalizeLabels(labels = []) {
66
71
  return labels.map((label) => String(label || "").trim()).filter(Boolean);
67
72
  }
68
73
 
74
+ function isRefreshableListStall(reason = "") {
75
+ return new Set([
76
+ "stable_visible_signature",
77
+ "max_scrolls_exhausted",
78
+ "scroll_failed",
79
+ "scroll_anchor_unavailable"
80
+ ]).has(String(reason || ""));
81
+ }
82
+
69
83
  function normalizeFilter(filter = {}) {
70
84
  const filterGroups = Array.isArray(filter.filterGroups)
71
85
  ? filter.filterGroups
@@ -347,6 +361,18 @@ function compactRefreshAttempt(refreshAttempt) {
347
361
  };
348
362
  }
349
363
 
364
+ function countPassedResults(results = []) {
365
+ return results.filter((item) => item?.screening?.passed).length;
366
+ }
367
+
368
+ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
369
+ if (!error) return null;
370
+ return {
371
+ code: error.code || fallbackCode,
372
+ message: error.message || String(error)
373
+ };
374
+ }
375
+
350
376
  export async function runRecommendWorkflow({
351
377
  client,
352
378
  targetUrl = "",
@@ -364,9 +390,9 @@ export async function runRecommendWorkflow({
364
390
  imageWheelDeltaY = 650,
365
391
  cvAcquisitionMode = "unknown",
366
392
  listMaxScrolls = 20,
367
- listStableSignatureLimit = 2,
393
+ listStableSignatureLimit = 5,
368
394
  listWheelDeltaY = 850,
369
- listSettleMs = 1200,
395
+ listSettleMs = 2200,
370
396
  listFallbackPoint = null,
371
397
  refreshOnEnd = true,
372
398
  maxRefreshRounds = 2,
@@ -393,9 +419,9 @@ export async function runRecommendWorkflow({
393
419
  const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
394
420
  const useLlmScreening = normalizedScreeningMode !== "deterministic";
395
421
  const postActionEnabled = normalizedPostAction !== "none";
396
- const limit = Math.max(1, Number(maxCandidates) || 1);
397
- const detailCountLimit = detailLimit == null ? limit : Math.max(0, Number(detailLimit) || 0);
398
- const effectiveDetailLimit = postActionEnabled ? limit : detailCountLimit;
422
+ const targetPassCount = Math.max(1, Number(maxCandidates) || 1);
423
+ const detailCountLimit = detailLimit == null ? Number.POSITIVE_INFINITY : Math.max(0, Number(detailLimit) || 0);
424
+ const effectiveDetailLimit = postActionEnabled ? Number.POSITIVE_INFINITY : detailCountLimit;
399
425
  const networkRecorder = effectiveDetailLimit > 0
400
426
  ? createRecommendDetailNetworkRecorder(client)
401
427
  : null;
@@ -508,7 +534,8 @@ export async function runRecommendWorkflow({
508
534
 
509
535
  runControl.updateProgress({
510
536
  card_count: cardNodeIds.length,
511
- target_count: limit,
537
+ target_count: targetPassCount,
538
+ target_count_semantics: "passed_candidates",
512
539
  processed: 0,
513
540
  screened: 0,
514
541
  detail_opened: 0,
@@ -525,7 +552,7 @@ export async function runRecommendWorkflow({
525
552
  viewport_recoveries: viewportGuard.getStats().recoveries
526
553
  });
527
554
 
528
- while (results.length < limit) {
555
+ while (countPassedResults(results) < targetPassCount) {
529
556
  const candidateStarted = Date.now();
530
557
  const timings = {};
531
558
  await runControl.waitIfPaused();
@@ -559,15 +586,22 @@ export async function runRecommendWorkflow({
559
586
  run_candidate_index: results.length,
560
587
  visible_index: visibleIndex
561
588
  }
589
+ }),
590
+ detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
591
+ rootNodeId: rootState?.iframe?.documentNodeId,
592
+ markerSelectors: RECOMMEND_BOTTOM_MARKER_SELECTORS,
593
+ refreshSelectors: [RECOMMEND_END_REFRESH_SELECTOR],
594
+ textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
595
+ maxTextScanNodes: 500
562
596
  })
563
597
  }));
564
598
  if (!nextCandidateResult.ok) {
565
599
  listEndReason = nextCandidateResult.reason || "list_exhausted";
566
600
  if (
567
- nextCandidateResult.end_reached
568
- && refreshOnEnd
569
- && results.length < limit
570
- && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
601
+ (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
602
+ && refreshOnEnd
603
+ && countPassedResults(results) < targetPassCount
604
+ && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
571
605
  ) {
572
606
  await runControl.waitIfPaused();
573
607
  runControl.throwIfCanceled();
@@ -592,7 +626,8 @@ export async function runRecommendWorkflow({
592
626
  });
593
627
  runControl.updateProgress({
594
628
  card_count: refreshResult.card_count || cardNodeIds.length,
595
- target_count: limit,
629
+ target_count: targetPassCount,
630
+ target_count_semantics: "passed_candidates",
596
631
  processed: results.length,
597
632
  screened: results.length,
598
633
  detail_opened: results.filter((item) => item.detail).length,
@@ -713,6 +748,9 @@ export async function runRecommendWorkflow({
713
748
  maxScreenshots: maxImagePages,
714
749
  wheelDeltaY: imageWheelDeltaY,
715
750
  settleMs: 350,
751
+ scrollMethod: "dom-anchor-fallback-input",
752
+ stepTimeoutMs: 45000,
753
+ totalTimeoutMs: 90000,
716
754
  duplicateStopCount: 1,
717
755
  skipDuplicateScreenshots: true,
718
756
  composeForLlm: true,
@@ -838,7 +876,8 @@ export async function runRecommendWorkflow({
838
876
 
839
877
  runControl.updateProgress({
840
878
  card_count: cardNodeIds.length,
841
- target_count: limit,
879
+ target_count: targetPassCount,
880
+ target_count_semantics: "passed_candidates",
842
881
  processed: results.length,
843
882
  screened: results.length,
844
883
  detail_opened: results.filter((item) => item.detail).length,
@@ -939,9 +978,9 @@ export function createRecommendRunService({
939
978
  imageWheelDeltaY = 650,
940
979
  cvAcquisitionMode = "unknown",
941
980
  listMaxScrolls = 20,
942
- listStableSignatureLimit = 2,
981
+ listStableSignatureLimit = 5,
943
982
  listWheelDeltaY = 850,
944
- listSettleMs = 1200,
983
+ listSettleMs = 2200,
945
984
  listFallbackPoint = null,
946
985
  refreshOnEnd = true,
947
986
  maxRefreshRounds = 2,
@@ -968,7 +1007,7 @@ export function createRecommendRunService({
968
1007
  const normalizedFallbackPageScope = normalizeRecommendPageScope(fallbackPageScope) || "recommend";
969
1008
  const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
970
1009
  const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
971
- const normalizedDetailLimit = detailLimit == null ? candidateLimit : Math.max(0, Number(detailLimit) || 0);
1010
+ const normalizedDetailLimit = detailLimit == null ? null : Math.max(0, Number(detailLimit) || 0);
972
1011
  return manager.startRun({
973
1012
  name,
974
1013
  context: {
@@ -980,6 +1019,7 @@ export function createRecommendRunService({
980
1019
  fallback_page_scope: normalizedFallbackPageScope,
981
1020
  filter: normalizedFilter,
982
1021
  max_candidates: maxCandidates,
1022
+ max_candidates_semantics: "passed_candidates",
983
1023
  detail_limit: normalizedDetailLimit,
984
1024
  close_detail: closeDetail,
985
1025
  cv_acquisition_mode: cvAcquisitionMode,
@@ -1008,6 +1048,7 @@ export function createRecommendRunService({
1008
1048
  progress: {
1009
1049
  card_count: 0,
1010
1050
  target_count: candidateLimit,
1051
+ target_count_semantics: "passed_candidates",
1011
1052
  processed: 0,
1012
1053
  screened: 0,
1013
1054
  detail_opened: 0,