@reconcrap/boss-recommend-mcp 2.0.11 → 2.0.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -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) {
@@ -1137,9 +1148,9 @@ export function createChatRunService({
1137
1148
  llmImageDetail = "high",
1138
1149
  screeningMode = "llm",
1139
1150
  listMaxScrolls = 20,
1140
- listStableSignatureLimit = 2,
1151
+ listStableSignatureLimit = 5,
1141
1152
  listWheelDeltaY = 850,
1142
- listSettleMs = 1200,
1153
+ listSettleMs = 2200,
1143
1154
  listFallbackPoint = null,
1144
1155
  imageOutputDir = "",
1145
1156
  name = "chat-domain-run"
@@ -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
@@ -364,9 +378,9 @@ export async function runRecommendWorkflow({
364
378
  imageWheelDeltaY = 650,
365
379
  cvAcquisitionMode = "unknown",
366
380
  listMaxScrolls = 20,
367
- listStableSignatureLimit = 2,
381
+ listStableSignatureLimit = 5,
368
382
  listWheelDeltaY = 850,
369
- listSettleMs = 1200,
383
+ listSettleMs = 2200,
370
384
  listFallbackPoint = null,
371
385
  refreshOnEnd = true,
372
386
  maxRefreshRounds = 2,
@@ -559,15 +573,22 @@ export async function runRecommendWorkflow({
559
573
  run_candidate_index: results.length,
560
574
  visible_index: visibleIndex
561
575
  }
576
+ }),
577
+ detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
578
+ rootNodeId: rootState?.iframe?.documentNodeId,
579
+ markerSelectors: RECOMMEND_BOTTOM_MARKER_SELECTORS,
580
+ refreshSelectors: [RECOMMEND_END_REFRESH_SELECTOR],
581
+ textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
582
+ maxTextScanNodes: 500
562
583
  })
563
584
  }));
564
585
  if (!nextCandidateResult.ok) {
565
586
  listEndReason = nextCandidateResult.reason || "list_exhausted";
566
587
  if (
567
- nextCandidateResult.end_reached
568
- && refreshOnEnd
569
- && results.length < limit
570
- && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
588
+ (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
589
+ && refreshOnEnd
590
+ && results.length < limit
591
+ && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
571
592
  ) {
572
593
  await runControl.waitIfPaused();
573
594
  runControl.throwIfCanceled();
@@ -939,9 +960,9 @@ export function createRecommendRunService({
939
960
  imageWheelDeltaY = 650,
940
961
  cvAcquisitionMode = "unknown",
941
962
  listMaxScrolls = 20,
942
- listStableSignatureLimit = 2,
963
+ listStableSignatureLimit = 5,
943
964
  listWheelDeltaY = 850,
944
- listSettleMs = 1200,
965
+ listSettleMs = 2200,
945
966
  listFallbackPoint = null,
946
967
  refreshOnEnd = true,
947
968
  maxRefreshRounds = 2,
@@ -24,6 +24,29 @@ export const RECRUIT_NO_DATA_SELECTORS = Object.freeze([
24
24
  '[class*="empty"]'
25
25
  ]);
26
26
 
27
+ export const RECRUIT_BOTTOM_MARKER_SELECTORS = Object.freeze([
28
+ ".finished-wrap",
29
+ ".load-tips",
30
+ ".tip-nodata",
31
+ ".empty-tip",
32
+ ".empty-text",
33
+ ".no-data",
34
+ "[class*=\"finished\"]",
35
+ "[class*=\"load-tips\"]",
36
+ "[class*=\"empty\"]"
37
+ ]);
38
+
39
+ export const RECRUIT_BOTTOM_REFRESH_SELECTORS = Object.freeze([
40
+ ".finished-wrap .btn-refresh",
41
+ ".finished-wrap .btn",
42
+ ".no-data-refresh .btn-refresh",
43
+ ".no-data-refresh .btn",
44
+ "[class*=\"refresh\"]",
45
+ "[ka*=\"refresh\"]",
46
+ "button",
47
+ "a"
48
+ ]);
49
+
27
50
  export const RECRUIT_SEARCH_SELECTORS = Object.freeze({
28
51
  keywordInput: [
29
52
  "input.search-input",
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  compactInfiniteListState,
21
21
  createInfiniteListState,
22
+ detectInfiniteListBottomMarker,
22
23
  getNextInfiniteListCandidate,
23
24
  markInfiniteListCandidateProcessed,
24
25
  resetInfiniteListForRefreshRound
@@ -49,6 +50,10 @@ import {
49
50
  } from "./search.js";
50
51
  import { refreshRecruitSearchAtEnd } from "./refresh.js";
51
52
  import { getRecruitRoots } from "./roots.js";
53
+ import {
54
+ RECRUIT_BOTTOM_MARKER_SELECTORS,
55
+ RECRUIT_BOTTOM_REFRESH_SELECTORS
56
+ } from "./constants.js";
52
57
 
53
58
  function compactScreening(screening) {
54
59
  return {
@@ -144,9 +149,9 @@ export async function runRecruitWorkflow({
144
149
  imageWheelDeltaY = 650,
145
150
  cvAcquisitionMode = "unknown",
146
151
  listMaxScrolls = 20,
147
- listStableSignatureLimit = 2,
152
+ listStableSignatureLimit = 5,
148
153
  listWheelDeltaY = 850,
149
- listSettleMs = 1200,
154
+ listSettleMs = 2200,
150
155
  listFallbackPoint = null,
151
156
  refreshOnEnd = true,
152
157
  maxRefreshRounds = 2,
@@ -298,6 +303,13 @@ export async function runRecruitWorkflow({
298
303
  visible_index: visibleIndex,
299
304
  search_params: normalizedSearchParams
300
305
  }
306
+ }),
307
+ detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
308
+ rootNodeId: rootState?.iframe?.documentNodeId,
309
+ markerSelectors: RECRUIT_BOTTOM_MARKER_SELECTORS,
310
+ refreshSelectors: RECRUIT_BOTTOM_REFRESH_SELECTORS,
311
+ textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
312
+ maxTextScanNodes: 500
301
313
  })
302
314
  }));
303
315
  if (!nextCandidateResult.ok) {
@@ -625,9 +637,9 @@ export function createRecruitRunService({
625
637
  imageWheelDeltaY = 650,
626
638
  cvAcquisitionMode = "unknown",
627
639
  listMaxScrolls = 20,
628
- listStableSignatureLimit = 2,
640
+ listStableSignatureLimit = 5,
629
641
  listWheelDeltaY = 850,
630
- listSettleMs = 1200,
642
+ listSettleMs = 2200,
631
643
  listFallbackPoint = null,
632
644
  refreshOnEnd = true,
633
645
  maxRefreshRounds = 2,