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