@reconcrap/boss-recommend-mcp 2.1.21 → 2.1.23

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 -2
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/config/screening-config.example.json +33 -33
  4. package/package.json +2 -1
  5. package/scripts/install-macos.sh +280 -280
  6. package/scripts/postinstall.cjs +44 -44
  7. package/skills/boss-chat/README.md +42 -42
  8. package/skills/boss-chat/SKILL.md +106 -106
  9. package/skills/boss-recommend-pipeline/README.md +13 -13
  10. package/skills/boss-recommend-pipeline/SKILL.md +219 -214
  11. package/skills/boss-recruit-pipeline/README.md +19 -19
  12. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  13. package/src/chat-mcp.js +127 -127
  14. package/src/chat-runtime-config.js +775 -775
  15. package/src/cli.js +573 -573
  16. package/src/core/boss-cards/index.js +199 -199
  17. package/src/core/browser/index.js +2416 -2385
  18. package/src/core/capture/index.js +1201 -1201
  19. package/src/core/cv-acquisition/index.js +238 -238
  20. package/src/core/cv-capture-target/index.js +299 -299
  21. package/src/core/greet-quota/index.js +71 -71
  22. package/src/core/infinite-list/index.js +1315 -1306
  23. package/src/core/reporting/legacy-csv.js +334 -332
  24. package/src/core/run/index.js +44 -27
  25. package/src/core/run/timing.js +33 -33
  26. package/src/core/screening/index.js +2135 -2135
  27. package/src/core/self-heal/index.js +973 -973
  28. package/src/core/self-heal/viewport.js +564 -564
  29. package/src/detached-worker.js +99 -99
  30. package/src/domains/chat/cards.js +137 -137
  31. package/src/domains/chat/constants.js +9 -9
  32. package/src/domains/chat/detail.js +113 -113
  33. package/src/domains/chat/index.js +7 -7
  34. package/src/domains/chat/jobs.js +620 -620
  35. package/src/domains/chat/page-guard.js +122 -122
  36. package/src/domains/chat/roots.js +56 -56
  37. package/src/domains/chat/run-service.js +571 -571
  38. package/src/domains/common/account-rights-panel.js +314 -314
  39. package/src/domains/common/recovery-settle.js +159 -159
  40. package/src/domains/recommend/actions.js +472 -472
  41. package/src/domains/recommend/cards.js +243 -243
  42. package/src/domains/recommend/colleague-contact.js +333 -333
  43. package/src/domains/recommend/constants.js +228 -159
  44. package/src/domains/recommend/detail.js +655 -646
  45. package/src/domains/recommend/filters.js +748 -377
  46. package/src/domains/recommend/index.js +4 -3
  47. package/src/domains/recommend/jobs.js +542 -542
  48. package/src/domains/recommend/location.js +736 -0
  49. package/src/domains/recommend/refresh.js +575 -351
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +1616 -878
  52. package/src/domains/recommend/scopes.js +246 -246
  53. package/src/domains/recruit/actions.js +277 -277
  54. package/src/domains/recruit/cards.js +74 -74
  55. package/src/domains/recruit/constants.js +236 -236
  56. package/src/domains/recruit/detail.js +588 -588
  57. package/src/domains/recruit/index.js +9 -9
  58. package/src/domains/recruit/instruction-parser.js +866 -866
  59. package/src/domains/recruit/refresh.js +45 -45
  60. package/src/domains/recruit/roots.js +68 -68
  61. package/src/domains/recruit/run-service.js +1620 -1620
  62. package/src/domains/recruit/search.js +3229 -3229
  63. package/src/index.js +121 -4
  64. package/src/parser.js +376 -8
  65. package/src/recommend-mcp.js +1174 -914
  66. package/src/recommend-scheduler.js +535 -469
  67. package/src/recruit-mcp.js +2121 -2121
@@ -1,1620 +1,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
- 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
+ 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
+ }