@reconcrap/boss-recommend-mcp 2.0.12 → 2.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.12",
3
+ "version": "2.0.13",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/chat-mcp.js CHANGED
@@ -71,6 +71,43 @@ const TERMINAL_STATUSES = new Set([
71
71
  RUN_STATUS_CANCELED
72
72
  ]);
73
73
 
74
+ const ARTIFACT_STATUSES = new Set([
75
+ RUN_STATUS_COMPLETED,
76
+ RUN_STATUS_FAILED,
77
+ RUN_STATUS_CANCELED,
78
+ RUN_STATUS_PAUSED
79
+ ]);
80
+
81
+ const STALE_PROCESS_STATUSES = new Set([
82
+ "queued",
83
+ "running",
84
+ RUN_STATUS_CANCELING
85
+ ]);
86
+
87
+ const CHAT_REQUEST_RESUME_ACTIONS = new Set([
88
+ "request_cv",
89
+ "ask_cv",
90
+ "request_resume",
91
+ "求简历",
92
+ "索要简历"
93
+ ]);
94
+
95
+ const CHAT_DISABLE_REQUEST_RESUME_ACTIONS = new Set([
96
+ "none",
97
+ "no",
98
+ "false",
99
+ "off",
100
+ "skip",
101
+ "do_nothing",
102
+ "nothing",
103
+ "不做",
104
+ "什么都不做",
105
+ "无",
106
+ "不用",
107
+ "不求简历",
108
+ "不请求简历"
109
+ ]);
110
+
74
111
  let chatWorkflowImpl = runChatWorkflow;
75
112
  let chatConnectorImpl = connectChatChromeSession;
76
113
  let chatJobReaderImpl = readChatJobOptionsFromSession;
@@ -156,8 +193,13 @@ function readJsonFile(filePath) {
156
193
  }
157
194
  }
158
195
 
159
- function selectedChatJobForCsv(meta = {}) {
160
- const job = normalizeText(meta.normalized?.job || meta.args?.job || "");
196
+ function selectedChatJobForCsv(meta = {}, snapshot = {}) {
197
+ const job = normalizeText(
198
+ meta.normalized?.job
199
+ || meta.args?.job
200
+ || snapshot.context?.job
201
+ || ""
202
+ );
161
203
  return {
162
204
  value: job,
163
205
  title: job,
@@ -167,31 +209,32 @@ function selectedChatJobForCsv(meta = {}) {
167
209
 
168
210
  function buildChatCsvInputRows(snapshot = {}, meta = {}) {
169
211
  const normalized = meta.normalized || {};
170
- const postAction = shouldRequestChatResume(meta.args)
212
+ const context = snapshot.context || {};
213
+ const postAction = shouldRequestChatResume(meta.args, context)
171
214
  ? "request_cv"
172
215
  : normalizeText(meta.args?.post_action || meta.args?.action || "") || "none";
173
216
  const searchParams = {
174
- job: normalized.job || meta.args?.job || "",
175
- start_from: normalized.startFrom || meta.args?.start_from || "",
217
+ job: normalized.job || meta.args?.job || context.job || "",
218
+ start_from: normalized.startFrom || meta.args?.start_from || context.start_from || "",
176
219
  target_count: normalized.publicTargetCount ?? normalized.targetCount ?? snapshot.progress?.target_count ?? "",
177
- detail_source: meta.args?.detail_source || snapshot.summary?.detail_source || snapshot.context?.detail_source || ""
220
+ detail_source: meta.args?.detail_source || snapshot.summary?.detail_source || context.detail_source || ""
178
221
  };
179
222
  return buildLegacyScreenInputRows({
180
223
  instruction: meta.args?.instruction || "启动boss聊天任务",
181
224
  selectedPage: "chat",
182
- selectedJob: selectedChatJobForCsv(meta),
225
+ selectedJob: selectedChatJobForCsv(meta, snapshot),
183
226
  userSearchParams: cloneReportInput(searchParams, {}),
184
227
  effectiveSearchParams: cloneReportInput(searchParams, {}),
185
228
  screenParams: {
186
- criteria: normalized.criteria || meta.args?.criteria || "",
229
+ criteria: normalized.criteria || meta.args?.criteria || context.criteria || "",
187
230
  target_count: searchParams.target_count,
188
231
  post_action: postAction,
189
232
  max_greet_count: meta.args?.max_greet_count ?? ""
190
233
  },
191
234
  followUp: meta.args?.follow_up || null,
192
235
  extraRows: [
193
- ["chat_params.greeting_text", normalized.greetingText || meta.args?.greeting_text || meta.args?.greetingText || DEFAULT_CHAT_GREETING_TEXT],
194
- ["chat_params.profile", normalized.profile || meta.args?.profile || "default"]
236
+ ["chat_params.greeting_text", normalized.greetingText || meta.args?.greeting_text || meta.args?.greetingText || context.greeting_text || DEFAULT_CHAT_GREETING_TEXT],
237
+ ["chat_params.profile", normalized.profile || meta.args?.profile || context.profile || "default"]
195
238
  ]
196
239
  });
197
240
  }
@@ -314,6 +357,12 @@ function ensureChatRunArtifacts(snapshot) {
314
357
  partial: true,
315
358
  partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
316
359
  results: checkpointResults
360
+ } : ARTIFACT_STATUSES.has(snapshot?.status || snapshot?.state) ? {
361
+ domain: "chat",
362
+ partial: (snapshot?.status || snapshot?.state) !== RUN_STATUS_COMPLETED,
363
+ partial_reason: snapshot?.status || snapshot?.state || "unknown",
364
+ completion_reason: completionReason(snapshot?.status || snapshot?.state),
365
+ results: []
317
366
  } : null);
318
367
  if (artifactSummary) {
319
368
  const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
@@ -337,6 +386,143 @@ function ensureChatRunArtifacts(snapshot) {
337
386
  return artifacts;
338
387
  }
339
388
 
389
+ function isPidAlive(pid) {
390
+ const numericPid = Number(pid);
391
+ if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
392
+ if (numericPid === process.pid) return true;
393
+ try {
394
+ process.kill(numericPid, 0);
395
+ return true;
396
+ } catch (error) {
397
+ return error?.code === "EPERM";
398
+ }
399
+ }
400
+
401
+ function snapshotFromPersistedChatRun(persisted = {}) {
402
+ return {
403
+ runId: persisted.run_id || persisted.runId,
404
+ name: persisted.name || persisted.run_id || persisted.runId,
405
+ status: persisted.status || persisted.state,
406
+ phase: persisted.stage || persisted.phase,
407
+ progress: persisted.progress || {},
408
+ context: persisted.context || {},
409
+ checkpoint: persisted.checkpoint || {},
410
+ startedAt: persisted.started_at || persisted.startedAt,
411
+ updatedAt: persisted.updated_at || persisted.updatedAt,
412
+ completedAt: persisted.completed_at || persisted.completedAt || null,
413
+ error: persisted.error || null,
414
+ summary: persisted.summary || null
415
+ };
416
+ }
417
+
418
+ function persistDiskChatRun(runId, payload) {
419
+ const artifacts = getChatRunArtifacts(runId);
420
+ if (!artifacts) return payload;
421
+ writeJsonAtomic(artifacts.run_state_path, payload);
422
+ return payload;
423
+ }
424
+
425
+ function attachLegacyArtifactsToPersistedChatRun(persisted = {}) {
426
+ const runId = normalizeRunId(persisted.run_id || persisted.runId);
427
+ if (!runId) return persisted;
428
+ const snapshot = snapshotFromPersistedChatRun(persisted);
429
+ const result = buildLegacyChatResult(snapshot);
430
+ const artifacts = getChatRunArtifacts(runId);
431
+ const next = {
432
+ ...persisted,
433
+ result,
434
+ resume: {
435
+ ...(persisted.resume || {}),
436
+ checkpoint_path: result?.checkpoint_path || persisted.resume?.checkpoint_path || artifacts?.checkpoint_path || null,
437
+ output_csv: result?.output_csv || persisted.resume?.output_csv || artifacts?.output_csv || null
438
+ },
439
+ artifacts: artifacts || persisted.artifacts || null
440
+ };
441
+ return persistDiskChatRun(runId, next);
442
+ }
443
+
444
+ function finalizePersistedChatRun(persisted = {}, {
445
+ status = RUN_STATUS_FAILED,
446
+ error = null,
447
+ message = ""
448
+ } = {}) {
449
+ const runId = normalizeRunId(persisted.run_id || persisted.runId);
450
+ if (!runId) return persisted;
451
+ const now = new Date().toISOString();
452
+ const normalizedError = status === RUN_STATUS_FAILED
453
+ ? {
454
+ name: error?.name || "Error",
455
+ code: error?.code || "STALE_RUN_PROCESS_EXITED",
456
+ message: error?.message || message || "Boss chat run process exited before it wrote a terminal state."
457
+ }
458
+ : null;
459
+ const next = {
460
+ ...persisted,
461
+ run_id: runId,
462
+ state: status,
463
+ status,
464
+ stage: persisted.stage || persisted.phase || "chat:stale",
465
+ updated_at: now,
466
+ heartbeat_at: now,
467
+ completed_at: persisted.completed_at || now,
468
+ last_message: normalizedError?.message || message || status,
469
+ control: {
470
+ ...(persisted.control || {}),
471
+ cancel_requested: false
472
+ },
473
+ error: normalizedError,
474
+ summary: persisted.summary || null
475
+ };
476
+ return attachLegacyArtifactsToPersistedChatRun(next);
477
+ }
478
+
479
+ function persistedChatRunArtifactMissing(persisted = {}) {
480
+ const runId = normalizeRunId(persisted.run_id || persisted.runId);
481
+ const artifacts = getChatRunArtifacts(runId);
482
+ const outputCsv = persisted.result?.output_csv
483
+ || persisted.resume?.output_csv
484
+ || persisted.artifacts?.output_csv
485
+ || artifacts?.output_csv;
486
+ const reportJson = persisted.result?.report_json
487
+ || persisted.artifacts?.report_json
488
+ || artifacts?.report_json;
489
+ return Boolean(
490
+ !outputCsv
491
+ || !reportJson
492
+ || !fs.existsSync(outputCsv)
493
+ || !fs.existsSync(reportJson)
494
+ );
495
+ }
496
+
497
+ function reconcilePersistedChatRun(persisted = {}, { cancelStale = false } = {}) {
498
+ const status = persisted.status || persisted.state;
499
+ if (STALE_PROCESS_STATUSES.has(status) && !isPidAlive(persisted.pid)) {
500
+ const shouldCancel = cancelStale || status === RUN_STATUS_CANCELING || persisted.control?.cancel_requested === true;
501
+ return {
502
+ run: finalizePersistedChatRun(persisted, {
503
+ status: shouldCancel ? RUN_STATUS_CANCELED : RUN_STATUS_FAILED,
504
+ error: shouldCancel ? null : {
505
+ code: "STALE_RUN_PROCESS_EXITED",
506
+ message: `Boss chat run process is no longer alive for pid=${persisted.pid || "unknown"}.`
507
+ },
508
+ message: shouldCancel
509
+ ? "Boss chat run was canceled after its worker process was no longer active."
510
+ : `Boss chat run process is no longer alive for pid=${persisted.pid || "unknown"}.`
511
+ }),
512
+ stale_finalized: true
513
+ };
514
+ }
515
+ if (ARTIFACT_STATUSES.has(status) && persistedChatRunArtifactMissing(persisted)) {
516
+ return {
517
+ run: attachLegacyArtifactsToPersistedChatRun(persisted),
518
+ artifacts_repaired: true
519
+ };
520
+ }
521
+ return {
522
+ run: persisted
523
+ };
524
+ }
525
+
340
526
  function buildLegacyChatResult(snapshot) {
341
527
  if (!snapshot) return null;
342
528
  const artifacts = ensureChatRunArtifacts(snapshot);
@@ -791,14 +977,32 @@ async function buildNeedInputResponse({ args, missingFields, normalized }) {
791
977
  };
792
978
  }
793
979
 
794
- function shouldRequestChatResume(args = {}) {
795
- return (
980
+ function shouldRequestChatResume(args = {}, context = {}) {
981
+ const action = normalizeText(args.post_action || args.action).toLowerCase();
982
+ if (
983
+ args.request_cv === false
984
+ || args.request_resume === false
985
+ || args.ask_cv === false
986
+ || args.execute_post_action === false
987
+ || args.no_request_cv === true
988
+ || args.no_request_resume === true
989
+ || CHAT_DISABLE_REQUEST_RESUME_ACTIONS.has(action)
990
+ ) {
991
+ return false;
992
+ }
993
+ if (
796
994
  args.request_cv === true
797
995
  || args.request_resume === true
798
996
  || args.ask_cv === true
799
997
  || args.execute_post_action === true
800
- || ["request_cv", "ask_cv", "request_resume", "求简历", "索要简历"].includes(normalizeText(args.post_action || args.action))
801
- );
998
+ || CHAT_REQUEST_RESUME_ACTIONS.has(action)
999
+ ) {
1000
+ return true;
1001
+ }
1002
+ if (typeof context.request_resume_for_passed === "boolean") {
1003
+ return context.request_resume_for_passed;
1004
+ }
1005
+ return true;
802
1006
  }
803
1007
 
804
1008
  function isDebugTestMode(args = {}) {
@@ -1300,12 +1504,15 @@ export function getBossChatRunTool({ args = {} } = {}) {
1300
1504
  } catch {
1301
1505
  const persisted = readChatRunState(runId);
1302
1506
  if (persisted) {
1507
+ const reconciled = reconcilePersistedChatRun(persisted);
1303
1508
  return {
1304
1509
  status: "RUN_STATUS",
1305
- run: persisted,
1510
+ run: reconciled.run,
1306
1511
  persistence: {
1307
1512
  source: "disk",
1308
- active_control_available: false
1513
+ active_control_available: false,
1514
+ stale_finalized: reconciled.stale_finalized === true,
1515
+ artifacts_repaired: reconciled.artifacts_repaired === true
1309
1516
  },
1310
1517
  runtime_evaluate_used: false,
1311
1518
  method_summary: {},
@@ -1354,9 +1561,10 @@ export function pauseBossChatRunTool({ args = {} } = {}) {
1354
1561
  } catch {
1355
1562
  const persisted = readChatRunState(runId);
1356
1563
  if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1564
+ const reconciled = reconcilePersistedChatRun(persisted);
1357
1565
  return {
1358
1566
  status: "PAUSE_IGNORED",
1359
- run: persisted,
1567
+ run: reconciled.run,
1360
1568
  message: "目标任务已结束,无需暂停。",
1361
1569
  runtime_evaluate_used: false,
1362
1570
  method_summary: {},
@@ -1412,19 +1620,23 @@ export function resumeBossChatRunTool({ args = {} } = {}) {
1412
1620
  } catch {
1413
1621
  const persisted = readChatRunState(runId);
1414
1622
  if (persisted) {
1623
+ const reconciled = reconcilePersistedChatRun(persisted);
1624
+ const reconciledStatus = reconciled.run?.status || reconciled.run?.state;
1415
1625
  return {
1416
1626
  status: "FAILED",
1417
1627
  error: {
1418
- code: TERMINAL_STATUSES.has(persisted.state) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
1419
- message: TERMINAL_STATUSES.has(persisted.state)
1628
+ code: TERMINAL_STATUSES.has(reconciledStatus) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
1629
+ message: TERMINAL_STATUSES.has(reconciledStatus)
1420
1630
  ? "目标任务已结束,无法继续。"
1421
1631
  : "该 run 只有磁盘快照,没有当前进程内的活动 CDP 会话,无法安全继续。",
1422
- retryable: !TERMINAL_STATUSES.has(persisted.state)
1632
+ retryable: !TERMINAL_STATUSES.has(reconciledStatus)
1423
1633
  },
1424
- run: persisted,
1634
+ run: reconciled.run,
1425
1635
  persistence: {
1426
1636
  source: "disk",
1427
- active_control_available: false
1637
+ active_control_available: false,
1638
+ stale_finalized: reconciled.stale_finalized === true,
1639
+ artifacts_repaired: reconciled.artifacts_repaired === true
1428
1640
  },
1429
1641
  runtime_evaluate_used: false,
1430
1642
  method_summary: {},
@@ -1458,9 +1670,10 @@ export function cancelBossChatRunTool({ args = {} } = {}) {
1458
1670
  } catch {
1459
1671
  const persisted = readChatRunState(runId);
1460
1672
  if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1673
+ const reconciled = reconcilePersistedChatRun(persisted);
1461
1674
  return {
1462
1675
  status: "CANCEL_IGNORED",
1463
- run: persisted,
1676
+ run: reconciled.run,
1464
1677
  message: "目标任务已结束,无需取消。",
1465
1678
  runtime_evaluate_used: false,
1466
1679
  method_summary: {},
@@ -1468,6 +1681,26 @@ export function cancelBossChatRunTool({ args = {} } = {}) {
1468
1681
  chrome: null
1469
1682
  };
1470
1683
  }
1684
+ if (persisted) {
1685
+ const reconciled = reconcilePersistedChatRun(persisted, { cancelStale: true });
1686
+ if (reconciled.stale_finalized) {
1687
+ return {
1688
+ status: "CANCEL_REQUESTED",
1689
+ run: reconciled.run,
1690
+ message: "该 run 的后台进程已经不在,已将磁盘状态安全标记为 canceled 并生成结果文件。",
1691
+ persistence: {
1692
+ source: "disk",
1693
+ active_control_available: false,
1694
+ stale_finalized: true,
1695
+ artifacts_repaired: reconciled.artifacts_repaired === true
1696
+ },
1697
+ runtime_evaluate_used: false,
1698
+ method_summary: {},
1699
+ method_log: [],
1700
+ chrome: null
1701
+ };
1702
+ }
1703
+ }
1471
1704
  return getBossChatRunTool({ args });
1472
1705
  }
1473
1706
  }
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import { createRequire } from "node:module";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import {
9
9
  assertNoForbiddenCdpCalls,
10
+ buildBossChromeLaunchArgs,
10
11
  bringPageToFront,
11
12
  connectToChromeTarget,
12
13
  enableDomains,
@@ -1994,14 +1995,7 @@ async function launchChrome(options = {}) {
1994
1995
  }
1995
1996
 
1996
1997
  const userDataDir = getChromeUserDataDir(port);
1997
- const args = [
1998
- `--remote-debugging-port=${port}`,
1999
- `--user-data-dir=${userDataDir}`,
2000
- "--no-first-run",
2001
- "--no-default-browser-check",
2002
- "--new-window",
2003
- bossUrl
2004
- ];
1998
+ const args = buildBossChromeLaunchArgs({ port, userDataDir, url: bossUrl });
2005
1999
  const child = spawn(chromePath, args, {
2006
2000
  detached: true,
2007
2001
  stdio: "ignore",
@@ -7,6 +7,12 @@ import CDP from "chrome-remote-interface";
7
7
  export const DEFAULT_CHROME_HOST = "127.0.0.1";
8
8
  export const DEFAULT_CHROME_PORT = 9222;
9
9
  export const BOSS_LOGIN_URL = "https://www.zhipin.com/web/user/?ka=bticket";
10
+ export const LID_CLOSED_SAFE_CHROME_ARGS = [
11
+ "--disable-backgrounding-occluded-windows",
12
+ "--disable-background-timer-throttling",
13
+ "--disable-renderer-backgrounding",
14
+ "--disable-features=CalculateNativeWinOcclusion"
15
+ ];
10
16
 
11
17
  export const ALLOWED_CDP_DOMAINS = new Set([
12
18
  "Accessibility",
@@ -208,6 +214,33 @@ export function getBossChromeUserDataDir(port = DEFAULT_CHROME_PORT) {
208
214
  return sharedPath;
209
215
  }
210
216
 
217
+ function parseExtraChromeArgs(value = "") {
218
+ return String(value || "")
219
+ .split(/\s+/)
220
+ .map((item) => item.trim())
221
+ .filter(Boolean);
222
+ }
223
+
224
+ export function buildBossChromeLaunchArgs({
225
+ port = DEFAULT_CHROME_PORT,
226
+ userDataDir = "",
227
+ url = "about:blank",
228
+ extraArgs = []
229
+ } = {}) {
230
+ const args = [
231
+ `--remote-debugging-port=${port}`,
232
+ `--user-data-dir=${userDataDir}`,
233
+ "--no-first-run",
234
+ "--no-default-browser-check",
235
+ ...LID_CLOSED_SAFE_CHROME_ARGS,
236
+ ...parseExtraChromeArgs(process.env.BOSS_MCP_EXTRA_CHROME_ARGS),
237
+ ...extraArgs,
238
+ "--new-window",
239
+ url
240
+ ];
241
+ return Array.from(new Set(args.filter(Boolean)));
242
+ }
243
+
211
244
  export async function waitForChromeDebugPort({
212
245
  host = DEFAULT_CHROME_HOST,
213
246
  port = DEFAULT_CHROME_PORT,
@@ -250,14 +283,7 @@ export async function launchChromeDebugInstance({
250
283
  throw new Error("Chrome executable not found. Set BOSS_MCP_CHROME_PATH or BOSS_RECOMMEND_CHROME_PATH.");
251
284
  }
252
285
  const userDataDir = getBossChromeUserDataDir(port);
253
- const args = [
254
- `--remote-debugging-port=${port}`,
255
- `--user-data-dir=${userDataDir}`,
256
- "--no-first-run",
257
- "--no-default-browser-check",
258
- "--new-window",
259
- url
260
- ];
286
+ const args = buildBossChromeLaunchArgs({ port, userDataDir, url });
261
287
  const child = spawn(chromePath, args, {
262
288
  detached: true,
263
289
  stdio: "ignore",
@@ -277,6 +303,7 @@ export async function launchChromeDebugInstance({
277
303
  launched: true,
278
304
  chrome_path: chromePath,
279
305
  user_data_dir: userDataDir,
306
+ launch_args: args,
280
307
  port,
281
308
  url,
282
309
  readiness: {
@@ -6,6 +6,7 @@ import {
6
6
  getAttributesMap,
7
7
  getNodeBox,
8
8
  getOuterHTML,
9
+ querySelectorAll,
9
10
  sleep
10
11
  } from "../browser/index.js";
11
12
  import {
@@ -158,6 +159,185 @@ function screenshotHash(buffer) {
158
159
  return crypto.createHash("sha256").update(buffer).digest("hex");
159
160
  }
160
161
 
162
+ function createCaptureTimeoutError(label, timeoutMs) {
163
+ const error = new Error(`Image fallback capture timed out during ${label} after ${timeoutMs}ms`);
164
+ error.code = "IMAGE_CAPTURE_TIMEOUT";
165
+ error.capture_step = label;
166
+ error.timeout_ms = timeoutMs;
167
+ return error;
168
+ }
169
+
170
+ async function withCaptureTimeout(promise, {
171
+ label = "capture_step",
172
+ timeoutMs = 0
173
+ } = {}) {
174
+ const safeTimeout = Math.max(0, Number(timeoutMs) || 0);
175
+ if (!safeTimeout) return promise;
176
+ let timer = null;
177
+ try {
178
+ return await Promise.race([
179
+ promise,
180
+ new Promise((_, reject) => {
181
+ timer = setTimeout(() => reject(createCaptureTimeoutError(label, safeTimeout)), safeTimeout);
182
+ })
183
+ ]);
184
+ } finally {
185
+ if (timer) clearTimeout(timer);
186
+ }
187
+ }
188
+
189
+ function assertCaptureTotalBudget(started, totalTimeoutMs, label) {
190
+ const safeTimeout = Math.max(0, Number(totalTimeoutMs) || 0);
191
+ if (!safeTimeout) return;
192
+ const elapsed = Date.now() - started;
193
+ if (elapsed <= safeTimeout) return;
194
+ const error = createCaptureTimeoutError(label, safeTimeout);
195
+ error.elapsed_ms = elapsed;
196
+ error.code = "IMAGE_CAPTURE_TOTAL_TIMEOUT";
197
+ throw error;
198
+ }
199
+
200
+ const DEFAULT_SCROLL_ANCHOR_SELECTOR = [
201
+ "h1",
202
+ "h2",
203
+ "h3",
204
+ "h4",
205
+ "h5",
206
+ "p",
207
+ "li",
208
+ "section",
209
+ "article",
210
+ "table",
211
+ "tr",
212
+ "dl",
213
+ "dt",
214
+ "dd",
215
+ "[class*='resume']",
216
+ "[class*='work']",
217
+ "[class*='project']",
218
+ "[class*='education']",
219
+ "[class*='experience']",
220
+ "[class*='item']",
221
+ "div"
222
+ ].join(",");
223
+
224
+ function normalizeScrollMethod(value = "dom-anchor-fallback-input") {
225
+ const normalized = normalizeText(value).toLowerCase();
226
+ if (["dom", "dom-anchor", "dom_anchor", "anchor"].includes(normalized)) return "dom-anchor";
227
+ if (["dom-anchor-fallback-input", "dom_anchor_fallback_input", "dom-fallback-input"].includes(normalized)) {
228
+ return "dom-anchor-fallback-input";
229
+ }
230
+ return "input";
231
+ }
232
+
233
+ function uniqueNumbers(values = []) {
234
+ return Array.from(new Set(values.map((value) => Number(value) || 0).filter(Boolean)));
235
+ }
236
+
237
+ function pickEvenly(items = [], limit = 1) {
238
+ const safeLimit = Math.max(1, Number(limit) || 1);
239
+ if (items.length <= safeLimit) return items;
240
+ const picked = [];
241
+ const last = items.length - 1;
242
+ for (let index = 0; index < safeLimit; index += 1) {
243
+ const sourceIndex = Math.round((index * last) / Math.max(1, safeLimit - 1));
244
+ picked.push(items[sourceIndex]);
245
+ }
246
+ return Array.from(new Map(picked.map((item) => [item.node_id, item])).values());
247
+ }
248
+
249
+ async function collectDomScrollAnchors(client, rootNodeId, {
250
+ selector = DEFAULT_SCROLL_ANCHOR_SELECTOR,
251
+ maxScreenshots = 6,
252
+ maxProbeNodes = 260,
253
+ minAnchorGap = 180,
254
+ stepTimeoutMs = 45000
255
+ } = {}) {
256
+ const started = Date.now();
257
+ let nodeIds = [];
258
+ try {
259
+ nodeIds = uniqueNumbers(await querySelectorAll(client, rootNodeId, selector));
260
+ } catch (error) {
261
+ return {
262
+ ok: false,
263
+ method: "dom-anchor",
264
+ reason: "query_selector_all_failed",
265
+ error: error?.message || String(error)
266
+ };
267
+ }
268
+ if (!nodeIds.length) {
269
+ return {
270
+ ok: false,
271
+ method: "dom-anchor",
272
+ reason: "no_anchor_nodes"
273
+ };
274
+ }
275
+
276
+ const probeLimit = Math.max(1, Number(maxProbeNodes) || 260);
277
+ const perNodeTimeoutMs = Math.min(1200, Math.max(250, Math.floor((Number(stepTimeoutMs) || 45000) / 30)));
278
+ const measured = [];
279
+ for (const nodeId of nodeIds.slice(0, probeLimit)) {
280
+ try {
281
+ const box = await withCaptureTimeout(getNodeBox(client, nodeId), {
282
+ label: `anchor_box_${nodeId}`,
283
+ timeoutMs: perNodeTimeoutMs
284
+ });
285
+ const rect = box?.rect || {};
286
+ if ((Number(rect.width) || 0) < 80 || (Number(rect.height) || 0) < 8) continue;
287
+ measured.push({
288
+ node_id: nodeId,
289
+ y: Math.round(Number(rect.y) || 0),
290
+ height: Math.round(Number(rect.height) || 0)
291
+ });
292
+ } catch {}
293
+ }
294
+
295
+ let anchors = [];
296
+ if (measured.length) {
297
+ const sorted = measured.sort((a, b) => a.y - b.y);
298
+ for (const item of sorted) {
299
+ const last = anchors[anchors.length - 1];
300
+ if (!last || Math.abs(item.y - last.y) >= Math.max(40, Number(minAnchorGap) || 180)) {
301
+ anchors.push(item);
302
+ }
303
+ }
304
+ }
305
+
306
+ if (anchors.length < 2) {
307
+ anchors = nodeIds.slice(0, probeLimit).map((nodeId, index) => ({
308
+ node_id: nodeId,
309
+ y: null,
310
+ height: null,
311
+ document_order: index
312
+ }));
313
+ }
314
+
315
+ anchors = pickEvenly(anchors, Math.max(1, Number(maxScreenshots) || 1));
316
+ return {
317
+ ok: anchors.length > 0,
318
+ method: "dom-anchor",
319
+ elapsed_ms: Date.now() - started,
320
+ selector,
321
+ discovered_node_count: nodeIds.length,
322
+ measured_node_count: measured.length,
323
+ anchor_count: anchors.length,
324
+ anchors
325
+ };
326
+ }
327
+
328
+ async function scrollDomAnchorIntoView(client, nodeId, {
329
+ timeoutMs = 10000,
330
+ label = "dom_scroll_anchor"
331
+ } = {}) {
332
+ if (client.DOM && typeof client.DOM.scrollIntoViewIfNeeded === "function") {
333
+ return withCaptureTimeout(client.DOM.scrollIntoViewIfNeeded({ nodeId }), { label, timeoutMs });
334
+ }
335
+ if (typeof client.send === "function") {
336
+ return withCaptureTimeout(client.send("DOM.scrollIntoViewIfNeeded", { nodeId }), { label, timeoutMs });
337
+ }
338
+ throw new Error("CDP client does not expose DOM.scrollIntoViewIfNeeded");
339
+ }
340
+
161
341
  async function optimizeScreenshotBuffer(buffer, {
162
342
  enabled = false,
163
343
  format = "png",
@@ -339,20 +519,83 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
339
519
  llmPagesPerImage = 3,
340
520
  llmResizeMaxWidth = 1100,
341
521
  llmQuality = 72,
522
+ stepTimeoutMs = 45000,
523
+ totalTimeoutMs = 90000,
524
+ scrollMethod = "dom-anchor-fallback-input",
525
+ scrollAnchorSelector = DEFAULT_SCROLL_ANCHOR_SELECTOR,
526
+ scrollAnchorMaxProbeNodes = 260,
527
+ scrollAnchorMinGap = 180,
342
528
  metadata = {}
343
529
  } = {}) {
344
530
  if (!nodeId) throw new Error("captureScrolledNodeScreenshots requires nodeId");
345
531
  const sequenceStarted = Date.now();
532
+ const normalizedScrollMethod = normalizeScrollMethod(scrollMethod);
533
+ const maxScreenshotCount = Math.max(1, Number(maxScreenshots) || 1);
534
+ const anchorPlan = normalizedScrollMethod !== "input"
535
+ ? await collectDomScrollAnchors(client, nodeId, {
536
+ selector: scrollAnchorSelector,
537
+ maxScreenshots: maxScreenshotCount,
538
+ maxProbeNodes: scrollAnchorMaxProbeNodes,
539
+ minAnchorGap: scrollAnchorMinGap,
540
+ stepTimeoutMs
541
+ })
542
+ : null;
346
543
  const screenshots = [];
347
544
  let consecutiveDuplicates = 0;
348
545
  let previousHash = "";
349
546
  let captureCount = 0;
350
547
  let droppedDuplicateCount = 0;
548
+ let currentScrollMetadata = {
549
+ before_capture: "initial",
550
+ method: normalizedScrollMethod,
551
+ anchor_plan: anchorPlan
552
+ ? {
553
+ ok: Boolean(anchorPlan.ok),
554
+ reason: anchorPlan.reason || null,
555
+ discovered_node_count: anchorPlan.discovered_node_count || 0,
556
+ measured_node_count: anchorPlan.measured_node_count || 0,
557
+ anchor_count: anchorPlan.anchor_count || 0,
558
+ elapsed_ms: anchorPlan.elapsed_ms || 0
559
+ }
560
+ : null
561
+ };
351
562
 
352
- for (let index = 0; index < Math.max(1, Number(maxScreenshots) || 1); index += 1) {
563
+ if (anchorPlan?.anchors?.[0]?.node_id && normalizedScrollMethod !== "input") {
564
+ try {
565
+ await scrollDomAnchorIntoView(client, anchorPlan.anchors[0].node_id, {
566
+ label: "scroll_dom_anchor_initial",
567
+ timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
568
+ });
569
+ currentScrollMetadata = {
570
+ before_capture: "dom_anchor_initial",
571
+ method: "DOM.scrollIntoViewIfNeeded",
572
+ anchor_node_id: anchorPlan.anchors[0].node_id,
573
+ anchor_y: anchorPlan.anchors[0].y,
574
+ anchor_height: anchorPlan.anchors[0].height,
575
+ anchor_plan: currentScrollMetadata.anchor_plan
576
+ };
577
+ } catch (error) {
578
+ if (normalizedScrollMethod === "dom-anchor") {
579
+ throw error;
580
+ }
581
+ currentScrollMetadata = {
582
+ before_capture: "dom_anchor_initial_failed",
583
+ method: "DOM.scrollIntoViewIfNeeded",
584
+ anchor_node_id: anchorPlan.anchors[0].node_id,
585
+ error: error?.message || String(error),
586
+ anchor_plan: currentScrollMetadata.anchor_plan
587
+ };
588
+ }
589
+ }
590
+
591
+ for (let index = 0; index < maxScreenshotCount; index += 1) {
592
+ assertCaptureTotalBudget(sequenceStarted, totalTimeoutMs, `capture_page_${index + 1}`);
353
593
  captureCount += 1;
354
594
  const captureStarted = Date.now();
355
- const box = await getNodeBox(client, nodeId);
595
+ const box = await withCaptureTimeout(getNodeBox(client, nodeId), {
596
+ label: `get_box_${index + 1}`,
597
+ timeoutMs: stepTimeoutMs
598
+ });
356
599
  const clip = withPadding(box.rect, padding);
357
600
  const captureOptions = captureViewport ? {
358
601
  format,
@@ -367,13 +610,19 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
367
610
  if (quality != null) {
368
611
  captureOptions.quality = quality;
369
612
  }
370
- const screenshot = await client.Page.captureScreenshot(captureOptions);
613
+ const screenshot = await withCaptureTimeout(client.Page.captureScreenshot(captureOptions), {
614
+ label: `capture_screenshot_${index + 1}`,
615
+ timeoutMs: stepTimeoutMs
616
+ });
371
617
  const originalBuffer = Buffer.from(screenshot.data || "", "base64");
372
- const optimized = await optimizeScreenshotBuffer(originalBuffer, {
618
+ const optimized = await withCaptureTimeout(optimizeScreenshotBuffer(originalBuffer, {
373
619
  enabled: optimize,
374
620
  format,
375
621
  quality,
376
622
  resizeMaxWidth
623
+ }), {
624
+ label: `optimize_screenshot_${index + 1}`,
625
+ timeoutMs: stepTimeoutMs
377
626
  });
378
627
  const buffer = optimized.buffer;
379
628
  const hash = screenshotHash(buffer);
@@ -412,9 +661,7 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
412
661
  clip,
413
662
  capture_viewport: Boolean(captureViewport),
414
663
  node_rect: box.rect,
415
- scroll: index === 0
416
- ? { before_capture: "initial" }
417
- : { before_capture: `wheel_down_${index}` },
664
+ scroll: currentScrollMetadata,
418
665
  metadata
419
666
  });
420
667
  }
@@ -424,27 +671,75 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
424
671
  break;
425
672
  }
426
673
 
427
- if (index < Math.max(1, Number(maxScreenshots) || 1) - 1) {
428
- const x = box.center.x;
429
- const y = box.center.y;
430
- await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
431
- await client.Input.dispatchMouseEvent({
432
- type: "mouseWheel",
433
- x,
434
- y,
435
- deltaX: 0,
436
- deltaY: Math.max(1, Number(wheelDeltaY) || 650)
437
- });
674
+ if (index < maxScreenshotCount - 1) {
675
+ assertCaptureTotalBudget(sequenceStarted, totalTimeoutMs, `scroll_after_page_${index + 1}`);
676
+ let scrolledByDomAnchor = false;
677
+ const nextAnchor = anchorPlan?.anchors?.[index + 1] || null;
678
+ if (nextAnchor?.node_id && normalizedScrollMethod !== "input") {
679
+ try {
680
+ await scrollDomAnchorIntoView(client, nextAnchor.node_id, {
681
+ label: `scroll_dom_anchor_${index + 1}`,
682
+ timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
683
+ });
684
+ scrolledByDomAnchor = true;
685
+ currentScrollMetadata = {
686
+ before_capture: `dom_anchor_${index + 1}`,
687
+ method: "DOM.scrollIntoViewIfNeeded",
688
+ anchor_node_id: nextAnchor.node_id,
689
+ anchor_y: nextAnchor.y,
690
+ anchor_height: nextAnchor.height
691
+ };
692
+ } catch (error) {
693
+ if (normalizedScrollMethod === "dom-anchor") {
694
+ throw error;
695
+ }
696
+ currentScrollMetadata = {
697
+ before_capture: `dom_anchor_${index + 1}_failed`,
698
+ method: "DOM.scrollIntoViewIfNeeded",
699
+ anchor_node_id: nextAnchor.node_id,
700
+ error: error?.message || String(error)
701
+ };
702
+ }
703
+ } else if (normalizedScrollMethod === "dom-anchor") {
704
+ break;
705
+ }
706
+
707
+ if (!scrolledByDomAnchor && normalizedScrollMethod !== "dom-anchor") {
708
+ const x = box.center.x;
709
+ const y = box.center.y;
710
+ await withCaptureTimeout(client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" }), {
711
+ label: `scroll_mouse_move_${index + 1}`,
712
+ timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
713
+ });
714
+ await withCaptureTimeout(client.Input.dispatchMouseEvent({
715
+ type: "mouseWheel",
716
+ x,
717
+ y,
718
+ deltaX: 0,
719
+ deltaY: Math.max(1, Number(wheelDeltaY) || 650)
720
+ }), {
721
+ label: `scroll_wheel_${index + 1}`,
722
+ timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
723
+ });
724
+ currentScrollMetadata = {
725
+ before_capture: `wheel_down_${index + 1}`,
726
+ method: "Input.dispatchMouseEvent",
727
+ fallback_from_dom_anchor: Boolean(anchorPlan && normalizedScrollMethod === "dom-anchor-fallback-input")
728
+ };
729
+ }
438
730
  if (settleMs > 0) await sleep(settleMs);
439
731
  }
440
732
  }
441
733
 
442
734
  const llmComposition = composeForLlm
443
- ? await composeScreenshotsForLlm(screenshots, {
735
+ ? await withCaptureTimeout(composeScreenshotsForLlm(screenshots, {
444
736
  basePath: filePath,
445
737
  pagesPerImage: llmPagesPerImage,
446
738
  resizeMaxWidth: llmResizeMaxWidth,
447
739
  quality: llmQuality
740
+ }), {
741
+ label: "compose_llm_screenshots",
742
+ timeoutMs: stepTimeoutMs
448
743
  })
449
744
  : {
450
745
  llm_file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
@@ -456,6 +751,7 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
456
751
 
457
752
  return {
458
753
  schema_version: 1,
754
+ ok: true,
459
755
  source: "image-scroll-sequence",
460
756
  captured_at: nowIso(),
461
757
  node_id: nodeId,
@@ -482,8 +778,15 @@ export async function captureScrolledNodeScreenshots(client, nodeId, {
482
778
  llm_compose_enabled: Boolean(composeForLlm),
483
779
  llm_pages_per_image: Math.max(1, Math.min(5, Number(llmPagesPerImage) || 3)),
484
780
  llm_resize_max_width: Math.max(0, Number(llmResizeMaxWidth) || 0),
485
- llm_quality: llmQuality ?? null
781
+ llm_quality: llmQuality ?? null,
782
+ step_timeout_ms: Math.max(0, Number(stepTimeoutMs) || 0),
783
+ total_timeout_ms: Math.max(0, Number(totalTimeoutMs) || 0),
784
+ scroll_method: normalizedScrollMethod,
785
+ scroll_anchor_selector: scrollAnchorSelector,
786
+ scroll_anchor_max_probe_nodes: Math.max(1, Number(scrollAnchorMaxProbeNodes) || 260),
787
+ scroll_anchor_min_gap: Math.max(0, Number(scrollAnchorMinGap) || 0)
486
788
  },
789
+ scroll_anchor_plan: anchorPlan,
487
790
  file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
488
791
  screenshots,
489
792
  metadata
@@ -124,6 +124,7 @@ export function hasParsedNetworkProfile(detailResult = {}) {
124
124
  export function summarizeImageEvidence(imageEvidence = null) {
125
125
  if (!imageEvidence) return null;
126
126
  return {
127
+ ok: imageEvidence.ok !== false,
127
128
  source: imageEvidence.source || "",
128
129
  elapsed_ms: imageEvidence.elapsed_ms || 0,
129
130
  capture_count: imageEvidence.capture_count || imageEvidence.screenshot_count || 0,
@@ -137,6 +138,8 @@ export function summarizeImageEvidence(imageEvidence = null) {
137
138
  llm_original_total_byte_length: imageEvidence.llm_original_total_byte_length || 0,
138
139
  llm_composition_error: imageEvidence.llm_composition_error || null,
139
140
  optimization: imageEvidence.optimization || null,
141
+ error_code: imageEvidence.error_code || imageEvidence.code || null,
142
+ error: imageEvidence.error || null,
140
143
  file_paths: imageEvidence.file_paths || [],
141
144
  llm_file_paths: imageEvidence.llm_file_paths || [],
142
145
  first_clip: imageEvidence.screenshots?.[0]?.clip || imageEvidence.clip || null
@@ -838,6 +838,9 @@ export async function runChatWorkflow({
838
838
  maxScreenshots: maxImagePages,
839
839
  wheelDeltaY: imageWheelDeltaY,
840
840
  settleMs: 350,
841
+ scrollMethod: "dom-anchor-fallback-input",
842
+ stepTimeoutMs: 45000,
843
+ totalTimeoutMs: 90000,
841
844
  duplicateStopCount: 1,
842
845
  skipDuplicateScreenshots: true,
843
846
  composeForLlm: true,
@@ -1174,6 +1177,8 @@ export function createChatRunService({
1174
1177
  detail_limit: normalizedDetailLimit,
1175
1178
  detail_source: normalizedDetailSource,
1176
1179
  close_resume: closeResume,
1180
+ request_resume_for_passed: Boolean(requestResumeForPassed),
1181
+ dry_run_request_cv: Boolean(dryRunRequestCv),
1177
1182
  cv_acquisition_mode: cvAcquisitionMode,
1178
1183
  call_llm_on_image: Boolean(callLlmOnImage),
1179
1184
  screening_mode: normalizedScreeningMode,
@@ -361,6 +361,18 @@ function compactRefreshAttempt(refreshAttempt) {
361
361
  };
362
362
  }
363
363
 
364
+ function countPassedResults(results = []) {
365
+ return results.filter((item) => item?.screening?.passed).length;
366
+ }
367
+
368
+ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
369
+ if (!error) return null;
370
+ return {
371
+ code: error.code || fallbackCode,
372
+ message: error.message || String(error)
373
+ };
374
+ }
375
+
364
376
  export async function runRecommendWorkflow({
365
377
  client,
366
378
  targetUrl = "",
@@ -407,9 +419,9 @@ export async function runRecommendWorkflow({
407
419
  const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
408
420
  const useLlmScreening = normalizedScreeningMode !== "deterministic";
409
421
  const postActionEnabled = normalizedPostAction !== "none";
410
- const limit = Math.max(1, Number(maxCandidates) || 1);
411
- const detailCountLimit = detailLimit == null ? limit : Math.max(0, Number(detailLimit) || 0);
412
- const effectiveDetailLimit = postActionEnabled ? limit : detailCountLimit;
422
+ const targetPassCount = Math.max(1, Number(maxCandidates) || 1);
423
+ const detailCountLimit = detailLimit == null ? Number.POSITIVE_INFINITY : Math.max(0, Number(detailLimit) || 0);
424
+ const effectiveDetailLimit = postActionEnabled ? Number.POSITIVE_INFINITY : detailCountLimit;
413
425
  const networkRecorder = effectiveDetailLimit > 0
414
426
  ? createRecommendDetailNetworkRecorder(client)
415
427
  : null;
@@ -522,7 +534,8 @@ export async function runRecommendWorkflow({
522
534
 
523
535
  runControl.updateProgress({
524
536
  card_count: cardNodeIds.length,
525
- target_count: limit,
537
+ target_count: targetPassCount,
538
+ target_count_semantics: "passed_candidates",
526
539
  processed: 0,
527
540
  screened: 0,
528
541
  detail_opened: 0,
@@ -539,7 +552,7 @@ export async function runRecommendWorkflow({
539
552
  viewport_recoveries: viewportGuard.getStats().recoveries
540
553
  });
541
554
 
542
- while (results.length < limit) {
555
+ while (countPassedResults(results) < targetPassCount) {
543
556
  const candidateStarted = Date.now();
544
557
  const timings = {};
545
558
  await runControl.waitIfPaused();
@@ -587,7 +600,7 @@ export async function runRecommendWorkflow({
587
600
  if (
588
601
  (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
589
602
  && refreshOnEnd
590
- && results.length < limit
603
+ && countPassedResults(results) < targetPassCount
591
604
  && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
592
605
  ) {
593
606
  await runControl.waitIfPaused();
@@ -613,7 +626,8 @@ export async function runRecommendWorkflow({
613
626
  });
614
627
  runControl.updateProgress({
615
628
  card_count: refreshResult.card_count || cardNodeIds.length,
616
- target_count: limit,
629
+ target_count: targetPassCount,
630
+ target_count_semantics: "passed_candidates",
617
631
  processed: results.length,
618
632
  screened: results.length,
619
633
  detail_opened: results.filter((item) => item.detail).length,
@@ -734,6 +748,9 @@ export async function runRecommendWorkflow({
734
748
  maxScreenshots: maxImagePages,
735
749
  wheelDeltaY: imageWheelDeltaY,
736
750
  settleMs: 350,
751
+ scrollMethod: "dom-anchor-fallback-input",
752
+ stepTimeoutMs: 45000,
753
+ totalTimeoutMs: 90000,
737
754
  duplicateStopCount: 1,
738
755
  skipDuplicateScreenshots: true,
739
756
  composeForLlm: true,
@@ -859,7 +876,8 @@ export async function runRecommendWorkflow({
859
876
 
860
877
  runControl.updateProgress({
861
878
  card_count: cardNodeIds.length,
862
- target_count: limit,
879
+ target_count: targetPassCount,
880
+ target_count_semantics: "passed_candidates",
863
881
  processed: results.length,
864
882
  screened: results.length,
865
883
  detail_opened: results.filter((item) => item.detail).length,
@@ -989,7 +1007,7 @@ export function createRecommendRunService({
989
1007
  const normalizedFallbackPageScope = normalizeRecommendPageScope(fallbackPageScope) || "recommend";
990
1008
  const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
991
1009
  const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
992
- const normalizedDetailLimit = detailLimit == null ? candidateLimit : Math.max(0, Number(detailLimit) || 0);
1010
+ const normalizedDetailLimit = detailLimit == null ? null : Math.max(0, Number(detailLimit) || 0);
993
1011
  return manager.startRun({
994
1012
  name,
995
1013
  context: {
@@ -1001,6 +1019,7 @@ export function createRecommendRunService({
1001
1019
  fallback_page_scope: normalizedFallbackPageScope,
1002
1020
  filter: normalizedFilter,
1003
1021
  max_candidates: maxCandidates,
1022
+ max_candidates_semantics: "passed_candidates",
1004
1023
  detail_limit: normalizedDetailLimit,
1005
1024
  close_detail: closeDetail,
1006
1025
  cv_acquisition_mode: cvAcquisitionMode,
@@ -1029,6 +1048,7 @@ export function createRecommendRunService({
1029
1048
  progress: {
1030
1049
  card_count: 0,
1031
1050
  target_count: candidateLimit,
1051
+ target_count_semantics: "passed_candidates",
1032
1052
  processed: 0,
1033
1053
  screened: 0,
1034
1054
  detail_opened: 0,
@@ -450,6 +450,9 @@ export async function runRecruitWorkflow({
450
450
  maxScreenshots: maxImagePages,
451
451
  wheelDeltaY: imageWheelDeltaY,
452
452
  settleMs: 350,
453
+ scrollMethod: "dom-anchor-fallback-input",
454
+ stepTimeoutMs: 45000,
455
+ totalTimeoutMs: 90000,
453
456
  duplicateStopCount: 1,
454
457
  skipDuplicateScreenshots: true,
455
458
  composeForLlm: true,