@reconcrap/boss-recommend-mcp 2.0.10 → 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.10",
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",
@@ -146,6 +146,14 @@ function filePathForSequence(basePath, index, extension) {
146
146
  return path.join(parsed.dir, `${parsed.name}-page-${page}${parsed.ext || `.${extension}`}`);
147
147
  }
148
148
 
149
+ function filePathForLlmSequence(basePath, index) {
150
+ const resolved = resolveOutputPath(basePath);
151
+ if (!resolved) return null;
152
+ const parsed = path.parse(resolved);
153
+ const page = String(index + 1).padStart(2, "0");
154
+ return path.join(parsed.dir, `${parsed.name}-llm-${page}.jpg`);
155
+ }
156
+
149
157
  function screenshotHash(buffer) {
150
158
  return crypto.createHash("sha256").update(buffer).digest("hex");
151
159
  }
@@ -207,6 +215,111 @@ async function optimizeScreenshotBuffer(buffer, {
207
215
  }
208
216
  }
209
217
 
218
+ async function composeScreenshotsForLlm(screenshots = [], {
219
+ basePath,
220
+ pagesPerImage = 3,
221
+ resizeMaxWidth = 1100,
222
+ quality = 72
223
+ } = {}) {
224
+ const fileScreenshots = screenshots.filter((item) => item?.file_path);
225
+ if (!basePath || fileScreenshots.length <= 1) {
226
+ return {
227
+ llm_file_paths: fileScreenshots.map((item) => item.file_path),
228
+ llm_screenshots: [],
229
+ llm_total_byte_length: 0,
230
+ llm_original_total_byte_length: 0,
231
+ llm_composition_error: null
232
+ };
233
+ }
234
+
235
+ const safePagesPerImage = Math.max(1, Math.min(5, Number(pagesPerImage) || 3));
236
+ const safeWidth = Math.max(700, Math.min(1400, Number(resizeMaxWidth) || 1100));
237
+ const safeQuality = Math.max(45, Math.min(90, Number(quality) || 72));
238
+ const llmScreenshots = [];
239
+
240
+ try {
241
+ for (let index = 0; index < fileScreenshots.length; index += safePagesPerImage) {
242
+ const group = fileScreenshots.slice(index, index + safePagesPerImage);
243
+ const prepared = [];
244
+ for (const item of group) {
245
+ const sourceBuffer = fs.readFileSync(item.file_path);
246
+ const { data, info } = await sharp(sourceBuffer, { failOn: "none" })
247
+ .resize({
248
+ width: safeWidth,
249
+ withoutEnlargement: true
250
+ })
251
+ .jpeg({
252
+ quality: safeQuality,
253
+ mozjpeg: true
254
+ })
255
+ .toBuffer({ resolveWithObject: true });
256
+ prepared.push({
257
+ input: data,
258
+ width: info.width,
259
+ height: info.height,
260
+ source_file_path: item.file_path
261
+ });
262
+ }
263
+
264
+ const width = Math.max(...prepared.map((item) => item.width), 1);
265
+ const height = prepared.reduce((sum, item) => sum + item.height, 0);
266
+ let top = 0;
267
+ const composites = prepared.map((item) => {
268
+ const layer = {
269
+ input: item.input,
270
+ left: 0,
271
+ top
272
+ };
273
+ top += item.height;
274
+ return layer;
275
+ });
276
+ const outputBuffer = await sharp({
277
+ create: {
278
+ width,
279
+ height,
280
+ channels: 3,
281
+ background: "#ffffff"
282
+ }
283
+ })
284
+ .composite(composites)
285
+ .jpeg({
286
+ quality: safeQuality,
287
+ mozjpeg: true
288
+ })
289
+ .toBuffer();
290
+ const outputPath = filePathForLlmSequence(basePath, llmScreenshots.length);
291
+ fs.writeFileSync(outputPath, outputBuffer);
292
+ llmScreenshots.push({
293
+ index: llmScreenshots.length,
294
+ file_path: outputPath,
295
+ byte_length: outputBuffer.length,
296
+ source_file_paths: prepared.map((item) => item.source_file_path),
297
+ source_page_count: prepared.length,
298
+ width,
299
+ height,
300
+ format: "jpeg",
301
+ mime_type: "image/jpeg"
302
+ });
303
+ }
304
+ } catch (error) {
305
+ return {
306
+ llm_file_paths: fileScreenshots.map((item) => item.file_path),
307
+ llm_screenshots: [],
308
+ llm_total_byte_length: 0,
309
+ llm_original_total_byte_length: fileScreenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
310
+ llm_composition_error: error?.message || String(error)
311
+ };
312
+ }
313
+
314
+ return {
315
+ llm_file_paths: llmScreenshots.map((item) => item.file_path),
316
+ llm_screenshots: llmScreenshots,
317
+ llm_total_byte_length: llmScreenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
318
+ llm_original_total_byte_length: fileScreenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
319
+ llm_composition_error: null
320
+ };
321
+ }
322
+
210
323
  export async function captureScrolledNodeScreenshots(client, nodeId, {
211
324
  filePath,
212
325
  format = "png",
@@ -222,6 +335,10 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
222
335
  skipDuplicateScreenshots = false,
223
336
  optimize = false,
224
337
  resizeMaxWidth = 0,
338
+ composeForLlm = false,
339
+ llmPagesPerImage = 3,
340
+ llmResizeMaxWidth = 1100,
341
+ llmQuality = 72,
225
342
  metadata = {}
226
343
  } = {}) {
227
344
  if (!nodeId) throw new Error("captureScrolledNodeScreenshots requires nodeId");
@@ -322,6 +439,21 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
322
439
  }
323
440
  }
324
441
 
442
+ const llmComposition = composeForLlm
443
+ ? await composeScreenshotsForLlm(screenshots, {
444
+ basePath: filePath,
445
+ pagesPerImage: llmPagesPerImage,
446
+ resizeMaxWidth: llmResizeMaxWidth,
447
+ quality: llmQuality
448
+ })
449
+ : {
450
+ llm_file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
451
+ llm_screenshots: [],
452
+ llm_total_byte_length: 0,
453
+ llm_original_total_byte_length: 0,
454
+ llm_composition_error: null
455
+ };
456
+
325
457
  return {
326
458
  schema_version: 1,
327
459
  source: "image-scroll-sequence",
@@ -335,12 +467,22 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
335
467
  dropped_duplicate_count: droppedDuplicateCount,
336
468
  total_byte_length: screenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
337
469
  original_total_byte_length: screenshots.reduce((sum, item) => sum + (Number(item.original_byte_length) || 0), 0),
470
+ llm_file_paths: llmComposition.llm_file_paths,
471
+ llm_screenshot_count: llmComposition.llm_file_paths.length,
472
+ llm_total_byte_length: llmComposition.llm_total_byte_length,
473
+ llm_original_total_byte_length: llmComposition.llm_original_total_byte_length,
474
+ llm_composition_error: llmComposition.llm_composition_error,
475
+ llm_screenshots: llmComposition.llm_screenshots,
338
476
  optimization: {
339
477
  enabled: Boolean(optimize),
340
478
  resize_max_width: Math.max(0, Number(resizeMaxWidth) || 0),
341
479
  capture_viewport: Boolean(captureViewport),
342
480
  format,
343
- quality: quality ?? null
481
+ quality: quality ?? null,
482
+ llm_compose_enabled: Boolean(composeForLlm),
483
+ llm_pages_per_image: Math.max(1, Math.min(5, Number(llmPagesPerImage) || 3)),
484
+ llm_resize_max_width: Math.max(0, Number(llmResizeMaxWidth) || 0),
485
+ llm_quality: llmQuality ?? null
344
486
  },
345
487
  file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
346
488
  screenshots,
@@ -132,8 +132,13 @@ export function summarizeImageEvidence(imageEvidence = null) {
132
132
  dropped_duplicate_count: imageEvidence.dropped_duplicate_count || 0,
133
133
  total_byte_length: imageEvidence.total_byte_length || 0,
134
134
  original_total_byte_length: imageEvidence.original_total_byte_length || 0,
135
+ llm_screenshot_count: imageEvidence.llm_screenshot_count || 0,
136
+ llm_total_byte_length: imageEvidence.llm_total_byte_length || 0,
137
+ llm_original_total_byte_length: imageEvidence.llm_original_total_byte_length || 0,
138
+ llm_composition_error: imageEvidence.llm_composition_error || null,
135
139
  optimization: imageEvidence.optimization || null,
136
140
  file_paths: imageEvidence.file_paths || [],
141
+ llm_file_paths: imageEvidence.llm_file_paths || [],
137
142
  first_clip: imageEvidence.screenshots?.[0]?.clip || imageEvidence.clip || null
138
143
  };
139
144
  }
@@ -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(/&nbsp;|&#160;/gi, " ")
81
+ .replace(/&amp;/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,
@@ -392,11 +392,18 @@ function normalizeImagePaths({ imageEvidence = null, imagePaths = [] } = {}) {
392
392
  if (Array.isArray(imagePaths)) {
393
393
  paths.push(...imagePaths);
394
394
  }
395
- if (Array.isArray(imageEvidence?.file_paths)) {
396
- paths.push(...imageEvidence.file_paths);
397
- }
398
- if (Array.isArray(imageEvidence?.screenshots)) {
399
- paths.push(...imageEvidence.screenshots.map((item) => item.file_path));
395
+ const evidenceLlmPaths = Array.isArray(imageEvidence?.llm_file_paths)
396
+ ? imageEvidence.llm_file_paths
397
+ : [];
398
+ if (evidenceLlmPaths.length) {
399
+ paths.push(...evidenceLlmPaths);
400
+ } else {
401
+ if (Array.isArray(imageEvidence?.file_paths)) {
402
+ paths.push(...imageEvidence.file_paths);
403
+ }
404
+ if (Array.isArray(imageEvidence?.screenshots)) {
405
+ paths.push(...imageEvidence.screenshots.map((item) => item.file_path));
406
+ }
400
407
  }
401
408
  return unique(paths.map((filePath) => String(filePath || "").trim()).filter(Boolean));
402
409
  }
@@ -1292,6 +1299,7 @@ export function compactScreeningLlmResult(llmResult) {
1292
1299
  usage: llmResult.usage || null,
1293
1300
  finish_reason: llmResult.finish_reason || null,
1294
1301
  image_input_count: llmResult.image_input_count || 0,
1302
+ attempt_count: llmResult.attempt_count || 0,
1295
1303
  error: llmResult.error || null,
1296
1304
  screened_at: llmResult.screened_at || null
1297
1305
  };
@@ -1324,6 +1332,7 @@ export function createFailedLlmScreeningResult(error) {
1324
1332
  raw_model_output: "",
1325
1333
  image_input_count: Number(error?.image_input_count) || 0,
1326
1334
  image_inputs: Array.isArray(error?.image_inputs) ? error.image_inputs : [],
1335
+ attempt_count: Number(error?.llm_attempt_count) || 0,
1327
1336
  error: error?.message || String(error || "unknown"),
1328
1337
  screened_at: nowIso()
1329
1338
  };
@@ -1352,7 +1361,7 @@ export function buildScreeningLlmMessages({
1352
1361
  `请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${safeCriteria}\n\n`
1353
1362
  + `候选人信息:\n${safeText || "候选人的完整简历信息在后续截图中,请按截图顺序阅读。"}\n\n`
1354
1363
  + (images.length
1355
- ? `候选人简历截图共 ${images.length} 张,按从上到下的滚动顺序排列。请完整阅读所有截图后再判断。\n\n`
1364
+ ? `候选人简历截图共 ${images.length} 张,按从上到下的滚动顺序排列。若截图是拼接长图,请按图内从上到下顺序完整阅读;不要跳过任何一段简历内容。\n\n`
1356
1365
  : "")
1357
1366
  + "要求:\n"
1358
1367
  + "1) 只能依据候选人信息或截图中真实出现的内容判断。\n"
@@ -1383,6 +1392,24 @@ export function buildScreeningLlmMessages({
1383
1392
  ];
1384
1393
  }
1385
1394
 
1395
+ function normalizeLlmMaxRetries(value) {
1396
+ if (value == null || value === "") return 1;
1397
+ const parsed = Number(value);
1398
+ if (!Number.isFinite(parsed) || parsed < 0) return 1;
1399
+ return Math.min(3, Math.floor(parsed));
1400
+ }
1401
+
1402
+ function isRetryableLlmRequestError(error) {
1403
+ const status = Number(error?.status);
1404
+ if ([408, 409, 425, 429].includes(status) || status >= 500) return true;
1405
+ return /(?:aborted|abort|timeout|timed out|fetch failed|socket|network|ECONNRESET|ETIMEDOUT|EAI_AGAIN)/i
1406
+ .test(String(error?.message || error || ""));
1407
+ }
1408
+
1409
+ function sleepMs(ms) {
1410
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
1411
+ }
1412
+
1386
1413
  export async function callScreeningLlm({
1387
1414
  candidate,
1388
1415
  criteria,
@@ -1425,9 +1452,13 @@ export async function callScreeningLlm({
1425
1452
  thinkingLevel: config.llmThinkingLevel || config.thinkingLevel || config.reasoningEffort || "low"
1426
1453
  });
1427
1454
 
1428
- const controller = new AbortController();
1429
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1430
- try {
1455
+ const maxRetries = normalizeLlmMaxRetries(config.llmMaxRetries ?? config.maxRetries);
1456
+ const maxAttempts = maxRetries + 1;
1457
+ let lastError = null;
1458
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1459
+ const controller = new AbortController();
1460
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1461
+ try {
1431
1462
  const headers = {
1432
1463
  "Content-Type": "application/json",
1433
1464
  Authorization: `Bearer ${apiKey}`
@@ -1443,7 +1474,9 @@ export async function callScreeningLlm({
1443
1474
  });
1444
1475
  const responseText = await response.text();
1445
1476
  if (!response.ok) {
1446
- throw new Error(`LLM request failed: ${response.status} ${responseText.slice(0, 400)}`);
1477
+ const error = new Error(`LLM request failed: ${response.status} ${responseText.slice(0, 400)}`);
1478
+ error.status = response.status;
1479
+ throw error;
1447
1480
  }
1448
1481
  const json = tryParseJson(responseText);
1449
1482
  if (!json) {
@@ -1485,13 +1518,25 @@ export async function callScreeningLlm({
1485
1518
  raw_content_length: content.length,
1486
1519
  image_input_count: imageInputs.length,
1487
1520
  image_inputs: summarizeLlmImageInputs(imageInputs),
1521
+ attempt_count: attempt,
1488
1522
  screened_at: nowIso()
1489
1523
  };
1490
1524
  } catch (error) {
1491
- error.image_input_count = imageInputs.length;
1492
- error.image_inputs = summarizeLlmImageInputs(imageInputs);
1493
- throw error;
1525
+ lastError = error;
1526
+ if (attempt >= maxAttempts || !isRetryableLlmRequestError(error)) {
1527
+ error.image_input_count = imageInputs.length;
1528
+ error.image_inputs = summarizeLlmImageInputs(imageInputs);
1529
+ error.llm_attempt_count = attempt;
1530
+ throw error;
1531
+ }
1532
+ await sleepMs(Math.min(2500, 500 * attempt));
1494
1533
  } finally {
1495
1534
  clearTimeout(timer);
1496
1535
  }
1536
+ }
1537
+ lastError = lastError || new Error("LLM request failed without response");
1538
+ lastError.image_input_count = imageInputs.length;
1539
+ lastError.image_inputs = summarizeLlmImageInputs(imageInputs);
1540
+ lastError.llm_attempt_count = maxAttempts;
1541
+ throw lastError;
1497
1542
  }
@@ -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,
@@ -98,6 +102,7 @@ function compactLlmResult(llmResult) {
98
102
  usage: llmResult.usage || null,
99
103
  finish_reason: llmResult.finish_reason || null,
100
104
  image_input_count: llmResult.image_input_count || 0,
105
+ attempt_count: llmResult.attempt_count || 0,
101
106
  error: llmResult.error || null
102
107
  };
103
108
  }
@@ -371,9 +376,9 @@ export async function runChatWorkflow({
371
376
  llmImageDetail = "high",
372
377
  screeningMode = "llm",
373
378
  listMaxScrolls = 20,
374
- listStableSignatureLimit = 2,
379
+ listStableSignatureLimit = 5,
375
380
  listWheelDeltaY = 850,
376
- listSettleMs = 1200,
381
+ listSettleMs = 2200,
377
382
  listFallbackPoint = null,
378
383
  imageOutputDir = ""
379
384
  } = {}, runControl) {
@@ -630,6 +635,13 @@ export async function runChatWorkflow({
630
635
  run_candidate_index: results.length,
631
636
  visible_index: visibleIndex
632
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
633
645
  })
634
646
  }));
635
647
  if (!nextCandidateResult.ok) {
@@ -826,11 +838,15 @@ export async function runChatWorkflow({
826
838
  maxScreenshots: maxImagePages,
827
839
  wheelDeltaY: imageWheelDeltaY,
828
840
  settleMs: 350,
829
- duplicateStopCount: 1,
830
- skipDuplicateScreenshots: true,
831
- metadata: {
832
- domain: "chat",
833
- capture_mode: "scroll_sequence",
841
+ duplicateStopCount: 1,
842
+ skipDuplicateScreenshots: true,
843
+ composeForLlm: true,
844
+ llmPagesPerImage: 3,
845
+ llmResizeMaxWidth: 1100,
846
+ llmQuality: 72,
847
+ metadata: {
848
+ domain: "chat",
849
+ capture_mode: "scroll_sequence",
834
850
  acquisition_reason: normalizedDetailSource === "image"
835
851
  ? "forced_image"
836
852
  : "network_miss_image_fallback",
@@ -1132,9 +1148,9 @@ export function createChatRunService({
1132
1148
  llmImageDetail = "high",
1133
1149
  screeningMode = "llm",
1134
1150
  listMaxScrolls = 20,
1135
- listStableSignatureLimit = 2,
1151
+ listStableSignatureLimit = 5,
1136
1152
  listWheelDeltaY = 850,
1137
- listSettleMs = 1200,
1153
+ listSettleMs = 2200,
1138
1154
  listFallbackPoint = null,
1139
1155
  imageOutputDir = "",
1140
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",
@@ -110,95 +110,117 @@ export async function refreshRecommendListAtEnd(client, {
110
110
  );
111
111
  attempts.push(buttonResult);
112
112
  if (buttonResult.ok) {
113
- currentRootState = await getRecommendRoots(client);
114
- const pageScopeResult = await selectRecommendPageScope(
115
- client,
116
- currentRootState.iframe.documentNodeId,
117
- {
118
- pageScope,
119
- fallbackScope: fallbackPageScope,
120
- settleMs: buttonSettleMs > 10000 ? 3000 : 1200,
121
- timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
113
+ try {
114
+ currentRootState = await getRecommendRoots(client);
115
+ const pageScopeResult = await selectRecommendPageScope(
116
+ client,
117
+ currentRootState.iframe.documentNodeId,
118
+ {
119
+ pageScope,
120
+ fallbackScope: fallbackPageScope,
121
+ settleMs: buttonSettleMs > 10000 ? 3000 : 1200,
122
+ timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
123
+ }
124
+ );
125
+ if (!pageScopeResult.selected) {
126
+ throw new Error(`Recommend page scope was not selected after end refresh: ${pageScopeResult.reason || pageScope}`);
122
127
  }
123
- );
124
- if (!pageScopeResult.selected) {
125
- throw new Error(`Recommend page scope was not selected after end refresh: ${pageScopeResult.reason || pageScope}`);
128
+ currentRootState = await getRecommendRoots(client);
129
+ const filterResult = await selectAndConfirmFirstSafeFilter(
130
+ client,
131
+ currentRootState.iframe.documentNodeId,
132
+ buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
133
+ );
134
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
135
+ timeoutMs: cardTimeoutMs,
136
+ intervalMs: 500
137
+ });
138
+ return {
139
+ ok: cardNodeIds.length > 0,
140
+ method: "end_refresh_button",
141
+ attempts,
142
+ page_scope: pageScopeResult,
143
+ filter: filterResult,
144
+ card_count: cardNodeIds.length,
145
+ root_state: currentRootState,
146
+ forced_recent_not_view: forceRecentNotView
147
+ };
148
+ } catch (error) {
149
+ attempts.push({
150
+ ok: false,
151
+ method: "end_refresh_button_after_click",
152
+ reason: "end_refresh_reapply_failed",
153
+ error: error?.message || String(error)
154
+ });
126
155
  }
127
- currentRootState = await getRecommendRoots(client);
128
- const filterResult = await selectAndConfirmFirstSafeFilter(
129
- client,
130
- currentRootState.iframe.documentNodeId,
131
- buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
132
- );
133
- const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
134
- timeoutMs: cardTimeoutMs,
135
- intervalMs: 500
136
- });
137
- return {
138
- ok: cardNodeIds.length > 0,
139
- method: "end_refresh_button",
140
- attempts,
141
- page_scope: pageScopeResult,
142
- filter: filterResult,
143
- card_count: cardNodeIds.length,
144
- root_state: currentRootState,
145
- forced_recent_not_view: forceRecentNotView
146
- };
147
156
  }
148
157
  }
149
158
 
150
- await client.Page.reload({ ignoreCache: true });
151
- if (reloadSettleMs > 0) await sleep(reloadSettleMs);
152
- currentRootState = await waitForRecommendRoots(client, {
153
- timeoutMs: Math.max(30000, reloadSettleMs * 4),
154
- intervalMs: 500
155
- });
156
- if (!currentRootState?.iframe?.documentNodeId) {
157
- throw new Error("Recommend iframe was not ready after refresh reload");
158
- }
159
- let jobSelection = null;
160
- if (jobLabel) {
161
- jobSelection = await selectRecommendJob(client, currentRootState.iframe.documentNodeId, {
162
- jobLabel,
163
- settleMs: reloadSettleMs > 10000 ? 12000 : 6000
159
+ try {
160
+ await client.Page.reload({ ignoreCache: true });
161
+ if (reloadSettleMs > 0) await sleep(reloadSettleMs);
162
+ currentRootState = await waitForRecommendRoots(client, {
163
+ timeoutMs: Math.max(30000, reloadSettleMs * 4),
164
+ intervalMs: 500
164
165
  });
165
- if (!jobSelection.selected) {
166
- throw new Error(`Requested recommend job was not selected after refresh reload: ${jobSelection.reason}`);
166
+ if (!currentRootState?.iframe?.documentNodeId) {
167
+ throw new Error("Recommend iframe was not ready after refresh reload");
167
168
  }
168
- currentRootState = await getRecommendRoots(client);
169
- }
170
- const pageScopeResult = await selectRecommendPageScope(
171
- client,
172
- currentRootState.iframe.documentNodeId,
173
- {
174
- pageScope,
175
- fallbackScope: fallbackPageScope,
176
- settleMs: reloadSettleMs > 10000 ? 3000 : 1200,
177
- timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
169
+ let jobSelection = null;
170
+ if (jobLabel) {
171
+ jobSelection = await selectRecommendJob(client, currentRootState.iframe.documentNodeId, {
172
+ jobLabel,
173
+ settleMs: reloadSettleMs > 10000 ? 12000 : 6000
174
+ });
175
+ if (!jobSelection.selected) {
176
+ throw new Error(`Requested recommend job was not selected after refresh reload: ${jobSelection.reason}`);
177
+ }
178
+ currentRootState = await getRecommendRoots(client);
178
179
  }
179
- );
180
- if (!pageScopeResult.selected) {
181
- throw new Error(`Recommend page scope was not selected after refresh reload: ${pageScopeResult.reason || pageScope}`);
180
+ const pageScopeResult = await selectRecommendPageScope(
181
+ client,
182
+ currentRootState.iframe.documentNodeId,
183
+ {
184
+ pageScope,
185
+ fallbackScope: fallbackPageScope,
186
+ settleMs: reloadSettleMs > 10000 ? 3000 : 1200,
187
+ timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
188
+ }
189
+ );
190
+ if (!pageScopeResult.selected) {
191
+ throw new Error(`Recommend page scope was not selected after refresh reload: ${pageScopeResult.reason || pageScope}`);
192
+ }
193
+ currentRootState = await getRecommendRoots(client);
194
+ const filterResult = await selectAndConfirmFirstSafeFilter(
195
+ client,
196
+ currentRootState.iframe.documentNodeId,
197
+ buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
198
+ );
199
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
200
+ timeoutMs: cardTimeoutMs,
201
+ intervalMs: 500
202
+ });
203
+ return {
204
+ ok: cardNodeIds.length > 0,
205
+ method: "page_reload",
206
+ attempts,
207
+ job_selection: jobSelection,
208
+ page_scope: pageScopeResult,
209
+ filter: filterResult,
210
+ card_count: cardNodeIds.length,
211
+ root_state: currentRootState,
212
+ forced_recent_not_view: forceRecentNotView
213
+ };
214
+ } catch (error) {
215
+ return {
216
+ ok: false,
217
+ method: "page_reload",
218
+ reason: "page_reload_failed",
219
+ error: error?.message || String(error),
220
+ attempts,
221
+ card_count: 0,
222
+ root_state: currentRootState,
223
+ forced_recent_not_view: forceRecentNotView
224
+ };
182
225
  }
183
- currentRootState = await getRecommendRoots(client);
184
- const filterResult = await selectAndConfirmFirstSafeFilter(
185
- client,
186
- currentRootState.iframe.documentNodeId,
187
- buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
188
- );
189
- const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
190
- timeoutMs: cardTimeoutMs,
191
- intervalMs: 500
192
- });
193
- return {
194
- ok: cardNodeIds.length > 0,
195
- method: "page_reload",
196
- attempts,
197
- job_selection: jobSelection,
198
- page_scope: pageScopeResult,
199
- filter: filterResult,
200
- card_count: cardNodeIds.length,
201
- root_state: currentRootState,
202
- forced_recent_not_view: forceRecentNotView
203
- };
204
226
  }
@@ -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();
@@ -715,6 +736,10 @@ export async function runRecommendWorkflow({
715
736
  settleMs: 350,
716
737
  duplicateStopCount: 1,
717
738
  skipDuplicateScreenshots: true,
739
+ composeForLlm: true,
740
+ llmPagesPerImage: 3,
741
+ llmResizeMaxWidth: 1100,
742
+ llmQuality: 72,
718
743
  metadata: {
719
744
  domain: "recommend",
720
745
  capture_mode: "scroll_sequence",
@@ -935,9 +960,9 @@ export function createRecommendRunService({
935
960
  imageWheelDeltaY = 650,
936
961
  cvAcquisitionMode = "unknown",
937
962
  listMaxScrolls = 20,
938
- listStableSignatureLimit = 2,
963
+ listStableSignatureLimit = 5,
939
964
  listWheelDeltaY = 850,
940
- listSettleMs = 1200,
965
+ listSettleMs = 2200,
941
966
  listFallbackPoint = null,
942
967
  refreshOnEnd = true,
943
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) {
@@ -440,6 +452,10 @@ export async function runRecruitWorkflow({
440
452
  settleMs: 350,
441
453
  duplicateStopCount: 1,
442
454
  skipDuplicateScreenshots: true,
455
+ composeForLlm: true,
456
+ llmPagesPerImage: 3,
457
+ llmResizeMaxWidth: 1100,
458
+ llmQuality: 72,
443
459
  metadata: {
444
460
  domain: "recruit",
445
461
  capture_mode: "scroll_sequence",
@@ -621,9 +637,9 @@ export function createRecruitRunService({
621
637
  imageWheelDeltaY = 650,
622
638
  cvAcquisitionMode = "unknown",
623
639
  listMaxScrolls = 20,
624
- listStableSignatureLimit = 2,
640
+ listStableSignatureLimit = 5,
625
641
  listWheelDeltaY = 850,
626
- listSettleMs = 1200,
642
+ listSettleMs = 2200,
627
643
  listFallbackPoint = null,
628
644
  refreshOnEnd = true,
629
645
  maxRefreshRounds = 2,