@reconcrap/boss-recommend-mcp 2.0.48 → 2.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.48",
3
+ "version": "2.0.50",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -443,6 +443,34 @@ export async function closeChatJobDropdown(client, rootNodeId, {
443
443
  });
444
444
  if (settleMs > 0) await sleep(settleMs);
445
445
  const after = await visibleChatJobOptions(client, rootNodeId);
446
+ if (after.length) {
447
+ const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
448
+ for (const selector of CHAT_JOB_TRIGGER_SELECTORS) {
449
+ const nodeIds = await safeQuerySelectorAll(client, currentRootNodeId, selector);
450
+ for (const nodeId of nodeIds) {
451
+ try {
452
+ const box = await getNodeBox(client, nodeId);
453
+ if (box.rect.width <= 2 || box.rect.height <= 2) continue;
454
+ await clickPoint(client, box.center.x, box.center.y, DETERMINISTIC_CLICK_OPTIONS);
455
+ if (settleMs > 0) await sleep(settleMs);
456
+ const afterToggle = await visibleChatJobOptions(client, currentRootNodeId);
457
+ if (!afterToggle.length) {
458
+ return {
459
+ ok: true,
460
+ closed: true,
461
+ reason: "trigger_toggle",
462
+ visible_before_count: before.length,
463
+ visible_after_count: 0,
464
+ first_visible_before: before[0] || null,
465
+ first_visible_after: null
466
+ };
467
+ }
468
+ } catch {
469
+ continue;
470
+ }
471
+ }
472
+ }
473
+ }
446
474
  return {
447
475
  ok: after.length === 0,
448
476
  closed: after.length === 0,
@@ -3,6 +3,14 @@ import {
3
3
  sleep,
4
4
  waitForMainFrameUrl
5
5
  } from "../../core/browser/index.js";
6
+ import {
7
+ buildChatSelfHealConfig,
8
+ resolveChatSelfHealRoots
9
+ } from "../../core/self-heal/index.js";
10
+ import {
11
+ createRecoverySettleError,
12
+ waitForMiniFreshStartSettle
13
+ } from "../common/recovery-settle.js";
6
14
  import { CHAT_TARGET_URL } from "./constants.js";
7
15
 
8
16
  export const CHAT_FORBIDDEN_TOP_LEVEL_RESUME_CODE = "CHAT_FORBIDDEN_TOP_LEVEL_RESUME_NAVIGATION";
@@ -63,7 +71,8 @@ export async function recoverChatShell(client, {
63
71
  timeoutMs = 60000,
64
72
  intervalMs = 500,
65
73
  forceNavigate = false,
66
- settleMs = 1200
74
+ settleMs = 1200,
75
+ settleAfterNavigate = false
67
76
  } = {}) {
68
77
  const before = await getChatTopLevelState(client);
69
78
  if (before.is_chat_shell && !forceNavigate) {
@@ -85,11 +94,26 @@ export async function recoverChatShell(client, {
85
94
  intervalMs
86
95
  });
87
96
  const after = await getChatTopLevelState(client);
97
+ let miniFreshStart = null;
98
+ if (after.is_chat_shell && settleAfterNavigate) {
99
+ miniFreshStart = await waitForMiniFreshStartSettle(client, {
100
+ domain: "chat",
101
+ timeoutMs,
102
+ intervalMs: Math.max(intervalMs, 800),
103
+ settleMs: Math.min(settleMs, 5000),
104
+ selfHealConfig: buildChatSelfHealConfig(),
105
+ resolveSelfHealRoots: resolveChatSelfHealRoots
106
+ });
107
+ if (!miniFreshStart.ok) {
108
+ throw createRecoverySettleError("chat", miniFreshStart);
109
+ }
110
+ }
88
111
  return {
89
112
  recovered: waited.ok && after.is_chat_shell,
90
113
  refreshed: Boolean(forceNavigate && before.is_chat_shell && after.is_chat_shell),
91
114
  before,
92
115
  after,
116
+ mini_fresh_start: miniFreshStart,
93
117
  wait: waited,
94
118
  navigate_result: navigateResult || null,
95
119
  navigate_url: targetUrl,
@@ -788,7 +788,8 @@ export async function runChatWorkflow({
788
788
  if (!initialTopLevelState.is_chat_shell) {
789
789
  const recovery = await recoverChatShell(client, {
790
790
  targetUrl,
791
- timeoutMs: readyTimeoutMs
791
+ timeoutMs: readyTimeoutMs,
792
+ settleAfterNavigate: true
792
793
  });
793
794
  runControl.checkpoint({
794
795
  chat_shell_recovery: {
@@ -831,7 +832,8 @@ export async function runChatWorkflow({
831
832
  const shellRecovery = await recoverChatShell(client, {
832
833
  targetUrl,
833
834
  timeoutMs: readyTimeoutMs,
834
- forceNavigate: forceRefresh
835
+ forceNavigate: forceRefresh,
836
+ settleAfterNavigate: true
835
837
  });
836
838
  runControl.checkpoint({
837
839
  chat_shell_recovery: {
@@ -0,0 +1,159 @@
1
+ import {
2
+ bringPageToFront,
3
+ detectBossLoginState,
4
+ sleep
5
+ } from "../../core/browser/index.js";
6
+ import {
7
+ HEALTH_STATUS,
8
+ runSelfHealCheck
9
+ } from "../../core/self-heal/index.js";
10
+
11
+ function compactProbe(probe = {}) {
12
+ return {
13
+ id: probe.id || "",
14
+ type: probe.type || "",
15
+ status: probe.status || "",
16
+ count: probe.count || 0,
17
+ required: Boolean(probe.required),
18
+ error: probe.error || null
19
+ };
20
+ }
21
+
22
+ export function compactRecoveryHealth(check = null) {
23
+ if (!check) return null;
24
+ return {
25
+ domain: check.domain || "",
26
+ status: check.status || "",
27
+ summary: check.summary || null,
28
+ drift_report: check.drift_report || null,
29
+ probes: (check.probes || []).map(compactProbe)
30
+ };
31
+ }
32
+
33
+ export function createRecoverySettleError(domain, settle = {}) {
34
+ const status = settle.status || settle.reason || "unknown";
35
+ const error = new Error(`${domain} mini fresh-start settle failed: ${status}`);
36
+ error.code = `${String(domain || "boss").toUpperCase()}_RECOVERY_SETTLE_FAILED`;
37
+ error.recovery_settle = settle;
38
+ error.retryable = true;
39
+ return error;
40
+ }
41
+
42
+ export async function waitForMiniFreshStartSettle(client, {
43
+ domain = "boss",
44
+ timeoutMs = 90000,
45
+ intervalMs = 800,
46
+ settleMs = 0,
47
+ readinessLabel = "ready",
48
+ checkReady = null,
49
+ selfHealConfig = null,
50
+ resolveSelfHealRoots = null
51
+ } = {}) {
52
+ const started = Date.now();
53
+ let lastReady = null;
54
+ let lastHealth = null;
55
+ let lastRoots = null;
56
+ let lastLoginDetection = null;
57
+
58
+ if (typeof client?.Network?.setCacheDisabled === "function") {
59
+ await client.Network.setCacheDisabled({ cacheDisabled: true }).catch(() => null);
60
+ }
61
+ await bringPageToFront(client).catch(() => null);
62
+ if (settleMs > 0) await sleep(settleMs);
63
+
64
+ while (Date.now() - started <= timeoutMs) {
65
+ lastLoginDetection = await detectBossLoginState(client).catch((error) => ({
66
+ requires_login: false,
67
+ reason: "login_detection_failed",
68
+ error: error?.message || String(error || "")
69
+ }));
70
+ if (lastLoginDetection?.requires_login) {
71
+ return {
72
+ ok: false,
73
+ domain,
74
+ status: "login_required",
75
+ reason: "login_required",
76
+ elapsed_ms: Date.now() - started,
77
+ login_detection: lastLoginDetection,
78
+ readiness: lastReady,
79
+ health: compactRecoveryHealth(lastHealth),
80
+ roots: lastRoots
81
+ };
82
+ }
83
+
84
+ if (typeof checkReady === "function") {
85
+ lastReady = await checkReady({
86
+ elapsedMs: Date.now() - started,
87
+ remainingMs: Math.max(1, timeoutMs - (Date.now() - started))
88
+ }).catch((error) => ({
89
+ ok: false,
90
+ reason: "readiness_check_failed",
91
+ error: error?.message || String(error || "")
92
+ }));
93
+ if (lastReady?.ok) {
94
+ return {
95
+ ok: true,
96
+ domain,
97
+ status: "ready",
98
+ reason: readinessLabel,
99
+ elapsed_ms: Date.now() - started,
100
+ readiness: lastReady,
101
+ health: compactRecoveryHealth(lastHealth),
102
+ roots: lastRoots
103
+ };
104
+ }
105
+ }
106
+
107
+ if (selfHealConfig && typeof resolveSelfHealRoots === "function") {
108
+ const rootsResult = await resolveSelfHealRoots(client, selfHealConfig).catch((error) => ({
109
+ roots: {},
110
+ error: error?.message || String(error || "")
111
+ }));
112
+ lastRoots = rootsResult?.roots || {};
113
+ lastHealth = await runSelfHealCheck({
114
+ client,
115
+ domain,
116
+ roots: lastRoots,
117
+ selectorProbes: selfHealConfig.selectorProbes || [],
118
+ accessibilityProbes: selfHealConfig.accessibilityProbes || [],
119
+ viewportProbes: selfHealConfig.viewportProbes || []
120
+ }).catch((error) => ({
121
+ domain,
122
+ status: "failed",
123
+ summary: {
124
+ status: "failed",
125
+ failed_required: 1
126
+ },
127
+ probes: [],
128
+ drift_report: [],
129
+ error: error?.message || String(error || "")
130
+ }));
131
+ if (lastHealth?.status === HEALTH_STATUS.HEALTHY) {
132
+ return {
133
+ ok: true,
134
+ domain,
135
+ status: HEALTH_STATUS.HEALTHY,
136
+ reason: "self_heal_healthy",
137
+ elapsed_ms: Date.now() - started,
138
+ readiness: lastReady,
139
+ health: compactRecoveryHealth(lastHealth),
140
+ roots: lastRoots
141
+ };
142
+ }
143
+ }
144
+
145
+ await sleep(intervalMs);
146
+ }
147
+
148
+ return {
149
+ ok: false,
150
+ domain,
151
+ status: lastHealth?.status || lastReady?.reason || "timeout",
152
+ reason: "timeout",
153
+ elapsed_ms: Date.now() - started,
154
+ login_detection: lastLoginDetection,
155
+ readiness: lastReady,
156
+ health: compactRecoveryHealth(lastHealth),
157
+ roots: lastRoots
158
+ };
159
+ }
@@ -312,7 +312,7 @@ export async function readRecommendDetailHtml(client, detailState) {
312
312
 
313
313
  export function isStaleRecommendNodeError(error) {
314
314
  const message = String(error?.message || error || "");
315
- return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
315
+ return /Could not find node with given id|No node with given id|Node is detached|Cannot find node|Could not compute box model/i.test(message);
316
316
  }
317
317
 
318
318
  export function isRecommendDetailOpenMissError(error) {
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  clickNodeCenter,
3
+ clickPoint,
3
4
  DETERMINISTIC_CLICK_OPTIONS,
4
5
  getAttributesMap,
5
6
  getNodeBox,
@@ -12,6 +13,7 @@ import {
12
13
  htmlToText,
13
14
  normalizeText
14
15
  } from "../../core/screening/index.js";
16
+ import { isStaleRecommendNodeError } from "./detail.js";
15
17
 
16
18
  export const RECOMMEND_JOB_SELECTORS = Object.freeze({
17
19
  trigger: ".job-selecter-wrap, [class*=\"job-selecter-wrap\"], .ui-dropmenu",
@@ -52,15 +54,26 @@ function isVisibleBox(box) {
52
54
  }
53
55
 
54
56
  async function readJobOption(client, nodeId, index) {
55
- const [attributes, outerHTML] = await Promise.all([
56
- getAttributesMap(client, nodeId),
57
- getOuterHTML(client, nodeId)
58
- ]);
57
+ let attributes = null;
58
+ let outerHTML = "";
59
+ try {
60
+ [attributes, outerHTML] = await Promise.all([
61
+ getAttributesMap(client, nodeId),
62
+ getOuterHTML(client, nodeId)
63
+ ]);
64
+ } catch (error) {
65
+ if (isStaleRecommendNodeError(error)) {
66
+ return null;
67
+ }
68
+ throw error;
69
+ }
59
70
  const label = normalizeText(htmlToText(outerHTML));
60
71
  let box = null;
61
72
  try {
62
73
  box = await getNodeBox(client, nodeId);
63
- } catch {}
74
+ } catch (error) {
75
+ if (!isStaleRecommendNodeError(error)) throw error;
76
+ }
64
77
  const className = attributes.class || "";
65
78
  return {
66
79
  node_id: nodeId,
@@ -75,19 +88,40 @@ async function readJobOption(client, nodeId, index) {
75
88
  };
76
89
  }
77
90
 
91
+ async function readJobTrigger(client, nodeId) {
92
+ let box = null;
93
+ try {
94
+ box = await getNodeBox(client, nodeId);
95
+ } catch {}
96
+ if (!isVisibleBox(box)) return null;
97
+
98
+ let label = "";
99
+ let className = "";
100
+ try {
101
+ const outerHTML = await getOuterHTML(client, nodeId);
102
+ label = normalizeText(htmlToText(outerHTML));
103
+ } catch {}
104
+ try {
105
+ const attributes = await getAttributesMap(client, nodeId);
106
+ className = attributes.class || "";
107
+ } catch {}
108
+
109
+ return {
110
+ node_id: nodeId,
111
+ center: box.center,
112
+ rect: box.rect,
113
+ label,
114
+ label_without_salary: trimSalarySuffix(label),
115
+ class_name: className,
116
+ visible: true
117
+ };
118
+ }
119
+
78
120
  export async function findRecommendJobTrigger(client, frameNodeId) {
79
121
  const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_JOB_SELECTORS.trigger);
80
122
  for (const nodeId of nodeIds) {
81
- try {
82
- const box = await getNodeBox(client, nodeId);
83
- if (isVisibleBox(box)) {
84
- return {
85
- node_id: nodeId,
86
- center: box.center,
87
- rect: box.rect
88
- };
89
- }
90
- } catch {}
123
+ const trigger = await readJobTrigger(client, nodeId);
124
+ if (trigger) return trigger;
91
125
  }
92
126
  return null;
93
127
  }
@@ -162,6 +196,7 @@ export async function openRecommendJobDropdown(client, frameNodeId, {
162
196
  }
163
197
  }
164
198
  const error = new Error("Recommend job dropdown did not expose visible options after trigger click");
199
+ error.trigger = trigger;
165
200
  error.job_dropdown_attempts = attempts;
166
201
  throw error;
167
202
  }
@@ -204,6 +239,7 @@ export async function listRecommendJobOptions(client, frameNodeId, {
204
239
  if (seen.has(nodeId)) continue;
205
240
  seen.add(nodeId);
206
241
  const option = await readJobOption(client, nodeId, index);
242
+ if (!option) continue;
207
243
  if (!option.label) continue;
208
244
  if (option.label.length > 120) continue;
209
245
  options.push(option);
@@ -230,6 +266,150 @@ export async function closeRecommendJobDropdown(client) {
230
266
  };
231
267
  }
232
268
 
269
+ async function readVisibleRecommendJobOptions(client, frameNodeId) {
270
+ const options = await listRecommendJobOptions(client, frameNodeId, {
271
+ openDropdown: false
272
+ }).catch(() => []);
273
+ return {
274
+ options,
275
+ visible_options: options.filter((option) => option.visible)
276
+ };
277
+ }
278
+
279
+ export async function closeRecommendJobDropdownFully(client, frameNodeId, {
280
+ settleMs = 300,
281
+ timeoutMs = 1200
282
+ } = {}) {
283
+ const before = await readVisibleRecommendJobOptions(client, frameNodeId);
284
+ const attempts = [];
285
+ if (!before.visible_options.length) {
286
+ return {
287
+ ok: true,
288
+ closed: false,
289
+ reason: "already_closed",
290
+ visible_before_count: 0,
291
+ visible_after_count: 0,
292
+ attempts
293
+ };
294
+ }
295
+
296
+ const started = Date.now();
297
+ for (let attempt = 1; attempt <= 2 && Date.now() - started <= timeoutMs; attempt += 1) {
298
+ const close = await closeRecommendJobDropdown(client);
299
+ if (settleMs > 0) await sleep(settleMs);
300
+ const afterEscape = await readVisibleRecommendJobOptions(client, frameNodeId);
301
+ attempts.push({
302
+ method: "escape",
303
+ attempt,
304
+ ok: afterEscape.visible_options.length === 0,
305
+ visible_after_count: afterEscape.visible_options.length,
306
+ close
307
+ });
308
+ if (!afterEscape.visible_options.length) {
309
+ return {
310
+ ok: true,
311
+ closed: true,
312
+ reason: "escape",
313
+ visible_before_count: before.visible_options.length,
314
+ visible_after_count: 0,
315
+ attempts
316
+ };
317
+ }
318
+ }
319
+
320
+ const trigger = await findRecommendJobTrigger(client, frameNodeId).catch(() => null);
321
+ if (trigger?.node_id) {
322
+ const click = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
323
+ error: error?.message || String(error || "")
324
+ }));
325
+ if (settleMs > 0) await sleep(settleMs);
326
+ const afterToggle = await readVisibleRecommendJobOptions(client, frameNodeId);
327
+ attempts.push({
328
+ method: "trigger_toggle",
329
+ ok: afterToggle.visible_options.length === 0,
330
+ visible_after_count: afterToggle.visible_options.length,
331
+ click
332
+ });
333
+ if (!afterToggle.visible_options.length) {
334
+ return {
335
+ ok: true,
336
+ closed: true,
337
+ reason: "trigger_toggle",
338
+ visible_before_count: before.visible_options.length,
339
+ visible_after_count: 0,
340
+ attempts
341
+ };
342
+ }
343
+ }
344
+
345
+ const outside = await clickPoint(client, 12, 12, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
346
+ error: error?.message || String(error || "")
347
+ }));
348
+ if (settleMs > 0) await sleep(settleMs);
349
+ const afterOutside = await readVisibleRecommendJobOptions(client, frameNodeId);
350
+ attempts.push({
351
+ method: "outside_click",
352
+ ok: afterOutside.visible_options.length === 0,
353
+ visible_after_count: afterOutside.visible_options.length,
354
+ click: outside
355
+ });
356
+
357
+ return {
358
+ ok: afterOutside.visible_options.length === 0,
359
+ closed: afterOutside.visible_options.length === 0,
360
+ reason: afterOutside.visible_options.length ? "still_visible_after_close_attempts" : "outside_click",
361
+ visible_before_count: before.visible_options.length,
362
+ visible_after_count: afterOutside.visible_options.length,
363
+ attempts,
364
+ first_visible_after: afterOutside.visible_options[0] ? compactJobOption(afterOutside.visible_options[0]) : null
365
+ };
366
+ }
367
+
368
+ export async function verifyRecommendJobSelection(client, frameNodeId, {
369
+ jobLabel = "",
370
+ delayMs = 2000,
371
+ dropdownTimeoutMs = 4000,
372
+ closeSettleMs = 300
373
+ } = {}) {
374
+ const requested = normalizeText(jobLabel);
375
+ if (delayMs > 0) await sleep(delayMs);
376
+ let options = [];
377
+ let openError = null;
378
+ try {
379
+ options = await listRecommendJobOptions(client, frameNodeId, {
380
+ openDropdown: true
381
+ });
382
+ } catch (error) {
383
+ openError = error;
384
+ options = await listRecommendJobOptions(client, frameNodeId, {
385
+ openDropdown: false
386
+ }).catch(() => []);
387
+ }
388
+ const current = options.find((option) => option.current) || null;
389
+ const verified = Boolean(current && jobLabelMatches(current.label, requested));
390
+ const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId, {
391
+ settleMs: closeSettleMs,
392
+ timeoutMs: Math.max(1200, Math.min(4000, dropdownTimeoutMs))
393
+ }).catch((error) => ({
394
+ ok: false,
395
+ closed: false,
396
+ reason: "close_failed",
397
+ error: error?.message || String(error || "")
398
+ }));
399
+ return {
400
+ verified,
401
+ requested,
402
+ current_label: current?.label || "",
403
+ current_label_without_salary: current?.label_without_salary || "",
404
+ current_option: current ? compactJobOption(current) : null,
405
+ option_count: options.length,
406
+ visible_option_count: options.filter((option) => option.visible).length,
407
+ options: options.map(compactJobOption),
408
+ open_error: openError ? (openError?.message || String(openError)) : null,
409
+ menu_close: menuClose
410
+ };
411
+ }
412
+
233
413
  export async function selectRecommendJob(client, frameNodeId, {
234
414
  jobLabel = "",
235
415
  settleMs = 6000,
@@ -245,10 +425,42 @@ export async function selectRecommendJob(client, frameNodeId, {
245
425
  };
246
426
  }
247
427
 
248
- const opened = await openRecommendJobDropdown(client, frameNodeId, {
249
- timeoutMs: dropdownTimeoutMs,
250
- triggerTimeoutMs: dropdownTimeoutMs
251
- });
428
+ let opened = null;
429
+ try {
430
+ opened = await openRecommendJobDropdown(client, frameNodeId, {
431
+ timeoutMs: dropdownTimeoutMs,
432
+ triggerTimeoutMs: dropdownTimeoutMs
433
+ });
434
+ } catch (error) {
435
+ const currentOptions = await listRecommendJobOptions(client, frameNodeId, {
436
+ openDropdown: false
437
+ }).catch(() => []);
438
+ const currentMatch = currentOptions.find((option) => (
439
+ option.current && jobLabelMatches(option.label, target)
440
+ ));
441
+ if (currentMatch) {
442
+ const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId).catch((closeError) => ({
443
+ ok: false,
444
+ closed: false,
445
+ reason: "close_failed",
446
+ error: closeError?.message || String(closeError || "")
447
+ }));
448
+ return {
449
+ requested: target,
450
+ selected: true,
451
+ already_current: true,
452
+ selected_option: compactJobOption({
453
+ ...currentMatch,
454
+ source: "current_option_without_visible_dropdown"
455
+ }),
456
+ options: currentOptions.map(compactJobOption),
457
+ dropdown_error: error?.message || String(error),
458
+ job_dropdown_attempts: error?.job_dropdown_attempts || [],
459
+ menu_close: menuClose
460
+ };
461
+ }
462
+ throw error;
463
+ }
252
464
  const options = opened.options.length
253
465
  ? opened.options
254
466
  : await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
@@ -272,13 +484,19 @@ export async function selectRecommendJob(client, frameNodeId, {
272
484
  }
273
485
 
274
486
  if (match.current) {
275
- await closeRecommendJobDropdown(client);
487
+ const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId).catch((error) => ({
488
+ ok: false,
489
+ closed: false,
490
+ reason: "close_failed",
491
+ error: error?.message || String(error || "")
492
+ }));
276
493
  return {
277
494
  requested: target,
278
495
  selected: true,
279
496
  already_current: true,
280
497
  selected_option: compactJobOption(match),
281
- options: options.map(compactJobOption)
498
+ options: options.map(compactJobOption),
499
+ menu_close: menuClose
282
500
  };
283
501
  }
284
502
 
@@ -289,6 +507,12 @@ export async function selectRecommendJob(client, frameNodeId, {
289
507
 
290
508
  const clickedBox = await clickNodeCenter(client, match.node_id, DETERMINISTIC_CLICK_OPTIONS);
291
509
  if (settleMs > 0) await sleep(settleMs);
510
+ const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId).catch((error) => ({
511
+ ok: false,
512
+ closed: false,
513
+ reason: "close_failed",
514
+ error: error?.message || String(error || "")
515
+ }));
292
516
  return {
293
517
  requested: target,
294
518
  selected: true,
@@ -298,7 +522,8 @@ export async function selectRecommendJob(client, frameNodeId, {
298
522
  center: clickedBox.center,
299
523
  rect: clickedBox.rect
300
524
  },
301
- options: options.map(compactJobOption)
525
+ options: options.map(compactJobOption),
526
+ menu_close: menuClose
302
527
  };
303
528
  }
304
529
 
@@ -311,6 +536,7 @@ function compactJobOption(option) {
311
536
  class_name: option.class_name,
312
537
  node_id: option.node_id,
313
538
  center: option.center,
314
- rect: option.rect
539
+ rect: option.rect,
540
+ source: option.source || null
315
541
  };
316
542
  }
@@ -1,4 +1,12 @@
1
1
  import { sleep } from "../../core/browser/index.js";
2
+ import {
3
+ buildRecommendSelfHealConfig,
4
+ resolveRecommendSelfHealRoots
5
+ } from "../../core/self-heal/index.js";
6
+ import {
7
+ createRecoverySettleError,
8
+ waitForMiniFreshStartSettle
9
+ } from "../common/recovery-settle.js";
2
10
  import {
3
11
  clickRecommendEndRefreshButton,
4
12
  waitForRecommendCardNodeIds
@@ -8,12 +16,16 @@ import {
8
16
  RECOMMEND_TARGET_URL
9
17
  } from "./constants.js";
10
18
  import { selectAndConfirmFirstSafeFilter } from "./filters.js";
11
- import { selectRecommendJob } from "./jobs.js";
19
+ import {
20
+ selectRecommendJob,
21
+ verifyRecommendJobSelection
22
+ } from "./jobs.js";
12
23
  import { selectRecommendPageScope } from "./scopes.js";
13
24
  import {
14
25
  getRecommendRoots,
15
26
  waitForRecommendRoots
16
27
  } from "./roots.js";
28
+ import { isStaleRecommendNodeError } from "./detail.js";
17
29
 
18
30
  function normalizeLabels(labels = []) {
19
31
  return labels.map((label) => String(label || "").trim()).filter(Boolean);
@@ -102,8 +114,9 @@ function compactFilterReapplyError(error) {
102
114
  }
103
115
 
104
116
  export function isRetryableRecommendJobSelectionError(error) {
117
+ if (isStaleRecommendNodeError(error)) return true;
105
118
  const message = String(error?.message || error || "");
106
- return /Recommend job trigger was not found|Recommend job dropdown did not mount options|Recommend job dropdown did not expose visible options|Matched recommend job has no clickable center|Matched recommend job has no visible clickable option/i.test(message);
119
+ return /Recommend job trigger was not found|Recommend job dropdown did not mount options|Recommend job dropdown did not expose visible options|Matched recommend job has no clickable center|Matched recommend job has no visible clickable option|Recommend job selection was not sticky|Recommend job dropdown remained open after sticky verification/i.test(message);
107
120
  }
108
121
 
109
122
  function compactJobSelectionAttempt({
@@ -121,10 +134,40 @@ function compactJobSelectionAttempt({
121
134
  attempt,
122
135
  iframe_document_node_id: iframeDocumentNodeId || 0,
123
136
  selected: Boolean(selection?.selected),
124
- selection_reason: selection?.reason || null
137
+ selection_reason: selection?.reason || null,
138
+ sticky_verified: selection?.sticky_verification?.verified ?? null,
139
+ sticky_current_label: selection?.sticky_verification?.current_label_without_salary
140
+ || selection?.sticky_verification?.current_label
141
+ || null,
142
+ sticky_menu_closed: selection?.sticky_verification?.menu_close?.ok ?? null
125
143
  };
126
144
  }
127
145
 
146
+ async function waitForRecommendRecoverySettle(client, {
147
+ reloadSettleMs = 8000,
148
+ timeoutMs = 90000
149
+ } = {}) {
150
+ return waitForMiniFreshStartSettle(client, {
151
+ domain: "recommend",
152
+ timeoutMs,
153
+ intervalMs: reloadSettleMs > 10000 ? 1200 : 800,
154
+ settleMs: Math.max(0, Math.min(reloadSettleMs || 0, 5000)),
155
+ selfHealConfig: buildRecommendSelfHealConfig(),
156
+ resolveSelfHealRoots: resolveRecommendSelfHealRoots
157
+ });
158
+ }
159
+
160
+ async function waitForFreshRecommendRoots(client, {
161
+ timeoutMs = 10000,
162
+ intervalMs = 500
163
+ } = {}) {
164
+ const rootState = await waitForRecommendRoots(client, {
165
+ timeoutMs,
166
+ intervalMs
167
+ });
168
+ return rootState?.iframe?.documentNodeId ? rootState : null;
169
+ }
170
+
128
171
  export async function selectRecommendJobWithRootRefresh(client, rootState, {
129
172
  jobLabel = "",
130
173
  settleMs = 6000,
@@ -141,7 +184,10 @@ export async function selectRecommendJobWithRootRefresh(client, rootState, {
141
184
  while (Date.now() - started <= totalTimeoutMs) {
142
185
  attempt += 1;
143
186
  if (!currentRootState?.iframe?.documentNodeId) {
144
- currentRootState = await getRecommendRoots(client);
187
+ currentRootState = await waitForFreshRecommendRoots(client, {
188
+ timeoutMs: Math.min(10000, Math.max(2000, totalTimeoutMs - (Date.now() - started))),
189
+ intervalMs: 500
190
+ });
145
191
  }
146
192
  const iframeDocumentNodeId = currentRootState?.iframe?.documentNodeId || 0;
147
193
  try {
@@ -150,6 +196,31 @@ export async function selectRecommendJobWithRootRefresh(client, rootState, {
150
196
  settleMs,
151
197
  dropdownTimeoutMs
152
198
  });
199
+ if (selection.selected) {
200
+ const stickyRootState = await waitForFreshRecommendRoots(client, {
201
+ timeoutMs: Math.min(10000, Math.max(2000, totalTimeoutMs - (Date.now() - started))),
202
+ intervalMs: 500
203
+ }) || currentRootState;
204
+ const stickyFrameNodeId = stickyRootState?.iframe?.documentNodeId || iframeDocumentNodeId;
205
+ const stickyVerification = await verifyRecommendJobSelection(client, stickyFrameNodeId, {
206
+ jobLabel,
207
+ delayMs: 2000,
208
+ dropdownTimeoutMs,
209
+ closeSettleMs: 300
210
+ });
211
+ selection.sticky_verification = stickyVerification;
212
+ currentRootState = stickyRootState || currentRootState;
213
+ if (!stickyVerification.verified) {
214
+ const stickyError = new Error(`Recommend job selection was not sticky after 2s: requested=${jobLabel}; current=${stickyVerification.current_label_without_salary || stickyVerification.current_label || "unknown"}`);
215
+ stickyError.sticky_verification = stickyVerification;
216
+ throw stickyError;
217
+ }
218
+ if (stickyVerification.menu_close && stickyVerification.menu_close.ok === false) {
219
+ const closeError = new Error(`Recommend job dropdown remained open after sticky verification: ${stickyVerification.menu_close.reason || "unknown"}`);
220
+ closeError.sticky_verification = stickyVerification;
221
+ throw closeError;
222
+ }
223
+ }
153
224
  attempts.push(compactJobSelectionAttempt({
154
225
  ok: true,
155
226
  attempt,
@@ -176,7 +247,10 @@ export async function selectRecommendJobWithRootRefresh(client, rootState, {
176
247
  break;
177
248
  }
178
249
  if (retryDelayMs > 0) await sleep(retryDelayMs);
179
- currentRootState = await getRecommendRoots(client);
250
+ currentRootState = await waitForFreshRecommendRoots(client, {
251
+ timeoutMs: Math.min(10000, Math.max(2000, totalTimeoutMs - (Date.now() - started))),
252
+ intervalMs: 500
253
+ });
180
254
  }
181
255
  }
182
256
 
@@ -251,13 +325,20 @@ async function applyRefreshMethod(client, method, {
251
325
  let pageScopeResult = null;
252
326
  let filterResult = null;
253
327
  let filterReapplyAttempts = [];
328
+ let recoverySettle = null;
254
329
  try {
255
330
  if (method === "page_navigate") {
256
331
  await client.Page.navigate({ url: targetUrl || RECOMMEND_TARGET_URL });
257
332
  } else {
258
333
  await client.Page.reload({ ignoreCache: true });
259
334
  }
260
- if (reloadSettleMs > 0) await sleep(reloadSettleMs);
335
+ recoverySettle = await waitForRecommendRecoverySettle(client, {
336
+ reloadSettleMs,
337
+ timeoutMs: Math.max(45000, reloadSettleMs * 6)
338
+ });
339
+ if (!recoverySettle.ok) {
340
+ throw createRecoverySettleError("recommend", recoverySettle);
341
+ }
261
342
  currentRootState = await waitForRecommendRoots(client, {
262
343
  timeoutMs: Math.max(45000, reloadSettleMs * 6),
263
344
  intervalMs: 500
@@ -318,6 +399,7 @@ async function applyRefreshMethod(client, method, {
318
399
  target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
319
400
  job_selection: jobSelection,
320
401
  job_selection_attempts: jobSelectionAttempts,
402
+ recovery_settle: recoverySettle,
321
403
  page_scope: pageScopeResult,
322
404
  filter: filterResult,
323
405
  filter_reapply_attempts: filterReapplyAttempts,
@@ -335,6 +417,7 @@ async function applyRefreshMethod(client, method, {
335
417
  target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
336
418
  job_selection: jobSelection,
337
419
  job_selection_attempts: error?.job_selection_attempts || jobSelectionAttempts,
420
+ recovery_settle: error?.recovery_settle || recoverySettle,
338
421
  page_scope: pageScopeResult,
339
422
  filter: filterResult,
340
423
  filter_reapply_attempts: error?.filter_reapply_attempts || filterReapplyAttempts,
@@ -144,10 +144,33 @@ function compactJobSelection(jobSelection) {
144
144
  return {
145
145
  requested: jobSelection.requested || "",
146
146
  selected: Boolean(jobSelection.selected),
147
- already_current: Boolean(jobSelection.already_current),
148
- reason: jobSelection.reason || null,
149
- selected_option: jobSelection.selected_option || null,
150
- options: (jobSelection.options || []).map((option) => ({
147
+ already_current: Boolean(jobSelection.already_current),
148
+ reason: jobSelection.reason || null,
149
+ selected_option: jobSelection.selected_option || null,
150
+ menu_close: jobSelection.menu_close
151
+ ? {
152
+ ok: Boolean(jobSelection.menu_close.ok),
153
+ closed: Boolean(jobSelection.menu_close.closed),
154
+ reason: jobSelection.menu_close.reason || ""
155
+ }
156
+ : null,
157
+ sticky_verification: jobSelection.sticky_verification
158
+ ? {
159
+ verified: Boolean(jobSelection.sticky_verification.verified),
160
+ current_label: jobSelection.sticky_verification.current_label_without_salary
161
+ || jobSelection.sticky_verification.current_label
162
+ || "",
163
+ visible_option_count: jobSelection.sticky_verification.visible_option_count || 0,
164
+ menu_close: jobSelection.sticky_verification.menu_close
165
+ ? {
166
+ ok: Boolean(jobSelection.sticky_verification.menu_close.ok),
167
+ closed: Boolean(jobSelection.sticky_verification.menu_close.closed),
168
+ reason: jobSelection.sticky_verification.menu_close.reason || ""
169
+ }
170
+ : null
171
+ }
172
+ : null,
173
+ options: (jobSelection.options || []).map((option) => ({
151
174
  label: option.label,
152
175
  label_without_salary: option.label_without_salary,
153
176
  current: Boolean(option.current),
@@ -364,11 +387,19 @@ function compactRefreshAttempt(refreshAttempt) {
364
387
  method: refreshAttempt.method || "",
365
388
  reason: refreshAttempt.reason || null,
366
389
  error: refreshAttempt.error || null,
367
- forced_recent_not_view: Boolean(refreshAttempt.forced_recent_not_view),
368
- target_url: refreshAttempt.target_url || null,
369
- card_count: refreshAttempt.card_count || 0,
370
- elapsed_ms: refreshAttempt.elapsed_ms || 0,
371
- attempts: (refreshAttempt.attempts || []).map((attempt) => ({
390
+ forced_recent_not_view: Boolean(refreshAttempt.forced_recent_not_view),
391
+ target_url: refreshAttempt.target_url || null,
392
+ card_count: refreshAttempt.card_count || 0,
393
+ elapsed_ms: refreshAttempt.elapsed_ms || 0,
394
+ recovery_settle: refreshAttempt.recovery_settle
395
+ ? {
396
+ ok: Boolean(refreshAttempt.recovery_settle.ok),
397
+ status: refreshAttempt.recovery_settle.status || "",
398
+ reason: refreshAttempt.recovery_settle.reason || "",
399
+ elapsed_ms: refreshAttempt.recovery_settle.elapsed_ms || 0
400
+ }
401
+ : null,
402
+ attempts: (refreshAttempt.attempts || []).map((attempt) => ({
372
403
  ok: Boolean(attempt.ok),
373
404
  method: attempt.method || "",
374
405
  reason: attempt.reason || null,
@@ -1256,11 +1287,13 @@ export async function runRecommendWorkflow({
1256
1287
  : useLlmScreening
1257
1288
  ? llmResultToScreening(llmResult, screeningCandidate)
1258
1289
  : screenCandidate(screeningCandidate, { criteria });
1259
- let actionDiscovery = null;
1260
- let postActionResult = null;
1261
- if (postActionEnabled && detailResult) {
1262
- const postActionStarted = Date.now();
1263
- await runControl.waitIfPaused();
1290
+ let actionDiscovery = null;
1291
+ let postActionResult = null;
1292
+ let closeFailureError = null;
1293
+ let closeRecoveryFailure = null;
1294
+ if (postActionEnabled && detailResult) {
1295
+ const postActionStarted = Date.now();
1296
+ await runControl.waitIfPaused();
1264
1297
  runControl.throwIfCanceled();
1265
1298
  runControl.setPhase("recommend:post-action");
1266
1299
  await maybeHumanActionCooldown("before_post_action", timings);
@@ -1288,21 +1321,34 @@ export async function runRecommendWorkflow({
1288
1321
  detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecommendDetail(client));
1289
1322
  await maybeHumanActionCooldown("after_detail_close", timings);
1290
1323
  if (!detailResult.close_result?.closed) {
1291
- const closeError = createRecommendCloseFailureError(detailResult.close_result);
1292
- const recovery = await recoverAndReapplyRecommendContext("detail_close_failed", closeError, {
1293
- forceRecentNotView: true
1294
- });
1295
- detailResult.cv_acquisition = {
1296
- ...(detailResult.cv_acquisition || {}),
1297
- close_recovery: {
1298
- ok: Boolean(recovery.ok),
1299
- method: recovery.method || "",
1300
- forced_recent_not_view: Boolean(recovery.forced_recent_not_view),
1301
- card_count: recovery.card_count || 0
1302
- }
1303
- };
1304
- }
1305
- }
1324
+ closeFailureError = createRecommendCloseFailureError(detailResult.close_result);
1325
+ try {
1326
+ const recovery = await recoverAndReapplyRecommendContext("detail_close_failed", closeFailureError, {
1327
+ forceRecentNotView: true
1328
+ });
1329
+ detailResult.cv_acquisition = {
1330
+ ...(detailResult.cv_acquisition || {}),
1331
+ close_recovery: {
1332
+ ok: Boolean(recovery.ok),
1333
+ method: recovery.method || "",
1334
+ forced_recent_not_view: Boolean(recovery.forced_recent_not_view),
1335
+ card_count: recovery.card_count || 0
1336
+ }
1337
+ };
1338
+ } catch (error) {
1339
+ closeRecoveryFailure = error;
1340
+ detailResult.cv_acquisition = {
1341
+ ...(detailResult.cv_acquisition || {}),
1342
+ close_recovery: {
1343
+ ok: false,
1344
+ reason: "context_recovery_failed",
1345
+ error: error?.message || String(error),
1346
+ forced_recent_not_view: true
1347
+ }
1348
+ };
1349
+ }
1350
+ }
1351
+ }
1306
1352
  timings.total_ms = Date.now() - candidateStarted;
1307
1353
  const compactResult = {
1308
1354
  index,
@@ -1313,12 +1359,14 @@ export async function runRecommendWorkflow({
1313
1359
  llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
1314
1360
  screening: compactScreening(screening),
1315
1361
  action_discovery: compactActionDiscovery(actionDiscovery),
1316
- post_action: postActionResult,
1317
- error: recoverableDetailError
1318
- ? compactRecoverableDetailError(recoverableDetailError)
1319
- : detailResult?.image_evidence?.ok === false
1320
- ? compactError({
1321
- code: detailResult.image_evidence.error_code,
1362
+ post_action: postActionResult,
1363
+ error: recoverableDetailError
1364
+ ? compactRecoverableDetailError(recoverableDetailError)
1365
+ : closeRecoveryFailure
1366
+ ? compactError(closeFailureError, "DETAIL_CLOSE_FAILED")
1367
+ : detailResult?.image_evidence?.ok === false
1368
+ ? compactError({
1369
+ code: detailResult.image_evidence.error_code,
1322
1370
  message: detailResult.image_evidence.error
1323
1371
  }, "IMAGE_CAPTURE_FAILED")
1324
1372
  : null,
@@ -1353,9 +1401,13 @@ export async function runRecommendWorkflow({
1353
1401
  error: compactResult.error,
1354
1402
  post_action: postActionResult
1355
1403
  }
1356
- });
1357
- addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
1358
-
1404
+ });
1405
+ addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
1406
+
1407
+ if (closeRecoveryFailure) {
1408
+ throw closeRecoveryFailure;
1409
+ }
1410
+
1359
1411
  if (postActionResult?.stop_run) {
1360
1412
  listEndReason = postActionResult.reason || "post_action_stop";
1361
1413
  break;
@@ -39,6 +39,7 @@ export async function refreshRecruitSearchAtEnd(client, {
39
39
  forced_recent_viewed: Boolean(forceRecentViewed),
40
40
  search_params: refreshSearchParams,
41
41
  card_count: cardCount,
42
+ recovery_settle: application.reset?.mini_fresh_start || null,
42
43
  application
43
44
  };
44
45
  }
@@ -129,6 +129,14 @@ function compactRefreshAttempt(refreshAttempt) {
129
129
  forced_recent_viewed: Boolean(refreshAttempt.forced_recent_viewed),
130
130
  card_count: refreshAttempt.card_count || 0,
131
131
  search_params: refreshAttempt.search_params || null,
132
+ recovery_settle: refreshAttempt.recovery_settle
133
+ ? {
134
+ ok: Boolean(refreshAttempt.recovery_settle.ok),
135
+ status: refreshAttempt.recovery_settle.status || "",
136
+ reason: refreshAttempt.recovery_settle.reason || "",
137
+ elapsed_ms: refreshAttempt.recovery_settle.elapsed_ms || 0
138
+ }
139
+ : null,
132
140
  application: refreshAttempt.application
133
141
  ? {
134
142
  applied: Boolean(refreshAttempt.application.applied),
@@ -17,6 +17,10 @@ import {
17
17
  htmlToText,
18
18
  normalizeText
19
19
  } from "../../core/screening/index.js";
20
+ import {
21
+ createRecoverySettleError,
22
+ waitForMiniFreshStartSettle
23
+ } from "../common/recovery-settle.js";
20
24
  import {
21
25
  RECRUIT_CARD_SELECTOR,
22
26
  RECRUIT_TARGET_URL,
@@ -402,12 +406,30 @@ export async function waitForRecruitSearchControls(client, {
402
406
  };
403
407
  }
404
408
 
409
+ async function settleRecruitSearchAfterReset(client, {
410
+ timeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS,
411
+ settleMs = 5000
412
+ } = {}) {
413
+ return waitForMiniFreshStartSettle(client, {
414
+ domain: "search",
415
+ timeoutMs,
416
+ intervalMs: 500,
417
+ settleMs: Math.max(0, Math.min(settleMs || 0, 5000)),
418
+ readinessLabel: "search_controls_ready",
419
+ checkReady: ({ remainingMs }) => waitForRecruitSearchControls(client, {
420
+ timeoutMs: Math.min(Math.max(1, remainingMs), 1500),
421
+ intervalMs: 300
422
+ })
423
+ });
424
+ }
425
+
405
426
  export async function resetRecruitSearchPage(client, {
406
427
  url = RECRUIT_TARGET_URL,
407
428
  settleMs = 5000,
408
429
  timeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS
409
430
  } = {}) {
410
431
  const actions = [];
432
+ let miniFreshStart = null;
411
433
  const rootTimeoutMs = Math.min(timeoutMs, 90000);
412
434
  async function waitForRootsAfterSettle() {
413
435
  await sleep(settleMs);
@@ -432,6 +454,21 @@ export async function resetRecruitSearchPage(client, {
432
454
  actions.push({ method: "Page.navigate", url });
433
455
  }
434
456
 
457
+ miniFreshStart = await settleRecruitSearchAfterReset(client, {
458
+ timeoutMs: Math.min(timeoutMs, 90000),
459
+ settleMs
460
+ });
461
+ actions.push({
462
+ method: "mini_fresh_start_settle",
463
+ ok: Boolean(miniFreshStart.ok),
464
+ status: miniFreshStart.status || "",
465
+ reason: miniFreshStart.reason || "",
466
+ elapsed_ms: miniFreshStart.elapsed_ms || 0
467
+ });
468
+ if (!miniFreshStart.ok) {
469
+ throw createRecoverySettleError("search", miniFreshStart);
470
+ }
471
+
435
472
  let roots = await waitForRootsAfterSettle();
436
473
  const frameReset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, {
437
474
  pageUrl: url,
@@ -459,6 +496,20 @@ export async function resetRecruitSearchPage(client, {
459
496
  actions.push(fallbackFrameReset);
460
497
  await sleep(settleMs);
461
498
  }
499
+ miniFreshStart = await settleRecruitSearchAfterReset(client, {
500
+ timeoutMs: Math.min(timeoutMs, 90000),
501
+ settleMs: Math.min(settleMs, 1500)
502
+ });
503
+ actions.push({
504
+ method: "mini_fresh_start_settle_after_navigate",
505
+ ok: Boolean(miniFreshStart.ok),
506
+ status: miniFreshStart.status || "",
507
+ reason: miniFreshStart.reason || "",
508
+ elapsed_ms: miniFreshStart.elapsed_ms || 0
509
+ });
510
+ if (!miniFreshStart.ok) {
511
+ throw createRecoverySettleError("search", miniFreshStart);
512
+ }
462
513
  controls = await waitForControls();
463
514
  }
464
515
  roots = await getRecruitRoots(client, { requireFrame: false });
@@ -473,6 +524,7 @@ export async function resetRecruitSearchPage(client, {
473
524
  target_url: url,
474
525
  iframe_selector: controls.iframe_selector || roots.iframe.selector,
475
526
  iframe_document_node_id: controls.iframe_document_node_id || roots.iframe.documentNodeId,
527
+ mini_fresh_start: miniFreshStart,
476
528
  controls
477
529
  };
478
530
  }