@reconcrap/boss-recommend-mcp 2.0.49 → 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.49",
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
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  clickNodeCenter,
3
+ clickPoint,
3
4
  DETERMINISTIC_CLICK_OPTIONS,
4
5
  getAttributesMap,
5
6
  getNodeBox,
@@ -265,6 +266,150 @@ export async function closeRecommendJobDropdown(client) {
265
266
  };
266
267
  }
267
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
+
268
413
  export async function selectRecommendJob(client, frameNodeId, {
269
414
  jobLabel = "",
270
415
  settleMs = 6000,
@@ -294,7 +439,12 @@ export async function selectRecommendJob(client, frameNodeId, {
294
439
  option.current && jobLabelMatches(option.label, target)
295
440
  ));
296
441
  if (currentMatch) {
297
- await closeRecommendJobDropdown(client);
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
+ }));
298
448
  return {
299
449
  requested: target,
300
450
  selected: true,
@@ -305,7 +455,8 @@ export async function selectRecommendJob(client, frameNodeId, {
305
455
  }),
306
456
  options: currentOptions.map(compactJobOption),
307
457
  dropdown_error: error?.message || String(error),
308
- job_dropdown_attempts: error?.job_dropdown_attempts || []
458
+ job_dropdown_attempts: error?.job_dropdown_attempts || [],
459
+ menu_close: menuClose
309
460
  };
310
461
  }
311
462
  throw error;
@@ -333,13 +484,19 @@ export async function selectRecommendJob(client, frameNodeId, {
333
484
  }
334
485
 
335
486
  if (match.current) {
336
- 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
+ }));
337
493
  return {
338
494
  requested: target,
339
495
  selected: true,
340
496
  already_current: true,
341
497
  selected_option: compactJobOption(match),
342
- options: options.map(compactJobOption)
498
+ options: options.map(compactJobOption),
499
+ menu_close: menuClose
343
500
  };
344
501
  }
345
502
 
@@ -350,6 +507,12 @@ export async function selectRecommendJob(client, frameNodeId, {
350
507
 
351
508
  const clickedBox = await clickNodeCenter(client, match.node_id, DETERMINISTIC_CLICK_OPTIONS);
352
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
+ }));
353
516
  return {
354
517
  requested: target,
355
518
  selected: true,
@@ -359,7 +522,8 @@ export async function selectRecommendJob(client, frameNodeId, {
359
522
  center: clickedBox.center,
360
523
  rect: clickedBox.rect
361
524
  },
362
- options: options.map(compactJobOption)
525
+ options: options.map(compactJobOption),
526
+ menu_close: menuClose
363
527
  };
364
528
  }
365
529
 
@@ -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,7 +16,10 @@ 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,
@@ -105,7 +116,7 @@ function compactFilterReapplyError(error) {
105
116
  export function isRetryableRecommendJobSelectionError(error) {
106
117
  if (isStaleRecommendNodeError(error)) return true;
107
118
  const message = String(error?.message || error || "");
108
- 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);
109
120
  }
110
121
 
111
122
  function compactJobSelectionAttempt({
@@ -123,10 +134,29 @@ function compactJobSelectionAttempt({
123
134
  attempt,
124
135
  iframe_document_node_id: iframeDocumentNodeId || 0,
125
136
  selected: Boolean(selection?.selected),
126
- 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
127
143
  };
128
144
  }
129
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
+
130
160
  async function waitForFreshRecommendRoots(client, {
131
161
  timeoutMs = 10000,
132
162
  intervalMs = 500
@@ -166,6 +196,31 @@ export async function selectRecommendJobWithRootRefresh(client, rootState, {
166
196
  settleMs,
167
197
  dropdownTimeoutMs
168
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
+ }
169
224
  attempts.push(compactJobSelectionAttempt({
170
225
  ok: true,
171
226
  attempt,
@@ -270,13 +325,20 @@ async function applyRefreshMethod(client, method, {
270
325
  let pageScopeResult = null;
271
326
  let filterResult = null;
272
327
  let filterReapplyAttempts = [];
328
+ let recoverySettle = null;
273
329
  try {
274
330
  if (method === "page_navigate") {
275
331
  await client.Page.navigate({ url: targetUrl || RECOMMEND_TARGET_URL });
276
332
  } else {
277
333
  await client.Page.reload({ ignoreCache: true });
278
334
  }
279
- 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
+ }
280
342
  currentRootState = await waitForRecommendRoots(client, {
281
343
  timeoutMs: Math.max(45000, reloadSettleMs * 6),
282
344
  intervalMs: 500
@@ -337,6 +399,7 @@ async function applyRefreshMethod(client, method, {
337
399
  target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
338
400
  job_selection: jobSelection,
339
401
  job_selection_attempts: jobSelectionAttempts,
402
+ recovery_settle: recoverySettle,
340
403
  page_scope: pageScopeResult,
341
404
  filter: filterResult,
342
405
  filter_reapply_attempts: filterReapplyAttempts,
@@ -354,6 +417,7 @@ async function applyRefreshMethod(client, method, {
354
417
  target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
355
418
  job_selection: jobSelection,
356
419
  job_selection_attempts: error?.job_selection_attempts || jobSelectionAttempts,
420
+ recovery_settle: error?.recovery_settle || recoverySettle,
357
421
  page_scope: pageScopeResult,
358
422
  filter: filterResult,
359
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,
@@ -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
  }