@reconcrap/boss-recommend-mcp 2.0.46 → 2.0.47

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 (56) hide show
  1. package/bin/boss-recommend-mcp.js +4 -4
  2. package/config/screening-config.example.json +27 -27
  3. package/package.json +1 -1
  4. package/scripts/postinstall.cjs +44 -44
  5. package/skills/boss-chat/README.md +39 -39
  6. package/skills/boss-chat/SKILL.md +93 -93
  7. package/skills/boss-recommend-pipeline/README.md +12 -12
  8. package/skills/boss-recommend-pipeline/SKILL.md +180 -180
  9. package/skills/boss-recruit-pipeline/README.md +17 -17
  10. package/skills/boss-recruit-pipeline/SKILL.md +58 -58
  11. package/src/chat-mcp.js +1780 -1780
  12. package/src/chat-runtime-config.js +749 -749
  13. package/src/cli.js +3054 -3054
  14. package/src/core/boss-cards/index.js +199 -199
  15. package/src/core/browser/index.js +1453 -1453
  16. package/src/core/capture/index.js +1201 -1201
  17. package/src/core/cv-acquisition/index.js +238 -238
  18. package/src/core/cv-capture-target/index.js +299 -299
  19. package/src/core/greet-quota/index.js +54 -54
  20. package/src/core/infinite-list/index.js +1326 -1326
  21. package/src/core/reporting/legacy-csv.js +341 -341
  22. package/src/core/run/timing.js +33 -33
  23. package/src/core/screening/index.js +50 -3
  24. package/src/core/self-heal/index.js +973 -973
  25. package/src/core/self-heal/viewport.js +564 -564
  26. package/src/domains/chat/cards.js +137 -137
  27. package/src/domains/chat/constants.js +221 -221
  28. package/src/domains/chat/detail.js +1668 -1668
  29. package/src/domains/chat/index.js +7 -7
  30. package/src/domains/chat/jobs.js +592 -592
  31. package/src/domains/chat/page-guard.js +98 -98
  32. package/src/domains/chat/roots.js +56 -56
  33. package/src/domains/chat/run-service.js +1977 -1977
  34. package/src/domains/recommend/actions.js +457 -457
  35. package/src/domains/recommend/cards.js +243 -243
  36. package/src/domains/recommend/constants.js +165 -165
  37. package/src/domains/recommend/detail.js +25 -18
  38. package/src/domains/recommend/filters.js +610 -610
  39. package/src/domains/recommend/index.js +10 -10
  40. package/src/domains/recommend/jobs.js +316 -316
  41. package/src/domains/recommend/refresh.js +472 -472
  42. package/src/domains/recommend/roots.js +80 -80
  43. package/src/domains/recommend/run-service.js +27 -20
  44. package/src/domains/recommend/scopes.js +246 -246
  45. package/src/domains/recruit/actions.js +277 -277
  46. package/src/domains/recruit/cards.js +74 -74
  47. package/src/domains/recruit/constants.js +167 -167
  48. package/src/domains/recruit/detail.js +461 -461
  49. package/src/domains/recruit/index.js +9 -9
  50. package/src/domains/recruit/instruction-parser.js +451 -451
  51. package/src/domains/recruit/refresh.js +44 -44
  52. package/src/domains/recruit/roots.js +68 -68
  53. package/src/domains/recruit/run-service.js +1207 -1207
  54. package/src/domains/recruit/search.js +1202 -1202
  55. package/src/recommend-mcp.js +22 -22
  56. package/src/recruit-mcp.js +1338 -1338
@@ -1,1207 +1,1207 @@
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
- } from "../../core/browser/index.js";
17
- import {
18
- compactCvAcquisitionState,
19
- countParsedNetworkProfiles,
20
- createCvAcquisitionState,
21
- DEFAULT_MAX_IMAGE_PAGES,
22
- getCvNetworkWaitPlan,
23
- recordCvImageFallback,
24
- recordCvNetworkHit,
25
- recordCvNetworkMiss,
26
- summarizeImageEvidence,
27
- waitForCvNetworkEvents
28
- } from "../../core/cv-acquisition/index.js";
29
- import {
30
- compactInfiniteListState,
31
- createInfiniteListState,
32
- detectInfiniteListBottomMarker,
33
- getNextInfiniteListCandidate,
34
- markInfiniteListCandidateProcessed,
35
- resetInfiniteListForRefreshRound,
36
- resolveInfiniteListFallbackPoint
37
- } from "../../core/infinite-list/index.js";
38
- import { createViewportRunGuard } from "../../core/self-heal/index.js";
39
- import {
40
- callScreeningLlm,
41
- compactScreeningLlmResult,
42
- createFailedLlmScreeningResult,
43
- llmResultToScreening,
44
- screenCandidate
45
- } from "../../core/screening/index.js";
46
- import {
47
- closeRecruitDetail,
48
- createRecruitDetailNetworkRecorder,
49
- extractRecruitDetailCandidate,
50
- openRecruitCardDetail,
51
- waitForRecruitDetailNetworkEvents
52
- } from "./detail.js";
53
- import {
54
- readRecruitCardCandidate,
55
- waitForRecruitCardNodeIds
56
- } from "./cards.js";
57
- import {
58
- applyRecruitSearchParams,
59
- hasRecruitSearchParams,
60
- normalizeRecruitSearchParams
61
- } from "./search.js";
62
- import { refreshRecruitSearchAtEnd } from "./refresh.js";
63
- import { getRecruitRoots } from "./roots.js";
64
- import {
65
- RECRUIT_BOTTOM_MARKER_SELECTORS,
66
- RECRUIT_BOTTOM_REFRESH_SELECTORS,
67
- RECRUIT_CARD_SELECTOR,
68
- RECRUIT_LIST_CONTAINER_SELECTORS
69
- } from "./constants.js";
70
-
71
- function compactScreening(screening) {
72
- return {
73
- status: screening.status,
74
- passed: screening.passed,
75
- score: screening.score,
76
- reasons: screening.reasons,
77
- candidate: {
78
- domain: screening.candidate?.domain || "recruit",
79
- source: screening.candidate?.source || "",
80
- id: screening.candidate?.id || null,
81
- identity: screening.candidate?.identity || {}
82
- }
83
- };
84
- }
85
-
86
- function compactCandidate(candidate) {
87
- return {
88
- id: candidate?.id || null,
89
- identity: candidate?.identity || {},
90
- text_length: candidate?.text?.raw?.length || 0,
91
- tag_count: candidate?.tags?.length || 0
92
- };
93
- }
94
-
95
- function compactDetail(detailResult) {
96
- if (!detailResult) return null;
97
- return {
98
- popup_text_length: detailResult.detail?.popup_text?.length || 0,
99
- resume_text_length: detailResult.detail?.resume_text?.length || 0,
100
- network_body_count: detailResult.network_bodies?.filter((item) => item.body).length || 0,
101
- parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
102
- cv_acquisition: detailResult.cv_acquisition || null,
103
- image_evidence: summarizeImageEvidence(detailResult.image_evidence),
104
- llm_screening: compactScreeningLlmResult(detailResult.llm_result),
105
- close_result: detailResult.close_result
106
- };
107
- }
108
-
109
- function normalizeScreeningMode(value) {
110
- const normalized = String(value || "llm").trim().toLowerCase();
111
- return ["deterministic", "local", "local_scorer"].includes(normalized)
112
- ? "deterministic"
113
- : "llm";
114
- }
115
-
116
- function createMissingLlmConfigResult() {
117
- return createFailedLlmScreeningResult(new Error("LLM screening config is required for production search runs"));
118
- }
119
-
120
- function normalizeSearchParams(searchParams = {}) {
121
- return normalizeRecruitSearchParams(searchParams);
122
- }
123
-
124
- function compactRefreshAttempt(refreshAttempt) {
125
- if (!refreshAttempt) return null;
126
- return {
127
- ok: Boolean(refreshAttempt.ok),
128
- method: refreshAttempt.method || "",
129
- forced_recent_viewed: Boolean(refreshAttempt.forced_recent_viewed),
130
- card_count: refreshAttempt.card_count || 0,
131
- search_params: refreshAttempt.search_params || null,
132
- application: refreshAttempt.application
133
- ? {
134
- applied: Boolean(refreshAttempt.application.applied),
135
- post_search_state: refreshAttempt.application.post_search_state,
136
- steps: (refreshAttempt.application.steps || []).map((step) => ({
137
- step: step.step,
138
- applied: step.result?.applied,
139
- clicked: step.result?.clicked,
140
- searched: step.result?.searched,
141
- reason: step.result?.reason || null
142
- }))
143
- }
144
- : null
145
- };
146
- }
147
-
148
- function compactError(error, fallbackCode = "RECRUIT_RUN_ERROR") {
149
- if (!error) return null;
150
- const result = {
151
- code: error.code || fallbackCode,
152
- message: error.message || String(error)
153
- };
154
- if (error.refresh_attempt) {
155
- result.refresh_attempt = error.refresh_attempt;
156
- }
157
- if (error.list_end_reason) {
158
- result.list_end_reason = error.list_end_reason;
159
- }
160
- if (error.target_count != null) {
161
- result.target_count = error.target_count;
162
- }
163
- if (error.processed_count != null) {
164
- result.processed_count = error.processed_count;
165
- }
166
- return result;
167
- }
168
-
169
- function createRecruitCloseFailureError(closeResult) {
170
- const error = new Error(closeResult?.reason || "Recruit detail did not close before recovery");
171
- error.code = "DETAIL_CLOSE_FAILED";
172
- error.close_result = closeResult || null;
173
- return error;
174
- }
175
-
176
- function createRecruitRefreshFailureError(refreshAttempt, {
177
- listEndReason = "",
178
- targetCount = 0,
179
- processedCount = 0
180
- } = {}) {
181
- const reason = refreshAttempt?.application?.post_search_state?.ok === false
182
- ? "search_result_not_ready"
183
- : refreshAttempt?.application?.post_search_state?.counts?.candidate_card === 0
184
- ? "no_cards_after_refresh"
185
- : "refresh_failed";
186
- const error = new Error(`Recruit/search refresh failed before target was reached (${reason})`);
187
- error.code = "RECRUIT_END_REFRESH_FAILED";
188
- error.refresh_attempt = refreshAttempt || null;
189
- error.list_end_reason = listEndReason || null;
190
- error.target_count = targetCount;
191
- error.processed_count = processedCount;
192
- return error;
193
- }
194
-
195
- function isRefreshableListStall(reason = "") {
196
- return new Set([
197
- "stable_visible_signature",
198
- "max_scrolls_exhausted",
199
- "scroll_failed",
200
- "scroll_anchor_unavailable"
201
- ]).has(String(reason || ""));
202
- }
203
-
204
- export function isStaleRecruitNodeError(error) {
205
- const message = String(error?.message || error || "");
206
- return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
207
- }
208
-
209
- export function isRecoverableRecruitImageCaptureError(error) {
210
- const code = String(error?.code || "");
211
- if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
212
- if (isStaleRecruitNodeError(error)) return true;
213
- return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
214
- }
215
-
216
- export function isRecoverableRecruitDetailError(error) {
217
- return isStaleRecruitNodeError(error);
218
- }
219
-
220
- function compactRecoverableDetailError(error) {
221
- return compactError(error, isStaleRecruitNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
222
- }
223
-
224
- function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
225
- const resolved = String(basePath || "").trim();
226
- if (!resolved) return [];
227
- const parsed = path.parse(resolved);
228
- const ext = parsed.ext || `.${String(extension || "jpg").replace(/^\./, "") || "jpg"}`;
229
- const files = [];
230
- for (let index = 0; index < Math.max(1, Number(maxCount) || 1); index += 1) {
231
- const page = String(index + 1).padStart(2, "0");
232
- const candidatePath = path.join(parsed.dir, `${parsed.name}-page-${page}${ext}`);
233
- if (fs.existsSync(candidatePath)) files.push(candidatePath);
234
- }
235
- return files;
236
- }
237
-
238
- export function createRecoverableRecruitImageCaptureEvidence(error, {
239
- elapsedMs = 0,
240
- filePath = "",
241
- extension = "jpg",
242
- maxScreenshots = DEFAULT_MAX_IMAGE_PAGES
243
- } = {}) {
244
- const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
245
- return {
246
- schema_version: 1,
247
- ok: false,
248
- source: "image-scroll-sequence",
249
- elapsed_ms: Math.max(0, Math.round(Number(error?.elapsed_ms ?? elapsedMs) || 0)),
250
- capture_count: filePaths.length,
251
- screenshot_count: filePaths.length,
252
- unique_screenshot_count: filePaths.length,
253
- dropped_duplicate_count: 0,
254
- total_byte_length: 0,
255
- original_total_byte_length: 0,
256
- llm_screenshot_count: 0,
257
- llm_total_byte_length: 0,
258
- llm_original_total_byte_length: 0,
259
- llm_composition_error: null,
260
- error_code: error?.code || (isStaleRecruitNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
261
- error: error?.message || String(error || "Image capture failed"),
262
- file_paths: filePaths,
263
- llm_file_paths: []
264
- };
265
- }
266
-
267
- function createImageCaptureFailureScreening(candidate, error) {
268
- return {
269
- status: "fail",
270
- passed: false,
271
- score: 0,
272
- reasons: ["image_capture_failed"],
273
- error: compactError(error, "IMAGE_CAPTURE_FAILED"),
274
- candidate
275
- };
276
- }
277
-
278
- function createRecoverableDetailFailureScreening(candidate, error) {
279
- return {
280
- status: "fail",
281
- passed: false,
282
- score: 0,
283
- reasons: isStaleRecruitNodeError(error)
284
- ? ["detail_open_failed", "stale_node"]
285
- : ["detail_open_failed"],
286
- error: compactRecoverableDetailError(error),
287
- candidate
288
- };
289
- }
290
-
291
- export function countRecruitResultStatuses(results = []) {
292
- return {
293
- processed: results.length,
294
- screened: results.length,
295
- detail_opened: results.filter((item) => item.detail).length,
296
- passed: results.filter((item) => item.screening?.passed).length,
297
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
298
- image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
299
- detail_open_failed: results.filter((item) => (
300
- item.error?.code === "DETAIL_STALE_NODE"
301
- || item.error?.code === "DETAIL_OPEN_FAILED"
302
- )).length,
303
- transient_recovered: results.filter((item) => (
304
- item.error?.code === "DETAIL_STALE_NODE"
305
- || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
306
- || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
307
- || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
308
- )).length
309
- };
310
- }
311
-
312
- export async function runRecruitWorkflow({
313
- client,
314
- targetUrl = "",
315
- criteria = "",
316
- searchParams = {},
317
- maxCandidates = 5,
318
- detailLimit = null,
319
- closeDetail = true,
320
- delayMs = 0,
321
- cardTimeoutMs = 90000,
322
- resetBeforeSearch = true,
323
- resetTimeoutMs = 180000,
324
- cityOptionTimeoutMs = 30000,
325
- maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
326
- imageWheelDeltaY = 650,
327
- cvAcquisitionMode = "unknown",
328
- listMaxScrolls = 20,
329
- listStableSignatureLimit = 5,
330
- listWheelDeltaY = 850,
331
- listSettleMs = 2200,
332
- listFallbackPoint = null,
333
- refreshOnEnd = true,
334
- maxRefreshRounds = 2,
335
- refreshResetSettleMs = 5000,
336
- screeningMode = "llm",
337
- llmConfig = null,
338
- llmTimeoutMs = 120000,
339
- llmImageLimit = 8,
340
- llmImageDetail = "high",
341
- imageOutputDir = "",
342
- humanRestEnabled = false,
343
- humanBehavior = null
344
- } = {}, runControl) {
345
- if (!client) throw new Error("runRecruitWorkflow requires a guarded CDP client");
346
- const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
347
- legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
348
- });
349
- const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
350
- configureHumanInteraction(client, {
351
- enabled: effectiveHumanBehavior.enabled,
352
- clickMovementEnabled: effectiveHumanBehavior.clickMovement,
353
- textEntryEnabled: effectiveHumanBehavior.textEntry,
354
- safeClickPointEnabled: effectiveHumanBehavior.clickMovement,
355
- actionCooldownEnabled: effectiveHumanBehavior.actionCooldown
356
- });
357
- const humanRestController = createHumanRestController({
358
- enabled: effectiveHumanRestEnabled,
359
- shortRestEnabled: effectiveHumanBehavior.shortRest,
360
- batchRestEnabled: effectiveHumanBehavior.batchRest
361
- });
362
- const normalizedSearchParams = normalizeSearchParams(searchParams);
363
- const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
364
- const useLlmScreening = normalizedScreeningMode !== "deterministic";
365
- const limit = Math.max(1, Number(maxCandidates) || 1);
366
- const detailCountLimit = detailLimit == null ? limit : Math.max(0, Number(detailLimit) || 0);
367
- const networkRecorder = detailCountLimit > 0
368
- ? createRecruitDetailNetworkRecorder(client)
369
- : null;
370
- const cvAcquisitionState = createCvAcquisitionState({ mode: cvAcquisitionMode });
371
- const listState = createInfiniteListState({
372
- domain: "recruit",
373
- listName: "search-results"
374
- });
375
- const viewportGuard = createViewportRunGuard({
376
- client,
377
- domain: "recruit",
378
- root: "frame",
379
- frameOwnerRoot: "frameOwner",
380
- runControl,
381
- getRoots: getRecruitRoots
382
- });
383
- async function ensureRecruitViewport(rootState, phase) {
384
- const result = await viewportGuard.ensure(rootState, { phase });
385
- return result.rootState || rootState;
386
- }
387
- const results = [];
388
- const refreshAttempts = [];
389
- let refreshRounds = 0;
390
- let contextRecoveryAttempts = 0;
391
- const candidateRecoveryCounts = new Map();
392
- let cardNodeIds = [];
393
- let listEndReason = "";
394
- let lastHumanEvent = null;
395
- const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
396
- rootNodeId: rootState?.iframe?.documentNodeId,
397
- containerSelectors: RECRUIT_LIST_CONTAINER_SELECTORS,
398
- itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
399
- itemSelectors: [RECRUIT_CARD_SELECTOR],
400
- viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
401
- validateViewportPoint: true
402
- }));
403
-
404
- function recordHumanEvent(event = null) {
405
- if (!event) return lastHumanEvent;
406
- lastHumanEvent = {
407
- at: new Date().toISOString(),
408
- ...event
409
- };
410
- return lastHumanEvent;
411
- }
412
-
413
- async function maybeHumanActionCooldown(phase, timings = {}) {
414
- if (!effectiveHumanBehavior.actionCooldown) return null;
415
- const pauseMs = humanDelay(280, 90, {
416
- minMs: 80,
417
- maxMs: 720
418
- });
419
- if (pauseMs > 0) {
420
- await runControl.sleep(pauseMs);
421
- addTiming(timings, `human_${phase}_pause_ms`, pauseMs);
422
- }
423
- return recordHumanEvent({
424
- kind: "action_cooldown",
425
- phase,
426
- pause_ms: pauseMs
427
- });
428
- }
429
-
430
- function updateRecruitProgress(extra = {}) {
431
- const counts = countRecruitResultStatuses(results);
432
- const listSnapshot = compactInfiniteListState(listState);
433
- const humanRestState = humanRestController.getState();
434
- runControl.updateProgress({
435
- card_count: cardNodeIds.length,
436
- target_count: limit,
437
- ...counts,
438
- screening_mode: normalizedScreeningMode,
439
- unique_seen: listSnapshot.seen_count,
440
- scroll_count: listSnapshot.scroll_count,
441
- refresh_rounds: refreshRounds,
442
- refresh_attempts: refreshAttempts.length,
443
- context_recoveries: contextRecoveryAttempts,
444
- list_end_reason: listEndReason || null,
445
- viewport_checks: viewportGuard.getStats().checks,
446
- viewport_recoveries: viewportGuard.getStats().recoveries,
447
- human_behavior_enabled: effectiveHumanBehavior.enabled,
448
- human_behavior_profile: effectiveHumanBehavior.profile,
449
- human_rest_enabled: effectiveHumanRestEnabled,
450
- human_rest_count: humanRestState.rest_count,
451
- human_rest_ms: humanRestState.total_rest_ms,
452
- last_human_event: lastHumanEvent,
453
- ...extra
454
- });
455
- }
456
-
457
- function checkpointInProgressCandidate({
458
- index = results.length,
459
- candidateKey = "",
460
- cardNodeId = null,
461
- detailStep = "",
462
- error = null
463
- } = {}) {
464
- runControl.checkpoint({
465
- in_progress_candidate: {
466
- index,
467
- key: candidateKey,
468
- card_node_id: cardNodeId,
469
- detail_step: detailStep || null,
470
- counters: countRecruitResultStatuses(results),
471
- error: compactError(error, "RECRUIT_IN_PROGRESS_ERROR")
472
- },
473
- candidate_list: compactInfiniteListState(listState)
474
- });
475
- }
476
-
477
- async function recoverAndReapplyRecruitContext(reason = "context_recovery", error = null, {
478
- forceRecentViewed = true
479
- } = {}) {
480
- await runControl.waitIfPaused();
481
- runControl.throwIfCanceled();
482
- const started = Date.now();
483
- runControl.setPhase("recruit:recover-context");
484
- contextRecoveryAttempts += 1;
485
- const refreshResult = await refreshRecruitSearchAtEnd(client, {
486
- searchParams: normalizedSearchParams,
487
- requireCards: true,
488
- searchTimeoutMs: cardTimeoutMs,
489
- resetTimeoutMs,
490
- resetSettleMs: refreshResetSettleMs,
491
- cityOptionTimeoutMs,
492
- forceRecentViewed
493
- });
494
- const compactRefresh = {
495
- ...compactRefreshAttempt(refreshResult),
496
- context_recovery: true,
497
- recovery_reason: reason,
498
- trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
499
- elapsed_ms: Date.now() - started
500
- };
501
- refreshAttempts.push(compactRefresh);
502
- runControl.checkpoint({
503
- context_recovery: {
504
- attempt: contextRecoveryAttempts,
505
- reason,
506
- trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
507
- refresh: compactRefresh,
508
- counters: countRecruitResultStatuses(results)
509
- },
510
- candidate_list: compactInfiniteListState(listState)
511
- });
512
- if (!refreshResult.ok) {
513
- updateRecruitProgress({
514
- refresh_method: refreshResult.method || null,
515
- refresh_forced_recent_viewed: forceRecentViewed,
516
- recovery_reason: reason
517
- });
518
- throw new Error(`Recruit context recovery failed after ${reason}: ${refreshResult.application?.reason || "refresh returned no cards"}`);
519
- }
520
- rootState = await getRecruitRoots(client);
521
- rootState = await ensureRecruitViewport(rootState, "recover_after");
522
- cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
523
- timeoutMs: cardTimeoutMs,
524
- intervalMs: 300
525
- });
526
- resetInfiniteListForRefreshRound(listState, {
527
- reason: `context_recovery:${reason}`,
528
- round: contextRecoveryAttempts,
529
- method: refreshResult.method,
530
- metadata: {
531
- card_count: cardNodeIds.length,
532
- forced_recent_viewed: forceRecentViewed,
533
- counters: countRecruitResultStatuses(results)
534
- }
535
- });
536
- listEndReason = "";
537
- updateRecruitProgress({
538
- card_count: cardNodeIds.length,
539
- refresh_method: refreshResult.method || null,
540
- refresh_forced_recent_viewed: forceRecentViewed,
541
- recovery_reason: reason
542
- });
543
- return refreshResult;
544
- }
545
-
546
- runControl.setPhase("recruit:cleanup");
547
- await closeRecruitDetail(client, { attemptsLimit: 2 });
548
-
549
- await runControl.waitIfPaused();
550
- runControl.throwIfCanceled();
551
- runControl.setPhase("recruit:roots");
552
- let rootState = await getRecruitRoots(client);
553
- rootState = await ensureRecruitViewport(rootState, "roots");
554
- runControl.checkpoint({
555
- iframe_selector: rootState.iframe.selector,
556
- iframe_document_node_id: rootState.iframe.documentNodeId,
557
- search_params: normalizedSearchParams
558
- });
559
-
560
- if (hasRecruitSearchParams(normalizedSearchParams)) {
561
- await runControl.waitIfPaused();
562
- runControl.throwIfCanceled();
563
- runControl.setPhase("recruit:search");
564
- const searchResult = await applyRecruitSearchParams(client, {
565
- searchParams: normalizedSearchParams,
566
- requireCards: true,
567
- resetBeforeApply: resetBeforeSearch,
568
- searchTimeoutMs: cardTimeoutMs,
569
- resetTimeoutMs,
570
- cityOptionTimeoutMs
571
- });
572
- runControl.checkpoint({
573
- search: {
574
- search_params: searchResult.search_params,
575
- before_counts: searchResult.before_counts,
576
- post_search_state: searchResult.post_search_state,
577
- steps: searchResult.steps.map((step) => ({
578
- step: step.step,
579
- applied: step.result?.applied,
580
- clicked: step.result?.clicked,
581
- searched: step.result?.searched,
582
- reason: step.result?.reason || null
583
- }))
584
- }
585
- });
586
- rootState = await getRecruitRoots(client);
587
- rootState = await ensureRecruitViewport(rootState, "search");
588
- }
589
-
590
- await runControl.waitIfPaused();
591
- runControl.throwIfCanceled();
592
- runControl.setPhase("recruit:cards");
593
- rootState = await ensureRecruitViewport(rootState, "cards");
594
- cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
595
- timeoutMs: cardTimeoutMs,
596
- intervalMs: 300
597
- });
598
- if (!cardNodeIds.length) {
599
- throw new Error("No recruit/search candidate cards found for run service");
600
- }
601
-
602
- updateRecruitProgress({
603
- list_end_reason: null
604
- });
605
-
606
- while (results.length < limit) {
607
- const candidateStarted = Date.now();
608
- const timings = {};
609
- await runControl.waitIfPaused();
610
- runControl.throwIfCanceled();
611
- runControl.setPhase("recruit:candidate");
612
- rootState = await ensureRecruitViewport(rootState, "candidate_loop");
613
-
614
- const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
615
- client,
616
- state: listState,
617
- maxScrolls: listMaxScrolls,
618
- stableSignatureLimit: listStableSignatureLimit,
619
- wheelDeltaY: listWheelDeltaY,
620
- settleMs: listSettleMs,
621
- listScrollJitterEnabled: effectiveHumanBehavior.listScrollJitter,
622
- fallbackPoint: listFallbackResolver,
623
- findNodeIds: async () => {
624
- let currentRootState = await getRecruitRoots(client);
625
- currentRootState = await ensureRecruitViewport(currentRootState, "candidate_find_nodes");
626
- rootState = currentRootState;
627
- const currentCardNodeIds = await waitForRecruitCardNodeIds(client, currentRootState.iframe.documentNodeId, {
628
- timeoutMs: Math.min(cardTimeoutMs, 5000),
629
- intervalMs: 300
630
- });
631
- cardNodeIds = currentCardNodeIds;
632
- return currentCardNodeIds;
633
- },
634
- readCandidate: async (nodeId, { visibleIndex }) => readRecruitCardCandidate(client, nodeId, {
635
- targetUrl,
636
- source: "recruit-run-card",
637
- metadata: {
638
- run_candidate_index: results.length,
639
- visible_index: visibleIndex,
640
- search_params: normalizedSearchParams
641
- }
642
- }),
643
- detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
644
- rootNodeId: rootState?.iframe?.documentNodeId,
645
- markerSelectors: RECRUIT_BOTTOM_MARKER_SELECTORS,
646
- refreshSelectors: RECRUIT_BOTTOM_REFRESH_SELECTORS,
647
- textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
648
- maxTextScanNodes: 500
649
- })
650
- }));
651
- if (!nextCandidateResult.ok) {
652
- listEndReason = nextCandidateResult.reason || "list_exhausted";
653
- if (
654
- (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
655
- && refreshOnEnd
656
- && results.length < limit
657
- && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
658
- ) {
659
- await runControl.waitIfPaused();
660
- runControl.throwIfCanceled();
661
- runControl.setPhase("recruit:refresh");
662
- refreshRounds += 1;
663
- const refreshResult = await refreshRecruitSearchAtEnd(client, {
664
- searchParams: normalizedSearchParams,
665
- requireCards: true,
666
- searchTimeoutMs: cardTimeoutMs,
667
- resetTimeoutMs,
668
- resetSettleMs: refreshResetSettleMs,
669
- cityOptionTimeoutMs
670
- });
671
- const compactRefresh = compactRefreshAttempt(refreshResult);
672
- refreshAttempts.push(compactRefresh);
673
- runControl.checkpoint({
674
- refresh_round: refreshRounds,
675
- refresh: compactRefresh
676
- });
677
- updateRecruitProgress({
678
- card_count: refreshResult.card_count || cardNodeIds.length,
679
- refresh_method: refreshResult.method || null,
680
- refresh_forced_recent_viewed: true,
681
- list_end_reason: listEndReason
682
- });
683
- if (refreshResult.ok) {
684
- rootState = await getRecruitRoots(client);
685
- rootState = await ensureRecruitViewport(rootState, "refresh_after");
686
- cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
687
- timeoutMs: cardTimeoutMs,
688
- intervalMs: 300
689
- });
690
- resetInfiniteListForRefreshRound(listState, {
691
- reason: listEndReason,
692
- round: refreshRounds,
693
- method: refreshResult.method,
694
- metadata: {
695
- card_count: cardNodeIds.length,
696
- forced_recent_viewed: true
697
- }
698
- });
699
- listEndReason = "";
700
- continue;
701
- }
702
- throw createRecruitRefreshFailureError(compactRefresh, {
703
- listEndReason,
704
- targetCount: limit,
705
- processedCount: results.length
706
- });
707
- }
708
- break;
709
- }
710
-
711
- const index = results.length;
712
- const cardNodeId = nextCandidateResult.item.node_id;
713
- const candidateKey = nextCandidateResult.item.key;
714
- const cardCandidate = nextCandidateResult.item.candidate;
715
-
716
- let screeningCandidate = cardCandidate;
717
- let detailResult = null;
718
- let recoverableDetailError = null;
719
- let detailStep = "not_started";
720
- if (index < detailCountLimit) {
721
- try {
722
- await runControl.waitIfPaused();
723
- runControl.throwIfCanceled();
724
- runControl.setPhase("recruit:detail");
725
- detailStep = "ensure_viewport";
726
- rootState = await ensureRecruitViewport(rootState, "detail");
727
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
728
- detailStep = "open_detail";
729
- networkRecorder.clear();
730
- await maybeHumanActionCooldown("before_detail_open", timings);
731
- const openedDetail = await openRecruitCardDetail(client, cardNodeId);
732
- addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
733
- addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
734
- const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
735
- detailStep = "wait_network";
736
- const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
737
- waitForRecruitDetailNetworkEvents,
738
- networkRecorder,
739
- {
740
- waitPlan,
741
- minCount: 1,
742
- requireLoaded: true,
743
- intervalMs: 120
744
- }
745
- ));
746
- if (networkWait?.elapsed_ms != null) {
747
- timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
748
- }
749
- detailStep = "extract_detail";
750
- detailResult = await extractRecruitDetailCandidate(client, {
751
- cardCandidate,
752
- cardNodeId,
753
- detailState: openedDetail.detail_state,
754
- networkEvents: networkRecorder.events,
755
- targetUrl,
756
- closeDetail: false,
757
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
758
- networkParseIntervalMs: 250
759
- });
760
- addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
761
- const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
762
- let source = "network";
763
- let imageEvidence = null;
764
- let captureTarget = null;
765
- let captureTargetWait = null;
766
- if (parsedNetworkProfileCount > 0) {
767
- recordCvNetworkHit(cvAcquisitionState, {
768
- parsedNetworkProfileCount,
769
- waitResult: networkWait
770
- });
771
- } else {
772
- detailStep = "wait_capture_target";
773
- captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
774
- domain: "recruit",
775
- timeoutMs: 6000,
776
- intervalMs: 250
777
- });
778
- captureTarget = captureTargetWait.target || null;
779
- const captureNodeId = captureTarget?.node_id || null;
780
- if (captureNodeId) {
781
- const imageEvidencePath = imageEvidenceFilePath({
782
- imageOutputDir,
783
- domain: "recruit",
784
- runId: runControl?.runId,
785
- index,
786
- extension: "jpg"
787
- });
788
- try {
789
- detailStep = "capture_image";
790
- imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
791
- filePath: imageEvidencePath,
792
- format: "jpeg",
793
- quality: 72,
794
- optimize: true,
795
- resizeMaxWidth: 1100,
796
- captureViewport: false,
797
- padding: 0,
798
- maxScreenshots: maxImagePages,
799
- wheelDeltaY: imageWheelDeltaY,
800
- settleMs: 350,
801
- scrollMethod: "dom-anchor-fallback-input",
802
- scrollDeltaJitterEnabled: effectiveHumanBehavior.listScrollJitter,
803
- stepTimeoutMs: 45000,
804
- totalTimeoutMs: 90000,
805
- duplicateStopCount: 1,
806
- skipDuplicateScreenshots: true,
807
- composeForLlm: true,
808
- llmPagesPerImage: 3,
809
- llmResizeMaxWidth: 1100,
810
- llmQuality: 72,
811
- metadata: {
812
- domain: "recruit",
813
- capture_mode: "scroll_sequence",
814
- acquisition_reason: "network_miss_image_fallback",
815
- run_candidate_index: index,
816
- candidate_key: candidateKey,
817
- capture_target: captureTarget,
818
- capture_target_wait: captureTargetWait
819
- }
820
- }));
821
- source = "image";
822
- } catch (error) {
823
- if (!isRecoverableRecruitImageCaptureError(error)) throw error;
824
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
825
- if (recoveryCount < 1) {
826
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
827
- timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
828
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
829
- await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
830
- await recoverAndReapplyRecruitContext(`image_capture:${detailStep}`, error, {
831
- forceRecentViewed: true
832
- });
833
- continue;
834
- }
835
- imageEvidence = createRecoverableRecruitImageCaptureEvidence(error, {
836
- elapsedMs: timings.screenshot_capture_ms,
837
- filePath: imageEvidencePath,
838
- extension: "jpg",
839
- maxScreenshots: maxImagePages
840
- });
841
- source = "image_capture_failed";
842
- }
843
- recordCvImageFallback(cvAcquisitionState, {
844
- reason: source === "image_capture_failed"
845
- ? "network_miss_image_capture_failed"
846
- : "network_miss_image_fallback",
847
- parsedNetworkProfileCount,
848
- waitResult: networkWait,
849
- imageEvidence
850
- });
851
- } else {
852
- source = "missing_capture_node";
853
- recordCvNetworkMiss(cvAcquisitionState, {
854
- reason: "network_miss_no_capture_node",
855
- parsedNetworkProfileCount,
856
- waitResult: networkWait
857
- });
858
- }
859
- }
860
-
861
- detailResult.image_evidence = imageEvidence;
862
- detailResult.cv_acquisition = {
863
- source,
864
- mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
865
- wait_plan: waitPlan,
866
- network_wait: networkWait,
867
- parsed_network_profile_count: parsedNetworkProfileCount,
868
- image_evidence: summarizeImageEvidence(imageEvidence),
869
- capture_target: captureTarget || null,
870
- capture_target_wait: captureTargetWait
871
- };
872
- screeningCandidate = detailResult.candidate;
873
- if (closeDetail) {
874
- detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
875
- await maybeHumanActionCooldown("after_detail_close", timings);
876
- if (!detailResult.close_result?.closed) {
877
- const closeError = createRecruitCloseFailureError(detailResult.close_result);
878
- const recovery = await recoverAndReapplyRecruitContext("detail_close_failed", closeError, {
879
- forceRecentViewed: true
880
- });
881
- detailResult.cv_acquisition = {
882
- ...(detailResult.cv_acquisition || {}),
883
- close_recovery: {
884
- ok: Boolean(recovery.ok),
885
- method: recovery.method || "",
886
- forced_recent_viewed: Boolean(recovery.forced_recent_viewed),
887
- card_count: recovery.card_count || 0
888
- }
889
- };
890
- }
891
- } else {
892
- detailResult.close_result = null;
893
- }
894
- } catch (error) {
895
- if (!isRecoverableRecruitDetailError(error)) throw error;
896
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
897
- if (recoveryCount < 1) {
898
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
899
- timings.detail_recovery_trigger = compactRecoverableDetailError(error);
900
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
901
- await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
902
- await recoverAndReapplyRecruitContext(`detail:${detailStep}`, error, {
903
- forceRecentViewed: true
904
- });
905
- continue;
906
- }
907
- recoverableDetailError = error;
908
- detailResult = null;
909
- timings.detail_recovered_error = compactRecoverableDetailError(error);
910
- await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
911
- }
912
- }
913
-
914
- await runControl.waitIfPaused();
915
- runControl.throwIfCanceled();
916
- runControl.setPhase("recruit:screening");
917
- let llmResult = null;
918
- if (useLlmScreening) {
919
- if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
920
- llmResult = null;
921
- } else if (!llmConfig) {
922
- llmResult = createMissingLlmConfigResult();
923
- } else {
924
- try {
925
- const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
926
- ? "vision_model_ms"
927
- : "text_model_ms";
928
- llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
929
- candidate: screeningCandidate,
930
- criteria,
931
- config: llmConfig,
932
- timeoutMs: llmTimeoutMs,
933
- imageEvidence: detailResult?.image_evidence || null,
934
- maxImages: llmImageLimit,
935
- imageDetail: llmImageDetail
936
- }));
937
- } catch (error) {
938
- llmResult = createFailedLlmScreeningResult(error);
939
- }
940
- }
941
- if (detailResult) detailResult.llm_result = llmResult;
942
- }
943
- const screening = recoverableDetailError
944
- ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
945
- : detailResult?.image_evidence?.ok === false
946
- ? createImageCaptureFailureScreening(screeningCandidate, {
947
- code: detailResult.image_evidence.error_code,
948
- message: detailResult.image_evidence.error
949
- })
950
- : useLlmScreening
951
- ? llmResultToScreening(llmResult, screeningCandidate)
952
- : screenCandidate(screeningCandidate, { criteria });
953
- timings.total_ms = Date.now() - candidateStarted;
954
- const compactResult = {
955
- index,
956
- candidate_key: candidateKey,
957
- card_node_id: cardNodeId,
958
- candidate: compactCandidate(screeningCandidate),
959
- detail: compactDetail(detailResult),
960
- llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
961
- screening: compactScreening(screening),
962
- error: recoverableDetailError
963
- ? compactRecoverableDetailError(recoverableDetailError)
964
- : detailResult?.image_evidence?.ok === false
965
- ? compactError({
966
- code: detailResult.image_evidence.error_code,
967
- message: detailResult.image_evidence.error
968
- }, "IMAGE_CAPTURE_FAILED")
969
- : null,
970
- timings
971
- };
972
- results.push(compactResult);
973
- markInfiniteListCandidateProcessed(listState, candidateKey, {
974
- metadata: {
975
- result_index: index,
976
- candidate_id: screeningCandidate.id || null
977
- }
978
- });
979
-
980
- updateRecruitProgress({
981
- last_candidate_id: screeningCandidate.id || null,
982
- last_candidate_key: candidateKey,
983
- last_score: screening.score
984
- });
985
- const checkpointStarted = Date.now();
986
- runControl.checkpoint({
987
- results,
988
- last_candidate: {
989
- id: screeningCandidate.id || null,
990
- key: candidateKey,
991
- identity: screeningCandidate.identity || {},
992
- screening: {
993
- status: screening.status,
994
- passed: screening.passed,
995
- score: screening.score
996
- },
997
- llm_screening: compactScreeningLlmResult(llmResult),
998
- error: compactResult.error
999
- }
1000
- });
1001
- addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
1002
-
1003
- if (effectiveHumanRestEnabled) {
1004
- const restStarted = Date.now();
1005
- const restResult = await humanRestController.takeBreakIfNeeded({
1006
- sleepFn: (ms) => runControl.sleep(ms)
1007
- });
1008
- const restElapsed = Date.now() - restStarted;
1009
- if (restResult.rested) {
1010
- recordHumanEvent({
1011
- kind: "rest",
1012
- pause_ms: restResult.pause_ms || restElapsed,
1013
- events: restResult.events || []
1014
- });
1015
- compactResult.human_rest = restResult;
1016
- addTiming(compactResult.timings, "human_rest_ms", restElapsed);
1017
- compactResult.timings.total_ms = Date.now() - candidateStarted;
1018
- updateRecruitProgress({
1019
- human_rest_last: restResult
1020
- });
1021
- }
1022
- }
1023
-
1024
- if (delayMs > 0) {
1025
- const sleepStarted = Date.now();
1026
- await runControl.sleep(delayMs);
1027
- addTiming(compactResult.timings, "sleep_ms", Date.now() - sleepStarted);
1028
- compactResult.timings.total_ms = Date.now() - candidateStarted;
1029
- }
1030
- }
1031
-
1032
- runControl.setPhase("recruit:done");
1033
- return {
1034
- domain: "recruit",
1035
- target_url: targetUrl,
1036
- search_params: normalizedSearchParams,
1037
- card_count: cardNodeIds.length,
1038
- candidate_list: compactInfiniteListState(listState),
1039
- viewport_health: {
1040
- stats: viewportGuard.getStats(),
1041
- events: viewportGuard.getEvents()
1042
- },
1043
- human_behavior: effectiveHumanBehavior,
1044
- human_rest: humanRestController.getState(),
1045
- last_human_event: lastHumanEvent,
1046
- list_end_reason: listEndReason || null,
1047
- refresh_rounds: refreshRounds,
1048
- refresh_attempts: refreshAttempts,
1049
- context_recoveries: contextRecoveryAttempts,
1050
- ...countRecruitResultStatuses(results),
1051
- results
1052
- };
1053
- }
1054
-
1055
- export function createRecruitRunService({
1056
- lifecycle,
1057
- idPrefix = "recruit",
1058
- workflow = runRecruitWorkflow,
1059
- onSnapshot = null
1060
- } = {}) {
1061
- const manager = lifecycle || createRunLifecycleManager({ idPrefix, onSnapshot });
1062
-
1063
- function startRecruitRun({
1064
- client,
1065
- targetUrl = "",
1066
- criteria = "",
1067
- searchParams = {},
1068
- maxCandidates = 5,
1069
- detailLimit = null,
1070
- closeDetail = true,
1071
- delayMs = 0,
1072
- cardTimeoutMs = 90000,
1073
- resetBeforeSearch = true,
1074
- resetTimeoutMs = 180000,
1075
- cityOptionTimeoutMs = 30000,
1076
- maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
1077
- imageWheelDeltaY = 650,
1078
- cvAcquisitionMode = "unknown",
1079
- listMaxScrolls = 20,
1080
- listStableSignatureLimit = 5,
1081
- listWheelDeltaY = 850,
1082
- listSettleMs = 2200,
1083
- listFallbackPoint = null,
1084
- refreshOnEnd = true,
1085
- maxRefreshRounds = 2,
1086
- refreshResetSettleMs = 5000,
1087
- screeningMode = "llm",
1088
- llmConfig = null,
1089
- llmTimeoutMs = 120000,
1090
- llmImageLimit = 8,
1091
- llmImageDetail = "high",
1092
- imageOutputDir = "",
1093
- humanRestEnabled = false,
1094
- humanBehavior = null,
1095
- name = "recruit-domain-run"
1096
- } = {}) {
1097
- if (!client) throw new Error("startRecruitRun requires a guarded CDP client");
1098
- const normalizedSearchParams = normalizeSearchParams(searchParams);
1099
- const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
1100
- const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
1101
- const normalizedDetailLimit = detailLimit == null ? candidateLimit : Math.max(0, Number(detailLimit) || 0);
1102
- const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
1103
- legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
1104
- });
1105
- const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
1106
- return manager.startRun({
1107
- name,
1108
- context: {
1109
- domain: "recruit",
1110
- target_url: targetUrl,
1111
- criteria_present: Boolean(criteria),
1112
- search_params: normalizedSearchParams,
1113
- max_candidates: maxCandidates,
1114
- detail_limit: normalizedDetailLimit,
1115
- close_detail: closeDetail,
1116
- reset_before_search: resetBeforeSearch,
1117
- reset_timeout_ms: resetTimeoutMs,
1118
- city_option_timeout_ms: cityOptionTimeoutMs,
1119
- cv_acquisition_mode: cvAcquisitionMode,
1120
- max_image_pages: maxImagePages,
1121
- image_wheel_delta_y: imageWheelDeltaY,
1122
- list_max_scrolls: listMaxScrolls,
1123
- list_stable_signature_limit: listStableSignatureLimit,
1124
- list_wheel_delta_y: listWheelDeltaY,
1125
- list_settle_ms: listSettleMs,
1126
- list_fallback_point: listFallbackPoint,
1127
- refresh_on_end: refreshOnEnd,
1128
- max_refresh_rounds: maxRefreshRounds,
1129
- refresh_reset_settle_ms: refreshResetSettleMs,
1130
- screening_mode: normalizedScreeningMode,
1131
- llm_configured: Boolean(llmConfig),
1132
- llm_timeout_ms: llmTimeoutMs,
1133
- llm_image_limit: llmImageLimit,
1134
- llm_image_detail: llmImageDetail,
1135
- image_output_dir: imageOutputDir || "",
1136
- human_behavior_enabled: effectiveHumanBehavior.enabled,
1137
- human_behavior_profile: effectiveHumanBehavior.profile,
1138
- human_behavior: effectiveHumanBehavior,
1139
- human_rest_enabled: effectiveHumanRestEnabled
1140
- },
1141
- progress: {
1142
- card_count: 0,
1143
- target_count: candidateLimit,
1144
- processed: 0,
1145
- screened: 0,
1146
- detail_opened: 0,
1147
- llm_screened: 0,
1148
- passed: 0,
1149
- image_capture_failed: 0,
1150
- detail_open_failed: 0,
1151
- transient_recovered: 0,
1152
- context_recoveries: 0,
1153
- human_behavior_enabled: effectiveHumanBehavior.enabled,
1154
- human_behavior_profile: effectiveHumanBehavior.profile,
1155
- human_rest_enabled: effectiveHumanRestEnabled,
1156
- human_rest_count: 0,
1157
- human_rest_ms: 0,
1158
- last_human_event: null
1159
- },
1160
- checkpoint: {},
1161
- task: (runControl) => workflow({
1162
- client,
1163
- targetUrl,
1164
- criteria,
1165
- searchParams: normalizedSearchParams,
1166
- maxCandidates,
1167
- detailLimit: normalizedDetailLimit,
1168
- closeDetail,
1169
- delayMs,
1170
- cardTimeoutMs,
1171
- resetBeforeSearch,
1172
- resetTimeoutMs,
1173
- cityOptionTimeoutMs,
1174
- maxImagePages,
1175
- imageWheelDeltaY,
1176
- cvAcquisitionMode,
1177
- listMaxScrolls,
1178
- listStableSignatureLimit,
1179
- listWheelDeltaY,
1180
- listSettleMs,
1181
- listFallbackPoint,
1182
- refreshOnEnd,
1183
- maxRefreshRounds,
1184
- refreshResetSettleMs,
1185
- screeningMode: normalizedScreeningMode,
1186
- llmConfig,
1187
- llmTimeoutMs,
1188
- llmImageLimit,
1189
- llmImageDetail,
1190
- imageOutputDir,
1191
- humanRestEnabled: effectiveHumanRestEnabled,
1192
- humanBehavior: effectiveHumanBehavior
1193
- }, runControl)
1194
- });
1195
- }
1196
-
1197
- return {
1198
- startRecruitRun,
1199
- getRecruitRun: manager.getRun,
1200
- pauseRecruitRun: manager.pauseRun,
1201
- resumeRecruitRun: manager.resumeRun,
1202
- cancelRecruitRun: manager.cancelRun,
1203
- waitForRecruitRun: manager.waitForRun,
1204
- listRecruitRuns: manager.listRuns,
1205
- manager
1206
- };
1207
- }
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
+ } from "../../core/browser/index.js";
17
+ import {
18
+ compactCvAcquisitionState,
19
+ countParsedNetworkProfiles,
20
+ createCvAcquisitionState,
21
+ DEFAULT_MAX_IMAGE_PAGES,
22
+ getCvNetworkWaitPlan,
23
+ recordCvImageFallback,
24
+ recordCvNetworkHit,
25
+ recordCvNetworkMiss,
26
+ summarizeImageEvidence,
27
+ waitForCvNetworkEvents
28
+ } from "../../core/cv-acquisition/index.js";
29
+ import {
30
+ compactInfiniteListState,
31
+ createInfiniteListState,
32
+ detectInfiniteListBottomMarker,
33
+ getNextInfiniteListCandidate,
34
+ markInfiniteListCandidateProcessed,
35
+ resetInfiniteListForRefreshRound,
36
+ resolveInfiniteListFallbackPoint
37
+ } from "../../core/infinite-list/index.js";
38
+ import { createViewportRunGuard } from "../../core/self-heal/index.js";
39
+ import {
40
+ callScreeningLlm,
41
+ compactScreeningLlmResult,
42
+ createFailedLlmScreeningResult,
43
+ llmResultToScreening,
44
+ screenCandidate
45
+ } from "../../core/screening/index.js";
46
+ import {
47
+ closeRecruitDetail,
48
+ createRecruitDetailNetworkRecorder,
49
+ extractRecruitDetailCandidate,
50
+ openRecruitCardDetail,
51
+ waitForRecruitDetailNetworkEvents
52
+ } from "./detail.js";
53
+ import {
54
+ readRecruitCardCandidate,
55
+ waitForRecruitCardNodeIds
56
+ } from "./cards.js";
57
+ import {
58
+ applyRecruitSearchParams,
59
+ hasRecruitSearchParams,
60
+ normalizeRecruitSearchParams
61
+ } from "./search.js";
62
+ import { refreshRecruitSearchAtEnd } from "./refresh.js";
63
+ import { getRecruitRoots } from "./roots.js";
64
+ import {
65
+ RECRUIT_BOTTOM_MARKER_SELECTORS,
66
+ RECRUIT_BOTTOM_REFRESH_SELECTORS,
67
+ RECRUIT_CARD_SELECTOR,
68
+ RECRUIT_LIST_CONTAINER_SELECTORS
69
+ } from "./constants.js";
70
+
71
+ function compactScreening(screening) {
72
+ return {
73
+ status: screening.status,
74
+ passed: screening.passed,
75
+ score: screening.score,
76
+ reasons: screening.reasons,
77
+ candidate: {
78
+ domain: screening.candidate?.domain || "recruit",
79
+ source: screening.candidate?.source || "",
80
+ id: screening.candidate?.id || null,
81
+ identity: screening.candidate?.identity || {}
82
+ }
83
+ };
84
+ }
85
+
86
+ function compactCandidate(candidate) {
87
+ return {
88
+ id: candidate?.id || null,
89
+ identity: candidate?.identity || {},
90
+ text_length: candidate?.text?.raw?.length || 0,
91
+ tag_count: candidate?.tags?.length || 0
92
+ };
93
+ }
94
+
95
+ function compactDetail(detailResult) {
96
+ if (!detailResult) return null;
97
+ return {
98
+ popup_text_length: detailResult.detail?.popup_text?.length || 0,
99
+ resume_text_length: detailResult.detail?.resume_text?.length || 0,
100
+ network_body_count: detailResult.network_bodies?.filter((item) => item.body).length || 0,
101
+ parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
102
+ cv_acquisition: detailResult.cv_acquisition || null,
103
+ image_evidence: summarizeImageEvidence(detailResult.image_evidence),
104
+ llm_screening: compactScreeningLlmResult(detailResult.llm_result),
105
+ close_result: detailResult.close_result
106
+ };
107
+ }
108
+
109
+ function normalizeScreeningMode(value) {
110
+ const normalized = String(value || "llm").trim().toLowerCase();
111
+ return ["deterministic", "local", "local_scorer"].includes(normalized)
112
+ ? "deterministic"
113
+ : "llm";
114
+ }
115
+
116
+ function createMissingLlmConfigResult() {
117
+ return createFailedLlmScreeningResult(new Error("LLM screening config is required for production search runs"));
118
+ }
119
+
120
+ function normalizeSearchParams(searchParams = {}) {
121
+ return normalizeRecruitSearchParams(searchParams);
122
+ }
123
+
124
+ function compactRefreshAttempt(refreshAttempt) {
125
+ if (!refreshAttempt) return null;
126
+ return {
127
+ ok: Boolean(refreshAttempt.ok),
128
+ method: refreshAttempt.method || "",
129
+ forced_recent_viewed: Boolean(refreshAttempt.forced_recent_viewed),
130
+ card_count: refreshAttempt.card_count || 0,
131
+ search_params: refreshAttempt.search_params || null,
132
+ application: refreshAttempt.application
133
+ ? {
134
+ applied: Boolean(refreshAttempt.application.applied),
135
+ post_search_state: refreshAttempt.application.post_search_state,
136
+ steps: (refreshAttempt.application.steps || []).map((step) => ({
137
+ step: step.step,
138
+ applied: step.result?.applied,
139
+ clicked: step.result?.clicked,
140
+ searched: step.result?.searched,
141
+ reason: step.result?.reason || null
142
+ }))
143
+ }
144
+ : null
145
+ };
146
+ }
147
+
148
+ function compactError(error, fallbackCode = "RECRUIT_RUN_ERROR") {
149
+ if (!error) return null;
150
+ const result = {
151
+ code: error.code || fallbackCode,
152
+ message: error.message || String(error)
153
+ };
154
+ if (error.refresh_attempt) {
155
+ result.refresh_attempt = error.refresh_attempt;
156
+ }
157
+ if (error.list_end_reason) {
158
+ result.list_end_reason = error.list_end_reason;
159
+ }
160
+ if (error.target_count != null) {
161
+ result.target_count = error.target_count;
162
+ }
163
+ if (error.processed_count != null) {
164
+ result.processed_count = error.processed_count;
165
+ }
166
+ return result;
167
+ }
168
+
169
+ function createRecruitCloseFailureError(closeResult) {
170
+ const error = new Error(closeResult?.reason || "Recruit detail did not close before recovery");
171
+ error.code = "DETAIL_CLOSE_FAILED";
172
+ error.close_result = closeResult || null;
173
+ return error;
174
+ }
175
+
176
+ function createRecruitRefreshFailureError(refreshAttempt, {
177
+ listEndReason = "",
178
+ targetCount = 0,
179
+ processedCount = 0
180
+ } = {}) {
181
+ const reason = refreshAttempt?.application?.post_search_state?.ok === false
182
+ ? "search_result_not_ready"
183
+ : refreshAttempt?.application?.post_search_state?.counts?.candidate_card === 0
184
+ ? "no_cards_after_refresh"
185
+ : "refresh_failed";
186
+ const error = new Error(`Recruit/search refresh failed before target was reached (${reason})`);
187
+ error.code = "RECRUIT_END_REFRESH_FAILED";
188
+ error.refresh_attempt = refreshAttempt || null;
189
+ error.list_end_reason = listEndReason || null;
190
+ error.target_count = targetCount;
191
+ error.processed_count = processedCount;
192
+ return error;
193
+ }
194
+
195
+ function isRefreshableListStall(reason = "") {
196
+ return new Set([
197
+ "stable_visible_signature",
198
+ "max_scrolls_exhausted",
199
+ "scroll_failed",
200
+ "scroll_anchor_unavailable"
201
+ ]).has(String(reason || ""));
202
+ }
203
+
204
+ export function isStaleRecruitNodeError(error) {
205
+ const message = String(error?.message || error || "");
206
+ return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
207
+ }
208
+
209
+ export function isRecoverableRecruitImageCaptureError(error) {
210
+ const code = String(error?.code || "");
211
+ if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
212
+ if (isStaleRecruitNodeError(error)) return true;
213
+ return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
214
+ }
215
+
216
+ export function isRecoverableRecruitDetailError(error) {
217
+ return isStaleRecruitNodeError(error);
218
+ }
219
+
220
+ function compactRecoverableDetailError(error) {
221
+ return compactError(error, isStaleRecruitNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
222
+ }
223
+
224
+ function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
225
+ const resolved = String(basePath || "").trim();
226
+ if (!resolved) return [];
227
+ const parsed = path.parse(resolved);
228
+ const ext = parsed.ext || `.${String(extension || "jpg").replace(/^\./, "") || "jpg"}`;
229
+ const files = [];
230
+ for (let index = 0; index < Math.max(1, Number(maxCount) || 1); index += 1) {
231
+ const page = String(index + 1).padStart(2, "0");
232
+ const candidatePath = path.join(parsed.dir, `${parsed.name}-page-${page}${ext}`);
233
+ if (fs.existsSync(candidatePath)) files.push(candidatePath);
234
+ }
235
+ return files;
236
+ }
237
+
238
+ export function createRecoverableRecruitImageCaptureEvidence(error, {
239
+ elapsedMs = 0,
240
+ filePath = "",
241
+ extension = "jpg",
242
+ maxScreenshots = DEFAULT_MAX_IMAGE_PAGES
243
+ } = {}) {
244
+ const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
245
+ return {
246
+ schema_version: 1,
247
+ ok: false,
248
+ source: "image-scroll-sequence",
249
+ elapsed_ms: Math.max(0, Math.round(Number(error?.elapsed_ms ?? elapsedMs) || 0)),
250
+ capture_count: filePaths.length,
251
+ screenshot_count: filePaths.length,
252
+ unique_screenshot_count: filePaths.length,
253
+ dropped_duplicate_count: 0,
254
+ total_byte_length: 0,
255
+ original_total_byte_length: 0,
256
+ llm_screenshot_count: 0,
257
+ llm_total_byte_length: 0,
258
+ llm_original_total_byte_length: 0,
259
+ llm_composition_error: null,
260
+ error_code: error?.code || (isStaleRecruitNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
261
+ error: error?.message || String(error || "Image capture failed"),
262
+ file_paths: filePaths,
263
+ llm_file_paths: []
264
+ };
265
+ }
266
+
267
+ function createImageCaptureFailureScreening(candidate, error) {
268
+ return {
269
+ status: "fail",
270
+ passed: false,
271
+ score: 0,
272
+ reasons: ["image_capture_failed"],
273
+ error: compactError(error, "IMAGE_CAPTURE_FAILED"),
274
+ candidate
275
+ };
276
+ }
277
+
278
+ function createRecoverableDetailFailureScreening(candidate, error) {
279
+ return {
280
+ status: "fail",
281
+ passed: false,
282
+ score: 0,
283
+ reasons: isStaleRecruitNodeError(error)
284
+ ? ["detail_open_failed", "stale_node"]
285
+ : ["detail_open_failed"],
286
+ error: compactRecoverableDetailError(error),
287
+ candidate
288
+ };
289
+ }
290
+
291
+ export function countRecruitResultStatuses(results = []) {
292
+ return {
293
+ processed: results.length,
294
+ screened: results.length,
295
+ detail_opened: results.filter((item) => item.detail).length,
296
+ passed: results.filter((item) => item.screening?.passed).length,
297
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
298
+ image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
299
+ detail_open_failed: results.filter((item) => (
300
+ item.error?.code === "DETAIL_STALE_NODE"
301
+ || item.error?.code === "DETAIL_OPEN_FAILED"
302
+ )).length,
303
+ transient_recovered: results.filter((item) => (
304
+ item.error?.code === "DETAIL_STALE_NODE"
305
+ || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
306
+ || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
307
+ || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
308
+ )).length
309
+ };
310
+ }
311
+
312
+ export async function runRecruitWorkflow({
313
+ client,
314
+ targetUrl = "",
315
+ criteria = "",
316
+ searchParams = {},
317
+ maxCandidates = 5,
318
+ detailLimit = null,
319
+ closeDetail = true,
320
+ delayMs = 0,
321
+ cardTimeoutMs = 90000,
322
+ resetBeforeSearch = true,
323
+ resetTimeoutMs = 180000,
324
+ cityOptionTimeoutMs = 30000,
325
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
326
+ imageWheelDeltaY = 650,
327
+ cvAcquisitionMode = "unknown",
328
+ listMaxScrolls = 20,
329
+ listStableSignatureLimit = 5,
330
+ listWheelDeltaY = 850,
331
+ listSettleMs = 2200,
332
+ listFallbackPoint = null,
333
+ refreshOnEnd = true,
334
+ maxRefreshRounds = 2,
335
+ refreshResetSettleMs = 5000,
336
+ screeningMode = "llm",
337
+ llmConfig = null,
338
+ llmTimeoutMs = 120000,
339
+ llmImageLimit = 8,
340
+ llmImageDetail = "high",
341
+ imageOutputDir = "",
342
+ humanRestEnabled = false,
343
+ humanBehavior = null
344
+ } = {}, runControl) {
345
+ if (!client) throw new Error("runRecruitWorkflow requires a guarded CDP client");
346
+ const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
347
+ legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
348
+ });
349
+ const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
350
+ configureHumanInteraction(client, {
351
+ enabled: effectiveHumanBehavior.enabled,
352
+ clickMovementEnabled: effectiveHumanBehavior.clickMovement,
353
+ textEntryEnabled: effectiveHumanBehavior.textEntry,
354
+ safeClickPointEnabled: effectiveHumanBehavior.clickMovement,
355
+ actionCooldownEnabled: effectiveHumanBehavior.actionCooldown
356
+ });
357
+ const humanRestController = createHumanRestController({
358
+ enabled: effectiveHumanRestEnabled,
359
+ shortRestEnabled: effectiveHumanBehavior.shortRest,
360
+ batchRestEnabled: effectiveHumanBehavior.batchRest
361
+ });
362
+ const normalizedSearchParams = normalizeSearchParams(searchParams);
363
+ const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
364
+ const useLlmScreening = normalizedScreeningMode !== "deterministic";
365
+ const limit = Math.max(1, Number(maxCandidates) || 1);
366
+ const detailCountLimit = detailLimit == null ? limit : Math.max(0, Number(detailLimit) || 0);
367
+ const networkRecorder = detailCountLimit > 0
368
+ ? createRecruitDetailNetworkRecorder(client)
369
+ : null;
370
+ const cvAcquisitionState = createCvAcquisitionState({ mode: cvAcquisitionMode });
371
+ const listState = createInfiniteListState({
372
+ domain: "recruit",
373
+ listName: "search-results"
374
+ });
375
+ const viewportGuard = createViewportRunGuard({
376
+ client,
377
+ domain: "recruit",
378
+ root: "frame",
379
+ frameOwnerRoot: "frameOwner",
380
+ runControl,
381
+ getRoots: getRecruitRoots
382
+ });
383
+ async function ensureRecruitViewport(rootState, phase) {
384
+ const result = await viewportGuard.ensure(rootState, { phase });
385
+ return result.rootState || rootState;
386
+ }
387
+ const results = [];
388
+ const refreshAttempts = [];
389
+ let refreshRounds = 0;
390
+ let contextRecoveryAttempts = 0;
391
+ const candidateRecoveryCounts = new Map();
392
+ let cardNodeIds = [];
393
+ let listEndReason = "";
394
+ let lastHumanEvent = null;
395
+ const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
396
+ rootNodeId: rootState?.iframe?.documentNodeId,
397
+ containerSelectors: RECRUIT_LIST_CONTAINER_SELECTORS,
398
+ itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
399
+ itemSelectors: [RECRUIT_CARD_SELECTOR],
400
+ viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
401
+ validateViewportPoint: true
402
+ }));
403
+
404
+ function recordHumanEvent(event = null) {
405
+ if (!event) return lastHumanEvent;
406
+ lastHumanEvent = {
407
+ at: new Date().toISOString(),
408
+ ...event
409
+ };
410
+ return lastHumanEvent;
411
+ }
412
+
413
+ async function maybeHumanActionCooldown(phase, timings = {}) {
414
+ if (!effectiveHumanBehavior.actionCooldown) return null;
415
+ const pauseMs = humanDelay(280, 90, {
416
+ minMs: 80,
417
+ maxMs: 720
418
+ });
419
+ if (pauseMs > 0) {
420
+ await runControl.sleep(pauseMs);
421
+ addTiming(timings, `human_${phase}_pause_ms`, pauseMs);
422
+ }
423
+ return recordHumanEvent({
424
+ kind: "action_cooldown",
425
+ phase,
426
+ pause_ms: pauseMs
427
+ });
428
+ }
429
+
430
+ function updateRecruitProgress(extra = {}) {
431
+ const counts = countRecruitResultStatuses(results);
432
+ const listSnapshot = compactInfiniteListState(listState);
433
+ const humanRestState = humanRestController.getState();
434
+ runControl.updateProgress({
435
+ card_count: cardNodeIds.length,
436
+ target_count: limit,
437
+ ...counts,
438
+ screening_mode: normalizedScreeningMode,
439
+ unique_seen: listSnapshot.seen_count,
440
+ scroll_count: listSnapshot.scroll_count,
441
+ refresh_rounds: refreshRounds,
442
+ refresh_attempts: refreshAttempts.length,
443
+ context_recoveries: contextRecoveryAttempts,
444
+ list_end_reason: listEndReason || null,
445
+ viewport_checks: viewportGuard.getStats().checks,
446
+ viewport_recoveries: viewportGuard.getStats().recoveries,
447
+ human_behavior_enabled: effectiveHumanBehavior.enabled,
448
+ human_behavior_profile: effectiveHumanBehavior.profile,
449
+ human_rest_enabled: effectiveHumanRestEnabled,
450
+ human_rest_count: humanRestState.rest_count,
451
+ human_rest_ms: humanRestState.total_rest_ms,
452
+ last_human_event: lastHumanEvent,
453
+ ...extra
454
+ });
455
+ }
456
+
457
+ function checkpointInProgressCandidate({
458
+ index = results.length,
459
+ candidateKey = "",
460
+ cardNodeId = null,
461
+ detailStep = "",
462
+ error = null
463
+ } = {}) {
464
+ runControl.checkpoint({
465
+ in_progress_candidate: {
466
+ index,
467
+ key: candidateKey,
468
+ card_node_id: cardNodeId,
469
+ detail_step: detailStep || null,
470
+ counters: countRecruitResultStatuses(results),
471
+ error: compactError(error, "RECRUIT_IN_PROGRESS_ERROR")
472
+ },
473
+ candidate_list: compactInfiniteListState(listState)
474
+ });
475
+ }
476
+
477
+ async function recoverAndReapplyRecruitContext(reason = "context_recovery", error = null, {
478
+ forceRecentViewed = true
479
+ } = {}) {
480
+ await runControl.waitIfPaused();
481
+ runControl.throwIfCanceled();
482
+ const started = Date.now();
483
+ runControl.setPhase("recruit:recover-context");
484
+ contextRecoveryAttempts += 1;
485
+ const refreshResult = await refreshRecruitSearchAtEnd(client, {
486
+ searchParams: normalizedSearchParams,
487
+ requireCards: true,
488
+ searchTimeoutMs: cardTimeoutMs,
489
+ resetTimeoutMs,
490
+ resetSettleMs: refreshResetSettleMs,
491
+ cityOptionTimeoutMs,
492
+ forceRecentViewed
493
+ });
494
+ const compactRefresh = {
495
+ ...compactRefreshAttempt(refreshResult),
496
+ context_recovery: true,
497
+ recovery_reason: reason,
498
+ trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
499
+ elapsed_ms: Date.now() - started
500
+ };
501
+ refreshAttempts.push(compactRefresh);
502
+ runControl.checkpoint({
503
+ context_recovery: {
504
+ attempt: contextRecoveryAttempts,
505
+ reason,
506
+ trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
507
+ refresh: compactRefresh,
508
+ counters: countRecruitResultStatuses(results)
509
+ },
510
+ candidate_list: compactInfiniteListState(listState)
511
+ });
512
+ if (!refreshResult.ok) {
513
+ updateRecruitProgress({
514
+ refresh_method: refreshResult.method || null,
515
+ refresh_forced_recent_viewed: forceRecentViewed,
516
+ recovery_reason: reason
517
+ });
518
+ throw new Error(`Recruit context recovery failed after ${reason}: ${refreshResult.application?.reason || "refresh returned no cards"}`);
519
+ }
520
+ rootState = await getRecruitRoots(client);
521
+ rootState = await ensureRecruitViewport(rootState, "recover_after");
522
+ cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
523
+ timeoutMs: cardTimeoutMs,
524
+ intervalMs: 300
525
+ });
526
+ resetInfiniteListForRefreshRound(listState, {
527
+ reason: `context_recovery:${reason}`,
528
+ round: contextRecoveryAttempts,
529
+ method: refreshResult.method,
530
+ metadata: {
531
+ card_count: cardNodeIds.length,
532
+ forced_recent_viewed: forceRecentViewed,
533
+ counters: countRecruitResultStatuses(results)
534
+ }
535
+ });
536
+ listEndReason = "";
537
+ updateRecruitProgress({
538
+ card_count: cardNodeIds.length,
539
+ refresh_method: refreshResult.method || null,
540
+ refresh_forced_recent_viewed: forceRecentViewed,
541
+ recovery_reason: reason
542
+ });
543
+ return refreshResult;
544
+ }
545
+
546
+ runControl.setPhase("recruit:cleanup");
547
+ await closeRecruitDetail(client, { attemptsLimit: 2 });
548
+
549
+ await runControl.waitIfPaused();
550
+ runControl.throwIfCanceled();
551
+ runControl.setPhase("recruit:roots");
552
+ let rootState = await getRecruitRoots(client);
553
+ rootState = await ensureRecruitViewport(rootState, "roots");
554
+ runControl.checkpoint({
555
+ iframe_selector: rootState.iframe.selector,
556
+ iframe_document_node_id: rootState.iframe.documentNodeId,
557
+ search_params: normalizedSearchParams
558
+ });
559
+
560
+ if (hasRecruitSearchParams(normalizedSearchParams)) {
561
+ await runControl.waitIfPaused();
562
+ runControl.throwIfCanceled();
563
+ runControl.setPhase("recruit:search");
564
+ const searchResult = await applyRecruitSearchParams(client, {
565
+ searchParams: normalizedSearchParams,
566
+ requireCards: true,
567
+ resetBeforeApply: resetBeforeSearch,
568
+ searchTimeoutMs: cardTimeoutMs,
569
+ resetTimeoutMs,
570
+ cityOptionTimeoutMs
571
+ });
572
+ runControl.checkpoint({
573
+ search: {
574
+ search_params: searchResult.search_params,
575
+ before_counts: searchResult.before_counts,
576
+ post_search_state: searchResult.post_search_state,
577
+ steps: searchResult.steps.map((step) => ({
578
+ step: step.step,
579
+ applied: step.result?.applied,
580
+ clicked: step.result?.clicked,
581
+ searched: step.result?.searched,
582
+ reason: step.result?.reason || null
583
+ }))
584
+ }
585
+ });
586
+ rootState = await getRecruitRoots(client);
587
+ rootState = await ensureRecruitViewport(rootState, "search");
588
+ }
589
+
590
+ await runControl.waitIfPaused();
591
+ runControl.throwIfCanceled();
592
+ runControl.setPhase("recruit:cards");
593
+ rootState = await ensureRecruitViewport(rootState, "cards");
594
+ cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
595
+ timeoutMs: cardTimeoutMs,
596
+ intervalMs: 300
597
+ });
598
+ if (!cardNodeIds.length) {
599
+ throw new Error("No recruit/search candidate cards found for run service");
600
+ }
601
+
602
+ updateRecruitProgress({
603
+ list_end_reason: null
604
+ });
605
+
606
+ while (results.length < limit) {
607
+ const candidateStarted = Date.now();
608
+ const timings = {};
609
+ await runControl.waitIfPaused();
610
+ runControl.throwIfCanceled();
611
+ runControl.setPhase("recruit:candidate");
612
+ rootState = await ensureRecruitViewport(rootState, "candidate_loop");
613
+
614
+ const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
615
+ client,
616
+ state: listState,
617
+ maxScrolls: listMaxScrolls,
618
+ stableSignatureLimit: listStableSignatureLimit,
619
+ wheelDeltaY: listWheelDeltaY,
620
+ settleMs: listSettleMs,
621
+ listScrollJitterEnabled: effectiveHumanBehavior.listScrollJitter,
622
+ fallbackPoint: listFallbackResolver,
623
+ findNodeIds: async () => {
624
+ let currentRootState = await getRecruitRoots(client);
625
+ currentRootState = await ensureRecruitViewport(currentRootState, "candidate_find_nodes");
626
+ rootState = currentRootState;
627
+ const currentCardNodeIds = await waitForRecruitCardNodeIds(client, currentRootState.iframe.documentNodeId, {
628
+ timeoutMs: Math.min(cardTimeoutMs, 5000),
629
+ intervalMs: 300
630
+ });
631
+ cardNodeIds = currentCardNodeIds;
632
+ return currentCardNodeIds;
633
+ },
634
+ readCandidate: async (nodeId, { visibleIndex }) => readRecruitCardCandidate(client, nodeId, {
635
+ targetUrl,
636
+ source: "recruit-run-card",
637
+ metadata: {
638
+ run_candidate_index: results.length,
639
+ visible_index: visibleIndex,
640
+ search_params: normalizedSearchParams
641
+ }
642
+ }),
643
+ detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
644
+ rootNodeId: rootState?.iframe?.documentNodeId,
645
+ markerSelectors: RECRUIT_BOTTOM_MARKER_SELECTORS,
646
+ refreshSelectors: RECRUIT_BOTTOM_REFRESH_SELECTORS,
647
+ textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
648
+ maxTextScanNodes: 500
649
+ })
650
+ }));
651
+ if (!nextCandidateResult.ok) {
652
+ listEndReason = nextCandidateResult.reason || "list_exhausted";
653
+ if (
654
+ (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
655
+ && refreshOnEnd
656
+ && results.length < limit
657
+ && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
658
+ ) {
659
+ await runControl.waitIfPaused();
660
+ runControl.throwIfCanceled();
661
+ runControl.setPhase("recruit:refresh");
662
+ refreshRounds += 1;
663
+ const refreshResult = await refreshRecruitSearchAtEnd(client, {
664
+ searchParams: normalizedSearchParams,
665
+ requireCards: true,
666
+ searchTimeoutMs: cardTimeoutMs,
667
+ resetTimeoutMs,
668
+ resetSettleMs: refreshResetSettleMs,
669
+ cityOptionTimeoutMs
670
+ });
671
+ const compactRefresh = compactRefreshAttempt(refreshResult);
672
+ refreshAttempts.push(compactRefresh);
673
+ runControl.checkpoint({
674
+ refresh_round: refreshRounds,
675
+ refresh: compactRefresh
676
+ });
677
+ updateRecruitProgress({
678
+ card_count: refreshResult.card_count || cardNodeIds.length,
679
+ refresh_method: refreshResult.method || null,
680
+ refresh_forced_recent_viewed: true,
681
+ list_end_reason: listEndReason
682
+ });
683
+ if (refreshResult.ok) {
684
+ rootState = await getRecruitRoots(client);
685
+ rootState = await ensureRecruitViewport(rootState, "refresh_after");
686
+ cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
687
+ timeoutMs: cardTimeoutMs,
688
+ intervalMs: 300
689
+ });
690
+ resetInfiniteListForRefreshRound(listState, {
691
+ reason: listEndReason,
692
+ round: refreshRounds,
693
+ method: refreshResult.method,
694
+ metadata: {
695
+ card_count: cardNodeIds.length,
696
+ forced_recent_viewed: true
697
+ }
698
+ });
699
+ listEndReason = "";
700
+ continue;
701
+ }
702
+ throw createRecruitRefreshFailureError(compactRefresh, {
703
+ listEndReason,
704
+ targetCount: limit,
705
+ processedCount: results.length
706
+ });
707
+ }
708
+ break;
709
+ }
710
+
711
+ const index = results.length;
712
+ const cardNodeId = nextCandidateResult.item.node_id;
713
+ const candidateKey = nextCandidateResult.item.key;
714
+ const cardCandidate = nextCandidateResult.item.candidate;
715
+
716
+ let screeningCandidate = cardCandidate;
717
+ let detailResult = null;
718
+ let recoverableDetailError = null;
719
+ let detailStep = "not_started";
720
+ if (index < detailCountLimit) {
721
+ try {
722
+ await runControl.waitIfPaused();
723
+ runControl.throwIfCanceled();
724
+ runControl.setPhase("recruit:detail");
725
+ detailStep = "ensure_viewport";
726
+ rootState = await ensureRecruitViewport(rootState, "detail");
727
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
728
+ detailStep = "open_detail";
729
+ networkRecorder.clear();
730
+ await maybeHumanActionCooldown("before_detail_open", timings);
731
+ const openedDetail = await openRecruitCardDetail(client, cardNodeId);
732
+ addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
733
+ addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
734
+ const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
735
+ detailStep = "wait_network";
736
+ const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
737
+ waitForRecruitDetailNetworkEvents,
738
+ networkRecorder,
739
+ {
740
+ waitPlan,
741
+ minCount: 1,
742
+ requireLoaded: true,
743
+ intervalMs: 120
744
+ }
745
+ ));
746
+ if (networkWait?.elapsed_ms != null) {
747
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
748
+ }
749
+ detailStep = "extract_detail";
750
+ detailResult = await extractRecruitDetailCandidate(client, {
751
+ cardCandidate,
752
+ cardNodeId,
753
+ detailState: openedDetail.detail_state,
754
+ networkEvents: networkRecorder.events,
755
+ targetUrl,
756
+ closeDetail: false,
757
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
758
+ networkParseIntervalMs: 250
759
+ });
760
+ addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
761
+ const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
762
+ let source = "network";
763
+ let imageEvidence = null;
764
+ let captureTarget = null;
765
+ let captureTargetWait = null;
766
+ if (parsedNetworkProfileCount > 0) {
767
+ recordCvNetworkHit(cvAcquisitionState, {
768
+ parsedNetworkProfileCount,
769
+ waitResult: networkWait
770
+ });
771
+ } else {
772
+ detailStep = "wait_capture_target";
773
+ captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
774
+ domain: "recruit",
775
+ timeoutMs: 6000,
776
+ intervalMs: 250
777
+ });
778
+ captureTarget = captureTargetWait.target || null;
779
+ const captureNodeId = captureTarget?.node_id || null;
780
+ if (captureNodeId) {
781
+ const imageEvidencePath = imageEvidenceFilePath({
782
+ imageOutputDir,
783
+ domain: "recruit",
784
+ runId: runControl?.runId,
785
+ index,
786
+ extension: "jpg"
787
+ });
788
+ try {
789
+ detailStep = "capture_image";
790
+ imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
791
+ filePath: imageEvidencePath,
792
+ format: "jpeg",
793
+ quality: 72,
794
+ optimize: true,
795
+ resizeMaxWidth: 1100,
796
+ captureViewport: false,
797
+ padding: 0,
798
+ maxScreenshots: maxImagePages,
799
+ wheelDeltaY: imageWheelDeltaY,
800
+ settleMs: 350,
801
+ scrollMethod: "dom-anchor-fallback-input",
802
+ scrollDeltaJitterEnabled: effectiveHumanBehavior.listScrollJitter,
803
+ stepTimeoutMs: 45000,
804
+ totalTimeoutMs: 90000,
805
+ duplicateStopCount: 1,
806
+ skipDuplicateScreenshots: true,
807
+ composeForLlm: true,
808
+ llmPagesPerImage: 3,
809
+ llmResizeMaxWidth: 1100,
810
+ llmQuality: 72,
811
+ metadata: {
812
+ domain: "recruit",
813
+ capture_mode: "scroll_sequence",
814
+ acquisition_reason: "network_miss_image_fallback",
815
+ run_candidate_index: index,
816
+ candidate_key: candidateKey,
817
+ capture_target: captureTarget,
818
+ capture_target_wait: captureTargetWait
819
+ }
820
+ }));
821
+ source = "image";
822
+ } catch (error) {
823
+ if (!isRecoverableRecruitImageCaptureError(error)) throw error;
824
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
825
+ if (recoveryCount < 1) {
826
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
827
+ timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
828
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
829
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
830
+ await recoverAndReapplyRecruitContext(`image_capture:${detailStep}`, error, {
831
+ forceRecentViewed: true
832
+ });
833
+ continue;
834
+ }
835
+ imageEvidence = createRecoverableRecruitImageCaptureEvidence(error, {
836
+ elapsedMs: timings.screenshot_capture_ms,
837
+ filePath: imageEvidencePath,
838
+ extension: "jpg",
839
+ maxScreenshots: maxImagePages
840
+ });
841
+ source = "image_capture_failed";
842
+ }
843
+ recordCvImageFallback(cvAcquisitionState, {
844
+ reason: source === "image_capture_failed"
845
+ ? "network_miss_image_capture_failed"
846
+ : "network_miss_image_fallback",
847
+ parsedNetworkProfileCount,
848
+ waitResult: networkWait,
849
+ imageEvidence
850
+ });
851
+ } else {
852
+ source = "missing_capture_node";
853
+ recordCvNetworkMiss(cvAcquisitionState, {
854
+ reason: "network_miss_no_capture_node",
855
+ parsedNetworkProfileCount,
856
+ waitResult: networkWait
857
+ });
858
+ }
859
+ }
860
+
861
+ detailResult.image_evidence = imageEvidence;
862
+ detailResult.cv_acquisition = {
863
+ source,
864
+ mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
865
+ wait_plan: waitPlan,
866
+ network_wait: networkWait,
867
+ parsed_network_profile_count: parsedNetworkProfileCount,
868
+ image_evidence: summarizeImageEvidence(imageEvidence),
869
+ capture_target: captureTarget || null,
870
+ capture_target_wait: captureTargetWait
871
+ };
872
+ screeningCandidate = detailResult.candidate;
873
+ if (closeDetail) {
874
+ detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecruitDetail(client));
875
+ await maybeHumanActionCooldown("after_detail_close", timings);
876
+ if (!detailResult.close_result?.closed) {
877
+ const closeError = createRecruitCloseFailureError(detailResult.close_result);
878
+ const recovery = await recoverAndReapplyRecruitContext("detail_close_failed", closeError, {
879
+ forceRecentViewed: true
880
+ });
881
+ detailResult.cv_acquisition = {
882
+ ...(detailResult.cv_acquisition || {}),
883
+ close_recovery: {
884
+ ok: Boolean(recovery.ok),
885
+ method: recovery.method || "",
886
+ forced_recent_viewed: Boolean(recovery.forced_recent_viewed),
887
+ card_count: recovery.card_count || 0
888
+ }
889
+ };
890
+ }
891
+ } else {
892
+ detailResult.close_result = null;
893
+ }
894
+ } catch (error) {
895
+ if (!isRecoverableRecruitDetailError(error)) throw error;
896
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
897
+ if (recoveryCount < 1) {
898
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
899
+ timings.detail_recovery_trigger = compactRecoverableDetailError(error);
900
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
901
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
902
+ await recoverAndReapplyRecruitContext(`detail:${detailStep}`, error, {
903
+ forceRecentViewed: true
904
+ });
905
+ continue;
906
+ }
907
+ recoverableDetailError = error;
908
+ detailResult = null;
909
+ timings.detail_recovered_error = compactRecoverableDetailError(error);
910
+ await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
911
+ }
912
+ }
913
+
914
+ await runControl.waitIfPaused();
915
+ runControl.throwIfCanceled();
916
+ runControl.setPhase("recruit:screening");
917
+ let llmResult = null;
918
+ if (useLlmScreening) {
919
+ if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
920
+ llmResult = null;
921
+ } else if (!llmConfig) {
922
+ llmResult = createMissingLlmConfigResult();
923
+ } else {
924
+ try {
925
+ const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
926
+ ? "vision_model_ms"
927
+ : "text_model_ms";
928
+ llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
929
+ candidate: screeningCandidate,
930
+ criteria,
931
+ config: llmConfig,
932
+ timeoutMs: llmTimeoutMs,
933
+ imageEvidence: detailResult?.image_evidence || null,
934
+ maxImages: llmImageLimit,
935
+ imageDetail: llmImageDetail
936
+ }));
937
+ } catch (error) {
938
+ llmResult = createFailedLlmScreeningResult(error);
939
+ }
940
+ }
941
+ if (detailResult) detailResult.llm_result = llmResult;
942
+ }
943
+ const screening = recoverableDetailError
944
+ ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
945
+ : detailResult?.image_evidence?.ok === false
946
+ ? createImageCaptureFailureScreening(screeningCandidate, {
947
+ code: detailResult.image_evidence.error_code,
948
+ message: detailResult.image_evidence.error
949
+ })
950
+ : useLlmScreening
951
+ ? llmResultToScreening(llmResult, screeningCandidate)
952
+ : screenCandidate(screeningCandidate, { criteria });
953
+ timings.total_ms = Date.now() - candidateStarted;
954
+ const compactResult = {
955
+ index,
956
+ candidate_key: candidateKey,
957
+ card_node_id: cardNodeId,
958
+ candidate: compactCandidate(screeningCandidate),
959
+ detail: compactDetail(detailResult),
960
+ llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
961
+ screening: compactScreening(screening),
962
+ error: recoverableDetailError
963
+ ? compactRecoverableDetailError(recoverableDetailError)
964
+ : detailResult?.image_evidence?.ok === false
965
+ ? compactError({
966
+ code: detailResult.image_evidence.error_code,
967
+ message: detailResult.image_evidence.error
968
+ }, "IMAGE_CAPTURE_FAILED")
969
+ : null,
970
+ timings
971
+ };
972
+ results.push(compactResult);
973
+ markInfiniteListCandidateProcessed(listState, candidateKey, {
974
+ metadata: {
975
+ result_index: index,
976
+ candidate_id: screeningCandidate.id || null
977
+ }
978
+ });
979
+
980
+ updateRecruitProgress({
981
+ last_candidate_id: screeningCandidate.id || null,
982
+ last_candidate_key: candidateKey,
983
+ last_score: screening.score
984
+ });
985
+ const checkpointStarted = Date.now();
986
+ runControl.checkpoint({
987
+ results,
988
+ last_candidate: {
989
+ id: screeningCandidate.id || null,
990
+ key: candidateKey,
991
+ identity: screeningCandidate.identity || {},
992
+ screening: {
993
+ status: screening.status,
994
+ passed: screening.passed,
995
+ score: screening.score
996
+ },
997
+ llm_screening: compactScreeningLlmResult(llmResult),
998
+ error: compactResult.error
999
+ }
1000
+ });
1001
+ addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
1002
+
1003
+ if (effectiveHumanRestEnabled) {
1004
+ const restStarted = Date.now();
1005
+ const restResult = await humanRestController.takeBreakIfNeeded({
1006
+ sleepFn: (ms) => runControl.sleep(ms)
1007
+ });
1008
+ const restElapsed = Date.now() - restStarted;
1009
+ if (restResult.rested) {
1010
+ recordHumanEvent({
1011
+ kind: "rest",
1012
+ pause_ms: restResult.pause_ms || restElapsed,
1013
+ events: restResult.events || []
1014
+ });
1015
+ compactResult.human_rest = restResult;
1016
+ addTiming(compactResult.timings, "human_rest_ms", restElapsed);
1017
+ compactResult.timings.total_ms = Date.now() - candidateStarted;
1018
+ updateRecruitProgress({
1019
+ human_rest_last: restResult
1020
+ });
1021
+ }
1022
+ }
1023
+
1024
+ if (delayMs > 0) {
1025
+ const sleepStarted = Date.now();
1026
+ await runControl.sleep(delayMs);
1027
+ addTiming(compactResult.timings, "sleep_ms", Date.now() - sleepStarted);
1028
+ compactResult.timings.total_ms = Date.now() - candidateStarted;
1029
+ }
1030
+ }
1031
+
1032
+ runControl.setPhase("recruit:done");
1033
+ return {
1034
+ domain: "recruit",
1035
+ target_url: targetUrl,
1036
+ search_params: normalizedSearchParams,
1037
+ card_count: cardNodeIds.length,
1038
+ candidate_list: compactInfiniteListState(listState),
1039
+ viewport_health: {
1040
+ stats: viewportGuard.getStats(),
1041
+ events: viewportGuard.getEvents()
1042
+ },
1043
+ human_behavior: effectiveHumanBehavior,
1044
+ human_rest: humanRestController.getState(),
1045
+ last_human_event: lastHumanEvent,
1046
+ list_end_reason: listEndReason || null,
1047
+ refresh_rounds: refreshRounds,
1048
+ refresh_attempts: refreshAttempts,
1049
+ context_recoveries: contextRecoveryAttempts,
1050
+ ...countRecruitResultStatuses(results),
1051
+ results
1052
+ };
1053
+ }
1054
+
1055
+ export function createRecruitRunService({
1056
+ lifecycle,
1057
+ idPrefix = "recruit",
1058
+ workflow = runRecruitWorkflow,
1059
+ onSnapshot = null
1060
+ } = {}) {
1061
+ const manager = lifecycle || createRunLifecycleManager({ idPrefix, onSnapshot });
1062
+
1063
+ function startRecruitRun({
1064
+ client,
1065
+ targetUrl = "",
1066
+ criteria = "",
1067
+ searchParams = {},
1068
+ maxCandidates = 5,
1069
+ detailLimit = null,
1070
+ closeDetail = true,
1071
+ delayMs = 0,
1072
+ cardTimeoutMs = 90000,
1073
+ resetBeforeSearch = true,
1074
+ resetTimeoutMs = 180000,
1075
+ cityOptionTimeoutMs = 30000,
1076
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
1077
+ imageWheelDeltaY = 650,
1078
+ cvAcquisitionMode = "unknown",
1079
+ listMaxScrolls = 20,
1080
+ listStableSignatureLimit = 5,
1081
+ listWheelDeltaY = 850,
1082
+ listSettleMs = 2200,
1083
+ listFallbackPoint = null,
1084
+ refreshOnEnd = true,
1085
+ maxRefreshRounds = 2,
1086
+ refreshResetSettleMs = 5000,
1087
+ screeningMode = "llm",
1088
+ llmConfig = null,
1089
+ llmTimeoutMs = 120000,
1090
+ llmImageLimit = 8,
1091
+ llmImageDetail = "high",
1092
+ imageOutputDir = "",
1093
+ humanRestEnabled = false,
1094
+ humanBehavior = null,
1095
+ name = "recruit-domain-run"
1096
+ } = {}) {
1097
+ if (!client) throw new Error("startRecruitRun requires a guarded CDP client");
1098
+ const normalizedSearchParams = normalizeSearchParams(searchParams);
1099
+ const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
1100
+ const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
1101
+ const normalizedDetailLimit = detailLimit == null ? candidateLimit : Math.max(0, Number(detailLimit) || 0);
1102
+ const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
1103
+ legacyEnabled: humanRestEnabled === true || llmConfig?.humanRestEnabled === true
1104
+ });
1105
+ const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
1106
+ return manager.startRun({
1107
+ name,
1108
+ context: {
1109
+ domain: "recruit",
1110
+ target_url: targetUrl,
1111
+ criteria_present: Boolean(criteria),
1112
+ search_params: normalizedSearchParams,
1113
+ max_candidates: maxCandidates,
1114
+ detail_limit: normalizedDetailLimit,
1115
+ close_detail: closeDetail,
1116
+ reset_before_search: resetBeforeSearch,
1117
+ reset_timeout_ms: resetTimeoutMs,
1118
+ city_option_timeout_ms: cityOptionTimeoutMs,
1119
+ cv_acquisition_mode: cvAcquisitionMode,
1120
+ max_image_pages: maxImagePages,
1121
+ image_wheel_delta_y: imageWheelDeltaY,
1122
+ list_max_scrolls: listMaxScrolls,
1123
+ list_stable_signature_limit: listStableSignatureLimit,
1124
+ list_wheel_delta_y: listWheelDeltaY,
1125
+ list_settle_ms: listSettleMs,
1126
+ list_fallback_point: listFallbackPoint,
1127
+ refresh_on_end: refreshOnEnd,
1128
+ max_refresh_rounds: maxRefreshRounds,
1129
+ refresh_reset_settle_ms: refreshResetSettleMs,
1130
+ screening_mode: normalizedScreeningMode,
1131
+ llm_configured: Boolean(llmConfig),
1132
+ llm_timeout_ms: llmTimeoutMs,
1133
+ llm_image_limit: llmImageLimit,
1134
+ llm_image_detail: llmImageDetail,
1135
+ image_output_dir: imageOutputDir || "",
1136
+ human_behavior_enabled: effectiveHumanBehavior.enabled,
1137
+ human_behavior_profile: effectiveHumanBehavior.profile,
1138
+ human_behavior: effectiveHumanBehavior,
1139
+ human_rest_enabled: effectiveHumanRestEnabled
1140
+ },
1141
+ progress: {
1142
+ card_count: 0,
1143
+ target_count: candidateLimit,
1144
+ processed: 0,
1145
+ screened: 0,
1146
+ detail_opened: 0,
1147
+ llm_screened: 0,
1148
+ passed: 0,
1149
+ image_capture_failed: 0,
1150
+ detail_open_failed: 0,
1151
+ transient_recovered: 0,
1152
+ context_recoveries: 0,
1153
+ human_behavior_enabled: effectiveHumanBehavior.enabled,
1154
+ human_behavior_profile: effectiveHumanBehavior.profile,
1155
+ human_rest_enabled: effectiveHumanRestEnabled,
1156
+ human_rest_count: 0,
1157
+ human_rest_ms: 0,
1158
+ last_human_event: null
1159
+ },
1160
+ checkpoint: {},
1161
+ task: (runControl) => workflow({
1162
+ client,
1163
+ targetUrl,
1164
+ criteria,
1165
+ searchParams: normalizedSearchParams,
1166
+ maxCandidates,
1167
+ detailLimit: normalizedDetailLimit,
1168
+ closeDetail,
1169
+ delayMs,
1170
+ cardTimeoutMs,
1171
+ resetBeforeSearch,
1172
+ resetTimeoutMs,
1173
+ cityOptionTimeoutMs,
1174
+ maxImagePages,
1175
+ imageWheelDeltaY,
1176
+ cvAcquisitionMode,
1177
+ listMaxScrolls,
1178
+ listStableSignatureLimit,
1179
+ listWheelDeltaY,
1180
+ listSettleMs,
1181
+ listFallbackPoint,
1182
+ refreshOnEnd,
1183
+ maxRefreshRounds,
1184
+ refreshResetSettleMs,
1185
+ screeningMode: normalizedScreeningMode,
1186
+ llmConfig,
1187
+ llmTimeoutMs,
1188
+ llmImageLimit,
1189
+ llmImageDetail,
1190
+ imageOutputDir,
1191
+ humanRestEnabled: effectiveHumanRestEnabled,
1192
+ humanBehavior: effectiveHumanBehavior
1193
+ }, runControl)
1194
+ });
1195
+ }
1196
+
1197
+ return {
1198
+ startRecruitRun,
1199
+ getRecruitRun: manager.getRun,
1200
+ pauseRecruitRun: manager.pauseRun,
1201
+ resumeRecruitRun: manager.resumeRun,
1202
+ cancelRecruitRun: manager.cancelRun,
1203
+ waitForRecruitRun: manager.waitForRun,
1204
+ listRecruitRuns: manager.listRuns,
1205
+ manager
1206
+ };
1207
+ }