@reconcrap/boss-recommend-mcp 2.0.30 → 2.0.32

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.
@@ -58,6 +58,74 @@ function buildChatCompletionsUrl(baseUrl) {
58
58
  return `${normalized}/chat/completions`;
59
59
  }
60
60
 
61
+ function redactBaseUrl(baseUrl) {
62
+ const normalized = normalizeBaseUrl(baseUrl);
63
+ return normalized ? normalized.replace(/\/\/[^/]+/, "//[redacted-host]") : "";
64
+ }
65
+
66
+ function firstConfiguredValue(...values) {
67
+ for (const value of values) {
68
+ if (value === undefined || value === null) continue;
69
+ if (typeof value === "string" && !value.trim()) continue;
70
+ return value;
71
+ }
72
+ return "";
73
+ }
74
+
75
+ function normalizeLlmProviderEntry(rawEntry, inherited = {}, index = 0) {
76
+ const entry = typeof rawEntry === "string"
77
+ ? { model: rawEntry }
78
+ : (rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {});
79
+ const providerName = firstConfiguredValue(
80
+ entry.name,
81
+ entry.label,
82
+ entry.id,
83
+ entry.providerName,
84
+ entry.provider,
85
+ ""
86
+ );
87
+ const next = {
88
+ ...inherited,
89
+ ...entry,
90
+ baseUrl: firstConfiguredValue(entry.baseUrl, entry.base_url, inherited.baseUrl, inherited.base_url),
91
+ apiKey: firstConfiguredValue(entry.apiKey, entry.api_key, inherited.apiKey, inherited.api_key),
92
+ model: firstConfiguredValue(entry.model, entry.modelName, entry.model_name, typeof rawEntry === "string" ? rawEntry : "", inherited.model),
93
+ openaiOrganization: firstConfiguredValue(entry.openaiOrganization, entry.organization, inherited.openaiOrganization, inherited.organization),
94
+ openaiProject: firstConfiguredValue(entry.openaiProject, entry.project, inherited.openaiProject, inherited.project),
95
+ topP: firstConfiguredValue(entry.topP, entry.top_p, inherited.topP, inherited.top_p),
96
+ llmProviderName: normalizeText(providerName),
97
+ llmProviderIndex: index
98
+ };
99
+ delete next.llmModels;
100
+ delete next.models;
101
+ return next;
102
+ }
103
+
104
+ function normalizeLlmProviderConfigs(config = {}) {
105
+ if (Array.isArray(config)) {
106
+ return config.map((entry, index) => normalizeLlmProviderEntry(entry, {}, index));
107
+ }
108
+ const inherited = config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {};
109
+ const rawProviders = Array.isArray(inherited.llmModels) && inherited.llmModels.length > 0
110
+ ? inherited.llmModels
111
+ : (Array.isArray(inherited.models) && inherited.models.length > 0 ? inherited.models : [inherited]);
112
+ delete inherited.llmModels;
113
+ delete inherited.models;
114
+ return rawProviders.map((entry, index) => normalizeLlmProviderEntry(entry, inherited, index));
115
+ }
116
+
117
+ function compactLlmProviderFailure(error, providerConfig = {}, providerIndex = 0) {
118
+ return {
119
+ index: providerIndex + 1,
120
+ name: normalizeText(providerConfig.llmProviderName || providerConfig.name || providerConfig.label || providerConfig.id) || null,
121
+ baseUrl: redactBaseUrl(providerConfig.baseUrl),
122
+ model: normalizeText(providerConfig.model) || null,
123
+ status: Number.isFinite(Number(error?.status)) ? Number(error.status) : null,
124
+ attempts: Number(error?.llm_attempt_count) || 0,
125
+ message: String(error?.message || error || "").slice(0, 500)
126
+ };
127
+ }
128
+
61
129
  function isVolcengineModel(baseUrl, model) {
62
130
  return /volces|volcengine|ark\.cn|doubao|seed/i.test(`${baseUrl || ""} ${model || ""}`);
63
131
  }
@@ -1325,6 +1393,8 @@ export function compactScreeningLlmResult(llmResult) {
1325
1393
  finish_reason: llmResult.finish_reason || null,
1326
1394
  image_input_count: llmResult.image_input_count || 0,
1327
1395
  attempt_count: llmResult.attempt_count || 0,
1396
+ fallback_count: llmResult.fallback_count || 0,
1397
+ llm_model_failures: Array.isArray(llmResult.llm_model_failures) ? llmResult.llm_model_failures : [],
1328
1398
  error: llmResult.error || null,
1329
1399
  screened_at: llmResult.screened_at || null
1330
1400
  };
@@ -1358,6 +1428,8 @@ export function createFailedLlmScreeningResult(error) {
1358
1428
  image_input_count: Number(error?.image_input_count) || 0,
1359
1429
  image_inputs: Array.isArray(error?.image_inputs) ? error.image_inputs : [],
1360
1430
  attempt_count: Number(error?.llm_attempt_count) || 0,
1431
+ fallback_count: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures.length : 0,
1432
+ llm_model_failures: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures : [],
1361
1433
  error: error?.message || String(error || "unknown"),
1362
1434
  screened_at: nowIso()
1363
1435
  };
@@ -1435,7 +1507,7 @@ function sleepMs(ms) {
1435
1507
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
1436
1508
  }
1437
1509
 
1438
- export async function callScreeningLlm({
1510
+ async function callScreeningLlmWithProvider({
1439
1511
  candidate,
1440
1512
  criteria,
1441
1513
  config = {},
@@ -1488,81 +1560,89 @@ export async function callScreeningLlm({
1488
1560
  thinkingLevel
1489
1561
  });
1490
1562
 
1563
+ const effectiveTimeoutMs = parsePositiveNumber(config.llmTimeoutMs ?? config.timeoutMs, timeoutMs) || timeoutMs;
1491
1564
  const maxRetries = normalizeLlmMaxRetries(config.llmMaxRetries ?? config.maxRetries);
1492
1565
  const maxAttempts = maxRetries + 1;
1493
1566
  let lastError = null;
1494
1567
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1495
1568
  const controller = new AbortController();
1496
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1569
+ const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs);
1497
1570
  try {
1498
- const headers = {
1499
- "Content-Type": "application/json",
1500
- Authorization: `Bearer ${apiKey}`
1501
- };
1502
- if (config.openaiOrganization) headers["OpenAI-Organization"] = config.openaiOrganization;
1503
- if (config.openaiProject) headers["OpenAI-Project"] = config.openaiProject;
1504
-
1505
- const response = await fetch(buildChatCompletionsUrl(baseUrl), {
1506
- method: "POST",
1507
- headers,
1508
- body: JSON.stringify(payload),
1509
- signal: controller.signal
1510
- });
1511
- const responseText = await response.text();
1512
- if (!response.ok) {
1513
- const error = new Error(`LLM request failed: ${response.status} ${responseText.slice(0, 400)}`);
1514
- error.status = response.status;
1515
- throw error;
1516
- }
1517
- const json = tryParseJson(responseText);
1518
- if (!json) {
1519
- throw new Error("LLM response was not valid JSON");
1520
- }
1521
- const choice = json?.choices?.[0] || {};
1522
- const content = flattenChatMessageContent(choice?.message?.content);
1523
- const reasoningContent = collectLlmReasoningText(choice);
1524
- const parsed = tryExtractJsonObject(content) || tryExtractJsonObject(reasoningContent);
1525
- const passed = parsePassedDecision(parsed?.passed);
1526
- if (passed === null) {
1527
- throw new Error(`LLM response missing boolean passed decision: ${content.slice(0, 240)}`);
1528
- }
1529
- const evidence = Array.isArray(parsed?.evidence)
1530
- ? parsed.evidence.map(normalizeText).filter(Boolean)
1531
- : [];
1532
- const decisionCot = firstUsefulLine([
1533
- parsed?.cot,
1534
- parsed?.decision_cot,
1535
- parsed?.reasoning,
1536
- parsed?.chain_of_thought,
1537
- reasoningContent
1538
- ].map(normalizeBlockText).filter(Boolean)) || reasoningContent;
1539
- return {
1540
- ok: true,
1541
- provider: {
1542
- baseUrl: baseUrl.replace(/\/\/[^/]+/, "//[redacted-host]"),
1543
- model,
1544
- thinking_level: normalizeLlmThinkingLevel(thinkingLevel) || "low",
1545
- thinking: payload.thinking || null,
1546
- reasoning_effort: payload.reasoning_effort || null,
1547
- max_tokens: payload.max_tokens,
1548
- max_completion_tokens: payload.max_completion_tokens || null
1549
- },
1550
- passed,
1551
- reason: "",
1552
- evidence,
1553
- cot: decisionCot,
1554
- decision_cot: decisionCot,
1555
- reasoning_content: reasoningContent,
1556
- raw_model_output: content,
1557
- usage: json.usage || null,
1558
- finish_reason: choice.finish_reason || null,
1559
- raw_content_length: content.length,
1560
- image_input_count: imageInputs.length,
1561
- image_inputs: summarizeLlmImageInputs(imageInputs),
1562
- attempt_count: attempt,
1563
- screened_at: nowIso()
1564
- };
1565
- } catch (error) {
1571
+ const headers = {
1572
+ "Content-Type": "application/json",
1573
+ Authorization: `Bearer ${apiKey}`
1574
+ };
1575
+ if (config.openaiOrganization) headers["OpenAI-Organization"] = config.openaiOrganization;
1576
+ if (config.openaiProject) headers["OpenAI-Project"] = config.openaiProject;
1577
+
1578
+ const response = await fetch(buildChatCompletionsUrl(baseUrl), {
1579
+ method: "POST",
1580
+ headers,
1581
+ body: JSON.stringify(payload),
1582
+ signal: controller.signal
1583
+ });
1584
+ const responseText = await response.text();
1585
+ if (!response.ok) {
1586
+ const error = new Error(`LLM request failed: ${response.status} ${responseText.slice(0, 400)}`);
1587
+ error.status = response.status;
1588
+ throw error;
1589
+ }
1590
+ const json = tryParseJson(responseText);
1591
+ if (!json) {
1592
+ throw new Error("LLM response was not valid JSON");
1593
+ }
1594
+ const choice = json?.choices?.[0] || {};
1595
+ const content = flattenChatMessageContent(choice?.message?.content);
1596
+ const reasoningContent = collectLlmReasoningText(choice);
1597
+ const parsed = tryExtractJsonObject(content) || tryExtractJsonObject(reasoningContent);
1598
+ const passed = parsePassedDecision(parsed?.passed);
1599
+ if (passed === null) {
1600
+ throw new Error(`LLM response missing boolean passed decision: ${content.slice(0, 240)}`);
1601
+ }
1602
+ const evidence = Array.isArray(parsed?.evidence)
1603
+ ? parsed.evidence.map(normalizeText).filter(Boolean)
1604
+ : [];
1605
+ const decisionCot = firstUsefulLine([
1606
+ parsed?.cot,
1607
+ parsed?.decision_cot,
1608
+ parsed?.reasoning,
1609
+ parsed?.chain_of_thought,
1610
+ reasoningContent
1611
+ ].map(normalizeBlockText).filter(Boolean)) || reasoningContent;
1612
+ const providerName = normalizeText(config.llmProviderName || config.name || config.label || config.id);
1613
+ const providerIndex = Number.isFinite(Number(config.llmProviderIndex)) ? Number(config.llmProviderIndex) : 0;
1614
+ const providerCount = Number.isFinite(Number(config.llmProviderCount)) ? Number(config.llmProviderCount) : 1;
1615
+ return {
1616
+ ok: true,
1617
+ provider: {
1618
+ baseUrl: redactBaseUrl(baseUrl),
1619
+ model,
1620
+ name: providerName || null,
1621
+ index: providerIndex + 1,
1622
+ total: providerCount,
1623
+ thinking_level: normalizeLlmThinkingLevel(thinkingLevel) || "low",
1624
+ thinking: payload.thinking || null,
1625
+ reasoning_effort: payload.reasoning_effort || null,
1626
+ max_tokens: payload.max_tokens,
1627
+ max_completion_tokens: payload.max_completion_tokens || null
1628
+ },
1629
+ passed,
1630
+ reason: "",
1631
+ evidence,
1632
+ cot: decisionCot,
1633
+ decision_cot: decisionCot,
1634
+ reasoning_content: reasoningContent,
1635
+ raw_model_output: content,
1636
+ usage: json.usage || null,
1637
+ finish_reason: choice.finish_reason || null,
1638
+ raw_content_length: content.length,
1639
+ image_input_count: imageInputs.length,
1640
+ image_inputs: summarizeLlmImageInputs(imageInputs),
1641
+ attempt_count: attempt,
1642
+ provider_attempt_count: attempt,
1643
+ screened_at: nowIso()
1644
+ };
1645
+ } catch (error) {
1566
1646
  lastError = error;
1567
1647
  if (attempt >= maxAttempts || !isRetryableLlmRequestError(error)) {
1568
1648
  error.image_input_count = imageInputs.length;
@@ -1571,9 +1651,9 @@ export async function callScreeningLlm({
1571
1651
  throw error;
1572
1652
  }
1573
1653
  await sleepMs(Math.min(2500, 500 * attempt));
1574
- } finally {
1575
- clearTimeout(timer);
1576
- }
1654
+ } finally {
1655
+ clearTimeout(timer);
1656
+ }
1577
1657
  }
1578
1658
  lastError = lastError || new Error("LLM request failed without response");
1579
1659
  lastError.image_input_count = imageInputs.length;
@@ -1581,3 +1661,58 @@ export async function callScreeningLlm({
1581
1661
  lastError.llm_attempt_count = maxAttempts;
1582
1662
  throw lastError;
1583
1663
  }
1664
+
1665
+ export async function callScreeningLlm(args = {}) {
1666
+ const providers = normalizeLlmProviderConfigs(args.config || {});
1667
+ if (providers.length <= 1) {
1668
+ return callScreeningLlmWithProvider({
1669
+ ...args,
1670
+ config: {
1671
+ ...(providers[0] || args.config || {}),
1672
+ llmProviderCount: 1
1673
+ }
1674
+ });
1675
+ }
1676
+
1677
+ const providerFailures = [];
1678
+ let lastError = null;
1679
+ for (let index = 0; index < providers.length; index += 1) {
1680
+ const providerConfig = {
1681
+ ...providers[index],
1682
+ llmProviderIndex: index,
1683
+ llmProviderCount: providers.length
1684
+ };
1685
+ try {
1686
+ const previousAttempts = providerFailures.reduce((sum, item) => sum + (Number(item.attempts) || 0), 0);
1687
+ const result = await callScreeningLlmWithProvider({
1688
+ ...args,
1689
+ config: providerConfig
1690
+ });
1691
+ const providerAttempts = Number(result.provider_attempt_count ?? result.attempt_count) || 0;
1692
+ return {
1693
+ ...result,
1694
+ attempt_count: previousAttempts + providerAttempts,
1695
+ llm_model_failures: providerFailures,
1696
+ fallback_count: providerFailures.length
1697
+ };
1698
+ } catch (error) {
1699
+ lastError = error;
1700
+ providerFailures.push(compactLlmProviderFailure(error, providerConfig, index));
1701
+ if (index < providers.length - 1) {
1702
+ await sleepMs(Math.min(1500, 250 * (index + 1)));
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ const totalAttempts = providerFailures.reduce((sum, item) => sum + (Number(item.attempts) || 0), 0);
1708
+ const finalError = new Error(
1709
+ `All configured LLM models failed (${providers.length}); last error: ${lastError?.message || "unknown error"}`
1710
+ );
1711
+ finalError.cause = lastError || null;
1712
+ finalError.llm_provider_failures = providerFailures;
1713
+ finalError.llm_model_failures = providerFailures;
1714
+ finalError.llm_attempt_count = totalAttempts;
1715
+ finalError.image_input_count = lastError?.image_input_count || 0;
1716
+ finalError.image_inputs = lastError?.image_inputs || [];
1717
+ throw finalError;
1718
+ }
@@ -1,4 +1,5 @@
1
1
  import { captureScrolledNodeScreenshots } from "../../core/capture/index.js";
2
+ import { waitForCvCaptureTarget } from "../../core/cv-capture-target/index.js";
2
3
  import {
3
4
  clickPoint,
4
5
  getNodeBox,
@@ -9,6 +10,7 @@ import {
9
10
  compactCvAcquisitionState,
10
11
  countParsedNetworkProfiles,
11
12
  createCvAcquisitionState,
13
+ DEFAULT_MAX_IMAGE_PAGES,
12
14
  getCvNetworkWaitPlan,
13
15
  recordCvImageFallback,
14
16
  recordCvNetworkHit,
@@ -109,6 +111,8 @@ function compactLlmResult(llmResult) {
109
111
  finish_reason: llmResult.finish_reason || null,
110
112
  image_input_count: llmResult.image_input_count || 0,
111
113
  attempt_count: llmResult.attempt_count || 0,
114
+ fallback_count: llmResult.fallback_count || 0,
115
+ llm_model_failures: Array.isArray(llmResult.llm_model_failures) ? llmResult.llm_model_failures : [],
112
116
  error: llmResult.error || null
113
117
  };
114
118
  }
@@ -203,9 +207,9 @@ function llmToScreening(llmResult, candidate) {
203
207
  }
204
208
 
205
209
  export function captureNodeIdFromResumeState(resumeState) {
206
- return resumeState?.popup?.node_id
207
- || resumeState?.content?.node_id
210
+ return resumeState?.content?.node_id
208
211
  || resumeState?.resumeIframe?.node_id
212
+ || resumeState?.popup?.node_id
209
213
  || null;
210
214
  }
211
215
 
@@ -287,6 +291,9 @@ function createFailedLlmResult(error) {
287
291
  decision_cot: "",
288
292
  reasoning_content: "",
289
293
  raw_model_output: "",
294
+ attempt_count: Number(error?.llm_attempt_count) || 0,
295
+ fallback_count: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures.length : 0,
296
+ llm_model_failures: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures : [],
290
297
  error: error?.message || String(error || "unknown"),
291
298
  screened_at: new Date().toISOString()
292
299
  };
@@ -636,7 +643,7 @@ export async function runChatWorkflow({
636
643
  readyTimeoutMs = 60000,
637
644
  onlineResumeButtonTimeoutMs = 30000,
638
645
  resumeDomTimeoutMs = 60000,
639
- maxImagePages = 8,
646
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
640
647
  imageWheelDeltaY = 650,
641
648
  cvAcquisitionMode = "unknown",
642
649
  callLlmOnImage = false,
@@ -1226,11 +1233,19 @@ export async function runChatWorkflow({
1226
1233
  let source = normalizedDetailSource === "dom" ? "dom" : "network";
1227
1234
  let imageEvidence = null;
1228
1235
  let llmResult = null;
1229
- const captureNodeId = captureNodeIdFromResumeState(resumeState);
1236
+ let captureTarget = null;
1237
+ let captureTargetWait = null;
1230
1238
  let fullCvEvidence = summarizeChatFullCvEvidence({ detailResult, contentWait });
1231
1239
  const shouldCaptureImage = normalizedDetailSource === "image"
1232
1240
  || (normalizedDetailSource === "cascade" && !fullCvEvidence.full_cv_acquired);
1233
1241
  if (shouldCaptureImage) {
1242
+ captureTargetWait = await waitForCvCaptureTarget(client, resumeState, {
1243
+ domain: "chat",
1244
+ timeoutMs: 6000,
1245
+ intervalMs: 250
1246
+ });
1247
+ captureTarget = captureTargetWait.target || null;
1248
+ const captureNodeId = captureTarget?.node_id || null;
1234
1249
  if (captureNodeId) {
1235
1250
  detailStep = "capture_image_fallback";
1236
1251
  imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
@@ -1272,7 +1287,9 @@ export async function runChatWorkflow({
1272
1287
  ? "forced_image"
1273
1288
  : "network_miss_image_fallback",
1274
1289
  run_candidate_index: index,
1275
- candidate_key: candidateKey
1290
+ candidate_key: candidateKey,
1291
+ capture_target: captureTarget,
1292
+ capture_target_wait: captureTargetWait
1276
1293
  }
1277
1294
  }));
1278
1295
  source = "image";
@@ -1416,6 +1433,8 @@ export async function runChatWorkflow({
1416
1433
  },
1417
1434
  parsed_network_profile_count: parsedNetworkProfileCount,
1418
1435
  image_evidence: summarizeImageEvidence(imageEvidence),
1436
+ capture_target: captureTarget || null,
1437
+ capture_target_wait: captureTargetWait,
1419
1438
  full_cv_evidence: fullCvEvidence
1420
1439
  };
1421
1440
  }
@@ -1600,7 +1619,7 @@ export function createChatRunService({
1600
1619
  readyTimeoutMs = 60000,
1601
1620
  onlineResumeButtonTimeoutMs = 30000,
1602
1621
  resumeDomTimeoutMs = 60000,
1603
- maxImagePages = 8,
1622
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
1604
1623
  imageWheelDeltaY = 650,
1605
1624
  cvAcquisitionMode = "unknown",
1606
1625
  callLlmOnImage = false,
@@ -1638,6 +1657,7 @@ export function createChatRunService({
1638
1657
  close_resume: closeResume,
1639
1658
  request_resume_for_passed: Boolean(requestResumeForPassed),
1640
1659
  dry_run_request_cv: Boolean(dryRunRequestCv),
1660
+ greeting_text: greetingText,
1641
1661
  cv_acquisition_mode: cvAcquisitionMode,
1642
1662
  call_llm_on_image: Boolean(callLlmOnImage),
1643
1663
  screening_mode: normalizedScreeningMode,
@@ -20,8 +20,7 @@ import {
20
20
  DETAIL_RESUME_IFRAME_SELECTORS
21
21
  } from "./constants.js";
22
22
  import {
23
- getRecommendRoots,
24
- queryFirstAcrossRoots
23
+ getRecommendRoots
25
24
  } from "./roots.js";
26
25
  import {
27
26
  findRecommendCardNodeIds,
@@ -133,8 +132,8 @@ export async function waitForRecommendDetail(client, {
133
132
  let lastState = null;
134
133
  while (Date.now() - started <= timeoutMs) {
135
134
  const rootState = await getRecommendRoots(client);
136
- const popup = await queryFirstAcrossRoots(client, rootState.roots, DETAIL_POPUP_SELECTORS);
137
- const resumeIframe = await queryFirstAcrossRoots(client, rootState.roots, DETAIL_RESUME_IFRAME_SELECTORS);
135
+ const popup = await findVisibleDetailTarget(client, rootState.roots, DETAIL_POPUP_SELECTORS);
136
+ const resumeIframe = await findVisibleDetailTarget(client, rootState.roots, DETAIL_RESUME_IFRAME_SELECTORS);
138
137
  lastState = {
139
138
  iframe: rootState.iframe,
140
139
  roots: rootState.roots,
@@ -147,6 +146,31 @@ export async function waitForRecommendDetail(client, {
147
146
  return lastState;
148
147
  }
149
148
 
149
+ async function findVisibleDetailTarget(client, roots, selectors) {
150
+ for (const root of roots) {
151
+ if (!root?.nodeId) continue;
152
+ for (const selector of selectors) {
153
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
154
+ for (const nodeId of nodeIds) {
155
+ try {
156
+ const box = await getNodeBox(client, nodeId);
157
+ if (box.rect.width > 2 && box.rect.height > 2) {
158
+ return {
159
+ root: root.name,
160
+ root_node_id: root.nodeId,
161
+ selector,
162
+ node_id: nodeId,
163
+ center: box.center,
164
+ rect: box.rect
165
+ };
166
+ }
167
+ } catch {}
168
+ }
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+
150
174
  export async function readRecommendDetailHtml(client, detailState) {
151
175
  let popupHTML = "";
152
176
  let resumeHTML = "";
@@ -7,12 +7,14 @@ import {
7
7
  measureTiming
8
8
  } from "../../core/run/timing.js";
9
9
  import { captureScrolledNodeScreenshots } from "../../core/capture/index.js";
10
+ import { waitForCvCaptureTarget } from "../../core/cv-capture-target/index.js";
10
11
  import { sleep } from "../../core/browser/index.js";
11
12
  import { GREET_CREDITS_EXHAUSTED_CODE } from "../../core/greet-quota/index.js";
12
13
  import {
13
14
  compactCvAcquisitionState,
14
15
  countParsedNetworkProfiles,
15
16
  createCvAcquisitionState,
17
+ DEFAULT_MAX_IMAGE_PAGES,
16
18
  getCvNetworkWaitPlan,
17
19
  recordCvImageFallback,
18
20
  recordCvNetworkHit,
@@ -404,7 +406,7 @@ export function createRecoverableImageCaptureEvidence(error, {
404
406
  elapsedMs = 0,
405
407
  filePath = "",
406
408
  extension = "jpg",
407
- maxScreenshots = 8
409
+ maxScreenshots = DEFAULT_MAX_IMAGE_PAGES
408
410
  } = {}) {
409
411
  const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
410
412
  return {
@@ -474,7 +476,7 @@ export async function runRecommendWorkflow({
474
476
  closeDetail = true,
475
477
  delayMs = 0,
476
478
  cardTimeoutMs = 10000,
477
- maxImagePages = 8,
479
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
478
480
  imageWheelDeltaY = 650,
479
481
  cvAcquisitionMode = "unknown",
480
482
  listMaxScrolls = 20,
@@ -820,15 +822,21 @@ export async function runRecommendWorkflow({
820
822
  const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
821
823
  let source = "network";
822
824
  let imageEvidence = null;
825
+ let captureTarget = null;
826
+ let captureTargetWait = null;
823
827
  if (parsedNetworkProfileCount > 0) {
824
828
  recordCvNetworkHit(cvAcquisitionState, {
825
829
  parsedNetworkProfileCount,
826
830
  waitResult: networkWait
827
831
  });
828
832
  } else {
829
- const captureNodeId = openedDetail.detail_state?.popup?.node_id
830
- || openedDetail.detail_state?.resumeIframe?.node_id
831
- || null;
833
+ captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
834
+ domain: "recommend",
835
+ timeoutMs: 6000,
836
+ intervalMs: 250
837
+ });
838
+ captureTarget = captureTargetWait.target || null;
839
+ const captureNodeId = captureTarget?.node_id || null;
832
840
  if (captureNodeId) {
833
841
  const imageEvidencePath = imageEvidenceFilePath({
834
842
  imageOutputDir,
@@ -844,8 +852,8 @@ export async function runRecommendWorkflow({
844
852
  quality: 72,
845
853
  optimize: true,
846
854
  resizeMaxWidth: 1100,
847
- captureViewport: true,
848
- padding: 4,
855
+ captureViewport: false,
856
+ padding: 0,
849
857
  maxScreenshots: maxImagePages,
850
858
  wheelDeltaY: imageWheelDeltaY,
851
859
  settleMs: 350,
@@ -863,7 +871,9 @@ export async function runRecommendWorkflow({
863
871
  capture_mode: "scroll_sequence",
864
872
  acquisition_reason: "network_miss_image_fallback",
865
873
  run_candidate_index: index,
866
- candidate_key: candidateKey
874
+ candidate_key: candidateKey,
875
+ capture_target: captureTarget,
876
+ capture_target_wait: captureTargetWait
867
877
  }
868
878
  }));
869
879
  source = "image";
@@ -902,7 +912,9 @@ export async function runRecommendWorkflow({
902
912
  wait_plan: waitPlan,
903
913
  network_wait: networkWait,
904
914
  parsed_network_profile_count: parsedNetworkProfileCount,
905
- image_evidence: summarizeImageEvidence(imageEvidence)
915
+ image_evidence: summarizeImageEvidence(imageEvidence),
916
+ capture_target: captureTarget || null,
917
+ capture_target_wait: captureTargetWait
906
918
  };
907
919
  screeningCandidate = detailResult.candidate;
908
920
  } catch (error) {
@@ -1120,7 +1132,7 @@ export function createRecommendRunService({
1120
1132
  closeDetail = true,
1121
1133
  delayMs = 0,
1122
1134
  cardTimeoutMs = 10000,
1123
- maxImagePages = 8,
1135
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
1124
1136
  imageWheelDeltaY = 650,
1125
1137
  cvAcquisitionMode = "unknown",
1126
1138
  listMaxScrolls = 20,
@@ -19,8 +19,7 @@ import {
19
19
  RECRUIT_DETAIL_RESUME_IFRAME_SELECTORS
20
20
  } from "./constants.js";
21
21
  import {
22
- getRecruitRoots,
23
- queryFirstAcrossRoots
22
+ getRecruitRoots
24
23
  } from "./roots.js";
25
24
 
26
25
  export function matchesRecruitDetailNetwork(url) {
@@ -128,8 +127,8 @@ export async function waitForRecruitDetail(client, {
128
127
  let lastState = null;
129
128
  while (Date.now() - started <= timeoutMs) {
130
129
  const rootState = await getRecruitRoots(client);
131
- const popup = await queryFirstAcrossRoots(client, rootState.roots, RECRUIT_DETAIL_POPUP_SELECTORS);
132
- const resumeIframe = await queryFirstAcrossRoots(client, rootState.roots, RECRUIT_DETAIL_RESUME_IFRAME_SELECTORS);
130
+ const popup = await findVisibleDetailTarget(client, rootState.roots, RECRUIT_DETAIL_POPUP_SELECTORS);
131
+ const resumeIframe = await findVisibleDetailTarget(client, rootState.roots, RECRUIT_DETAIL_RESUME_IFRAME_SELECTORS);
133
132
  lastState = {
134
133
  iframe: rootState.iframe,
135
134
  roots: rootState.roots,
@@ -142,6 +141,31 @@ export async function waitForRecruitDetail(client, {
142
141
  return lastState;
143
142
  }
144
143
 
144
+ async function findVisibleDetailTarget(client, roots, selectors) {
145
+ for (const root of roots) {
146
+ if (!root?.nodeId) continue;
147
+ for (const selector of selectors) {
148
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
149
+ for (const nodeId of nodeIds) {
150
+ try {
151
+ const box = await getNodeBox(client, nodeId);
152
+ if (box.rect.width > 2 && box.rect.height > 2) {
153
+ return {
154
+ root: root.name,
155
+ root_node_id: root.nodeId,
156
+ selector,
157
+ node_id: nodeId,
158
+ center: box.center,
159
+ rect: box.rect
160
+ };
161
+ }
162
+ } catch {}
163
+ }
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+
145
169
  export async function readRecruitDetailHtml(client, detailState) {
146
170
  let popupHTML = "";
147
171
  let resumeHTML = "";