@reconcrap/boss-recommend-mcp 2.1.23 → 2.1.25

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.
Files changed (67) hide show
  1. package/README.md +5 -0
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/package.json +6 -1
  4. package/scripts/install-macos.sh +280 -280
  5. package/scripts/postinstall.cjs +44 -44
  6. package/skills/boss-chat/README.md +43 -42
  7. package/skills/boss-chat/SKILL.md +107 -106
  8. package/skills/boss-recommend-pipeline/README.md +13 -13
  9. package/skills/boss-recommend-pipeline/SKILL.md +47 -47
  10. package/skills/boss-recruit-pipeline/README.md +19 -19
  11. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  12. package/src/chat-mcp.js +301 -127
  13. package/src/chat-runtime-config.js +7 -5
  14. package/src/core/boss-cards/index.js +199 -199
  15. package/src/core/browser/index.js +302 -145
  16. package/src/core/capture/index.js +2930 -1201
  17. package/src/core/cv-acquisition/index.js +512 -238
  18. package/src/core/cv-capture-target/index.js +513 -299
  19. package/src/core/greet-quota/index.js +71 -71
  20. package/src/core/infinite-list/index.js +31 -31
  21. package/src/core/reporting/legacy-csv.js +12 -12
  22. package/src/core/run/detached-launcher.js +305 -0
  23. package/src/core/run/index.js +109 -55
  24. package/src/core/run/timing.js +33 -33
  25. package/src/core/run/windows-detached-worker.ps1 +106 -0
  26. package/src/core/screening/index.js +2400 -2135
  27. package/src/core/self-heal/index.js +989 -973
  28. package/src/core/self-heal/viewport.js +1505 -564
  29. package/src/detached-worker.js +99 -99
  30. package/src/domains/chat/action-journal.js +536 -0
  31. package/src/domains/chat/cards.js +137 -137
  32. package/src/domains/chat/constants.js +9 -9
  33. package/src/domains/chat/detail.js +1684 -401
  34. package/src/domains/chat/index.js +8 -7
  35. package/src/domains/chat/jobs.js +620 -620
  36. package/src/domains/chat/page-guard.js +157 -122
  37. package/src/domains/chat/roots.js +56 -56
  38. package/src/domains/chat/run-service.js +1801 -760
  39. package/src/domains/common/account-rights-panel.js +314 -314
  40. package/src/domains/common/recovery-settle.js +159 -159
  41. package/src/domains/recommend/actions.js +1219 -472
  42. package/src/domains/recommend/cards.js +243 -243
  43. package/src/domains/recommend/colleague-contact.js +1079 -333
  44. package/src/domains/recommend/constants.js +92 -92
  45. package/src/domains/recommend/detail.js +4037 -136
  46. package/src/domains/recommend/filters.js +608 -590
  47. package/src/domains/recommend/index.js +9 -9
  48. package/src/domains/recommend/jobs.js +571 -542
  49. package/src/domains/recommend/location.js +754 -707
  50. package/src/domains/recommend/refresh.js +677 -392
  51. package/src/domains/recommend/roots.js +80 -80
  52. package/src/domains/recommend/run-service.js +4048 -1447
  53. package/src/domains/recommend/scopes.js +246 -246
  54. package/src/domains/recruit/actions.js +277 -277
  55. package/src/domains/recruit/cards.js +74 -74
  56. package/src/domains/recruit/constants.js +236 -236
  57. package/src/domains/recruit/detail.js +588 -588
  58. package/src/domains/recruit/index.js +9 -9
  59. package/src/domains/recruit/instruction-parser.js +866 -866
  60. package/src/domains/recruit/refresh.js +45 -45
  61. package/src/domains/recruit/roots.js +68 -68
  62. package/src/domains/recruit/run-service.js +1817 -1620
  63. package/src/domains/recruit/search.js +3229 -3229
  64. package/src/index.js +16 -1
  65. package/src/parser.js +1296 -1296
  66. package/src/recommend-mcp.js +1061 -450
  67. package/src/recommend-scheduler.js +75 -75
@@ -1,1620 +1,1817 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { createRunLifecycleManager } from "../../core/run/index.js";
4
- import {
5
- addTiming,
6
- imageEvidenceFilePath,
7
- measureTiming
8
- } from "../../core/run/timing.js";
9
- import { captureScrolledNodeScreenshots } from "../../core/capture/index.js";
10
- import { waitForCvCaptureTarget } from "../../core/cv-capture-target/index.js";
11
- import {
12
- configureHumanInteraction,
13
- createHumanRestController,
14
- humanDelay,
15
- normalizeHumanBehaviorOptions,
16
- sleep
17
- } from "../../core/browser/index.js";
18
- import {
19
- compactCvAcquisitionState,
20
- countParsedNetworkProfiles,
21
- createCvAcquisitionState,
22
- DEFAULT_MAX_IMAGE_PAGES,
23
- getCvNetworkWaitPlan,
24
- recordCvImageFallback,
25
- recordCvNetworkHit,
26
- recordCvNetworkMiss,
27
- summarizeImageEvidence,
28
- waitForCvNetworkEvents
29
- } from "../../core/cv-acquisition/index.js";
30
- import {
31
- compactInfiniteListState,
32
- createInfiniteListState,
33
- detectInfiniteListBottomMarker,
34
- getNextInfiniteListCandidate,
35
- markInfiniteListCandidateProcessed,
36
- resetInfiniteListForRefreshRound,
37
- resolveInfiniteListFallbackPoint
38
- } from "../../core/infinite-list/index.js";
39
- import { createViewportRunGuard } from "../../core/self-heal/index.js";
40
- import {
41
- callScreeningLlm,
42
- compactScreeningLlmResult,
43
- createFatalLlmRunError,
44
- createFailedLlmScreeningResult,
45
- isFatalLlmProviderError,
46
- llmResultToScreening,
47
- screenCandidate
48
- } from "../../core/screening/index.js";
49
- import {
50
- closeRecruitBlockingPanels,
51
- closeRecruitDetail,
52
- createRecruitDetailNetworkRecorder,
53
- extractRecruitDetailCandidate,
54
- openRecruitCardDetail,
55
- waitForRecruitDetailNetworkEvents
56
- } from "./detail.js";
57
- import {
58
- clickRecruitActionControl,
59
- waitForRecruitDetailActionControls
60
- } from "./actions.js";
61
- import {
62
- readRecruitCardCandidate,
63
- waitForRecruitCardNodeIds
64
- } from "./cards.js";
65
- import {
66
- applyRecruitSearchParams,
67
- hasRecruitSearchParams,
68
- normalizeRecruitSearchParams
69
- } from "./search.js";
70
- import { refreshRecruitSearchAtEnd } from "./refresh.js";
71
- import { getRecruitRoots } from "./roots.js";
72
- import {
73
- RECRUIT_BOTTOM_MARKER_SELECTORS,
74
- RECRUIT_BOTTOM_REFRESH_SELECTORS,
75
- RECRUIT_CARD_SELECTOR,
76
- RECRUIT_LIST_CONTAINER_SELECTORS
77
- } from "./constants.js";
78
- import {
79
- describeGreetQuotaAfterSpend,
80
- GREET_CREDITS_EXHAUSTED_CODE
81
- } from "../../core/greet-quota/index.js";
82
-
83
- function compactScreening(screening) {
84
- return {
85
- status: screening.status,
86
- passed: screening.passed,
87
- score: screening.score,
88
- reasons: screening.reasons,
89
- candidate: {
90
- domain: screening.candidate?.domain || "recruit",
91
- source: screening.candidate?.source || "",
92
- id: screening.candidate?.id || null,
93
- identity: screening.candidate?.identity || {}
94
- }
95
- };
96
- }
97
-
98
- function compactCandidate(candidate) {
99
- return {
100
- id: candidate?.id || null,
101
- identity: candidate?.identity || {},
102
- text_length: candidate?.text?.raw?.length || 0,
103
- tag_count: candidate?.tags?.length || 0
104
- };
105
- }
106
-
107
- function compactDetail(detailResult) {
108
- if (!detailResult) return null;
109
- return {
110
- popup_text_length: detailResult.detail?.popup_text?.length || 0,
111
- resume_text_length: detailResult.detail?.resume_text?.length || 0,
112
- card_box: detailResult.card_box || null,
113
- open_attempts: detailResult.open_attempts || [],
114
- network_body_count: detailResult.network_bodies?.filter((item) => item.body).length || 0,
115
- parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
116
- cv_acquisition: detailResult.cv_acquisition || null,
117
- image_evidence: summarizeImageEvidence(detailResult.image_evidence),
118
- llm_screening: compactScreeningLlmResult(detailResult.llm_result),
119
- close_result: detailResult.close_result
120
- };
121
- }
122
-
123
- function normalizeScreeningMode(value) {
124
- const normalized = String(value || "llm").trim().toLowerCase();
125
- return ["deterministic", "local", "local_scorer"].includes(normalized)
126
- ? "deterministic"
127
- : "llm";
128
- }
129
-
130
- function createMissingLlmConfigResult() {
131
- return createFailedLlmScreeningResult(new Error("LLM screening config is required for production search runs"));
132
- }
133
-
134
- function normalizeRecruitPostAction(value) {
135
- const normalized = String(value || "").trim().toLowerCase();
136
- if (["", "none", "skip", "no", "不执行", "无", "什么也不做"].includes(normalized)) return "none";
137
- if (["greet", "chat", "打招呼", "直接沟通", "沟通"].includes(normalized)) return "greet";
138
- return "none";
139
- }
140
-
141
- function resolveRecruitPostAction({
142
- postAction = "none",
143
- greetCount = 0,
144
- maxGreetCount = null
145
- } = {}) {
146
- const requested = normalizeRecruitPostAction(postAction);
147
- const currentGreetCount = Number.isInteger(greetCount) && greetCount >= 0 ? greetCount : 0;
148
- const limit = Number.isInteger(maxGreetCount) && maxGreetCount > 0 ? maxGreetCount : null;
149
- if (requested === "greet" && limit !== null && currentGreetCount >= limit) {
150
- return {
151
- requested,
152
- effective: "none",
153
- reason: "greet_limit_reached",
154
- greet_count: currentGreetCount,
155
- max_greet_count: limit
156
- };
157
- }
158
- return {
159
- requested,
160
- effective: requested,
161
- reason: "requested_action",
162
- greet_count: currentGreetCount,
163
- max_greet_count: limit
164
- };
165
- }
166
-
167
- function compactActionDiscovery(discovery) {
168
- if (!discovery) return null;
169
- return {
170
- ok: Boolean(discovery.ok),
171
- elapsed_ms: discovery.elapsed_ms,
172
- summary: discovery.summary || null
173
- };
174
- }
175
-
176
- async function runRecruitPostAction({
177
- client,
178
- rootNodeIds = [],
179
- screening,
180
- actionDiscovery,
181
- postAction = "none",
182
- greetCount = 0,
183
- maxGreetCount = null,
184
- executePostAction = true,
185
- afterClickDelayMs = 900,
186
- lastGreetQuotaAfterSpend = null
187
- } = {}) {
188
- const plan = resolveRecruitPostAction({
189
- postAction,
190
- greetCount,
191
- maxGreetCount
192
- });
193
- const result = {
194
- requested: postAction,
195
- execute_post_action: Boolean(executePostAction),
196
- plan,
197
- eligible: Boolean(screening?.passed),
198
- action_attempted: false,
199
- action_clicked: false,
200
- counted_as_greet: false,
201
- reason: ""
202
- };
203
-
204
- if (!screening?.passed) {
205
- result.reason = "screening_not_passed";
206
- return result;
207
- }
208
- if (plan.effective === "none") {
209
- result.reason = plan.reason === "greet_limit_reached" ? "greet_limit_reached" : "post_action_none";
210
- return result;
211
- }
212
-
213
- const summary = actionDiscovery?.summary || {};
214
- const control = summary.greet?.control || summary.greet;
215
- if (!control?.found && !control?.node_id) {
216
- if (plan.effective === "greet" && lastGreetQuotaAfterSpend?.exhausted_after_spend) {
217
- result.reason = "greet_credits_exhausted";
218
- result.out_of_greet_credits = true;
219
- result.stop_run = true;
220
- result.greet_quota_after_last_click = lastGreetQuotaAfterSpend;
221
- return result;
222
- }
223
- result.reason = `${plan.effective}_control_not_found`;
224
- return result;
225
- }
226
- result.control = control;
227
-
228
- if (plan.effective === "greet" && control.continue_chat) {
229
- result.reason = "already_connected_continue_chat";
230
- result.already_connected = true;
231
- return result;
232
- }
233
- if (plan.effective === "greet" && control.greet_quota?.exhausted) {
234
- result.reason = "greet_credits_exhausted";
235
- result.out_of_greet_credits = true;
236
- result.stop_run = true;
237
- return result;
238
- }
239
- if (plan.effective === "greet" && control.available === false) {
240
- result.reason = "greet_control_not_available";
241
- return result;
242
- }
243
- if (control.disabled) {
244
- result.reason = `${plan.effective}_control_disabled`;
245
- return result;
246
- }
247
- if (!executePostAction) {
248
- result.reason = "dry_run_post_action";
249
- result.would_click = true;
250
- return result;
251
- }
252
-
253
- result.action_attempted = true;
254
- result.control_before = control;
255
- let clickResult;
256
- try {
257
- clickResult = await clickRecruitActionControl(client, {
258
- ...control,
259
- kind: plan.effective
260
- });
261
- } catch (error) {
262
- if (error?.code === GREET_CREDITS_EXHAUSTED_CODE) {
263
- result.reason = "greet_credits_exhausted";
264
- result.out_of_greet_credits = true;
265
- result.stop_run = true;
266
- result.greet_quota = error.greet_quota || control.greet_quota || null;
267
- return result;
268
- }
269
- throw error;
270
- }
271
- result.click_result = clickResult;
272
- result.action_clicked = true;
273
- result.greet_quota_after_click = describeGreetQuotaAfterSpend(
274
- clickResult.greet_quota?.found ? clickResult.greet_quota : control.greet_quota || control.label || ""
275
- );
276
- result.counted_as_greet = plan.effective === "greet";
277
- result.reason = "clicked";
278
- if (afterClickDelayMs > 0) await sleep(afterClickDelayMs);
279
- try {
280
- const afterDiscovery = await waitForRecruitDetailActionControls(client, {
281
- rootNodeIds,
282
- timeoutMs: 2500,
283
- intervalMs: 300,
284
- requireAny: false
285
- });
286
- const afterControl = afterDiscovery?.summary?.greet?.control || afterDiscovery?.summary?.greet || null;
287
- result.action_discovery_after = compactActionDiscovery(afterDiscovery);
288
- result.control_after = afterControl;
289
- if (plan.effective === "greet") {
290
- result.verified_after_click = Boolean(
291
- afterControl?.continue_chat
292
- || String(afterControl?.label || "").includes("继续沟通")
293
- );
294
- }
295
- } catch (error) {
296
- result.verify_error = {
297
- message: error?.message || String(error)
298
- };
299
- }
300
- return result;
301
- }
302
-
303
- function normalizeSearchParams(searchParams = {}) {
304
- return normalizeRecruitSearchParams(searchParams);
305
- }
306
-
307
- function compactRefreshAttempt(refreshAttempt) {
308
- if (!refreshAttempt) return null;
309
- return {
310
- ok: Boolean(refreshAttempt.ok),
311
- method: refreshAttempt.method || "",
312
- forced_recent_viewed: Boolean(refreshAttempt.forced_recent_viewed),
313
- card_count: refreshAttempt.card_count || 0,
314
- search_params: refreshAttempt.search_params || null,
315
- recovery_settle: refreshAttempt.recovery_settle
316
- ? {
317
- ok: Boolean(refreshAttempt.recovery_settle.ok),
318
- status: refreshAttempt.recovery_settle.status || "",
319
- reason: refreshAttempt.recovery_settle.reason || "",
320
- elapsed_ms: refreshAttempt.recovery_settle.elapsed_ms || 0
321
- }
322
- : null,
323
- application: refreshAttempt.application
324
- ? {
325
- applied: Boolean(refreshAttempt.application.applied),
326
- post_search_state: refreshAttempt.application.post_search_state,
327
- steps: (refreshAttempt.application.steps || []).map((step) => ({
328
- step: step.step,
329
- applied: step.result?.applied,
330
- clicked: step.result?.clicked,
331
- searched: step.result?.searched,
332
- reason: step.result?.reason || null
333
- }))
334
- }
335
- : null
336
- };
337
- }
338
-
339
- function compactError(error, fallbackCode = "RECRUIT_RUN_ERROR") {
340
- if (!error) return null;
341
- const result = {
342
- code: error.code || fallbackCode,
343
- message: error.message || String(error)
344
- };
345
- if (error.close_result) {
346
- result.close_result = compactCloseResult(error.close_result);
347
- }
348
- if (error.phase) {
349
- result.phase = error.phase;
350
- }
351
- if (error.refresh_attempt) {
352
- result.refresh_attempt = error.refresh_attempt;
353
- }
354
- if (error.list_end_reason) {
355
- result.list_end_reason = error.list_end_reason;
356
- }
357
- if (error.target_count != null) {
358
- result.target_count = error.target_count;
359
- }
360
- if (error.processed_count != null) {
361
- result.processed_count = error.processed_count;
362
- }
363
- return result;
364
- }
365
-
366
- function compactCloseResult(closeResult) {
367
- if (!closeResult) return null;
368
- const result = {
369
- closed: Boolean(closeResult.closed),
370
- reason: closeResult.reason || null,
371
- probe: closeResult.probe || null,
372
- attempts: closeResult.attempts || [],
373
- verification: closeResult.verification || null
374
- };
375
- if (closeResult.already_closed !== undefined) {
376
- result.already_closed = Boolean(closeResult.already_closed);
377
- }
378
- return result;
379
- }
380
-
381
- function createRecruitCloseFailureError(closeResult) {
382
- const error = new Error(closeResult?.reason || "Recruit detail did not close before recovery");
383
- error.code = "DETAIL_CLOSE_FAILED";
384
- error.close_result = closeResult || null;
385
- return error;
386
- }
387
-
388
- function createRecruitBlockingPanelCloseFailureError(closeResult, phase = "") {
389
- const error = new Error(closeResult?.reason || "Boss account-rights panel did not close before recovery");
390
- error.code = "ACCOUNT_RIGHTS_PANEL_CLOSE_FAILED";
391
- error.close_result = closeResult || null;
392
- error.phase = phase || null;
393
- return error;
394
- }
395
-
396
- function createRecruitRefreshFailureError(refreshAttempt, {
397
- listEndReason = "",
398
- targetCount = 0,
399
- processedCount = 0
400
- } = {}) {
401
- const reason = refreshAttempt?.application?.post_search_state?.ok === false
402
- ? "search_result_not_ready"
403
- : refreshAttempt?.application?.post_search_state?.counts?.candidate_card === 0
404
- ? "no_cards_after_refresh"
405
- : "refresh_failed";
406
- const error = new Error(`Recruit/search refresh failed before target was reached (${reason})`);
407
- error.code = "RECRUIT_END_REFRESH_FAILED";
408
- error.refresh_attempt = refreshAttempt || null;
409
- error.list_end_reason = listEndReason || null;
410
- error.target_count = targetCount;
411
- error.processed_count = processedCount;
412
- return error;
413
- }
414
-
415
- function isRefreshableListStall(reason = "") {
416
- return new Set([
417
- "stable_visible_signature",
418
- "max_scrolls_exhausted",
419
- "scroll_failed",
420
- "scroll_anchor_unavailable"
421
- ]).has(String(reason || ""));
422
- }
423
-
424
- export function isStaleRecruitNodeError(error) {
425
- const message = String(error?.message || error || "");
426
- return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
427
- }
428
-
429
- export function isRecoverableRecruitImageCaptureError(error) {
430
- const code = String(error?.code || "");
431
- if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
432
- if (isStaleRecruitNodeError(error)) return true;
433
- return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
434
- }
435
-
436
- export function isRecoverableRecruitDetailError(error) {
437
- return isStaleRecruitNodeError(error);
438
- }
439
-
440
- function compactRecoverableDetailError(error) {
441
- return compactError(error, isStaleRecruitNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
442
- }
443
-
444
- function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
445
- const resolved = String(basePath || "").trim();
446
- if (!resolved) return [];
447
- const parsed = path.parse(resolved);
448
- const ext = parsed.ext || `.${String(extension || "jpg").replace(/^\./, "") || "jpg"}`;
449
- const files = [];
450
- for (let index = 0; index < Math.max(1, Number(maxCount) || 1); index += 1) {
451
- const page = String(index + 1).padStart(2, "0");
452
- const candidatePath = path.join(parsed.dir, `${parsed.name}-page-${page}${ext}`);
453
- if (fs.existsSync(candidatePath)) files.push(candidatePath);
454
- }
455
- return files;
456
- }
457
-
458
- export function createRecoverableRecruitImageCaptureEvidence(error, {
459
- elapsedMs = 0,
460
- filePath = "",
461
- extension = "jpg",
462
- maxScreenshots = DEFAULT_MAX_IMAGE_PAGES
463
- } = {}) {
464
- const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
465
- return {
466
- schema_version: 1,
467
- ok: false,
468
- source: "image-scroll-sequence",
469
- elapsed_ms: Math.max(0, Math.round(Number(error?.elapsed_ms ?? elapsedMs) || 0)),
470
- capture_count: filePaths.length,
471
- screenshot_count: filePaths.length,
472
- unique_screenshot_count: filePaths.length,
473
- dropped_duplicate_count: 0,
474
- total_byte_length: 0,
475
- original_total_byte_length: 0,
476
- llm_screenshot_count: 0,
477
- llm_total_byte_length: 0,
478
- llm_original_total_byte_length: 0,
479
- llm_composition_error: null,
480
- error_code: error?.code || (isStaleRecruitNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
481
- error: error?.message || String(error || "Image capture failed"),
482
- file_paths: filePaths,
483
- llm_file_paths: []
484
- };
485
- }
486
-
487
- function createImageCaptureFailureScreening(candidate, error) {
488
- return {
489
- status: "fail",
490
- passed: false,
491
- score: 0,
492
- reasons: ["image_capture_failed"],
493
- error: compactError(error, "IMAGE_CAPTURE_FAILED"),
494
- candidate
495
- };
496
- }
497
-
498
- function createRecoverableDetailFailureScreening(candidate, error) {
499
- return {
500
- status: "fail",
501
- passed: false,
502
- score: 0,
503
- reasons: isStaleRecruitNodeError(error)
504
- ? ["detail_open_failed", "stale_node"]
505
- : ["detail_open_failed"],
506
- error: compactRecoverableDetailError(error),
507
- candidate
508
- };
509
- }
510
-
511
- export function countRecruitResultStatuses(results = [], {
512
- greetCount = 0
513
- } = {}) {
514
- return {
515
- processed: results.length,
516
- screened: results.length,
517
- detail_opened: results.filter((item) => item.detail).length,
518
- passed: results.filter((item) => item.screening?.passed).length,
519
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
520
- greet_count: greetCount,
521
- post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
522
- image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
523
- detail_open_failed: results.filter((item) => (
524
- item.error?.code === "DETAIL_STALE_NODE"
525
- || item.error?.code === "DETAIL_OPEN_FAILED"
526
- )).length,
527
- transient_recovered: results.filter((item) => (
528
- item.error?.code === "DETAIL_STALE_NODE"
529
- || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
530
- || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
531
- || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
532
- )).length
533
- };
534
- }
535
-
536
- export async function runRecruitWorkflow({
537
- client,
538
- targetUrl = "",
539
- criteria = "",
540
- searchParams = {},
541
- maxCandidates = 5,
542
- detailLimit = null,
543
- closeDetail = true,
544
- delayMs = 0,
545
- cardTimeoutMs = 90000,
546
- resetBeforeSearch = true,
547
- resetTimeoutMs = 180000,
548
- cityOptionTimeoutMs = 30000,
549
- maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
550
- imageWheelDeltaY = 650,
551
- cvAcquisitionMode = "unknown",
552
- listMaxScrolls = 20,
553
- listStableSignatureLimit = 5,
554
- listWheelDeltaY = 850,
555
- listSettleMs = 2200,
556
- listFallbackPoint = null,
557
- refreshOnEnd = true,
558
- maxRefreshRounds = 2,
559
- refreshResetSettleMs = 5000,
560
- screeningMode = "llm",
561
- llmConfig = null,
562
- llmTimeoutMs = 120000,
563
- llmImageLimit = 8,
564
- llmImageDetail = "high",
565
- imageOutputDir = "",
566
- humanRestEnabled = false,
567
- humanBehavior = null,
568
- postAction = "none",
569
- maxGreetCount = null,
570
- executePostAction = true,
571
- actionTimeoutMs = 8000,
572
- actionIntervalMs = 400,
573
- actionAfterClickDelayMs = 900
574
- } = {}, runControl) {
575
- if (!client) throw new Error("runRecruitWorkflow requires a guarded CDP client");
576
- const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
577
- legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
578
- });
579
- const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
580
- configureHumanInteraction(client, {
581
- enabled: effectiveHumanBehavior.enabled,
582
- clickMovementEnabled: effectiveHumanBehavior.clickMovement,
583
- textEntryEnabled: effectiveHumanBehavior.textEntry,
584
- safeClickPointEnabled: effectiveHumanBehavior.clickMovement,
585
- actionCooldownEnabled: effectiveHumanBehavior.actionCooldown
586
- });
587
- const humanRestController = createHumanRestController({
588
- enabled: effectiveHumanRestEnabled,
589
- shortRestEnabled: effectiveHumanBehavior.shortRest,
590
- batchRestEnabled: effectiveHumanBehavior.batchRest,
591
- restLevel: effectiveHumanBehavior.restLevel
592
- });
593
- const normalizedSearchParams = normalizeSearchParams(searchParams);
594
- const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
595
- const normalizedPostAction = normalizeRecruitPostAction(postAction);
596
- const postActionEnabled = normalizedPostAction !== "none";
597
- const useLlmScreening = normalizedScreeningMode !== "deterministic";
598
- const searchExchangeResumeFilterRequested = normalizedSearchParams.skip_recent_colleague_contacted === true;
599
- let searchExchangeResumeFilterApplied = false;
600
- const limit = Math.max(1, Number(maxCandidates) || 1);
601
- const detailCountLimit = detailLimit == null ? limit : Math.max(0, Number(detailLimit) || 0);
602
- const networkRecorder = detailCountLimit > 0
603
- ? createRecruitDetailNetworkRecorder(client)
604
- : null;
605
- const cvAcquisitionState = createCvAcquisitionState({ mode: cvAcquisitionMode });
606
- const listState = createInfiniteListState({
607
- domain: "recruit",
608
- listName: "search-results"
609
- });
610
- const viewportGuard = createViewportRunGuard({
611
- client,
612
- domain: "recruit",
613
- root: "frame",
614
- frameOwnerRoot: "frameOwner",
615
- runControl,
616
- getRoots: getRecruitRoots
617
- });
618
- async function ensureRecruitViewport(rootState, phase) {
619
- const result = await viewportGuard.ensure(rootState, { phase });
620
- return result.rootState || rootState;
621
- }
622
- const results = [];
623
- const refreshAttempts = [];
624
- let greetCount = 0;
625
- let lastGreetQuotaAfterSpend = null;
626
- let refreshRounds = 0;
627
- let contextRecoveryAttempts = 0;
628
- const candidateRecoveryCounts = new Map();
629
- let rootState = null;
630
- let cardNodeIds = [];
631
- let listEndReason = "";
632
- let lastHumanEvent = null;
633
- const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
634
- rootNodeId: rootState?.iframe?.documentNodeId,
635
- containerSelectors: RECRUIT_LIST_CONTAINER_SELECTORS,
636
- itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
637
- itemSelectors: [RECRUIT_CARD_SELECTOR],
638
- viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
639
- validateViewportPoint: true
640
- }));
641
-
642
- function recordHumanEvent(event = null) {
643
- if (!event) return lastHumanEvent;
644
- lastHumanEvent = {
645
- at: new Date().toISOString(),
646
- ...event
647
- };
648
- return lastHumanEvent;
649
- }
650
-
651
- async function maybeHumanActionCooldown(phase, timings = {}) {
652
- if (!effectiveHumanBehavior.actionCooldown) return null;
653
- const pauseMs = humanDelay(280, 90, {
654
- minMs: 80,
655
- maxMs: 720
656
- });
657
- if (pauseMs > 0) {
658
- await runControl.sleep(pauseMs);
659
- addTiming(timings, `human_${phase}_pause_ms`, pauseMs);
660
- }
661
- return recordHumanEvent({
662
- kind: "action_cooldown",
663
- phase,
664
- pause_ms: pauseMs
665
- });
666
- }
667
-
668
- function updateRecruitProgress(extra = {}) {
669
- const counts = countRecruitResultStatuses(results, { greetCount });
670
- const listSnapshot = compactInfiniteListState(listState);
671
- const humanRestState = humanRestController.getState();
672
- runControl.updateProgress({
673
- card_count: cardNodeIds.length,
674
- target_count: limit,
675
- ...counts,
676
- screening_mode: normalizedScreeningMode,
677
- unique_seen: listSnapshot.seen_count,
678
- scroll_count: listSnapshot.scroll_count,
679
- refresh_rounds: refreshRounds,
680
- refresh_attempts: refreshAttempts.length,
681
- context_recoveries: contextRecoveryAttempts,
682
- list_end_reason: listEndReason || null,
683
- viewport_checks: viewportGuard.getStats().checks,
684
- viewport_recoveries: viewportGuard.getStats().recoveries,
685
- human_behavior_enabled: effectiveHumanBehavior.enabled,
686
- human_behavior_profile: effectiveHumanBehavior.profile,
687
- human_rest_level: effectiveHumanBehavior.restLevel,
688
- human_rest_enabled: effectiveHumanRestEnabled,
689
- human_rest_count: humanRestState.rest_count,
690
- human_rest_ms: humanRestState.total_rest_ms,
691
- search_exchange_resume_filter_requested: searchExchangeResumeFilterRequested ? 1 : 0,
692
- search_exchange_resume_filter_applied: searchExchangeResumeFilterApplied ? 1 : 0,
693
- last_human_event: lastHumanEvent,
694
- ...extra
695
- });
696
- }
697
-
698
- function checkpointInProgressCandidate({
699
- index = results.length,
700
- candidateKey = "",
701
- cardNodeId = null,
702
- detailStep = "",
703
- error = null
704
- } = {}) {
705
- runControl.checkpoint({
706
- in_progress_candidate: {
707
- index,
708
- key: candidateKey,
709
- card_node_id: cardNodeId,
710
- detail_step: detailStep || null,
711
- counters: countRecruitResultStatuses(results, { greetCount }),
712
- error: compactError(error, "RECRUIT_IN_PROGRESS_ERROR")
713
- },
714
- candidate_list: compactInfiniteListState(listState)
715
- });
716
- }
717
-
718
- async function closeRecruitBlockingPanelsForRun(phase = "cleanup") {
719
- const result = await closeRecruitBlockingPanels(client, {
720
- attemptsLimit: 2,
721
- rootState
722
- });
723
- if (!result?.closed) {
724
- throw createRecruitBlockingPanelCloseFailureError(result, phase);
725
- }
726
- return result;
727
- }
728
-
729
- async function recoverAndReapplyRecruitContext(reason = "context_recovery", error = null, {
730
- forceRecentViewed = true
731
- } = {}) {
732
- await runControl.waitIfPaused();
733
- runControl.throwIfCanceled();
734
- const started = Date.now();
735
- runControl.setPhase("recruit:recover-context");
736
- contextRecoveryAttempts += 1;
737
- const refreshResult = await refreshRecruitSearchAtEnd(client, {
738
- searchParams: normalizedSearchParams,
739
- requireCards: true,
740
- searchTimeoutMs: cardTimeoutMs,
741
- resetTimeoutMs,
742
- resetSettleMs: refreshResetSettleMs,
743
- cityOptionTimeoutMs,
744
- forceRecentViewed
745
- });
746
- let blockingPanelClose = null;
747
- if (refreshResult.ok) {
748
- blockingPanelClose = await closeRecruitBlockingPanels(client, {
749
- attemptsLimit: 2
750
- });
751
- }
752
- const compactRefresh = {
753
- ...compactRefreshAttempt(refreshResult),
754
- context_recovery: true,
755
- recovery_reason: reason,
756
- trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
757
- account_rights_panel_close: compactCloseResult(blockingPanelClose),
758
- elapsed_ms: Date.now() - started
759
- };
760
- refreshAttempts.push(compactRefresh);
761
- runControl.checkpoint({
762
- context_recovery: {
763
- attempt: contextRecoveryAttempts,
764
- reason,
765
- trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
766
- refresh: compactRefresh,
767
- counters: countRecruitResultStatuses(results, { greetCount })
768
- },
769
- candidate_list: compactInfiniteListState(listState)
770
- });
771
- if (!refreshResult.ok) {
772
- updateRecruitProgress({
773
- refresh_method: refreshResult.method || null,
774
- refresh_forced_recent_viewed: forceRecentViewed,
775
- recovery_reason: reason
776
- });
777
- throw new Error(`Recruit context recovery failed after ${reason}: ${refreshResult.application?.reason || "refresh returned no cards"}`);
778
- }
779
- if (!blockingPanelClose?.closed) {
780
- const panelError = createRecruitBlockingPanelCloseFailureError(blockingPanelClose, `recover:${reason}`);
781
- panelError.refresh_attempt = compactRefresh;
782
- throw panelError;
783
- }
784
- rootState = await getRecruitRoots(client);
785
- rootState = await ensureRecruitViewport(rootState, "recover_after");
786
- cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
787
- timeoutMs: cardTimeoutMs,
788
- intervalMs: 300
789
- });
790
- resetInfiniteListForRefreshRound(listState, {
791
- reason: `context_recovery:${reason}`,
792
- round: contextRecoveryAttempts,
793
- method: refreshResult.method,
794
- metadata: {
795
- card_count: cardNodeIds.length,
796
- forced_recent_viewed: forceRecentViewed,
797
- counters: countRecruitResultStatuses(results, { greetCount })
798
- }
799
- });
800
- listEndReason = "";
801
- updateRecruitProgress({
802
- card_count: cardNodeIds.length,
803
- refresh_method: refreshResult.method || null,
804
- refresh_forced_recent_viewed: forceRecentViewed,
805
- recovery_reason: reason
806
- });
807
- return refreshResult;
808
- }
809
-
810
- runControl.setPhase("recruit:cleanup");
811
- await closeRecruitDetail(client, { attemptsLimit: 2 });
812
- await closeRecruitBlockingPanelsForRun("cleanup");
813
-
814
- await runControl.waitIfPaused();
815
- runControl.throwIfCanceled();
816
- runControl.setPhase("recruit:roots");
817
- rootState = await getRecruitRoots(client);
818
- rootState = await ensureRecruitViewport(rootState, "roots");
819
- runControl.checkpoint({
820
- iframe_selector: rootState.iframe.selector,
821
- iframe_document_node_id: rootState.iframe.documentNodeId,
822
- search_params: normalizedSearchParams
823
- });
824
-
825
- if (hasRecruitSearchParams(normalizedSearchParams)) {
826
- await runControl.waitIfPaused();
827
- runControl.throwIfCanceled();
828
- runControl.setPhase("recruit:search");
829
- const searchResult = await applyRecruitSearchParams(client, {
830
- searchParams: normalizedSearchParams,
831
- requireCards: true,
832
- resetBeforeApply: resetBeforeSearch,
833
- searchTimeoutMs: cardTimeoutMs,
834
- resetTimeoutMs,
835
- cityOptionTimeoutMs
836
- });
837
- const exchangeResumeStep = searchResult.steps.find((step) => step.step === "exchange_resume");
838
- searchExchangeResumeFilterApplied = Boolean(
839
- searchExchangeResumeFilterRequested
840
- && exchangeResumeStep?.result?.applied
841
- && exchangeResumeStep?.result?.requested === true
842
- );
843
- runControl.checkpoint({
844
- search: {
845
- search_params: searchResult.search_params,
846
- before_counts: searchResult.before_counts,
847
- post_search_state: searchResult.post_search_state,
848
- steps: searchResult.steps.map((step) => ({
849
- step: step.step,
850
- applied: step.result?.applied,
851
- clicked: step.result?.clicked,
852
- searched: step.result?.searched,
853
- reason: step.result?.reason || null
854
- }))
855
- }
856
- });
857
- rootState = await getRecruitRoots(client);
858
- rootState = await ensureRecruitViewport(rootState, "search");
859
- }
860
-
861
- await runControl.waitIfPaused();
862
- runControl.throwIfCanceled();
863
- runControl.setPhase("recruit:cards");
864
- rootState = await ensureRecruitViewport(rootState, "cards");
865
- cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
866
- timeoutMs: cardTimeoutMs,
867
- intervalMs: 300
868
- });
869
- if (!cardNodeIds.length) {
870
- throw new Error("No recruit/search candidate cards found for run service");
871
- }
872
-
873
- updateRecruitProgress({
874
- list_end_reason: null
875
- });
876
-
877
- while (results.length < limit) {
878
- const candidateStarted = Date.now();
879
- const timings = {};
880
- await runControl.waitIfPaused();
881
- runControl.throwIfCanceled();
882
- runControl.setPhase("recruit:candidate");
883
- rootState = await ensureRecruitViewport(rootState, "candidate_loop");
884
-
885
- const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
886
- client,
887
- state: listState,
888
- maxScrolls: listMaxScrolls,
889
- stableSignatureLimit: listStableSignatureLimit,
890
- wheelDeltaY: listWheelDeltaY,
891
- settleMs: listSettleMs,
892
- listScrollJitterEnabled: effectiveHumanBehavior.listScrollJitter,
893
- fallbackPoint: listFallbackResolver,
894
- findNodeIds: async () => {
895
- let currentRootState = await getRecruitRoots(client);
896
- currentRootState = await ensureRecruitViewport(currentRootState, "candidate_find_nodes");
897
- rootState = currentRootState;
898
- const currentCardNodeIds = await waitForRecruitCardNodeIds(client, currentRootState.iframe.documentNodeId, {
899
- timeoutMs: Math.min(cardTimeoutMs, 5000),
900
- intervalMs: 300
901
- });
902
- cardNodeIds = currentCardNodeIds;
903
- return currentCardNodeIds;
904
- },
905
- readCandidate: async (nodeId, { visibleIndex }) => readRecruitCardCandidate(client, nodeId, {
906
- targetUrl,
907
- source: "recruit-run-card",
908
- metadata: {
909
- run_candidate_index: results.length,
910
- visible_index: visibleIndex,
911
- search_params: normalizedSearchParams
912
- }
913
- }),
914
- detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
915
- rootNodeId: rootState?.iframe?.documentNodeId,
916
- markerSelectors: RECRUIT_BOTTOM_MARKER_SELECTORS,
917
- refreshSelectors: RECRUIT_BOTTOM_REFRESH_SELECTORS,
918
- textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
919
- maxTextScanNodes: 500
920
- })
921
- }));
922
- if (!nextCandidateResult.ok) {
923
- listEndReason = nextCandidateResult.reason || "list_exhausted";
924
- if (
925
- (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
926
- && refreshOnEnd
927
- && results.length < limit
928
- && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
929
- ) {
930
- await runControl.waitIfPaused();
931
- runControl.throwIfCanceled();
932
- runControl.setPhase("recruit:refresh");
933
- refreshRounds += 1;
934
- const refreshResult = await refreshRecruitSearchAtEnd(client, {
935
- searchParams: normalizedSearchParams,
936
- requireCards: true,
937
- searchTimeoutMs: cardTimeoutMs,
938
- resetTimeoutMs,
939
- resetSettleMs: refreshResetSettleMs,
940
- cityOptionTimeoutMs
941
- });
942
- const compactRefresh = compactRefreshAttempt(refreshResult);
943
- refreshAttempts.push(compactRefresh);
944
- runControl.checkpoint({
945
- refresh_round: refreshRounds,
946
- refresh: compactRefresh
947
- });
948
- updateRecruitProgress({
949
- card_count: refreshResult.card_count || cardNodeIds.length,
950
- refresh_method: refreshResult.method || null,
951
- refresh_forced_recent_viewed: true,
952
- list_end_reason: listEndReason
953
- });
954
- if (refreshResult.ok) {
955
- rootState = await getRecruitRoots(client);
956
- rootState = await ensureRecruitViewport(rootState, "refresh_after");
957
- cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
958
- timeoutMs: cardTimeoutMs,
959
- intervalMs: 300
960
- });
961
- resetInfiniteListForRefreshRound(listState, {
962
- reason: listEndReason,
963
- round: refreshRounds,
964
- method: refreshResult.method,
965
- metadata: {
966
- card_count: cardNodeIds.length,
967
- forced_recent_viewed: true
968
- }
969
- });
970
- listEndReason = "";
971
- continue;
972
- }
973
- throw createRecruitRefreshFailureError(compactRefresh, {
974
- listEndReason,
975
- targetCount: limit,
976
- processedCount: results.length
977
- });
978
- }
979
- break;
980
- }
981
-
982
- const index = results.length;
983
- const cardNodeId = nextCandidateResult.item.node_id;
984
- const candidateKey = nextCandidateResult.item.key;
985
- const cardCandidate = nextCandidateResult.item.candidate;
986
-
987
- let screeningCandidate = cardCandidate;
988
- let detailResult = null;
989
- let detailActionRootNodeIds = [];
990
- let recoverableDetailError = null;
991
- let detailStep = "not_started";
992
- if (index < detailCountLimit) {
993
- try {
994
- await runControl.waitIfPaused();
995
- runControl.throwIfCanceled();
996
- runControl.setPhase("recruit:detail");
997
- detailStep = "ensure_viewport";
998
- rootState = await ensureRecruitViewport(rootState, "detail");
999
- const blockingPanelClose = await closeRecruitBlockingPanels(client, {
1000
- attemptsLimit: 2,
1001
- rootState
1002
- });
1003
- if (!blockingPanelClose?.closed) {
1004
- const panelError = createRecruitBlockingPanelCloseFailureError(
1005
- blockingPanelClose,
1006
- "before_detail_open"
1007
- );
1008
- timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
1009
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error: panelError });
1010
- await recoverAndReapplyRecruitContext("account_rights_panel_before_detail", panelError, {
1011
- forceRecentViewed: true
1012
- });
1013
- continue;
1014
- }
1015
- if (blockingPanelClose.already_closed === false) {
1016
- timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
1017
- }
1018
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1019
- detailStep = "open_detail";
1020
- networkRecorder.clear();
1021
- await maybeHumanActionCooldown("before_detail_open", timings);
1022
- const openedDetail = await openRecruitCardDetail(client, cardNodeId);
1023
- detailActionRootNodeIds = (openedDetail.detail_state?.roots || []).map((root) => root.nodeId).filter(Boolean);
1024
- addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
1025
- addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
1026
- const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
1027
- detailStep = "wait_network";
1028
- const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
1029
- waitForRecruitDetailNetworkEvents,
1030
- networkRecorder,
1031
- {
1032
- waitPlan,
1033
- minCount: 1,
1034
- requireLoaded: true,
1035
- intervalMs: 120
1036
- }
1037
- ));
1038
- if (networkWait?.elapsed_ms != null) {
1039
- timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
1040
- }
1041
- detailStep = "extract_detail";
1042
- detailResult = await extractRecruitDetailCandidate(client, {
1043
- cardCandidate,
1044
- cardNodeId,
1045
- detailState: openedDetail.detail_state,
1046
- networkEvents: networkRecorder.events,
1047
- targetUrl,
1048
- closeDetail: false,
1049
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
1050
- networkParseIntervalMs: 250
1051
- });
1052
- detailResult.card_box = openedDetail.card_box || null;
1053
- detailResult.open_attempts = openedDetail.open_attempts || [];
1054
- addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
1055
- const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
1056
- let source = "network";
1057
- let imageEvidence = null;
1058
- let captureTarget = null;
1059
- let captureTargetWait = null;
1060
- if (parsedNetworkProfileCount > 0) {
1061
- recordCvNetworkHit(cvAcquisitionState, {
1062
- parsedNetworkProfileCount,
1063
- waitResult: networkWait
1064
- });
1065
- } else {
1066
- detailStep = "wait_capture_target";
1067
- captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
1068
- domain: "recruit",
1069
- timeoutMs: 6000,
1070
- intervalMs: 250
1071
- });
1072
- captureTarget = captureTargetWait.target || null;
1073
- const captureNodeId = captureTarget?.node_id || null;
1074
- if (captureNodeId) {
1075
- const imageEvidencePath = imageEvidenceFilePath({
1076
- imageOutputDir,
1077
- domain: "recruit",
1078
- runId: runControl?.runId,
1079
- index,
1080
- extension: "jpg"
1081
- });
1082
- try {
1083
- detailStep = "capture_image";
1084
- imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
1085
- filePath: imageEvidencePath,
1086
- format: "jpeg",
1087
- quality: 72,
1088
- optimize: true,
1089
- resizeMaxWidth: 1100,
1090
- captureViewport: false,
1091
- padding: 0,
1092
- maxScreenshots: maxImagePages,
1093
- wheelDeltaY: imageWheelDeltaY,
1094
- settleMs: 350,
1095
- scrollMethod: "dom-anchor-fallback-input",
1096
- scrollDeltaJitterEnabled: effectiveHumanBehavior.listScrollJitter,
1097
- stepTimeoutMs: 45000,
1098
- totalTimeoutMs: 90000,
1099
- duplicateStopCount: 1,
1100
- skipDuplicateScreenshots: true,
1101
- composeForLlm: true,
1102
- llmPagesPerImage: 3,
1103
- llmResizeMaxWidth: 1100,
1104
- llmQuality: 72,
1105
- metadata: {
1106
- domain: "recruit",
1107
- capture_mode: "scroll_sequence",
1108
- acquisition_reason: "network_miss_image_fallback",
1109
- run_candidate_index: index,
1110
- candidate_key: candidateKey,
1111
- capture_target: captureTarget,
1112
- capture_target_wait: captureTargetWait
1113
- }
1114
- }));
1115
- source = "image";
1116
- } catch (error) {
1117
- if (!isRecoverableRecruitImageCaptureError(error)) throw error;
1118
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1119
- if (recoveryCount < 1) {
1120
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1121
- timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
1122
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1123
- await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
1124
- await closeRecruitBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1125
- await recoverAndReapplyRecruitContext(`image_capture:${detailStep}`, error, {
1126
- forceRecentViewed: true
1127
- });
1128
- continue;
1129
- }
1130
- imageEvidence = createRecoverableRecruitImageCaptureEvidence(error, {
1131
- elapsedMs: timings.screenshot_capture_ms,
1132
- filePath: imageEvidencePath,
1133
- extension: "jpg",
1134
- maxScreenshots: maxImagePages
1135
- });
1136
- source = "image_capture_failed";
1137
- }
1138
- recordCvImageFallback(cvAcquisitionState, {
1139
- reason: source === "image_capture_failed"
1140
- ? "network_miss_image_capture_failed"
1141
- : "network_miss_image_fallback",
1142
- parsedNetworkProfileCount,
1143
- waitResult: networkWait,
1144
- imageEvidence
1145
- });
1146
- } else {
1147
- source = "missing_capture_node";
1148
- recordCvNetworkMiss(cvAcquisitionState, {
1149
- reason: "network_miss_no_capture_node",
1150
- parsedNetworkProfileCount,
1151
- waitResult: networkWait
1152
- });
1153
- }
1154
- }
1155
-
1156
- detailResult.image_evidence = imageEvidence;
1157
- detailResult.cv_acquisition = {
1158
- source,
1159
- mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
1160
- wait_plan: waitPlan,
1161
- network_wait: networkWait,
1162
- parsed_network_profile_count: parsedNetworkProfileCount,
1163
- image_evidence: summarizeImageEvidence(imageEvidence),
1164
- capture_target: captureTarget || null,
1165
- capture_target_wait: captureTargetWait
1166
- };
1167
- screeningCandidate = detailResult.candidate;
1168
- if (closeDetail && !postActionEnabled) {
1169
- detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
1170
- await maybeHumanActionCooldown("after_detail_close", timings);
1171
- if (!detailResult.close_result?.closed) {
1172
- const closeError = createRecruitCloseFailureError(detailResult.close_result);
1173
- const recovery = await recoverAndReapplyRecruitContext("detail_close_failed", closeError, {
1174
- forceRecentViewed: true
1175
- });
1176
- detailResult.cv_acquisition = {
1177
- ...(detailResult.cv_acquisition || {}),
1178
- close_recovery: {
1179
- ok: Boolean(recovery.ok),
1180
- method: recovery.method || "",
1181
- forced_recent_viewed: Boolean(recovery.forced_recent_viewed),
1182
- card_count: recovery.card_count || 0
1183
- }
1184
- };
1185
- }
1186
- } else {
1187
- detailResult.close_result = null;
1188
- }
1189
- } catch (error) {
1190
- if (!isRecoverableRecruitDetailError(error)) throw error;
1191
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1192
- if (recoveryCount < 1) {
1193
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1194
- timings.detail_recovery_trigger = compactRecoverableDetailError(error);
1195
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1196
- await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
1197
- await closeRecruitBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1198
- await recoverAndReapplyRecruitContext(`detail:${detailStep}`, error, {
1199
- forceRecentViewed: true
1200
- });
1201
- continue;
1202
- }
1203
- recoverableDetailError = error;
1204
- detailResult = null;
1205
- timings.detail_recovered_error = compactRecoverableDetailError(error);
1206
- await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
1207
- await closeRecruitBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1208
- }
1209
- }
1210
-
1211
- await runControl.waitIfPaused();
1212
- runControl.throwIfCanceled();
1213
- runControl.setPhase("recruit:screening");
1214
- let llmResult = null;
1215
- if (useLlmScreening) {
1216
- if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
1217
- llmResult = null;
1218
- } else if (!llmConfig) {
1219
- llmResult = createMissingLlmConfigResult();
1220
- } else {
1221
- try {
1222
- const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
1223
- ? "vision_model_ms"
1224
- : "text_model_ms";
1225
- llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
1226
- candidate: screeningCandidate,
1227
- criteria,
1228
- config: llmConfig,
1229
- timeoutMs: llmTimeoutMs,
1230
- imageEvidence: detailResult?.image_evidence || null,
1231
- maxImages: llmImageLimit,
1232
- imageDetail: llmImageDetail
1233
- }));
1234
- } catch (error) {
1235
- if (isFatalLlmProviderError(error)) {
1236
- throw createFatalLlmRunError(error, {
1237
- domain: "recruit",
1238
- candidate: screeningCandidate
1239
- });
1240
- }
1241
- llmResult = createFailedLlmScreeningResult(error);
1242
- }
1243
- }
1244
- if (detailResult) detailResult.llm_result = llmResult;
1245
- }
1246
- const screening = recoverableDetailError
1247
- ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
1248
- : detailResult?.image_evidence?.ok === false
1249
- ? createImageCaptureFailureScreening(screeningCandidate, {
1250
- code: detailResult.image_evidence.error_code,
1251
- message: detailResult.image_evidence.error
1252
- })
1253
- : useLlmScreening
1254
- ? llmResultToScreening(llmResult, screeningCandidate)
1255
- : screenCandidate(screeningCandidate, { criteria });
1256
- let actionDiscovery = null;
1257
- let postActionResult = null;
1258
- let closeFailureError = null;
1259
- let closeRecoveryFailure = null;
1260
- if (postActionEnabled && detailResult) {
1261
- const postActionStarted = Date.now();
1262
- await runControl.waitIfPaused();
1263
- runControl.throwIfCanceled();
1264
- runControl.setPhase("recruit:post-action");
1265
- await maybeHumanActionCooldown("before_post_action", timings);
1266
- actionDiscovery = await waitForRecruitDetailActionControls(client, {
1267
- rootNodeIds: detailActionRootNodeIds,
1268
- timeoutMs: actionTimeoutMs,
1269
- intervalMs: actionIntervalMs,
1270
- requireAny: true
1271
- });
1272
- postActionResult = await runRecruitPostAction({
1273
- client,
1274
- rootNodeIds: detailActionRootNodeIds,
1275
- screening,
1276
- actionDiscovery,
1277
- postAction: normalizedPostAction,
1278
- greetCount,
1279
- maxGreetCount: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
1280
- executePostAction,
1281
- afterClickDelayMs: actionAfterClickDelayMs,
1282
- lastGreetQuotaAfterSpend
1283
- });
1284
- if (postActionResult.counted_as_greet && postActionResult.action_clicked) {
1285
- greetCount += 1;
1286
- }
1287
- if (postActionResult.greet_quota_after_click?.found) {
1288
- lastGreetQuotaAfterSpend = postActionResult.greet_quota_after_click;
1289
- }
1290
- addTiming(timings, "post_action_ms", Date.now() - postActionStarted);
1291
- }
1292
- if (postActionEnabled && detailResult && closeDetail) {
1293
- detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
1294
- await maybeHumanActionCooldown("after_detail_close", timings);
1295
- if (!detailResult.close_result?.closed) {
1296
- closeFailureError = createRecruitCloseFailureError(detailResult.close_result);
1297
- try {
1298
- const recovery = await recoverAndReapplyRecruitContext("detail_close_failed", closeFailureError, {
1299
- forceRecentViewed: true
1300
- });
1301
- detailResult.cv_acquisition = {
1302
- ...(detailResult.cv_acquisition || {}),
1303
- close_recovery: {
1304
- ok: Boolean(recovery.ok),
1305
- method: recovery.method || "",
1306
- forced_recent_viewed: Boolean(recovery.forced_recent_viewed),
1307
- card_count: recovery.card_count || 0
1308
- }
1309
- };
1310
- } catch (error) {
1311
- closeRecoveryFailure = error;
1312
- detailResult.cv_acquisition = {
1313
- ...(detailResult.cv_acquisition || {}),
1314
- close_recovery: {
1315
- ok: false,
1316
- reason: "context_recovery_failed",
1317
- error: error?.message || String(error),
1318
- forced_recent_viewed: true
1319
- }
1320
- };
1321
- }
1322
- }
1323
- }
1324
- timings.total_ms = Date.now() - candidateStarted;
1325
- const compactResult = {
1326
- index,
1327
- candidate_key: candidateKey,
1328
- card_node_id: cardNodeId,
1329
- candidate: compactCandidate(screeningCandidate),
1330
- detail: compactDetail(detailResult),
1331
- llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
1332
- screening: compactScreening(screening),
1333
- action_discovery: compactActionDiscovery(actionDiscovery),
1334
- post_action: postActionResult,
1335
- error: recoverableDetailError
1336
- ? compactRecoverableDetailError(recoverableDetailError)
1337
- : closeRecoveryFailure
1338
- ? compactError(closeFailureError, "DETAIL_CLOSE_FAILED")
1339
- : detailResult?.image_evidence?.ok === false
1340
- ? compactError({
1341
- code: detailResult.image_evidence.error_code,
1342
- message: detailResult.image_evidence.error
1343
- }, "IMAGE_CAPTURE_FAILED")
1344
- : null,
1345
- timings
1346
- };
1347
- results.push(compactResult);
1348
- markInfiniteListCandidateProcessed(listState, candidateKey, {
1349
- metadata: {
1350
- result_index: index,
1351
- candidate_id: screeningCandidate.id || null
1352
- }
1353
- });
1354
-
1355
- updateRecruitProgress({
1356
- last_candidate_id: screeningCandidate.id || null,
1357
- last_candidate_key: candidateKey,
1358
- last_score: screening.score
1359
- });
1360
- const checkpointStarted = Date.now();
1361
- runControl.checkpoint({
1362
- results,
1363
- last_candidate: {
1364
- id: screeningCandidate.id || null,
1365
- key: candidateKey,
1366
- identity: screeningCandidate.identity || {},
1367
- screening: {
1368
- status: screening.status,
1369
- passed: screening.passed,
1370
- score: screening.score
1371
- },
1372
- llm_screening: compactScreeningLlmResult(llmResult),
1373
- error: compactResult.error
1374
- }
1375
- });
1376
- addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
1377
-
1378
- if (effectiveHumanRestEnabled) {
1379
- const restStarted = Date.now();
1380
- const restResult = await humanRestController.takeBreakIfNeeded({
1381
- sleepFn: (ms) => runControl.sleep(ms)
1382
- });
1383
- const restElapsed = Date.now() - restStarted;
1384
- if (restResult.rested) {
1385
- recordHumanEvent({
1386
- kind: "rest",
1387
- pause_ms: restResult.pause_ms || restElapsed,
1388
- events: restResult.events || []
1389
- });
1390
- compactResult.human_rest = restResult;
1391
- addTiming(compactResult.timings, "human_rest_ms", restElapsed);
1392
- compactResult.timings.total_ms = Date.now() - candidateStarted;
1393
- updateRecruitProgress({
1394
- human_rest_level: effectiveHumanBehavior.restLevel,
1395
- human_rest_last: restResult
1396
- });
1397
- }
1398
- }
1399
-
1400
- if (delayMs > 0) {
1401
- const sleepStarted = Date.now();
1402
- await runControl.sleep(delayMs);
1403
- addTiming(compactResult.timings, "sleep_ms", Date.now() - sleepStarted);
1404
- compactResult.timings.total_ms = Date.now() - candidateStarted;
1405
- }
1406
- if (postActionResult?.stop_run) {
1407
- listEndReason = postActionResult.reason || "post_action_stop";
1408
- break;
1409
- }
1410
- }
1411
-
1412
- runControl.setPhase("recruit:done");
1413
- return {
1414
- domain: "recruit",
1415
- target_url: targetUrl,
1416
- search_params: normalizedSearchParams,
1417
- card_count: cardNodeIds.length,
1418
- candidate_list: compactInfiniteListState(listState),
1419
- viewport_health: {
1420
- stats: viewportGuard.getStats(),
1421
- events: viewportGuard.getEvents()
1422
- },
1423
- human_behavior: effectiveHumanBehavior,
1424
- human_rest: humanRestController.getState(),
1425
- last_human_event: lastHumanEvent,
1426
- list_end_reason: listEndReason || null,
1427
- search_exchange_resume_filter_requested: searchExchangeResumeFilterRequested ? 1 : 0,
1428
- search_exchange_resume_filter_applied: searchExchangeResumeFilterApplied ? 1 : 0,
1429
- last_greet_quota_after_spend: lastGreetQuotaAfterSpend,
1430
- refresh_rounds: refreshRounds,
1431
- refresh_attempts: refreshAttempts,
1432
- context_recoveries: contextRecoveryAttempts,
1433
- ...countRecruitResultStatuses(results, { greetCount }),
1434
- results
1435
- };
1436
- }
1437
-
1438
- export function createRecruitRunService({
1439
- lifecycle,
1440
- idPrefix = "recruit",
1441
- workflow = runRecruitWorkflow,
1442
- onSnapshot = null
1443
- } = {}) {
1444
- const manager = lifecycle || createRunLifecycleManager({ idPrefix, onSnapshot });
1445
-
1446
- function startRecruitRun({
1447
- runId = "",
1448
- client,
1449
- targetUrl = "",
1450
- criteria = "",
1451
- searchParams = {},
1452
- maxCandidates = 5,
1453
- detailLimit = null,
1454
- closeDetail = true,
1455
- delayMs = 0,
1456
- cardTimeoutMs = 90000,
1457
- resetBeforeSearch = true,
1458
- resetTimeoutMs = 180000,
1459
- cityOptionTimeoutMs = 30000,
1460
- maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
1461
- imageWheelDeltaY = 650,
1462
- cvAcquisitionMode = "unknown",
1463
- listMaxScrolls = 20,
1464
- listStableSignatureLimit = 5,
1465
- listWheelDeltaY = 850,
1466
- listSettleMs = 2200,
1467
- listFallbackPoint = null,
1468
- refreshOnEnd = true,
1469
- maxRefreshRounds = 2,
1470
- refreshResetSettleMs = 5000,
1471
- screeningMode = "llm",
1472
- llmConfig = null,
1473
- llmTimeoutMs = 120000,
1474
- llmImageLimit = 8,
1475
- llmImageDetail = "high",
1476
- imageOutputDir = "",
1477
- humanRestEnabled = false,
1478
- humanBehavior = null,
1479
- postAction = "none",
1480
- maxGreetCount = null,
1481
- executePostAction = true,
1482
- actionTimeoutMs = 8000,
1483
- actionIntervalMs = 400,
1484
- actionAfterClickDelayMs = 900,
1485
- name = "recruit-domain-run"
1486
- } = {}) {
1487
- if (!client) throw new Error("startRecruitRun requires a guarded CDP client");
1488
- const normalizedSearchParams = normalizeSearchParams(searchParams);
1489
- const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
1490
- const normalizedPostAction = normalizeRecruitPostAction(postAction);
1491
- const searchExchangeResumeFilterRequested = normalizedSearchParams.skip_recent_colleague_contacted === true;
1492
- const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
1493
- const normalizedDetailLimit = detailLimit == null ? candidateLimit : Math.max(0, Number(detailLimit) || 0);
1494
- const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
1495
- legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
1496
- });
1497
- const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
1498
- return manager.startRun({
1499
- runId,
1500
- name,
1501
- context: {
1502
- domain: "recruit",
1503
- target_url: targetUrl,
1504
- criteria_present: Boolean(criteria),
1505
- search_params: normalizedSearchParams,
1506
- max_candidates: maxCandidates,
1507
- detail_limit: normalizedDetailLimit,
1508
- close_detail: closeDetail,
1509
- reset_before_search: resetBeforeSearch,
1510
- reset_timeout_ms: resetTimeoutMs,
1511
- city_option_timeout_ms: cityOptionTimeoutMs,
1512
- cv_acquisition_mode: cvAcquisitionMode,
1513
- max_image_pages: maxImagePages,
1514
- image_wheel_delta_y: imageWheelDeltaY,
1515
- list_max_scrolls: listMaxScrolls,
1516
- list_stable_signature_limit: listStableSignatureLimit,
1517
- list_wheel_delta_y: listWheelDeltaY,
1518
- list_settle_ms: listSettleMs,
1519
- list_fallback_point: listFallbackPoint,
1520
- refresh_on_end: refreshOnEnd,
1521
- max_refresh_rounds: maxRefreshRounds,
1522
- refresh_reset_settle_ms: refreshResetSettleMs,
1523
- screening_mode: normalizedScreeningMode,
1524
- llm_configured: Boolean(llmConfig),
1525
- llm_timeout_ms: llmTimeoutMs,
1526
- llm_image_limit: llmImageLimit,
1527
- llm_image_detail: llmImageDetail,
1528
- image_output_dir: imageOutputDir || "",
1529
- human_behavior_enabled: effectiveHumanBehavior.enabled,
1530
- human_behavior_profile: effectiveHumanBehavior.profile,
1531
- human_behavior: effectiveHumanBehavior,
1532
- human_rest_level: effectiveHumanBehavior.restLevel,
1533
- human_rest_enabled: effectiveHumanRestEnabled,
1534
- search_exchange_resume_filter_requested: searchExchangeResumeFilterRequested ? 1 : 0,
1535
- search_exchange_resume_filter_applied: 0,
1536
- post_action: normalizedPostAction,
1537
- max_greet_count: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
1538
- execute_post_action: Boolean(executePostAction),
1539
- action_timeout_ms: actionTimeoutMs,
1540
- action_interval_ms: actionIntervalMs,
1541
- action_after_click_delay_ms: actionAfterClickDelayMs
1542
- },
1543
- progress: {
1544
- card_count: 0,
1545
- target_count: candidateLimit,
1546
- processed: 0,
1547
- screened: 0,
1548
- detail_opened: 0,
1549
- llm_screened: 0,
1550
- passed: 0,
1551
- image_capture_failed: 0,
1552
- detail_open_failed: 0,
1553
- transient_recovered: 0,
1554
- context_recoveries: 0,
1555
- human_behavior_enabled: effectiveHumanBehavior.enabled,
1556
- human_behavior_profile: effectiveHumanBehavior.profile,
1557
- human_rest_level: effectiveHumanBehavior.restLevel,
1558
- human_rest_enabled: effectiveHumanRestEnabled,
1559
- human_rest_count: 0,
1560
- human_rest_ms: 0,
1561
- greet_count: 0,
1562
- post_action_clicked: 0,
1563
- search_exchange_resume_filter_requested: searchExchangeResumeFilterRequested ? 1 : 0,
1564
- search_exchange_resume_filter_applied: 0,
1565
- last_human_event: null
1566
- },
1567
- checkpoint: {},
1568
- task: (runControl) => workflow({
1569
- client,
1570
- targetUrl,
1571
- criteria,
1572
- searchParams: normalizedSearchParams,
1573
- maxCandidates,
1574
- detailLimit: normalizedDetailLimit,
1575
- closeDetail,
1576
- delayMs,
1577
- cardTimeoutMs,
1578
- resetBeforeSearch,
1579
- resetTimeoutMs,
1580
- cityOptionTimeoutMs,
1581
- maxImagePages,
1582
- imageWheelDeltaY,
1583
- cvAcquisitionMode,
1584
- listMaxScrolls,
1585
- listStableSignatureLimit,
1586
- listWheelDeltaY,
1587
- listSettleMs,
1588
- listFallbackPoint,
1589
- refreshOnEnd,
1590
- maxRefreshRounds,
1591
- refreshResetSettleMs,
1592
- screeningMode: normalizedScreeningMode,
1593
- llmConfig,
1594
- llmTimeoutMs,
1595
- llmImageLimit,
1596
- llmImageDetail,
1597
- imageOutputDir,
1598
- humanRestEnabled: effectiveHumanRestEnabled,
1599
- humanBehavior: effectiveHumanBehavior,
1600
- postAction: normalizedPostAction,
1601
- maxGreetCount,
1602
- executePostAction,
1603
- actionTimeoutMs,
1604
- actionIntervalMs,
1605
- actionAfterClickDelayMs
1606
- }, runControl)
1607
- });
1608
- }
1609
-
1610
- return {
1611
- startRecruitRun,
1612
- getRecruitRun: manager.getRun,
1613
- pauseRecruitRun: manager.pauseRun,
1614
- resumeRecruitRun: manager.resumeRun,
1615
- cancelRecruitRun: manager.cancelRun,
1616
- waitForRecruitRun: manager.waitForRun,
1617
- listRecruitRuns: manager.listRuns,
1618
- manager
1619
- };
1620
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRunLifecycleManager } from "../../core/run/index.js";
4
+ import {
5
+ addTiming,
6
+ imageEvidenceFilePath,
7
+ measureTiming
8
+ } from "../../core/run/timing.js";
9
+ import { captureScrolledNodeScreenshots } from "../../core/capture/index.js";
10
+ import { waitForCvCaptureTarget } from "../../core/cv-capture-target/index.js";
11
+ import {
12
+ configureHumanInteraction,
13
+ createHumanRestController,
14
+ humanDelay,
15
+ normalizeHumanBehaviorOptions,
16
+ sleep
17
+ } from "../../core/browser/index.js";
18
+ import {
19
+ attemptImageCaptureCheckpointResume,
20
+ compactCvAcquisitionState,
21
+ createImageCaptureWorkflowRetryTracker,
22
+ createRequiredImageEvidenceFailure,
23
+ imageCaptureResumeCheckpoint,
24
+ countParsedNetworkProfiles,
25
+ createCvAcquisitionState,
26
+ DEFAULT_MAX_IMAGE_PAGES,
27
+ getCvNetworkWaitPlan,
28
+ isFailedClosedImageAcquisition,
29
+ isIncompleteImageEvidence,
30
+ isRecoverableImageCaptureWorkflowError,
31
+ recordCvImageFallback,
32
+ recordCvNetworkHit,
33
+ recordCvNetworkMiss,
34
+ reacquireImageCaptureResumeTarget,
35
+ requireCompleteImageEvidence,
36
+ summarizeImageEvidence,
37
+ waitForCvNetworkEvents
38
+ } from "../../core/cv-acquisition/index.js";
39
+ import {
40
+ compactInfiniteListState,
41
+ createInfiniteListState,
42
+ detectInfiniteListBottomMarker,
43
+ getNextInfiniteListCandidate,
44
+ markInfiniteListCandidateProcessed,
45
+ resetInfiniteListForRefreshRound,
46
+ resolveInfiniteListFallbackPoint
47
+ } from "../../core/infinite-list/index.js";
48
+ import { createViewportRunGuard } from "../../core/self-heal/index.js";
49
+ import {
50
+ callScreeningLlm,
51
+ compactScreeningLlmResult,
52
+ createFatalLlmRunError,
53
+ createFailedLlmScreeningResult,
54
+ isFatalLlmProviderError,
55
+ llmResultToScreening,
56
+ screenCandidate
57
+ } from "../../core/screening/index.js";
58
+ import {
59
+ closeRecruitBlockingPanels,
60
+ closeRecruitDetail,
61
+ createRecruitDetailNetworkRecorder,
62
+ extractRecruitDetailCandidate,
63
+ openRecruitCardDetail,
64
+ waitForRecruitDetail,
65
+ waitForRecruitDetailNetworkEvents
66
+ } from "./detail.js";
67
+ import {
68
+ clickRecruitActionControl,
69
+ waitForRecruitDetailActionControls
70
+ } from "./actions.js";
71
+ import {
72
+ readRecruitCardCandidate,
73
+ waitForRecruitCardNodeIds
74
+ } from "./cards.js";
75
+ import {
76
+ applyRecruitSearchParams,
77
+ hasRecruitSearchParams,
78
+ normalizeRecruitSearchParams
79
+ } from "./search.js";
80
+ import { refreshRecruitSearchAtEnd } from "./refresh.js";
81
+ import { getRecruitRoots } from "./roots.js";
82
+ import {
83
+ RECRUIT_BOTTOM_MARKER_SELECTORS,
84
+ RECRUIT_BOTTOM_REFRESH_SELECTORS,
85
+ RECRUIT_CARD_SELECTOR,
86
+ RECRUIT_LIST_CONTAINER_SELECTORS
87
+ } from "./constants.js";
88
+ import {
89
+ describeGreetQuotaAfterSpend,
90
+ GREET_CREDITS_EXHAUSTED_CODE
91
+ } from "../../core/greet-quota/index.js";
92
+
93
+ function compactScreening(screening) {
94
+ return {
95
+ status: screening.status,
96
+ passed: screening.passed,
97
+ score: screening.score,
98
+ reasons: screening.reasons,
99
+ candidate: {
100
+ domain: screening.candidate?.domain || "recruit",
101
+ source: screening.candidate?.source || "",
102
+ id: screening.candidate?.id || null,
103
+ identity: screening.candidate?.identity || {}
104
+ }
105
+ };
106
+ }
107
+
108
+ function compactCandidate(candidate) {
109
+ return {
110
+ id: candidate?.id || null,
111
+ identity: candidate?.identity || {},
112
+ text_length: candidate?.text?.raw?.length || 0,
113
+ tag_count: candidate?.tags?.length || 0
114
+ };
115
+ }
116
+
117
+ function compactDetail(detailResult) {
118
+ if (!detailResult) return null;
119
+ return {
120
+ popup_text_length: detailResult.detail?.popup_text?.length || 0,
121
+ resume_text_length: detailResult.detail?.resume_text?.length || 0,
122
+ card_box: detailResult.card_box || null,
123
+ open_attempts: detailResult.open_attempts || [],
124
+ network_body_count: detailResult.network_bodies?.filter((item) => item.body).length || 0,
125
+ parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
126
+ cv_acquisition: detailResult.cv_acquisition || null,
127
+ image_evidence: summarizeImageEvidence(detailResult.image_evidence),
128
+ llm_screening: compactScreeningLlmResult(detailResult.llm_result),
129
+ close_result: detailResult.close_result
130
+ };
131
+ }
132
+
133
+ function normalizeScreeningMode(value) {
134
+ const normalized = String(value || "llm").trim().toLowerCase();
135
+ return ["deterministic", "local", "local_scorer"].includes(normalized)
136
+ ? "deterministic"
137
+ : "llm";
138
+ }
139
+
140
+ function createMissingLlmConfigResult() {
141
+ return createFailedLlmScreeningResult(new Error("LLM screening config is required for production search runs"));
142
+ }
143
+
144
+ function normalizeRecruitPostAction(value) {
145
+ const normalized = String(value || "").trim().toLowerCase();
146
+ if (["", "none", "skip", "no", "不执行", "无", "什么也不做"].includes(normalized)) return "none";
147
+ if (["greet", "chat", "打招呼", "直接沟通", "沟通"].includes(normalized)) return "greet";
148
+ return "none";
149
+ }
150
+
151
+ function resolveRecruitPostAction({
152
+ postAction = "none",
153
+ greetCount = 0,
154
+ maxGreetCount = null
155
+ } = {}) {
156
+ const requested = normalizeRecruitPostAction(postAction);
157
+ const currentGreetCount = Number.isInteger(greetCount) && greetCount >= 0 ? greetCount : 0;
158
+ const limit = Number.isInteger(maxGreetCount) && maxGreetCount > 0 ? maxGreetCount : null;
159
+ if (requested === "greet" && limit !== null && currentGreetCount >= limit) {
160
+ return {
161
+ requested,
162
+ effective: "none",
163
+ reason: "greet_limit_reached",
164
+ greet_count: currentGreetCount,
165
+ max_greet_count: limit
166
+ };
167
+ }
168
+ return {
169
+ requested,
170
+ effective: requested,
171
+ reason: "requested_action",
172
+ greet_count: currentGreetCount,
173
+ max_greet_count: limit
174
+ };
175
+ }
176
+
177
+ function compactActionDiscovery(discovery) {
178
+ if (!discovery) return null;
179
+ return {
180
+ ok: Boolean(discovery.ok),
181
+ elapsed_ms: discovery.elapsed_ms,
182
+ summary: discovery.summary || null
183
+ };
184
+ }
185
+
186
+ async function runRecruitPostAction({
187
+ client,
188
+ rootNodeIds = [],
189
+ screening,
190
+ actionDiscovery,
191
+ postAction = "none",
192
+ greetCount = 0,
193
+ maxGreetCount = null,
194
+ executePostAction = true,
195
+ afterClickDelayMs = 900,
196
+ lastGreetQuotaAfterSpend = null
197
+ } = {}) {
198
+ const plan = resolveRecruitPostAction({
199
+ postAction,
200
+ greetCount,
201
+ maxGreetCount
202
+ });
203
+ const result = {
204
+ requested: postAction,
205
+ execute_post_action: Boolean(executePostAction),
206
+ plan,
207
+ eligible: Boolean(screening?.passed),
208
+ action_attempted: false,
209
+ action_clicked: false,
210
+ counted_as_greet: false,
211
+ reason: ""
212
+ };
213
+
214
+ if (!screening?.passed) {
215
+ result.reason = "screening_not_passed";
216
+ return result;
217
+ }
218
+ if (plan.effective === "none") {
219
+ result.reason = plan.reason === "greet_limit_reached" ? "greet_limit_reached" : "post_action_none";
220
+ return result;
221
+ }
222
+
223
+ const summary = actionDiscovery?.summary || {};
224
+ const control = summary.greet?.control || summary.greet;
225
+ if (!control?.found && !control?.node_id) {
226
+ if (plan.effective === "greet" && lastGreetQuotaAfterSpend?.exhausted_after_spend) {
227
+ result.reason = "greet_credits_exhausted";
228
+ result.out_of_greet_credits = true;
229
+ result.stop_run = true;
230
+ result.greet_quota_after_last_click = lastGreetQuotaAfterSpend;
231
+ return result;
232
+ }
233
+ result.reason = `${plan.effective}_control_not_found`;
234
+ return result;
235
+ }
236
+ result.control = control;
237
+
238
+ if (plan.effective === "greet" && control.continue_chat) {
239
+ result.reason = "already_connected_continue_chat";
240
+ result.already_connected = true;
241
+ return result;
242
+ }
243
+ if (plan.effective === "greet" && control.greet_quota?.exhausted) {
244
+ result.reason = "greet_credits_exhausted";
245
+ result.out_of_greet_credits = true;
246
+ result.stop_run = true;
247
+ return result;
248
+ }
249
+ if (plan.effective === "greet" && control.available === false) {
250
+ result.reason = "greet_control_not_available";
251
+ return result;
252
+ }
253
+ if (control.disabled) {
254
+ result.reason = `${plan.effective}_control_disabled`;
255
+ return result;
256
+ }
257
+ if (!executePostAction) {
258
+ result.reason = "dry_run_post_action";
259
+ result.would_click = true;
260
+ return result;
261
+ }
262
+
263
+ result.action_attempted = true;
264
+ result.control_before = control;
265
+ let clickResult;
266
+ try {
267
+ clickResult = await clickRecruitActionControl(client, {
268
+ ...control,
269
+ kind: plan.effective
270
+ });
271
+ } catch (error) {
272
+ if (error?.code === GREET_CREDITS_EXHAUSTED_CODE) {
273
+ result.reason = "greet_credits_exhausted";
274
+ result.out_of_greet_credits = true;
275
+ result.stop_run = true;
276
+ result.greet_quota = error.greet_quota || control.greet_quota || null;
277
+ return result;
278
+ }
279
+ throw error;
280
+ }
281
+ result.click_result = clickResult;
282
+ result.action_clicked = true;
283
+ result.greet_quota_after_click = describeGreetQuotaAfterSpend(
284
+ clickResult.greet_quota?.found ? clickResult.greet_quota : control.greet_quota || control.label || ""
285
+ );
286
+ result.counted_as_greet = plan.effective === "greet";
287
+ result.reason = "clicked";
288
+ if (afterClickDelayMs > 0) await sleep(afterClickDelayMs);
289
+ try {
290
+ const afterDiscovery = await waitForRecruitDetailActionControls(client, {
291
+ rootNodeIds,
292
+ timeoutMs: 2500,
293
+ intervalMs: 300,
294
+ requireAny: false
295
+ });
296
+ const afterControl = afterDiscovery?.summary?.greet?.control || afterDiscovery?.summary?.greet || null;
297
+ result.action_discovery_after = compactActionDiscovery(afterDiscovery);
298
+ result.control_after = afterControl;
299
+ if (plan.effective === "greet") {
300
+ result.verified_after_click = Boolean(
301
+ afterControl?.continue_chat
302
+ || String(afterControl?.label || "").includes("继续沟通")
303
+ );
304
+ }
305
+ } catch (error) {
306
+ result.verify_error = {
307
+ message: error?.message || String(error)
308
+ };
309
+ }
310
+ return result;
311
+ }
312
+
313
+ function normalizeSearchParams(searchParams = {}) {
314
+ return normalizeRecruitSearchParams(searchParams);
315
+ }
316
+
317
+ function compactRefreshAttempt(refreshAttempt) {
318
+ if (!refreshAttempt) return null;
319
+ return {
320
+ ok: Boolean(refreshAttempt.ok),
321
+ method: refreshAttempt.method || "",
322
+ forced_recent_viewed: Boolean(refreshAttempt.forced_recent_viewed),
323
+ card_count: refreshAttempt.card_count || 0,
324
+ search_params: refreshAttempt.search_params || null,
325
+ recovery_settle: refreshAttempt.recovery_settle
326
+ ? {
327
+ ok: Boolean(refreshAttempt.recovery_settle.ok),
328
+ status: refreshAttempt.recovery_settle.status || "",
329
+ reason: refreshAttempt.recovery_settle.reason || "",
330
+ elapsed_ms: refreshAttempt.recovery_settle.elapsed_ms || 0
331
+ }
332
+ : null,
333
+ application: refreshAttempt.application
334
+ ? {
335
+ applied: Boolean(refreshAttempt.application.applied),
336
+ post_search_state: refreshAttempt.application.post_search_state,
337
+ steps: (refreshAttempt.application.steps || []).map((step) => ({
338
+ step: step.step,
339
+ applied: step.result?.applied,
340
+ clicked: step.result?.clicked,
341
+ searched: step.result?.searched,
342
+ reason: step.result?.reason || null
343
+ }))
344
+ }
345
+ : null
346
+ };
347
+ }
348
+
349
+ function compactError(error, fallbackCode = "RECRUIT_RUN_ERROR") {
350
+ if (!error) return null;
351
+ const result = {
352
+ code: error.code || fallbackCode,
353
+ message: error.message || String(error)
354
+ };
355
+ if (error.close_result) {
356
+ result.close_result = compactCloseResult(error.close_result);
357
+ }
358
+ if (error.phase) {
359
+ result.phase = error.phase;
360
+ }
361
+ if (error.refresh_attempt) {
362
+ result.refresh_attempt = error.refresh_attempt;
363
+ }
364
+ if (error.list_end_reason) {
365
+ result.list_end_reason = error.list_end_reason;
366
+ }
367
+ if (error.target_count != null) {
368
+ result.target_count = error.target_count;
369
+ }
370
+ if (error.processed_count != null) {
371
+ result.processed_count = error.processed_count;
372
+ }
373
+ return result;
374
+ }
375
+
376
+ function compactCloseResult(closeResult) {
377
+ if (!closeResult) return null;
378
+ const result = {
379
+ closed: Boolean(closeResult.closed),
380
+ reason: closeResult.reason || null,
381
+ probe: closeResult.probe || null,
382
+ attempts: closeResult.attempts || [],
383
+ verification: closeResult.verification || null
384
+ };
385
+ if (closeResult.already_closed !== undefined) {
386
+ result.already_closed = Boolean(closeResult.already_closed);
387
+ }
388
+ return result;
389
+ }
390
+
391
+ function createRecruitCloseFailureError(closeResult) {
392
+ const error = new Error(closeResult?.reason || "Recruit detail did not close before recovery");
393
+ error.code = "DETAIL_CLOSE_FAILED";
394
+ error.close_result = closeResult || null;
395
+ return error;
396
+ }
397
+
398
+ function createRecruitBlockingPanelCloseFailureError(closeResult, phase = "") {
399
+ const error = new Error(closeResult?.reason || "Boss account-rights panel did not close before recovery");
400
+ error.code = "ACCOUNT_RIGHTS_PANEL_CLOSE_FAILED";
401
+ error.close_result = closeResult || null;
402
+ error.phase = phase || null;
403
+ return error;
404
+ }
405
+
406
+ function createRecruitRefreshFailureError(refreshAttempt, {
407
+ listEndReason = "",
408
+ targetCount = 0,
409
+ processedCount = 0
410
+ } = {}) {
411
+ const reason = refreshAttempt?.application?.post_search_state?.ok === false
412
+ ? "search_result_not_ready"
413
+ : refreshAttempt?.application?.post_search_state?.counts?.candidate_card === 0
414
+ ? "no_cards_after_refresh"
415
+ : "refresh_failed";
416
+ const error = new Error(`Recruit/search refresh failed before target was reached (${reason})`);
417
+ error.code = "RECRUIT_END_REFRESH_FAILED";
418
+ error.refresh_attempt = refreshAttempt || null;
419
+ error.list_end_reason = listEndReason || null;
420
+ error.target_count = targetCount;
421
+ error.processed_count = processedCount;
422
+ return error;
423
+ }
424
+
425
+ function isRefreshableListStall(reason = "") {
426
+ return new Set([
427
+ "stable_visible_signature",
428
+ "max_scrolls_exhausted",
429
+ "scroll_failed",
430
+ "scroll_anchor_unavailable"
431
+ ]).has(String(reason || ""));
432
+ }
433
+
434
+ export function isStaleRecruitNodeError(error) {
435
+ const message = String(error?.message || error || "");
436
+ return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
437
+ }
438
+
439
+ export function isRecoverableRecruitImageCaptureError(error) {
440
+ if (isRecoverableImageCaptureWorkflowError(error)) return true;
441
+ if (isStaleRecruitNodeError(error)) return true;
442
+ return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
443
+ }
444
+
445
+ export function shouldFailClosedRecruitImageAcquisition(detailResult = null) {
446
+ return isFailedClosedImageAcquisition({
447
+ source: detailResult?.cv_acquisition?.source,
448
+ imageEvidence: detailResult?.image_evidence
449
+ });
450
+ }
451
+
452
+ export function isTerminalRecruitImageCaptureFailureSource(source = "") {
453
+ return String(source || "").trim().toLowerCase() === "image_capture_failed";
454
+ }
455
+
456
+ export function isRecoverableRecruitDetailError(error) {
457
+ return isStaleRecruitNodeError(error);
458
+ }
459
+
460
+ export function shouldRetryRecruitDetailRecovery({
461
+ recoveryCount = 0,
462
+ imageCaptureTerminalFailure = false,
463
+ retryLimit = 1
464
+ } = {}) {
465
+ const count = Math.max(0, Math.floor(Number(recoveryCount) || 0));
466
+ const limit = Math.max(0, Math.floor(Number(retryLimit) || 0));
467
+ return imageCaptureTerminalFailure !== true && count < limit;
468
+ }
469
+
470
+ function compactRecoverableDetailError(error) {
471
+ return compactError(error, isStaleRecruitNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
472
+ }
473
+
474
+ function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
475
+ const resolved = String(basePath || "").trim();
476
+ if (!resolved) return [];
477
+ const parsed = path.parse(resolved);
478
+ const ext = parsed.ext || `.${String(extension || "jpg").replace(/^\./, "") || "jpg"}`;
479
+ const files = [];
480
+ for (let index = 0; index < Math.max(1, Number(maxCount) || 1); index += 1) {
481
+ const page = String(index + 1).padStart(2, "0");
482
+ const candidatePath = path.join(parsed.dir, `${parsed.name}-page-${page}${ext}`);
483
+ if (fs.existsSync(candidatePath)) files.push(candidatePath);
484
+ }
485
+ return files;
486
+ }
487
+
488
+ export function createRecoverableRecruitImageCaptureEvidence(error, {
489
+ elapsedMs = 0,
490
+ filePath = "",
491
+ extension = "jpg",
492
+ maxScreenshots = DEFAULT_MAX_IMAGE_PAGES
493
+ } = {}) {
494
+ const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
495
+ return {
496
+ schema_version: 1,
497
+ ok: false,
498
+ source: "image-scroll-sequence",
499
+ elapsed_ms: Math.max(0, Math.round(Number(error?.elapsed_ms ?? elapsedMs) || 0)),
500
+ capture_count: filePaths.length,
501
+ screenshot_count: filePaths.length,
502
+ unique_screenshot_count: filePaths.length,
503
+ dropped_duplicate_count: 0,
504
+ total_byte_length: 0,
505
+ original_total_byte_length: 0,
506
+ llm_screenshot_count: 0,
507
+ llm_total_byte_length: 0,
508
+ llm_original_total_byte_length: 0,
509
+ llm_composition_error: null,
510
+ error_code: error?.code || (isStaleRecruitNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
511
+ error: error?.message || String(error || "Image capture failed"),
512
+ file_paths: filePaths,
513
+ llm_file_paths: []
514
+ };
515
+ }
516
+
517
+ function createImageCaptureFailureScreening(candidate, error) {
518
+ return {
519
+ status: "fail",
520
+ passed: false,
521
+ score: 0,
522
+ reasons: ["image_capture_failed"],
523
+ error: compactError(error, "IMAGE_CAPTURE_FAILED"),
524
+ candidate
525
+ };
526
+ }
527
+
528
+ function createRecoverableDetailFailureScreening(candidate, error) {
529
+ return {
530
+ status: "fail",
531
+ passed: false,
532
+ score: 0,
533
+ reasons: isStaleRecruitNodeError(error)
534
+ ? ["detail_open_failed", "stale_node"]
535
+ : ["detail_open_failed"],
536
+ error: compactRecoverableDetailError(error),
537
+ candidate
538
+ };
539
+ }
540
+
541
+ export function countRecruitResultStatuses(results = [], {
542
+ greetCount = 0
543
+ } = {}) {
544
+ return {
545
+ processed: results.length,
546
+ screened: results.length,
547
+ detail_opened: results.filter((item) => item.detail).length,
548
+ passed: results.filter((item) => item.screening?.passed).length,
549
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
550
+ greet_count: greetCount,
551
+ post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
552
+ image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
553
+ detail_open_failed: results.filter((item) => (
554
+ item.error?.code === "DETAIL_STALE_NODE"
555
+ || item.error?.code === "DETAIL_OPEN_FAILED"
556
+ )).length,
557
+ transient_recovered: results.filter((item) => (
558
+ item.error?.code === "DETAIL_STALE_NODE"
559
+ || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
560
+ || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
561
+ || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
562
+ )).length
563
+ };
564
+ }
565
+
566
+ export async function runRecruitWorkflow({
567
+ client,
568
+ targetUrl = "",
569
+ criteria = "",
570
+ searchParams = {},
571
+ maxCandidates = 5,
572
+ detailLimit = null,
573
+ closeDetail = true,
574
+ delayMs = 0,
575
+ cardTimeoutMs = 90000,
576
+ resetBeforeSearch = true,
577
+ resetTimeoutMs = 180000,
578
+ cityOptionTimeoutMs = 30000,
579
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
580
+ imageWheelDeltaY = 650,
581
+ cvAcquisitionMode = "unknown",
582
+ listMaxScrolls = 20,
583
+ listStableSignatureLimit = 5,
584
+ listWheelDeltaY = 850,
585
+ listSettleMs = 2200,
586
+ listFallbackPoint = null,
587
+ refreshOnEnd = true,
588
+ maxRefreshRounds = 2,
589
+ refreshResetSettleMs = 5000,
590
+ screeningMode = "llm",
591
+ llmConfig = null,
592
+ llmTimeoutMs = 120000,
593
+ llmImageLimit = 8,
594
+ llmImageDetail = "high",
595
+ imageOutputDir = "",
596
+ humanRestEnabled = false,
597
+ humanBehavior = null,
598
+ postAction = "none",
599
+ maxGreetCount = null,
600
+ executePostAction = true,
601
+ actionTimeoutMs = 8000,
602
+ actionIntervalMs = 400,
603
+ actionAfterClickDelayMs = 900
604
+ } = {}, runControl) {
605
+ if (!client) throw new Error("runRecruitWorkflow requires a guarded CDP client");
606
+ const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
607
+ legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
608
+ });
609
+ const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
610
+ configureHumanInteraction(client, {
611
+ enabled: effectiveHumanBehavior.enabled,
612
+ clickMovementEnabled: effectiveHumanBehavior.clickMovement,
613
+ textEntryEnabled: effectiveHumanBehavior.textEntry,
614
+ safeClickPointEnabled: effectiveHumanBehavior.clickMovement,
615
+ actionCooldownEnabled: effectiveHumanBehavior.actionCooldown
616
+ });
617
+ const humanRestController = createHumanRestController({
618
+ enabled: effectiveHumanRestEnabled,
619
+ shortRestEnabled: effectiveHumanBehavior.shortRest,
620
+ batchRestEnabled: effectiveHumanBehavior.batchRest,
621
+ restLevel: effectiveHumanBehavior.restLevel
622
+ });
623
+ const normalizedSearchParams = normalizeSearchParams(searchParams);
624
+ const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
625
+ const normalizedPostAction = normalizeRecruitPostAction(postAction);
626
+ const postActionEnabled = normalizedPostAction !== "none";
627
+ const useLlmScreening = normalizedScreeningMode !== "deterministic";
628
+ const searchExchangeResumeFilterRequested = normalizedSearchParams.skip_recent_colleague_contacted === true;
629
+ let searchExchangeResumeFilterApplied = false;
630
+ const limit = Math.max(1, Number(maxCandidates) || 1);
631
+ const detailCountLimit = detailLimit == null ? limit : Math.max(0, Number(detailLimit) || 0);
632
+ const networkRecorder = detailCountLimit > 0
633
+ ? createRecruitDetailNetworkRecorder(client)
634
+ : null;
635
+ const cvAcquisitionState = createCvAcquisitionState({ mode: cvAcquisitionMode });
636
+ const listState = createInfiniteListState({
637
+ domain: "recruit",
638
+ listName: "search-results"
639
+ });
640
+ const viewportGuard = createViewportRunGuard({
641
+ client,
642
+ domain: "recruit",
643
+ root: "frame",
644
+ frameOwnerRoot: "frameOwner",
645
+ runControl,
646
+ getRoots: getRecruitRoots
647
+ });
648
+ async function ensureRecruitViewport(rootState, phase) {
649
+ const result = await viewportGuard.ensure(rootState, { phase });
650
+ return result.rootState || rootState;
651
+ }
652
+ const results = [];
653
+ const refreshAttempts = [];
654
+ let greetCount = 0;
655
+ let lastGreetQuotaAfterSpend = null;
656
+ let refreshRounds = 0;
657
+ let contextRecoveryAttempts = 0;
658
+ const candidateRecoveryCounts = new Map();
659
+ const imageCaptureWorkflowRetries = createImageCaptureWorkflowRetryTracker();
660
+ let rootState = null;
661
+ let cardNodeIds = [];
662
+ let listEndReason = "";
663
+ let lastHumanEvent = null;
664
+ const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
665
+ rootNodeId: rootState?.iframe?.documentNodeId,
666
+ containerSelectors: RECRUIT_LIST_CONTAINER_SELECTORS,
667
+ itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
668
+ itemSelectors: [RECRUIT_CARD_SELECTOR],
669
+ viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
670
+ validateViewportPoint: true
671
+ }));
672
+
673
+ function recordHumanEvent(event = null) {
674
+ if (!event) return lastHumanEvent;
675
+ lastHumanEvent = {
676
+ at: new Date().toISOString(),
677
+ ...event
678
+ };
679
+ return lastHumanEvent;
680
+ }
681
+
682
+ async function maybeHumanActionCooldown(phase, timings = {}) {
683
+ if (!effectiveHumanBehavior.actionCooldown) return null;
684
+ const pauseMs = humanDelay(280, 90, {
685
+ minMs: 80,
686
+ maxMs: 720
687
+ });
688
+ if (pauseMs > 0) {
689
+ await runControl.sleep(pauseMs);
690
+ addTiming(timings, `human_${phase}_pause_ms`, pauseMs);
691
+ }
692
+ return recordHumanEvent({
693
+ kind: "action_cooldown",
694
+ phase,
695
+ pause_ms: pauseMs
696
+ });
697
+ }
698
+
699
+ function updateRecruitProgress(extra = {}) {
700
+ const counts = countRecruitResultStatuses(results, { greetCount });
701
+ const listSnapshot = compactInfiniteListState(listState);
702
+ const humanRestState = humanRestController.getState();
703
+ runControl.updateProgress({
704
+ card_count: cardNodeIds.length,
705
+ target_count: limit,
706
+ ...counts,
707
+ screening_mode: normalizedScreeningMode,
708
+ unique_seen: listSnapshot.seen_count,
709
+ scroll_count: listSnapshot.scroll_count,
710
+ refresh_rounds: refreshRounds,
711
+ refresh_attempts: refreshAttempts.length,
712
+ context_recoveries: contextRecoveryAttempts,
713
+ list_end_reason: listEndReason || null,
714
+ viewport_checks: viewportGuard.getStats().checks,
715
+ viewport_recoveries: viewportGuard.getStats().recoveries,
716
+ human_behavior_enabled: effectiveHumanBehavior.enabled,
717
+ human_behavior_profile: effectiveHumanBehavior.profile,
718
+ human_rest_level: effectiveHumanBehavior.restLevel,
719
+ human_rest_enabled: effectiveHumanRestEnabled,
720
+ human_rest_count: humanRestState.rest_count,
721
+ human_rest_ms: humanRestState.total_rest_ms,
722
+ search_exchange_resume_filter_requested: searchExchangeResumeFilterRequested ? 1 : 0,
723
+ search_exchange_resume_filter_applied: searchExchangeResumeFilterApplied ? 1 : 0,
724
+ last_human_event: lastHumanEvent,
725
+ ...extra
726
+ });
727
+ }
728
+
729
+ function checkpointInProgressCandidate({
730
+ index = results.length,
731
+ candidateKey = "",
732
+ cardNodeId = null,
733
+ detailStep = "",
734
+ error = null
735
+ } = {}) {
736
+ runControl.checkpoint({
737
+ in_progress_candidate: {
738
+ index,
739
+ key: candidateKey,
740
+ card_node_id: cardNodeId,
741
+ detail_step: detailStep || null,
742
+ counters: countRecruitResultStatuses(results, { greetCount }),
743
+ error: compactError(error, "RECRUIT_IN_PROGRESS_ERROR")
744
+ },
745
+ candidate_list: compactInfiniteListState(listState)
746
+ });
747
+ }
748
+
749
+ async function closeRecruitBlockingPanelsForRun(phase = "cleanup") {
750
+ const result = await closeRecruitBlockingPanels(client, {
751
+ attemptsLimit: 2,
752
+ rootState
753
+ });
754
+ if (!result?.closed) {
755
+ throw createRecruitBlockingPanelCloseFailureError(result, phase);
756
+ }
757
+ return result;
758
+ }
759
+
760
+ async function recoverAndReapplyRecruitContext(reason = "context_recovery", error = null, {
761
+ forceRecentViewed = true
762
+ } = {}) {
763
+ await runControl.waitIfPaused();
764
+ runControl.throwIfCanceled();
765
+ const started = Date.now();
766
+ runControl.setPhase("recruit:recover-context");
767
+ contextRecoveryAttempts += 1;
768
+ const refreshResult = await refreshRecruitSearchAtEnd(client, {
769
+ searchParams: normalizedSearchParams,
770
+ requireCards: true,
771
+ searchTimeoutMs: cardTimeoutMs,
772
+ resetTimeoutMs,
773
+ resetSettleMs: refreshResetSettleMs,
774
+ cityOptionTimeoutMs,
775
+ forceRecentViewed
776
+ });
777
+ let blockingPanelClose = null;
778
+ if (refreshResult.ok) {
779
+ blockingPanelClose = await closeRecruitBlockingPanels(client, {
780
+ attemptsLimit: 2
781
+ });
782
+ }
783
+ const compactRefresh = {
784
+ ...compactRefreshAttempt(refreshResult),
785
+ context_recovery: true,
786
+ recovery_reason: reason,
787
+ trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
788
+ account_rights_panel_close: compactCloseResult(blockingPanelClose),
789
+ elapsed_ms: Date.now() - started
790
+ };
791
+ refreshAttempts.push(compactRefresh);
792
+ runControl.checkpoint({
793
+ context_recovery: {
794
+ attempt: contextRecoveryAttempts,
795
+ reason,
796
+ trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
797
+ refresh: compactRefresh,
798
+ counters: countRecruitResultStatuses(results, { greetCount })
799
+ },
800
+ candidate_list: compactInfiniteListState(listState)
801
+ });
802
+ if (!refreshResult.ok) {
803
+ updateRecruitProgress({
804
+ refresh_method: refreshResult.method || null,
805
+ refresh_forced_recent_viewed: forceRecentViewed,
806
+ recovery_reason: reason
807
+ });
808
+ throw new Error(`Recruit context recovery failed after ${reason}: ${refreshResult.application?.reason || "refresh returned no cards"}`);
809
+ }
810
+ if (!blockingPanelClose?.closed) {
811
+ const panelError = createRecruitBlockingPanelCloseFailureError(blockingPanelClose, `recover:${reason}`);
812
+ panelError.refresh_attempt = compactRefresh;
813
+ throw panelError;
814
+ }
815
+ rootState = await getRecruitRoots(client);
816
+ rootState = await ensureRecruitViewport(rootState, "recover_after");
817
+ cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
818
+ timeoutMs: cardTimeoutMs,
819
+ intervalMs: 300
820
+ });
821
+ resetInfiniteListForRefreshRound(listState, {
822
+ reason: `context_recovery:${reason}`,
823
+ round: contextRecoveryAttempts,
824
+ method: refreshResult.method,
825
+ metadata: {
826
+ card_count: cardNodeIds.length,
827
+ forced_recent_viewed: forceRecentViewed,
828
+ counters: countRecruitResultStatuses(results, { greetCount })
829
+ }
830
+ });
831
+ listEndReason = "";
832
+ updateRecruitProgress({
833
+ card_count: cardNodeIds.length,
834
+ refresh_method: refreshResult.method || null,
835
+ refresh_forced_recent_viewed: forceRecentViewed,
836
+ recovery_reason: reason
837
+ });
838
+ return refreshResult;
839
+ }
840
+
841
+ runControl.setPhase("recruit:cleanup");
842
+ await closeRecruitDetail(client, { attemptsLimit: 2 });
843
+ await closeRecruitBlockingPanelsForRun("cleanup");
844
+
845
+ await runControl.waitIfPaused();
846
+ runControl.throwIfCanceled();
847
+ runControl.setPhase("recruit:roots");
848
+ rootState = await getRecruitRoots(client);
849
+ rootState = await ensureRecruitViewport(rootState, "roots");
850
+ runControl.checkpoint({
851
+ iframe_selector: rootState.iframe.selector,
852
+ iframe_document_node_id: rootState.iframe.documentNodeId,
853
+ search_params: normalizedSearchParams
854
+ });
855
+
856
+ if (hasRecruitSearchParams(normalizedSearchParams)) {
857
+ await runControl.waitIfPaused();
858
+ runControl.throwIfCanceled();
859
+ runControl.setPhase("recruit:search");
860
+ const searchResult = await applyRecruitSearchParams(client, {
861
+ searchParams: normalizedSearchParams,
862
+ requireCards: true,
863
+ resetBeforeApply: resetBeforeSearch,
864
+ searchTimeoutMs: cardTimeoutMs,
865
+ resetTimeoutMs,
866
+ cityOptionTimeoutMs
867
+ });
868
+ const exchangeResumeStep = searchResult.steps.find((step) => step.step === "exchange_resume");
869
+ searchExchangeResumeFilterApplied = Boolean(
870
+ searchExchangeResumeFilterRequested
871
+ && exchangeResumeStep?.result?.applied
872
+ && exchangeResumeStep?.result?.requested === true
873
+ );
874
+ runControl.checkpoint({
875
+ search: {
876
+ search_params: searchResult.search_params,
877
+ before_counts: searchResult.before_counts,
878
+ post_search_state: searchResult.post_search_state,
879
+ steps: searchResult.steps.map((step) => ({
880
+ step: step.step,
881
+ applied: step.result?.applied,
882
+ clicked: step.result?.clicked,
883
+ searched: step.result?.searched,
884
+ reason: step.result?.reason || null
885
+ }))
886
+ }
887
+ });
888
+ rootState = await getRecruitRoots(client);
889
+ rootState = await ensureRecruitViewport(rootState, "search");
890
+ }
891
+
892
+ await runControl.waitIfPaused();
893
+ runControl.throwIfCanceled();
894
+ runControl.setPhase("recruit:cards");
895
+ rootState = await ensureRecruitViewport(rootState, "cards");
896
+ cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
897
+ timeoutMs: cardTimeoutMs,
898
+ intervalMs: 300
899
+ });
900
+ if (!cardNodeIds.length) {
901
+ throw new Error("No recruit/search candidate cards found for run service");
902
+ }
903
+
904
+ updateRecruitProgress({
905
+ list_end_reason: null
906
+ });
907
+
908
+ while (results.length < limit) {
909
+ const candidateStarted = Date.now();
910
+ const timings = {};
911
+ await runControl.waitIfPaused();
912
+ runControl.throwIfCanceled();
913
+ runControl.setPhase("recruit:candidate");
914
+ rootState = await ensureRecruitViewport(rootState, "candidate_loop");
915
+
916
+ const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
917
+ client,
918
+ state: listState,
919
+ maxScrolls: listMaxScrolls,
920
+ stableSignatureLimit: listStableSignatureLimit,
921
+ wheelDeltaY: listWheelDeltaY,
922
+ settleMs: listSettleMs,
923
+ listScrollJitterEnabled: effectiveHumanBehavior.listScrollJitter,
924
+ fallbackPoint: listFallbackResolver,
925
+ findNodeIds: async () => {
926
+ let currentRootState = await getRecruitRoots(client);
927
+ currentRootState = await ensureRecruitViewport(currentRootState, "candidate_find_nodes");
928
+ rootState = currentRootState;
929
+ const currentCardNodeIds = await waitForRecruitCardNodeIds(client, currentRootState.iframe.documentNodeId, {
930
+ timeoutMs: Math.min(cardTimeoutMs, 5000),
931
+ intervalMs: 300
932
+ });
933
+ cardNodeIds = currentCardNodeIds;
934
+ return currentCardNodeIds;
935
+ },
936
+ readCandidate: async (nodeId, { visibleIndex }) => readRecruitCardCandidate(client, nodeId, {
937
+ targetUrl,
938
+ source: "recruit-run-card",
939
+ metadata: {
940
+ run_candidate_index: results.length,
941
+ visible_index: visibleIndex,
942
+ search_params: normalizedSearchParams
943
+ }
944
+ }),
945
+ detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
946
+ rootNodeId: rootState?.iframe?.documentNodeId,
947
+ markerSelectors: RECRUIT_BOTTOM_MARKER_SELECTORS,
948
+ refreshSelectors: RECRUIT_BOTTOM_REFRESH_SELECTORS,
949
+ textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
950
+ maxTextScanNodes: 500
951
+ })
952
+ }));
953
+ if (!nextCandidateResult.ok) {
954
+ listEndReason = nextCandidateResult.reason || "list_exhausted";
955
+ if (
956
+ (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
957
+ && refreshOnEnd
958
+ && results.length < limit
959
+ && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
960
+ ) {
961
+ await runControl.waitIfPaused();
962
+ runControl.throwIfCanceled();
963
+ runControl.setPhase("recruit:refresh");
964
+ refreshRounds += 1;
965
+ const refreshResult = await refreshRecruitSearchAtEnd(client, {
966
+ searchParams: normalizedSearchParams,
967
+ requireCards: true,
968
+ searchTimeoutMs: cardTimeoutMs,
969
+ resetTimeoutMs,
970
+ resetSettleMs: refreshResetSettleMs,
971
+ cityOptionTimeoutMs
972
+ });
973
+ const compactRefresh = compactRefreshAttempt(refreshResult);
974
+ refreshAttempts.push(compactRefresh);
975
+ runControl.checkpoint({
976
+ refresh_round: refreshRounds,
977
+ refresh: compactRefresh
978
+ });
979
+ updateRecruitProgress({
980
+ card_count: refreshResult.card_count || cardNodeIds.length,
981
+ refresh_method: refreshResult.method || null,
982
+ refresh_forced_recent_viewed: true,
983
+ list_end_reason: listEndReason
984
+ });
985
+ if (refreshResult.ok) {
986
+ rootState = await getRecruitRoots(client);
987
+ rootState = await ensureRecruitViewport(rootState, "refresh_after");
988
+ cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
989
+ timeoutMs: cardTimeoutMs,
990
+ intervalMs: 300
991
+ });
992
+ resetInfiniteListForRefreshRound(listState, {
993
+ reason: listEndReason,
994
+ round: refreshRounds,
995
+ method: refreshResult.method,
996
+ metadata: {
997
+ card_count: cardNodeIds.length,
998
+ forced_recent_viewed: true
999
+ }
1000
+ });
1001
+ listEndReason = "";
1002
+ continue;
1003
+ }
1004
+ throw createRecruitRefreshFailureError(compactRefresh, {
1005
+ listEndReason,
1006
+ targetCount: limit,
1007
+ processedCount: results.length
1008
+ });
1009
+ }
1010
+ break;
1011
+ }
1012
+
1013
+ const index = results.length;
1014
+ const cardNodeId = nextCandidateResult.item.node_id;
1015
+ const candidateKey = nextCandidateResult.item.key;
1016
+ const cardCandidate = nextCandidateResult.item.candidate;
1017
+
1018
+ let screeningCandidate = cardCandidate;
1019
+ let detailResult = null;
1020
+ let detailActionRootNodeIds = [];
1021
+ let recoverableDetailError = null;
1022
+ let detailStep = "not_started";
1023
+ let imageCaptureTerminalFailure = false;
1024
+ if (index < detailCountLimit) {
1025
+ try {
1026
+ await runControl.waitIfPaused();
1027
+ runControl.throwIfCanceled();
1028
+ runControl.setPhase("recruit:detail");
1029
+ detailStep = "ensure_viewport";
1030
+ rootState = await ensureRecruitViewport(rootState, "detail");
1031
+ const blockingPanelClose = await closeRecruitBlockingPanels(client, {
1032
+ attemptsLimit: 2,
1033
+ rootState
1034
+ });
1035
+ if (!blockingPanelClose?.closed) {
1036
+ const panelError = createRecruitBlockingPanelCloseFailureError(
1037
+ blockingPanelClose,
1038
+ "before_detail_open"
1039
+ );
1040
+ timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
1041
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error: panelError });
1042
+ await recoverAndReapplyRecruitContext("account_rights_panel_before_detail", panelError, {
1043
+ forceRecentViewed: true
1044
+ });
1045
+ continue;
1046
+ }
1047
+ if (blockingPanelClose.already_closed === false) {
1048
+ timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
1049
+ }
1050
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1051
+ detailStep = "open_detail";
1052
+ networkRecorder.clear();
1053
+ await maybeHumanActionCooldown("before_detail_open", timings);
1054
+ const openedDetail = await openRecruitCardDetail(client, cardNodeId);
1055
+ detailActionRootNodeIds = (openedDetail.detail_state?.roots || []).map((root) => root.nodeId).filter(Boolean);
1056
+ addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
1057
+ addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
1058
+ const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
1059
+ detailStep = "wait_network";
1060
+ const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
1061
+ waitForRecruitDetailNetworkEvents,
1062
+ networkRecorder,
1063
+ {
1064
+ waitPlan,
1065
+ minCount: 1,
1066
+ requireLoaded: true,
1067
+ intervalMs: 120
1068
+ }
1069
+ ));
1070
+ if (networkWait?.elapsed_ms != null) {
1071
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
1072
+ }
1073
+ detailStep = "extract_detail";
1074
+ detailResult = await extractRecruitDetailCandidate(client, {
1075
+ cardCandidate,
1076
+ cardNodeId,
1077
+ detailState: openedDetail.detail_state,
1078
+ networkEvents: networkRecorder.events,
1079
+ targetUrl,
1080
+ closeDetail: false,
1081
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
1082
+ networkParseIntervalMs: 250
1083
+ });
1084
+ detailResult.card_box = openedDetail.card_box || null;
1085
+ detailResult.open_attempts = openedDetail.open_attempts || [];
1086
+ addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
1087
+ const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
1088
+ let source = "network";
1089
+ let imageEvidence = null;
1090
+ let captureTarget = null;
1091
+ let captureTargetWait = null;
1092
+ if (parsedNetworkProfileCount > 0) {
1093
+ recordCvNetworkHit(cvAcquisitionState, {
1094
+ parsedNetworkProfileCount,
1095
+ waitResult: networkWait
1096
+ });
1097
+ } else {
1098
+ detailStep = "wait_capture_target";
1099
+ captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
1100
+ domain: "recruit",
1101
+ timeoutMs: 6000,
1102
+ intervalMs: 250
1103
+ });
1104
+ captureTarget = captureTargetWait.target || null;
1105
+ const captureNodeId = captureTarget?.node_id || null;
1106
+ if (captureNodeId) {
1107
+ const imageEvidencePath = imageEvidenceFilePath({
1108
+ imageOutputDir,
1109
+ domain: "recruit",
1110
+ runId: runControl?.runId,
1111
+ index,
1112
+ extension: "jpg"
1113
+ });
1114
+ const captureImageForTarget = (target, targetWait, resumeCheckpoint = null) => captureScrolledNodeScreenshots(
1115
+ client,
1116
+ target.node_id,
1117
+ {
1118
+ filePath: imageEvidencePath,
1119
+ format: "jpeg",
1120
+ quality: 72,
1121
+ optimize: true,
1122
+ resizeMaxWidth: 1100,
1123
+ captureViewport: false,
1124
+ iframeOwnerNodeId: target?.iframe_node_id || null,
1125
+ padding: 0,
1126
+ maxScreenshots: maxImagePages,
1127
+ wheelDeltaY: imageWheelDeltaY,
1128
+ settleMs: 350,
1129
+ scrollMethod: "dom-anchor-fallback-input",
1130
+ scrollDeltaJitterEnabled: effectiveHumanBehavior.listScrollJitter,
1131
+ stepTimeoutMs: 45000,
1132
+ totalTimeoutMs: 90000,
1133
+ duplicateStopCount: 2,
1134
+ skipDuplicateScreenshots: true,
1135
+ requireTerminalProof: true,
1136
+ composeForLlm: true,
1137
+ llmPagesPerImage: 3,
1138
+ llmResizeMaxWidth: 1100,
1139
+ llmQuality: 72,
1140
+ resumeCheckpoint,
1141
+ metadata: {
1142
+ domain: "recruit",
1143
+ capture_mode: "scroll_sequence",
1144
+ acquisition_reason: "network_miss_image_fallback",
1145
+ run_candidate_index: index,
1146
+ candidate_key: candidateKey,
1147
+ capture_target: target,
1148
+ capture_target_wait: targetWait,
1149
+ resumed_from_checkpoint: Boolean(resumeCheckpoint)
1150
+ }
1151
+ }
1152
+ );
1153
+ try {
1154
+ detailStep = "capture_image";
1155
+ imageEvidence = await measureTiming(
1156
+ timings,
1157
+ "screenshot_capture_ms",
1158
+ () => captureImageForTarget(captureTarget, captureTargetWait)
1159
+ );
1160
+ imageEvidence = requireCompleteImageEvidence(imageEvidence, {
1161
+ code: "IMAGE_CAPTURE_EVIDENCE_MISSING",
1162
+ message: "Recruit CV capture returned no complete persisted evidence",
1163
+ metadata: { domain: "recruit", candidate_key: candidateKey }
1164
+ });
1165
+ source = isIncompleteImageEvidence(imageEvidence) ? "image_capture_failed" : "image";
1166
+ } catch (error) {
1167
+ if (!isRecoverableRecruitImageCaptureError(error)) throw error;
1168
+ const retryReservation = imageCaptureWorkflowRetries.consume(candidateKey);
1169
+ if (retryReservation.allowed) {
1170
+ timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
1171
+ timings.image_capture_workflow_retry = retryReservation;
1172
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1173
+ const resumeCheckpoint = imageCaptureResumeCheckpoint(error);
1174
+ if (resumeCheckpoint) {
1175
+ detailStep = "resume_image_capture_reacquire";
1176
+ const resumeAttempt = await attemptImageCaptureCheckpointResume({
1177
+ checkpoint: resumeCheckpoint,
1178
+ reacquire: () => reacquireImageCaptureResumeTarget({
1179
+ domain: "recruit",
1180
+ getRoots: () => getRecruitRoots(client),
1181
+ ensureViewport: (freshRoots) => ensureRecruitViewport(
1182
+ freshRoots,
1183
+ "image_capture_resume_reacquire"
1184
+ ),
1185
+ getDetailState: () => waitForRecruitDetail(client, {
1186
+ timeoutMs: 4000,
1187
+ intervalMs: 200
1188
+ }),
1189
+ isDetailAvailable: (state) => Boolean(state?.popup || state?.resumeIframe),
1190
+ waitForTarget: (detailState) => waitForCvCaptureTarget(client, detailState, {
1191
+ domain: "recruit",
1192
+ timeoutMs: 4000,
1193
+ intervalMs: 200
1194
+ })
1195
+ }),
1196
+ capture: (reacquired) => {
1197
+ rootState = reacquired.root_state;
1198
+ captureTarget = reacquired.target;
1199
+ captureTargetWait = reacquired.target_wait;
1200
+ detailStep = "resume_image_capture";
1201
+ return measureTiming(
1202
+ timings,
1203
+ "screenshot_capture_ms",
1204
+ () => captureImageForTarget(captureTarget, captureTargetWait, resumeCheckpoint)
1205
+ );
1206
+ }
1207
+ });
1208
+ if (resumeAttempt.outcome === "reacquire_failed") {
1209
+ const reacquireError = resumeAttempt.error;
1210
+ imageEvidence = {
1211
+ ...createRecoverableRecruitImageCaptureEvidence(reacquireError, {
1212
+ elapsedMs: timings.screenshot_capture_ms,
1213
+ filePath: imageEvidencePath,
1214
+ extension: "jpg",
1215
+ maxScreenshots: maxImagePages
1216
+ }),
1217
+ coverage_complete: false,
1218
+ resumed_from_checkpoint: true,
1219
+ resume_checkpoint_id: resumeCheckpoint.checkpoint_id || null,
1220
+ coverage_checkpoint: resumeCheckpoint
1221
+ };
1222
+ source = "image_capture_failed";
1223
+ timings.image_capture_resume = {
1224
+ attempted: true,
1225
+ ok: false,
1226
+ phase: "reacquire",
1227
+ checkpoint_id: resumeCheckpoint.checkpoint_id || null,
1228
+ error: compactError(reacquireError, "IMAGE_CAPTURE_RESUME_REACQUIRE_FAILED")
1229
+ };
1230
+ }
1231
+ if (resumeAttempt.outcome === "completed") {
1232
+ imageEvidence = requireCompleteImageEvidence(resumeAttempt.evidence, {
1233
+ code: "IMAGE_CAPTURE_RESUME_EVIDENCE_MISSING",
1234
+ message: "Recruit resumed CV capture returned no complete persisted evidence",
1235
+ metadata: { domain: "recruit", candidate_key: candidateKey }
1236
+ });
1237
+ source = isIncompleteImageEvidence(imageEvidence) ? "image_capture_failed" : "image";
1238
+ timings.image_capture_resume = {
1239
+ attempted: true,
1240
+ ok: !isIncompleteImageEvidence(imageEvidence),
1241
+ phase: "capture",
1242
+ checkpoint_id: resumeCheckpoint.checkpoint_id || null,
1243
+ confirmed_screenshot_count: resumeCheckpoint.unique_screenshot_count || 0
1244
+ };
1245
+ } else if (resumeAttempt.outcome === "capture_failed") {
1246
+ const resumeError = resumeAttempt.error;
1247
+ imageEvidence = {
1248
+ ...createRecoverableRecruitImageCaptureEvidence(resumeError, {
1249
+ elapsedMs: timings.screenshot_capture_ms,
1250
+ filePath: imageEvidencePath,
1251
+ extension: "jpg",
1252
+ maxScreenshots: maxImagePages
1253
+ }),
1254
+ coverage_complete: false,
1255
+ resumed_from_checkpoint: true,
1256
+ resume_checkpoint_id: resumeCheckpoint.checkpoint_id || null,
1257
+ coverage_checkpoint: resumeError.capture_checkpoint || resumeCheckpoint
1258
+ };
1259
+ source = "image_capture_failed";
1260
+ timings.image_capture_resume = {
1261
+ attempted: true,
1262
+ ok: false,
1263
+ phase: "capture",
1264
+ checkpoint_id: resumeCheckpoint.checkpoint_id || null,
1265
+ error: compactError(resumeError, "IMAGE_CAPTURE_RESUME_FAILED")
1266
+ };
1267
+ }
1268
+ } else {
1269
+ imageEvidence = {
1270
+ ...createRecoverableRecruitImageCaptureEvidence(error, {
1271
+ elapsedMs: timings.screenshot_capture_ms,
1272
+ filePath: imageEvidencePath,
1273
+ extension: "jpg",
1274
+ maxScreenshots: maxImagePages
1275
+ }),
1276
+ coverage_complete: false
1277
+ };
1278
+ source = "image_capture_failed";
1279
+ timings.image_capture_resume = {
1280
+ attempted: false,
1281
+ ok: false,
1282
+ phase: "checkpoint",
1283
+ error: {
1284
+ code: "IMAGE_CAPTURE_CHECKPOINT_UNAVAILABLE",
1285
+ message: "Capture failed without a resumable checkpoint"
1286
+ }
1287
+ };
1288
+ }
1289
+ } else {
1290
+ imageEvidence = {
1291
+ ...createRecoverableRecruitImageCaptureEvidence(error, {
1292
+ elapsedMs: timings.screenshot_capture_ms,
1293
+ filePath: imageEvidencePath,
1294
+ extension: "jpg",
1295
+ maxScreenshots: maxImagePages
1296
+ }),
1297
+ coverage_complete: false,
1298
+ metadata: {
1299
+ image_capture_workflow_retry: retryReservation
1300
+ }
1301
+ };
1302
+ source = "image_capture_failed";
1303
+ }
1304
+ }
1305
+ imageCaptureTerminalFailure = isTerminalRecruitImageCaptureFailureSource(source);
1306
+ recordCvImageFallback(cvAcquisitionState, {
1307
+ reason: source === "image_capture_failed"
1308
+ ? "network_miss_image_capture_failed"
1309
+ : "network_miss_image_fallback",
1310
+ parsedNetworkProfileCount,
1311
+ waitResult: networkWait,
1312
+ imageEvidence
1313
+ });
1314
+ } else {
1315
+ source = "missing_capture_node";
1316
+ imageEvidence = createRequiredImageEvidenceFailure({
1317
+ code: "IMAGE_CAPTURE_TARGET_UNAVAILABLE",
1318
+ message: "Recruit CV capture target was unavailable after the network fallback missed",
1319
+ metadata: {
1320
+ domain: "recruit",
1321
+ candidate_key: candidateKey,
1322
+ capture_target_wait: captureTargetWait || null
1323
+ }
1324
+ });
1325
+ recordCvNetworkMiss(cvAcquisitionState, {
1326
+ reason: "network_miss_no_capture_node",
1327
+ parsedNetworkProfileCount,
1328
+ waitResult: networkWait
1329
+ });
1330
+ }
1331
+ }
1332
+
1333
+ detailResult.image_evidence = imageEvidence;
1334
+ detailResult.cv_acquisition = {
1335
+ source,
1336
+ mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
1337
+ wait_plan: waitPlan,
1338
+ network_wait: networkWait,
1339
+ parsed_network_profile_count: parsedNetworkProfileCount,
1340
+ image_evidence: summarizeImageEvidence(imageEvidence),
1341
+ capture_target: captureTarget || null,
1342
+ capture_target_wait: captureTargetWait
1343
+ };
1344
+ screeningCandidate = detailResult.candidate;
1345
+ if (closeDetail && !postActionEnabled) {
1346
+ detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
1347
+ await maybeHumanActionCooldown("after_detail_close", timings);
1348
+ if (!detailResult.close_result?.closed) {
1349
+ const closeError = createRecruitCloseFailureError(detailResult.close_result);
1350
+ const recovery = await recoverAndReapplyRecruitContext("detail_close_failed", closeError, {
1351
+ forceRecentViewed: true
1352
+ });
1353
+ detailResult.cv_acquisition = {
1354
+ ...(detailResult.cv_acquisition || {}),
1355
+ close_recovery: {
1356
+ ok: Boolean(recovery.ok),
1357
+ method: recovery.method || "",
1358
+ forced_recent_viewed: Boolean(recovery.forced_recent_viewed),
1359
+ card_count: recovery.card_count || 0
1360
+ }
1361
+ };
1362
+ }
1363
+ } else {
1364
+ detailResult.close_result = null;
1365
+ }
1366
+ } catch (error) {
1367
+ if (!isRecoverableRecruitDetailError(error)) throw error;
1368
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1369
+ if (imageCaptureTerminalFailure && detailResult) {
1370
+ timings.detail_cleanup_after_terminal_image_capture = compactRecoverableDetailError(error);
1371
+ detailResult.close_result = {
1372
+ closed: false,
1373
+ error: error?.message || String(error),
1374
+ terminal_image_capture_failure: true
1375
+ };
1376
+ detailResult.cv_acquisition = {
1377
+ ...(detailResult.cv_acquisition || {}),
1378
+ cleanup_error: compactRecoverableDetailError(error),
1379
+ cleanup_retry_suppressed: true,
1380
+ image_capture_workflow_retry_count: imageCaptureWorkflowRetries.count(candidateKey)
1381
+ };
1382
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
1383
+ await closeRecruitBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1384
+ } else if (shouldRetryRecruitDetailRecovery({
1385
+ recoveryCount,
1386
+ imageCaptureTerminalFailure
1387
+ })) {
1388
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1389
+ timings.detail_recovery_trigger = compactRecoverableDetailError(error);
1390
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1391
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
1392
+ await closeRecruitBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1393
+ await recoverAndReapplyRecruitContext(`detail:${detailStep}`, error, {
1394
+ forceRecentViewed: true
1395
+ });
1396
+ continue;
1397
+ } else {
1398
+ recoverableDetailError = error;
1399
+ detailResult = null;
1400
+ timings.detail_recovered_error = compactRecoverableDetailError(error);
1401
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
1402
+ await closeRecruitBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ await runControl.waitIfPaused();
1408
+ runControl.throwIfCanceled();
1409
+ runControl.setPhase("recruit:screening");
1410
+ let llmResult = null;
1411
+ const imageAcquisitionFailed = shouldFailClosedRecruitImageAcquisition(detailResult);
1412
+ if (useLlmScreening) {
1413
+ if (recoverableDetailError || imageAcquisitionFailed) {
1414
+ llmResult = null;
1415
+ } else if (!llmConfig) {
1416
+ llmResult = createMissingLlmConfigResult();
1417
+ } else {
1418
+ try {
1419
+ const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
1420
+ ? "vision_model_ms"
1421
+ : "text_model_ms";
1422
+ llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
1423
+ candidate: screeningCandidate,
1424
+ criteria,
1425
+ config: llmConfig,
1426
+ timeoutMs: llmTimeoutMs,
1427
+ imageEvidence: detailResult?.image_evidence || null,
1428
+ maxImages: llmImageLimit,
1429
+ imageDetail: llmImageDetail
1430
+ }));
1431
+ } catch (error) {
1432
+ if (isFatalLlmProviderError(error)) {
1433
+ throw createFatalLlmRunError(error, {
1434
+ domain: "recruit",
1435
+ candidate: screeningCandidate
1436
+ });
1437
+ }
1438
+ llmResult = createFailedLlmScreeningResult(error);
1439
+ }
1440
+ }
1441
+ if (detailResult) detailResult.llm_result = llmResult;
1442
+ }
1443
+ const screening = recoverableDetailError
1444
+ ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
1445
+ : imageAcquisitionFailed
1446
+ ? createImageCaptureFailureScreening(screeningCandidate, {
1447
+ code: detailResult?.image_evidence?.error_code || "IMAGE_CAPTURE_EVIDENCE_MISSING",
1448
+ message: detailResult?.image_evidence?.error || "Required CV image evidence is unavailable"
1449
+ })
1450
+ : useLlmScreening
1451
+ ? llmResultToScreening(llmResult, screeningCandidate)
1452
+ : screenCandidate(screeningCandidate, { criteria });
1453
+ let actionDiscovery = null;
1454
+ let postActionResult = null;
1455
+ let closeFailureError = null;
1456
+ let closeRecoveryFailure = null;
1457
+ if (postActionEnabled && detailResult) {
1458
+ const postActionStarted = Date.now();
1459
+ await runControl.waitIfPaused();
1460
+ runControl.throwIfCanceled();
1461
+ runControl.setPhase("recruit:post-action");
1462
+ await maybeHumanActionCooldown("before_post_action", timings);
1463
+ actionDiscovery = await waitForRecruitDetailActionControls(client, {
1464
+ rootNodeIds: detailActionRootNodeIds,
1465
+ timeoutMs: actionTimeoutMs,
1466
+ intervalMs: actionIntervalMs,
1467
+ requireAny: true
1468
+ });
1469
+ postActionResult = await runRecruitPostAction({
1470
+ client,
1471
+ rootNodeIds: detailActionRootNodeIds,
1472
+ screening,
1473
+ actionDiscovery,
1474
+ postAction: normalizedPostAction,
1475
+ greetCount,
1476
+ maxGreetCount: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
1477
+ executePostAction,
1478
+ afterClickDelayMs: actionAfterClickDelayMs,
1479
+ lastGreetQuotaAfterSpend
1480
+ });
1481
+ if (postActionResult.counted_as_greet && postActionResult.action_clicked) {
1482
+ greetCount += 1;
1483
+ }
1484
+ if (postActionResult.greet_quota_after_click?.found) {
1485
+ lastGreetQuotaAfterSpend = postActionResult.greet_quota_after_click;
1486
+ }
1487
+ addTiming(timings, "post_action_ms", Date.now() - postActionStarted);
1488
+ }
1489
+ if (postActionEnabled && detailResult && closeDetail) {
1490
+ detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
1491
+ await maybeHumanActionCooldown("after_detail_close", timings);
1492
+ if (!detailResult.close_result?.closed) {
1493
+ closeFailureError = createRecruitCloseFailureError(detailResult.close_result);
1494
+ try {
1495
+ const recovery = await recoverAndReapplyRecruitContext("detail_close_failed", closeFailureError, {
1496
+ forceRecentViewed: true
1497
+ });
1498
+ detailResult.cv_acquisition = {
1499
+ ...(detailResult.cv_acquisition || {}),
1500
+ close_recovery: {
1501
+ ok: Boolean(recovery.ok),
1502
+ method: recovery.method || "",
1503
+ forced_recent_viewed: Boolean(recovery.forced_recent_viewed),
1504
+ card_count: recovery.card_count || 0
1505
+ }
1506
+ };
1507
+ } catch (error) {
1508
+ closeRecoveryFailure = error;
1509
+ detailResult.cv_acquisition = {
1510
+ ...(detailResult.cv_acquisition || {}),
1511
+ close_recovery: {
1512
+ ok: false,
1513
+ reason: "context_recovery_failed",
1514
+ error: error?.message || String(error),
1515
+ forced_recent_viewed: true
1516
+ }
1517
+ };
1518
+ }
1519
+ }
1520
+ }
1521
+ timings.total_ms = Date.now() - candidateStarted;
1522
+ const compactResult = {
1523
+ index,
1524
+ candidate_key: candidateKey,
1525
+ card_node_id: cardNodeId,
1526
+ candidate: compactCandidate(screeningCandidate),
1527
+ detail: compactDetail(detailResult),
1528
+ llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
1529
+ screening: compactScreening(screening),
1530
+ action_discovery: compactActionDiscovery(actionDiscovery),
1531
+ post_action: postActionResult,
1532
+ error: recoverableDetailError
1533
+ ? compactRecoverableDetailError(recoverableDetailError)
1534
+ : closeRecoveryFailure
1535
+ ? compactError(closeFailureError, "DETAIL_CLOSE_FAILED")
1536
+ : detailResult?.image_evidence?.ok === false
1537
+ ? compactError({
1538
+ code: detailResult.image_evidence.error_code,
1539
+ message: detailResult.image_evidence.error
1540
+ }, "IMAGE_CAPTURE_FAILED")
1541
+ : null,
1542
+ timings
1543
+ };
1544
+ results.push(compactResult);
1545
+ markInfiniteListCandidateProcessed(listState, candidateKey, {
1546
+ metadata: {
1547
+ result_index: index,
1548
+ candidate_id: screeningCandidate.id || null
1549
+ }
1550
+ });
1551
+
1552
+ updateRecruitProgress({
1553
+ last_candidate_id: screeningCandidate.id || null,
1554
+ last_candidate_key: candidateKey,
1555
+ last_score: screening.score
1556
+ });
1557
+ const checkpointStarted = Date.now();
1558
+ runControl.checkpoint({
1559
+ results,
1560
+ last_candidate: {
1561
+ id: screeningCandidate.id || null,
1562
+ key: candidateKey,
1563
+ identity: screeningCandidate.identity || {},
1564
+ screening: {
1565
+ status: screening.status,
1566
+ passed: screening.passed,
1567
+ score: screening.score
1568
+ },
1569
+ llm_screening: compactScreeningLlmResult(llmResult),
1570
+ error: compactResult.error
1571
+ }
1572
+ });
1573
+ addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
1574
+
1575
+ if (effectiveHumanRestEnabled) {
1576
+ const restStarted = Date.now();
1577
+ const restResult = await humanRestController.takeBreakIfNeeded({
1578
+ sleepFn: (ms) => runControl.sleep(ms)
1579
+ });
1580
+ const restElapsed = Date.now() - restStarted;
1581
+ if (restResult.rested) {
1582
+ recordHumanEvent({
1583
+ kind: "rest",
1584
+ pause_ms: restResult.pause_ms || restElapsed,
1585
+ events: restResult.events || []
1586
+ });
1587
+ compactResult.human_rest = restResult;
1588
+ addTiming(compactResult.timings, "human_rest_ms", restElapsed);
1589
+ compactResult.timings.total_ms = Date.now() - candidateStarted;
1590
+ updateRecruitProgress({
1591
+ human_rest_level: effectiveHumanBehavior.restLevel,
1592
+ human_rest_last: restResult
1593
+ });
1594
+ }
1595
+ }
1596
+
1597
+ if (delayMs > 0) {
1598
+ const sleepStarted = Date.now();
1599
+ await runControl.sleep(delayMs);
1600
+ addTiming(compactResult.timings, "sleep_ms", Date.now() - sleepStarted);
1601
+ compactResult.timings.total_ms = Date.now() - candidateStarted;
1602
+ }
1603
+ if (postActionResult?.stop_run) {
1604
+ listEndReason = postActionResult.reason || "post_action_stop";
1605
+ break;
1606
+ }
1607
+ }
1608
+
1609
+ runControl.setPhase("recruit:done");
1610
+ return {
1611
+ domain: "recruit",
1612
+ target_url: targetUrl,
1613
+ search_params: normalizedSearchParams,
1614
+ card_count: cardNodeIds.length,
1615
+ candidate_list: compactInfiniteListState(listState),
1616
+ viewport_health: {
1617
+ stats: viewportGuard.getStats(),
1618
+ events: viewportGuard.getEvents()
1619
+ },
1620
+ human_behavior: effectiveHumanBehavior,
1621
+ human_rest: humanRestController.getState(),
1622
+ last_human_event: lastHumanEvent,
1623
+ list_end_reason: listEndReason || null,
1624
+ search_exchange_resume_filter_requested: searchExchangeResumeFilterRequested ? 1 : 0,
1625
+ search_exchange_resume_filter_applied: searchExchangeResumeFilterApplied ? 1 : 0,
1626
+ last_greet_quota_after_spend: lastGreetQuotaAfterSpend,
1627
+ refresh_rounds: refreshRounds,
1628
+ refresh_attempts: refreshAttempts,
1629
+ context_recoveries: contextRecoveryAttempts,
1630
+ ...countRecruitResultStatuses(results, { greetCount }),
1631
+ results
1632
+ };
1633
+ }
1634
+
1635
+ export function createRecruitRunService({
1636
+ lifecycle,
1637
+ idPrefix = "recruit",
1638
+ workflow = runRecruitWorkflow,
1639
+ onSnapshot = null
1640
+ } = {}) {
1641
+ const manager = lifecycle || createRunLifecycleManager({ idPrefix, onSnapshot });
1642
+
1643
+ function startRecruitRun({
1644
+ runId = "",
1645
+ client,
1646
+ targetUrl = "",
1647
+ criteria = "",
1648
+ searchParams = {},
1649
+ maxCandidates = 5,
1650
+ detailLimit = null,
1651
+ closeDetail = true,
1652
+ delayMs = 0,
1653
+ cardTimeoutMs = 90000,
1654
+ resetBeforeSearch = true,
1655
+ resetTimeoutMs = 180000,
1656
+ cityOptionTimeoutMs = 30000,
1657
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
1658
+ imageWheelDeltaY = 650,
1659
+ cvAcquisitionMode = "unknown",
1660
+ listMaxScrolls = 20,
1661
+ listStableSignatureLimit = 5,
1662
+ listWheelDeltaY = 850,
1663
+ listSettleMs = 2200,
1664
+ listFallbackPoint = null,
1665
+ refreshOnEnd = true,
1666
+ maxRefreshRounds = 2,
1667
+ refreshResetSettleMs = 5000,
1668
+ screeningMode = "llm",
1669
+ llmConfig = null,
1670
+ llmTimeoutMs = 120000,
1671
+ llmImageLimit = 8,
1672
+ llmImageDetail = "high",
1673
+ imageOutputDir = "",
1674
+ humanRestEnabled = false,
1675
+ humanBehavior = null,
1676
+ postAction = "none",
1677
+ maxGreetCount = null,
1678
+ executePostAction = true,
1679
+ actionTimeoutMs = 8000,
1680
+ actionIntervalMs = 400,
1681
+ actionAfterClickDelayMs = 900,
1682
+ name = "recruit-domain-run"
1683
+ } = {}) {
1684
+ if (!client) throw new Error("startRecruitRun requires a guarded CDP client");
1685
+ const normalizedSearchParams = normalizeSearchParams(searchParams);
1686
+ const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
1687
+ const normalizedPostAction = normalizeRecruitPostAction(postAction);
1688
+ const searchExchangeResumeFilterRequested = normalizedSearchParams.skip_recent_colleague_contacted === true;
1689
+ const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
1690
+ const normalizedDetailLimit = detailLimit == null ? candidateLimit : Math.max(0, Number(detailLimit) || 0);
1691
+ const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
1692
+ legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
1693
+ });
1694
+ const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
1695
+ return manager.startRun({
1696
+ runId,
1697
+ name,
1698
+ context: {
1699
+ domain: "recruit",
1700
+ target_url: targetUrl,
1701
+ criteria_present: Boolean(criteria),
1702
+ search_params: normalizedSearchParams,
1703
+ max_candidates: maxCandidates,
1704
+ detail_limit: normalizedDetailLimit,
1705
+ close_detail: closeDetail,
1706
+ reset_before_search: resetBeforeSearch,
1707
+ reset_timeout_ms: resetTimeoutMs,
1708
+ city_option_timeout_ms: cityOptionTimeoutMs,
1709
+ cv_acquisition_mode: cvAcquisitionMode,
1710
+ max_image_pages: maxImagePages,
1711
+ image_wheel_delta_y: imageWheelDeltaY,
1712
+ list_max_scrolls: listMaxScrolls,
1713
+ list_stable_signature_limit: listStableSignatureLimit,
1714
+ list_wheel_delta_y: listWheelDeltaY,
1715
+ list_settle_ms: listSettleMs,
1716
+ list_fallback_point: listFallbackPoint,
1717
+ refresh_on_end: refreshOnEnd,
1718
+ max_refresh_rounds: maxRefreshRounds,
1719
+ refresh_reset_settle_ms: refreshResetSettleMs,
1720
+ screening_mode: normalizedScreeningMode,
1721
+ llm_configured: Boolean(llmConfig),
1722
+ llm_timeout_ms: llmTimeoutMs,
1723
+ llm_image_limit: llmImageLimit,
1724
+ llm_image_detail: llmImageDetail,
1725
+ image_output_dir: imageOutputDir || "",
1726
+ human_behavior_enabled: effectiveHumanBehavior.enabled,
1727
+ human_behavior_profile: effectiveHumanBehavior.profile,
1728
+ human_behavior: effectiveHumanBehavior,
1729
+ human_rest_level: effectiveHumanBehavior.restLevel,
1730
+ human_rest_enabled: effectiveHumanRestEnabled,
1731
+ search_exchange_resume_filter_requested: searchExchangeResumeFilterRequested ? 1 : 0,
1732
+ search_exchange_resume_filter_applied: 0,
1733
+ post_action: normalizedPostAction,
1734
+ max_greet_count: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
1735
+ execute_post_action: Boolean(executePostAction),
1736
+ action_timeout_ms: actionTimeoutMs,
1737
+ action_interval_ms: actionIntervalMs,
1738
+ action_after_click_delay_ms: actionAfterClickDelayMs
1739
+ },
1740
+ progress: {
1741
+ card_count: 0,
1742
+ target_count: candidateLimit,
1743
+ processed: 0,
1744
+ screened: 0,
1745
+ detail_opened: 0,
1746
+ llm_screened: 0,
1747
+ passed: 0,
1748
+ image_capture_failed: 0,
1749
+ detail_open_failed: 0,
1750
+ transient_recovered: 0,
1751
+ context_recoveries: 0,
1752
+ human_behavior_enabled: effectiveHumanBehavior.enabled,
1753
+ human_behavior_profile: effectiveHumanBehavior.profile,
1754
+ human_rest_level: effectiveHumanBehavior.restLevel,
1755
+ human_rest_enabled: effectiveHumanRestEnabled,
1756
+ human_rest_count: 0,
1757
+ human_rest_ms: 0,
1758
+ greet_count: 0,
1759
+ post_action_clicked: 0,
1760
+ search_exchange_resume_filter_requested: searchExchangeResumeFilterRequested ? 1 : 0,
1761
+ search_exchange_resume_filter_applied: 0,
1762
+ last_human_event: null
1763
+ },
1764
+ checkpoint: {},
1765
+ task: (runControl) => workflow({
1766
+ client,
1767
+ targetUrl,
1768
+ criteria,
1769
+ searchParams: normalizedSearchParams,
1770
+ maxCandidates,
1771
+ detailLimit: normalizedDetailLimit,
1772
+ closeDetail,
1773
+ delayMs,
1774
+ cardTimeoutMs,
1775
+ resetBeforeSearch,
1776
+ resetTimeoutMs,
1777
+ cityOptionTimeoutMs,
1778
+ maxImagePages,
1779
+ imageWheelDeltaY,
1780
+ cvAcquisitionMode,
1781
+ listMaxScrolls,
1782
+ listStableSignatureLimit,
1783
+ listWheelDeltaY,
1784
+ listSettleMs,
1785
+ listFallbackPoint,
1786
+ refreshOnEnd,
1787
+ maxRefreshRounds,
1788
+ refreshResetSettleMs,
1789
+ screeningMode: normalizedScreeningMode,
1790
+ llmConfig,
1791
+ llmTimeoutMs,
1792
+ llmImageLimit,
1793
+ llmImageDetail,
1794
+ imageOutputDir,
1795
+ humanRestEnabled: effectiveHumanRestEnabled,
1796
+ humanBehavior: effectiveHumanBehavior,
1797
+ postAction: normalizedPostAction,
1798
+ maxGreetCount,
1799
+ executePostAction,
1800
+ actionTimeoutMs,
1801
+ actionIntervalMs,
1802
+ actionAfterClickDelayMs
1803
+ }, runControl)
1804
+ });
1805
+ }
1806
+
1807
+ return {
1808
+ startRecruitRun,
1809
+ getRecruitRun: manager.getRun,
1810
+ pauseRecruitRun: manager.pauseRun,
1811
+ resumeRecruitRun: manager.resumeRun,
1812
+ cancelRecruitRun: manager.cancelRun,
1813
+ waitForRecruitRun: manager.waitForRun,
1814
+ listRecruitRuns: manager.listRuns,
1815
+ manager
1816
+ };
1817
+ }