@reconcrap/boss-recommend-mcp 2.0.45 → 2.0.46

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.45",
3
+ "version": "2.0.46",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -42,6 +42,9 @@ const BOSS_LOGIN_DOM_SELECTORS = [
42
42
  ];
43
43
  const HUMAN_INTERACTION_CONFIG = new WeakMap();
44
44
  const DEFAULT_HUMAN_BEHAVIOR_PROFILE = "paced_with_rests";
45
+ export const DETERMINISTIC_CLICK_OPTIONS = Object.freeze({
46
+ humanRestEnabled: false
47
+ });
45
48
  const HUMAN_BEHAVIOR_PROFILES = Object.freeze({
46
49
  baseline: Object.freeze({
47
50
  enabled: false,
@@ -1293,8 +1296,12 @@ export async function clickNodeCenter(client, nodeId, {
1293
1296
  const clickPointTarget = humanClickPointEnabled
1294
1297
  ? resolveHumanClickPointForBox(box, mergedHumanInteraction)
1295
1298
  : { ...box.center, mode: "center" };
1296
- await clickPoint(client, clickPointTarget.x, clickPointTarget.y, clickOptions);
1297
- return box;
1299
+ const clickResult = await clickPoint(client, clickPointTarget.x, clickPointTarget.y, clickOptions);
1300
+ return {
1301
+ ...box,
1302
+ click_target: clickPointTarget,
1303
+ click_result: clickResult
1304
+ };
1298
1305
  }
1299
1306
 
1300
1307
  export async function pressKey(client, key, {
@@ -2,6 +2,7 @@ import {
2
2
  clearFocusedInput,
3
3
  clickNodeCenter,
4
4
  clickPoint,
5
+ DETERMINISTIC_CLICK_OPTIONS,
5
6
  getFrameDocumentNodeId,
6
7
  getAttributesMap,
7
8
  getNodeBox,
@@ -572,9 +573,12 @@ export async function selectChatPrimaryLabel(client, {
572
573
  }
573
574
  if (matched) {
574
575
  if (matched.center) {
575
- await clickPoint(client, matched.center.x, matched.center.y);
576
+ await clickPoint(client, matched.center.x, matched.center.y, DETERMINISTIC_CLICK_OPTIONS);
576
577
  } else {
577
- await clickNodeCenter(client, matched.node_id, { scrollIntoView: true });
578
+ await clickNodeCenter(client, matched.node_id, {
579
+ ...DETERMINISTIC_CLICK_OPTIONS,
580
+ scrollIntoView: true
581
+ });
578
582
  }
579
583
  if (settleMs > 0) await sleep(settleMs);
580
584
  return {
@@ -633,9 +637,12 @@ export async function selectChatMessageFilter(client, {
633
637
  const matched = candidates[0];
634
638
  if (matched) {
635
639
  if (matched.center) {
636
- await clickPoint(client, matched.center.x, matched.center.y);
640
+ await clickPoint(client, matched.center.x, matched.center.y, DETERMINISTIC_CLICK_OPTIONS);
637
641
  } else {
638
- await clickNodeCenter(client, matched.node_id, { scrollIntoView: true });
642
+ await clickNodeCenter(client, matched.node_id, {
643
+ ...DETERMINISTIC_CLICK_OPTIONS,
644
+ scrollIntoView: true
645
+ });
639
646
  }
640
647
  if (settleMs > 0) await sleep(settleMs);
641
648
  return {
@@ -1499,7 +1506,7 @@ export async function closeChatResumeModal(client, {
1499
1506
  const closeTarget = await findVisibleTarget(client, rootState.roots, CHAT_RESUME_CLOSE_SELECTORS);
1500
1507
  if (closeTarget) {
1501
1508
  try {
1502
- await clickPoint(client, closeTarget.center.x, closeTarget.center.y);
1509
+ await clickPoint(client, closeTarget.center.x, closeTarget.center.y, DETERMINISTIC_CLICK_OPTIONS);
1503
1510
  attempts.push({
1504
1511
  mode: "close-selector",
1505
1512
  selector: closeTarget.selector,
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  clickNodeCenter,
3
3
  clickPoint,
4
+ DETERMINISTIC_CLICK_OPTIONS,
4
5
  getAttributesMap,
5
6
  getNodeBox,
6
7
  getOuterHTML,
@@ -240,7 +241,7 @@ async function clickFirstVisible(client, rootNodeId, selectors = []) {
240
241
  try {
241
242
  const box = await getNodeBox(client, nodeId);
242
243
  if (box.rect.width <= 2 || box.rect.height <= 2) continue;
243
- await clickPoint(client, box.center.x, box.center.y);
244
+ await clickPoint(client, box.center.x, box.center.y, DETERMINISTIC_CLICK_OPTIONS);
244
245
  return {
245
246
  clicked: true,
246
247
  selector,
@@ -265,6 +266,7 @@ async function openChatJobDropdown(client, rootNodeId, {
265
266
  const started = Date.now();
266
267
  const triedPoints = new Set();
267
268
  const attempts = [];
269
+ const initialClose = await closeChatJobDropdownQuietly(client, rootNodeId, Math.min(settleMs, 300));
268
270
  for (const selector of CHAT_JOB_TRIGGER_SELECTORS) {
269
271
  const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
270
272
  const nodeIds = await safeQuerySelectorAll(client, currentRootNodeId, selector);
@@ -283,7 +285,7 @@ async function openChatJobDropdown(client, rootNodeId, {
283
285
  const pointKey = `${nodeId}:${Math.round(x)}:${Math.round(y)}`;
284
286
  if (triedPoints.has(pointKey)) continue;
285
287
  triedPoints.add(pointKey);
286
- await clickPoint(client, x, y);
288
+ await clickPoint(client, x, y, DETERMINISTIC_CLICK_OPTIONS);
287
289
  if (settleMs > 0) await sleep(Math.min(settleMs, 800));
288
290
  const remaining = Math.max(300, timeoutMs - (Date.now() - started));
289
291
  const optionsResult = await waitForChatJobOptions(client, currentRootNodeId, {
@@ -298,7 +300,8 @@ async function openChatJobDropdown(client, rootNodeId, {
298
300
  node_id: nodeId,
299
301
  point: pointName,
300
302
  center: { x, y },
301
- visible_option_count: visibleCount
303
+ visible_option_count: visibleCount,
304
+ initial_close: initialClose
302
305
  };
303
306
  attempts.push(attempt);
304
307
  if (visibleCount > 0) {
@@ -548,9 +551,10 @@ export async function selectChatJob(client, rootNodeId, {
548
551
  }
549
552
 
550
553
  if (matched.center) {
551
- await clickPoint(client, matched.center.x, matched.center.y);
554
+ await clickPoint(client, matched.center.x, matched.center.y, DETERMINISTIC_CLICK_OPTIONS);
552
555
  } else {
553
556
  await clickNodeCenter(client, matched.node_id, {
557
+ ...DETERMINISTIC_CLICK_OPTIONS,
554
558
  scrollIntoView: true
555
559
  });
556
560
  }
@@ -754,6 +754,7 @@ export async function runChatWorkflow({
754
754
  let requestSatisfiedCount = 0;
755
755
  let requestSkippedCount = 0;
756
756
  let contextSetup = {};
757
+ let contextRecoveryAttempts = 0;
757
758
  let lastHumanEvent = null;
758
759
 
759
760
  function recordHumanEvent(event = null) {
@@ -826,6 +827,7 @@ export async function runChatWorkflow({
826
827
  forceRefresh = false
827
828
  } = {}) {
828
829
  runControl.setPhase("chat:recover_shell");
830
+ contextRecoveryAttempts += 1;
829
831
  const shellRecovery = await recoverChatShell(client, {
830
832
  targetUrl,
831
833
  timeoutMs: readyTimeoutMs,
@@ -866,6 +868,7 @@ export async function runChatWorkflow({
866
868
  const recovery = {
867
869
  reason,
868
870
  total_refresh: Boolean(forceRefresh),
871
+ attempt: contextRecoveryAttempts,
869
872
  shell: shellRecovery,
870
873
  candidate_list: candidateList,
871
874
  counters
@@ -917,6 +920,7 @@ export async function runChatWorkflow({
917
920
  request_skipped: 0,
918
921
  unique_seen: compactInfiniteListState(listState).seen_count,
919
922
  scroll_count: compactInfiniteListState(listState).scroll_count,
923
+ context_recoveries: contextRecoveryAttempts,
920
924
  list_end_reason: listEndReason,
921
925
  viewport_checks: viewportGuard.getStats().checks,
922
926
  viewport_recoveries: viewportGuard.getStats().recoveries,
@@ -960,6 +964,7 @@ export async function runChatWorkflow({
960
964
  requested: requestedCount,
961
965
  request_satisfied: requestSatisfiedCount,
962
966
  request_skipped: requestSkippedCount,
967
+ context_recoveries: contextRecoveryAttempts,
963
968
  results
964
969
  };
965
970
  }
@@ -981,6 +986,7 @@ export async function runChatWorkflow({
981
986
  screening_mode: normalizedScreeningMode,
982
987
  unique_seen: compactInfiniteListState(listState).seen_count,
983
988
  scroll_count: 0,
989
+ context_recoveries: contextRecoveryAttempts,
984
990
  viewport_checks: viewportGuard.getStats().checks,
985
991
  viewport_recoveries: viewportGuard.getStats().recoveries
986
992
  });
@@ -1015,6 +1021,18 @@ export async function runChatWorkflow({
1015
1021
  const error = new Error(`CHAT_JOB_GUARD_FAILED: requested=${job}; selected=${jobGuard.selected_label || "unknown"}; reason=${jobGuard.reason || "unknown"}`);
1016
1022
  error.code = "CHAT_JOB_GUARD_FAILED";
1017
1023
  error.chat_job_guard = compactChatJobGuard(jobGuard);
1024
+ runControl.checkpoint({
1025
+ chat_context_step: "job_guard_failed",
1026
+ job_guard: compactChatJobGuard(jobGuard),
1027
+ error: {
1028
+ code: error.code,
1029
+ message: error.message
1030
+ }
1031
+ });
1032
+ if (contextRecoveryAttempts < 2) {
1033
+ await recoverAndReapplyChatContext("job_guard_failed", error, { forceRefresh: true });
1034
+ continue;
1035
+ }
1018
1036
  throw error;
1019
1037
  }
1020
1038
  if (!jobGuard.already_current) {
@@ -1688,6 +1706,7 @@ export async function runChatWorkflow({
1688
1706
  request_skipped: requestSkippedCount,
1689
1707
  unique_seen: compactInfiniteListState(listState).seen_count,
1690
1708
  scroll_count: compactInfiniteListState(listState).scroll_count,
1709
+ context_recoveries: contextRecoveryAttempts,
1691
1710
  list_end_reason: listEndReason || null,
1692
1711
  viewport_checks: viewportGuard.getStats().checks,
1693
1712
  viewport_recoveries: viewportGuard.getStats().recoveries,
@@ -1739,6 +1758,7 @@ export async function runChatWorkflow({
1739
1758
  human_rest_count: humanRestController.getState().rest_count,
1740
1759
  human_rest_ms: humanRestController.getState().total_rest_ms,
1741
1760
  human_rest_last: restResult,
1761
+ context_recoveries: contextRecoveryAttempts,
1742
1762
  last_human_event: lastHumanEvent
1743
1763
  });
1744
1764
  }
@@ -1781,6 +1801,7 @@ export async function runChatWorkflow({
1781
1801
  requested: requestedCount,
1782
1802
  request_satisfied: requestSatisfiedCount,
1783
1803
  request_skipped: requestSkippedCount,
1804
+ context_recoveries: contextRecoveryAttempts,
1784
1805
  results
1785
1806
  };
1786
1807
  }
@@ -1893,6 +1914,7 @@ export function createChatRunService({
1893
1914
  requested: 0,
1894
1915
  request_satisfied: 0,
1895
1916
  request_skipped: 0,
1917
+ context_recoveries: 0,
1896
1918
  human_behavior_enabled: effectiveHumanBehavior.enabled,
1897
1919
  human_behavior_profile: effectiveHumanBehavior.profile,
1898
1920
  human_rest_enabled: effectiveHumanRestEnabled,
@@ -1,7 +1,8 @@
1
- import {
2
- clickNodeCenter,
3
- clickPoint,
4
- getFrameDocumentNodeId,
1
+ import {
2
+ clickNodeCenter,
3
+ clickPoint,
4
+ DETERMINISTIC_CLICK_OPTIONS,
5
+ getFrameDocumentNodeId,
5
6
  getNodeBox,
6
7
  getOuterHTML,
7
8
  pressKey,
@@ -507,11 +508,11 @@ export async function closeRecommendDetail(client, {
507
508
  const closeTarget = await findVisibleCloseTarget(client, rootState.roots, DETAIL_CLOSE_SELECTORS);
508
509
  if (closeTarget) {
509
510
  try {
510
- if (closeTarget.center) {
511
- await clickPoint(client, closeTarget.center.x, closeTarget.center.y);
512
- } else {
513
- await clickNodeCenter(client, closeTarget.node_id);
514
- }
511
+ if (closeTarget.center) {
512
+ await clickPoint(client, closeTarget.center.x, closeTarget.center.y, DETERMINISTIC_CLICK_OPTIONS);
513
+ } else {
514
+ await clickNodeCenter(client, closeTarget.node_id, DETERMINISTIC_CLICK_OPTIONS);
515
+ }
515
516
  attempts.push({
516
517
  mode: "close-selector",
517
518
  selector: closeTarget.selector,
@@ -732,7 +733,7 @@ async function clickOutsideRecommendDetail(client, detailState) {
732
733
  root: target?.root || null
733
734
  };
734
735
  }
735
- await clickPoint(client, point.x, point.y);
736
+ await clickPoint(client, point.x, point.y, DETERMINISTIC_CLICK_OPTIONS);
736
737
  return {
737
738
  clicked: true,
738
739
  mode: "outside-modal-click",
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  clickNodeCenter,
3
3
  countSelectors,
4
+ DETERMINISTIC_CLICK_OPTIONS,
4
5
  findFirstNode,
5
6
  getAttributesMap,
6
7
  getNodeBox,
@@ -117,7 +118,7 @@ export async function ensureFilterPanelClosed(client, frameNodeId, triggerNodeId
117
118
  attempts.push("Escape");
118
119
 
119
120
  if (await getFilterPanelCount(client, frameNodeId) > 0 && triggerNodeId) {
120
- await clickNodeCenter(client, triggerNodeId);
121
+ await clickNodeCenter(client, triggerNodeId, DETERMINISTIC_CLICK_OPTIONS);
121
122
  await sleep(500);
122
123
  attempts.push("filter-trigger-toggle");
123
124
  }
@@ -125,6 +126,19 @@ export async function ensureFilterPanelClosed(client, frameNodeId, triggerNodeId
125
126
  return attempts;
126
127
  }
127
128
 
129
+ async function dismissRecommendControlOverlays(client, settleMs = 250) {
130
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
131
+ return ["Escape-unavailable"];
132
+ }
133
+ await pressKey(client, "Escape", {
134
+ code: "Escape",
135
+ windowsVirtualKeyCode: 27,
136
+ nativeVirtualKeyCode: 27
137
+ });
138
+ if (settleMs > 0) await sleep(settleMs);
139
+ return ["Escape"];
140
+ }
141
+
128
142
  async function findFilterTriggerCandidates(client, frameNodeId) {
129
143
  const candidates = [];
130
144
  const seen = new Set();
@@ -147,6 +161,7 @@ export async function openFilterPanel(client, frameNodeId) {
147
161
  throw new Error("Recommend filter trigger was not found");
148
162
  }
149
163
 
164
+ const preOpenDismissalAttempts = await dismissRecommendControlOverlays(client);
150
165
  const existingPanelNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel, {
151
166
  timeoutMs: 300,
152
167
  intervalMs: 100
@@ -157,7 +172,7 @@ export async function openFilterPanel(client, frameNodeId) {
157
172
  trigger: triggerCandidates[0],
158
173
  trigger_box: triggerBox,
159
174
  panel_node_id: existingPanelNodeId,
160
- initial_close_attempts: [],
175
+ initial_close_attempts: preOpenDismissalAttempts,
161
176
  already_open: true
162
177
  };
163
178
  }
@@ -169,11 +184,13 @@ export async function openFilterPanel(client, frameNodeId) {
169
184
  triggerCandidates = await findFilterTriggerCandidates(client, frameNodeId);
170
185
  for (const trigger of triggerCandidates) {
171
186
  const triggerBox = await getNodeBox(client, trigger.nodeId);
172
- await clickNodeCenter(client, trigger.nodeId);
187
+ const clickBox = await clickNodeCenter(client, trigger.nodeId, DETERMINISTIC_CLICK_OPTIONS);
173
188
  attempts.push({
174
189
  selector: trigger.selector,
175
190
  node_id: trigger.nodeId,
176
- center: triggerBox.center
191
+ center: triggerBox.center,
192
+ click_target: clickBox.click_target,
193
+ click_result: clickBox.click_result
177
194
  });
178
195
  const panelNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel, {
179
196
  timeoutMs: 2500,
@@ -184,7 +201,10 @@ export async function openFilterPanel(client, frameNodeId) {
184
201
  trigger,
185
202
  trigger_box: triggerBox,
186
203
  panel_node_id: panelNodeId,
187
- initial_close_attempts: closeAttempts,
204
+ initial_close_attempts: [
205
+ ...preOpenDismissalAttempts,
206
+ ...closeAttempts
207
+ ],
188
208
  open_attempts: attempts
189
209
  };
190
210
  }
@@ -236,7 +256,7 @@ async function clickFirstAvailableNode(client, nodeIds) {
236
256
  const errors = [];
237
257
  for (const nodeId of nodeIds) {
238
258
  try {
239
- const box = await clickNodeCenter(client, nodeId);
259
+ const box = await clickNodeCenter(client, nodeId, DETERMINISTIC_CLICK_OPTIONS);
240
260
  return {
241
261
  clicked: true,
242
262
  node_id: nodeId,
@@ -301,7 +321,10 @@ export async function selectFirstSafeFilterOption(client, frameNodeId, {
301
321
  throw new Error("No safe non-active recommend filter option was found");
302
322
  }
303
323
 
304
- const box = await clickNodeCenter(client, selected.node_id, { scrollIntoView: true });
324
+ const box = await clickNodeCenter(client, selected.node_id, {
325
+ ...DETERMINISTIC_CLICK_OPTIONS,
326
+ scrollIntoView: true
327
+ });
305
328
  await sleep(300);
306
329
 
307
330
  return {
@@ -338,7 +361,10 @@ export async function selectFilterOption(client, frameNodeId, {
338
361
  throw new Error(`No matching recommend filter option was found for ${target}`);
339
362
  }
340
363
 
341
- const box = await clickNodeCenter(client, selected.node_id, { scrollIntoView: true });
364
+ const box = await clickNodeCenter(client, selected.node_id, {
365
+ ...DETERMINISTIC_CLICK_OPTIONS,
366
+ scrollIntoView: true
367
+ });
342
368
  await sleep(300);
343
369
 
344
370
  return {
@@ -409,7 +435,10 @@ export async function selectFilterOptions(client, frameNodeId, {
409
435
  continue;
410
436
  }
411
437
 
412
- const box = await clickNodeCenter(client, selected.node_id, { scrollIntoView: true });
438
+ const box = await clickNodeCenter(client, selected.node_id, {
439
+ ...DETERMINISTIC_CLICK_OPTIONS,
440
+ scrollIntoView: true
441
+ });
413
442
  selectedOptions.push({
414
443
  group: selected.group,
415
444
  label: selected.label,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  clickNodeCenter,
3
+ DETERMINISTIC_CLICK_OPTIONS,
3
4
  getAttributesMap,
4
5
  getNodeBox,
5
6
  getOuterHTML,
6
7
  pressKey,
7
8
  querySelectorAll,
8
- sleep,
9
- waitForSelector
9
+ sleep
10
10
  } from "../../core/browser/index.js";
11
11
  import {
12
12
  htmlToText,
@@ -108,7 +108,9 @@ export async function waitForRecommendJobTrigger(client, frameNodeId, {
108
108
  export async function openRecommendJobDropdown(client, frameNodeId, {
109
109
  timeoutMs = 4000,
110
110
  triggerTimeoutMs = Math.max(8000, timeoutMs),
111
- triggerIntervalMs = 250
111
+ triggerIntervalMs = 250,
112
+ dismissBeforeOpen = true,
113
+ maxAttempts = 3
112
114
  } = {}) {
113
115
  const trigger = await waitForRecommendJobTrigger(client, frameNodeId, {
114
116
  timeoutMs: triggerTimeoutMs,
@@ -118,36 +120,72 @@ export async function openRecommendJobDropdown(client, frameNodeId, {
118
120
  throw new Error("Recommend job trigger was not found");
119
121
  }
120
122
 
121
- let optionNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_JOB_SELECTORS.option, {
123
+ const alreadyOpen = await waitForVisibleRecommendJobOptions(client, frameNodeId, {
122
124
  timeoutMs: 300,
123
125
  intervalMs: 100
124
126
  });
125
- if (optionNodeId) {
126
- const options = await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
127
- if (options.some((option) => option.visible)) {
127
+ if (alreadyOpen.visible_options.length) {
128
+ return {
129
+ opened: true,
130
+ already_open: true,
131
+ trigger,
132
+ options: alreadyOpen.options
133
+ };
134
+ }
135
+
136
+ const attempts = [];
137
+ const attemptLimit = Math.max(1, Math.floor(Number(maxAttempts) || 1));
138
+ if (dismissBeforeOpen) {
139
+ await closeRecommendJobDropdown(client);
140
+ }
141
+ for (let attempt = 1; attempt <= attemptLimit; attempt += 1) {
142
+ if (attempt > 1) await closeRecommendJobDropdown(client);
143
+ const triggerBox = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS);
144
+ const opened = await waitForVisibleRecommendJobOptions(client, frameNodeId, {
145
+ timeoutMs,
146
+ intervalMs: 200
147
+ });
148
+ attempts.push({
149
+ attempt,
150
+ trigger_box: triggerBox,
151
+ option_count: opened.options.length,
152
+ visible_option_count: opened.visible_options.length
153
+ });
154
+ if (opened.visible_options.length) {
128
155
  return {
129
156
  opened: true,
130
- already_open: true,
157
+ already_open: false,
131
158
  trigger,
132
- options
159
+ options: opened.options,
160
+ attempts
133
161
  };
134
162
  }
135
163
  }
164
+ const error = new Error("Recommend job dropdown did not expose visible options after trigger click");
165
+ error.job_dropdown_attempts = attempts;
166
+ throw error;
167
+ }
136
168
 
137
- await clickNodeCenter(client, trigger.node_id);
138
- optionNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_JOB_SELECTORS.option, {
139
- timeoutMs,
140
- intervalMs: 200
141
- });
142
- if (!optionNodeId) {
143
- throw new Error("Recommend job dropdown did not mount options after trigger click");
169
+ async function waitForVisibleRecommendJobOptions(client, frameNodeId, {
170
+ timeoutMs = 4000,
171
+ intervalMs = 200
172
+ } = {}) {
173
+ const started = Date.now();
174
+ let lastOptions = [];
175
+ while (Date.now() - started <= timeoutMs) {
176
+ lastOptions = await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
177
+ const visibleOptions = lastOptions.filter((option) => option.visible);
178
+ if (visibleOptions.length) {
179
+ return {
180
+ options: lastOptions,
181
+ visible_options: visibleOptions
182
+ };
183
+ }
184
+ await sleep(intervalMs);
144
185
  }
145
- const options = await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
146
186
  return {
147
- opened: true,
148
- already_open: false,
149
- trigger,
150
- options
187
+ options: lastOptions,
188
+ visible_options: []
151
189
  };
152
190
  }
153
191
 
@@ -174,12 +212,22 @@ export async function listRecommendJobOptions(client, frameNodeId, {
174
212
  }
175
213
 
176
214
  export async function closeRecommendJobDropdown(client) {
215
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
216
+ return {
217
+ ok: false,
218
+ reason: "dispatch_key_unavailable"
219
+ };
220
+ }
177
221
  await pressKey(client, "Escape", {
178
222
  code: "Escape",
179
223
  windowsVirtualKeyCode: 27,
180
224
  nativeVirtualKeyCode: 27
181
225
  });
182
226
  await sleep(300);
227
+ return {
228
+ ok: true,
229
+ reason: "escape"
230
+ };
183
231
  }
184
232
 
185
233
  export async function selectRecommendJob(client, frameNodeId, {
@@ -205,11 +253,16 @@ export async function selectRecommendJob(client, frameNodeId, {
205
253
  ? opened.options
206
254
  : await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
207
255
  const visibleOptions = options.filter((option) => option.visible);
208
- const match = visibleOptions.find((option) => jobLabelMatches(option.label, target))
209
- || options.find((option) => jobLabelMatches(option.label, target));
256
+ const hiddenMatches = options.filter((option) => !option.visible && jobLabelMatches(option.label, target));
257
+ const match = visibleOptions.find((option) => jobLabelMatches(option.label, target));
210
258
 
211
259
  if (!match) {
212
260
  await closeRecommendJobDropdown(client);
261
+ if (hiddenMatches.length) {
262
+ const error = new Error(`Matched recommend job has no visible clickable option: ${hiddenMatches[0].label}`);
263
+ error.hidden_job_matches = hiddenMatches.map(compactJobOption);
264
+ throw error;
265
+ }
213
266
  return {
214
267
  requested: target,
215
268
  selected: false,
@@ -234,7 +287,7 @@ export async function selectRecommendJob(client, frameNodeId, {
234
287
  throw new Error(`Matched recommend job has no clickable center: ${match.label}`);
235
288
  }
236
289
 
237
- const clickedBox = await clickNodeCenter(client, match.node_id);
290
+ const clickedBox = await clickNodeCenter(client, match.node_id, DETERMINISTIC_CLICK_OPTIONS);
238
291
  if (settleMs > 0) await sleep(settleMs);
239
292
  return {
240
293
  requested: target,
@@ -103,7 +103,7 @@ function compactFilterReapplyError(error) {
103
103
 
104
104
  export function isRetryableRecommendJobSelectionError(error) {
105
105
  const message = String(error?.message || error || "");
106
- return /Recommend job trigger was not found|Recommend job dropdown did not mount options/i.test(message);
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);
107
107
  }
108
108
 
109
109
  function compactJobSelectionAttempt({
@@ -449,15 +449,43 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
449
449
  if (error.close_result) {
450
450
  result.close_result = compactCloseResult(error.close_result);
451
451
  }
452
+ if (error.refresh_attempt) {
453
+ result.refresh_attempt = error.refresh_attempt;
454
+ }
455
+ if (error.list_end_reason) {
456
+ result.list_end_reason = error.list_end_reason;
457
+ }
458
+ if (error.target_count != null) {
459
+ result.target_count = error.target_count;
460
+ }
461
+ if (error.passed_count != null) {
462
+ result.passed_count = error.passed_count;
463
+ }
452
464
  return result;
453
465
  }
454
466
 
455
- function createRecommendCloseFailureError(closeResult) {
456
- const error = new Error(closeResult?.reason || "Recommend detail did not close before recovery");
457
- error.code = "DETAIL_CLOSE_FAILED";
458
- error.close_result = closeResult || null;
459
- return error;
460
- }
467
+ function createRecommendCloseFailureError(closeResult) {
468
+ const error = new Error(closeResult?.reason || "Recommend detail did not close before recovery");
469
+ error.code = "DETAIL_CLOSE_FAILED";
470
+ error.close_result = closeResult || null;
471
+ return error;
472
+ }
473
+
474
+ function createRecommendRefreshFailureError(refreshAttempt, {
475
+ listEndReason = "",
476
+ targetCount = 0,
477
+ passedCount = 0
478
+ } = {}) {
479
+ const reason = refreshAttempt?.reason || "refresh_failed";
480
+ const detail = refreshAttempt?.error ? `: ${refreshAttempt.error}` : "";
481
+ const error = new Error(`Recommend refresh failed before target was reached (${reason}${detail})`);
482
+ error.code = "RECOMMEND_END_REFRESH_FAILED";
483
+ error.refresh_attempt = refreshAttempt || null;
484
+ error.list_end_reason = listEndReason || null;
485
+ error.target_count = targetCount;
486
+ error.passed_count = passedCount;
487
+ return error;
488
+ }
461
489
 
462
490
  export function isRecoverableImageCaptureError(error) {
463
491
  const code = String(error?.code || "");
@@ -960,9 +988,9 @@ export async function runRecommendWorkflow({
960
988
  refresh_forced_recent_not_view: true,
961
989
  list_end_reason: listEndReason
962
990
  });
963
- if (refreshResult.ok) {
964
- rootState = refreshResult.root_state || await getRecommendRoots(client);
965
- rootState = await ensureRecommendViewport(rootState, "refresh_after");
991
+ if (refreshResult.ok) {
992
+ rootState = refreshResult.root_state || await getRecommendRoots(client);
993
+ rootState = await ensureRecommendViewport(rootState, "refresh_after");
966
994
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
967
995
  timeoutMs: cardTimeoutMs,
968
996
  intervalMs: 300
@@ -976,12 +1004,17 @@ export async function runRecommendWorkflow({
976
1004
  forced_recent_not_view: true
977
1005
  }
978
1006
  });
979
- listEndReason = "";
980
- continue;
981
- }
982
- }
983
- break;
984
- }
1007
+ listEndReason = "";
1008
+ continue;
1009
+ }
1010
+ throw createRecommendRefreshFailureError(compactRefresh, {
1011
+ listEndReason,
1012
+ targetCount: targetPassCount,
1013
+ passedCount: countPassedResults(results)
1014
+ });
1015
+ }
1016
+ break;
1017
+ }
985
1018
 
986
1019
  const index = results.length;
987
1020
  let cardNodeId = nextCandidateResult.item.node_id;
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  clickNodeCenter,
3
+ DETERMINISTIC_CLICK_OPTIONS,
3
4
  getAttributesMap,
4
5
  getNodeBox,
5
6
  getOuterHTML,
@@ -220,7 +221,7 @@ export async function selectRecommendPageScope(client, frameNodeId, {
220
221
  };
221
222
  }
222
223
 
223
- const clickBox = await clickNodeCenter(client, targetTab.node_id);
224
+ const clickBox = await clickNodeCenter(client, targetTab.node_id, DETERMINISTIC_CLICK_OPTIONS);
224
225
  if (settleMs > 0) await sleep(settleMs);
225
226
  const after = await waitForRecommendPageScope(client, frameNodeId, effectiveScope, {
226
227
  timeoutMs,
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  clickNodeCenter,
3
3
  clickPoint,
4
+ DETERMINISTIC_CLICK_OPTIONS,
4
5
  getFrameDocumentNodeId,
5
6
  getNodeBox,
6
7
  getOuterHTML,
@@ -305,9 +306,9 @@ export async function closeRecruitDetail(client, {
305
306
  if (closeTarget) {
306
307
  try {
307
308
  if (closeTarget.center) {
308
- await clickPoint(client, closeTarget.center.x, closeTarget.center.y);
309
+ await clickPoint(client, closeTarget.center.x, closeTarget.center.y, DETERMINISTIC_CLICK_OPTIONS);
309
310
  } else {
310
- await clickNodeCenter(client, closeTarget.node_id);
311
+ await clickNodeCenter(client, closeTarget.node_id, DETERMINISTIC_CLICK_OPTIONS);
311
312
  }
312
313
  attempts.push({
313
314
  mode: "close-selector",
@@ -147,10 +147,23 @@ function compactRefreshAttempt(refreshAttempt) {
147
147
 
148
148
  function compactError(error, fallbackCode = "RECRUIT_RUN_ERROR") {
149
149
  if (!error) return null;
150
- return {
150
+ const result = {
151
151
  code: error.code || fallbackCode,
152
152
  message: error.message || String(error)
153
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;
154
167
  }
155
168
 
156
169
  function createRecruitCloseFailureError(closeResult) {
@@ -160,6 +173,34 @@ function createRecruitCloseFailureError(closeResult) {
160
173
  return error;
161
174
  }
162
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
+
163
204
  export function isStaleRecruitNodeError(error) {
164
205
  const message = String(error?.message || error || "");
165
206
  return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
@@ -610,7 +651,7 @@ export async function runRecruitWorkflow({
610
651
  if (!nextCandidateResult.ok) {
611
652
  listEndReason = nextCandidateResult.reason || "list_exhausted";
612
653
  if (
613
- nextCandidateResult.end_reached
654
+ (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
614
655
  && refreshOnEnd
615
656
  && results.length < limit
616
657
  && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
@@ -658,6 +699,11 @@ export async function runRecruitWorkflow({
658
699
  listEndReason = "";
659
700
  continue;
660
701
  }
702
+ throw createRecruitRefreshFailureError(compactRefresh, {
703
+ listEndReason,
704
+ targetCount: limit,
705
+ processedCount: results.length
706
+ });
661
707
  }
662
708
  break;
663
709
  }
@@ -2,6 +2,7 @@ import {
2
2
  clearFocusedInput,
3
3
  clickNodeCenter,
4
4
  countSelectors,
5
+ DETERMINISTIC_CLICK_OPTIONS,
5
6
  describeNode,
6
7
  findFirstNode,
7
8
  getAttributesMap,
@@ -315,7 +316,10 @@ async function clickFirstNodeBySelectors(client, rootNodeId, selectors, {
315
316
  throw new Error(`Recruit search node was not found for selectors: ${selectors.join(", ")}`);
316
317
  }
317
318
  try {
318
- const box = await clickNodeCenter(client, found.nodeId, { scrollIntoView });
319
+ const box = await clickNodeCenter(client, found.nodeId, {
320
+ ...DETERMINISTIC_CLICK_OPTIONS,
321
+ scrollIntoView
322
+ });
319
323
  await sleep(250);
320
324
  return {
321
325
  clicked: true,
@@ -337,6 +341,26 @@ async function clickFirstNodeBySelectors(client, rootNodeId, selectors, {
337
341
  }
338
342
  }
339
343
 
344
+ async function dismissRecruitSearchOverlays(client, settleMs = 250) {
345
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
346
+ return {
347
+ method: "Escape",
348
+ skipped: true,
349
+ reason: "dispatch_key_unavailable"
350
+ };
351
+ }
352
+ await pressKey(client, "Escape", {
353
+ code: "Escape",
354
+ windowsVirtualKeyCode: 27,
355
+ nativeVirtualKeyCode: 27
356
+ });
357
+ if (settleMs > 0) await sleep(settleMs);
358
+ return {
359
+ method: "Escape",
360
+ settle_ms: settleMs
361
+ };
362
+ }
363
+
340
364
  export async function getRecruitSearchCounts(client, frameNodeId) {
341
365
  return countSelectors(client, frameNodeId, {
342
366
  keyword_input: RECRUIT_SEARCH_SELECTORS.keywordInput.join(", "),
@@ -495,7 +519,10 @@ export async function setRecruitJobTitle(client, frameNodeId, jobTitle, {
495
519
  }
496
520
  let box = null;
497
521
  if (!lookup.candidate.active) {
498
- box = await clickNodeCenter(client, lookup.candidate.node_id, { scrollIntoView: true });
522
+ box = await clickNodeCenter(client, lookup.candidate.node_id, {
523
+ ...DETERMINISTIC_CLICK_OPTIONS,
524
+ scrollIntoView: true
525
+ });
499
526
  await sleep(500);
500
527
  }
501
528
  return {
@@ -527,7 +554,10 @@ export async function setRecruitDegree(client, frameNodeId, degree) {
527
554
  if (!candidate) {
528
555
  throw new Error(`Recruit degree option was not found: ${degreeLabel}`);
529
556
  }
530
- const box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
557
+ const box = await clickNodeCenter(client, candidate.node_id, {
558
+ ...DETERMINISTIC_CLICK_OPTIONS,
559
+ scrollIntoView: true
560
+ });
531
561
  await sleep(350);
532
562
  return {
533
563
  applied: true,
@@ -573,7 +603,10 @@ export async function setRecruitDegrees(client, frameNodeId, degrees = []) {
573
603
 
574
604
  let box = null;
575
605
  if (!candidate.active) {
576
- box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
606
+ box = await clickNodeCenter(client, candidate.node_id, {
607
+ ...DETERMINISTIC_CLICK_OPTIONS,
608
+ scrollIntoView: true
609
+ });
577
610
  await sleep(350);
578
611
  }
579
612
  selected.push({
@@ -638,7 +671,10 @@ export async function setRecruitSchools(client, frameNodeId, schools = []) {
638
671
 
639
672
  let box = null;
640
673
  if (!clickableActive) {
641
- box = await clickNodeCenter(client, clickable.node_id, { scrollIntoView: true });
674
+ box = await clickNodeCenter(client, clickable.node_id, {
675
+ ...DETERMINISTIC_CLICK_OPTIONS,
676
+ scrollIntoView: true
677
+ });
642
678
  await sleep(350);
643
679
  }
644
680
 
@@ -683,7 +719,10 @@ export async function setRecruitRecentViewedFilter(client, frameNodeId, enabled)
683
719
 
684
720
  let box = null;
685
721
  if (candidate.active !== enabled) {
686
- box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
722
+ box = await clickNodeCenter(client, candidate.node_id, {
723
+ ...DETERMINISTIC_CLICK_OPTIONS,
724
+ scrollIntoView: true
725
+ });
687
726
  await sleep(900);
688
727
  }
689
728
 
@@ -722,7 +761,10 @@ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
722
761
  { match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
723
762
  );
724
763
  if (categoryLookup.candidate) {
725
- const box = await clickNodeCenter(client, categoryLookup.candidate.node_id, { scrollIntoView: true });
764
+ const box = await clickNodeCenter(client, categoryLookup.candidate.node_id, {
765
+ ...DETERMINISTIC_CLICK_OPTIONS,
766
+ scrollIntoView: true
767
+ });
726
768
  await sleep(400);
727
769
  path.push({
728
770
  label: "城市",
@@ -765,7 +807,10 @@ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
765
807
  discovered_options: summarizeTextCandidates(popularLookup.candidates)
766
808
  };
767
809
  }
768
- const popularBox = await clickNodeCenter(client, popularLookup.candidate.node_id, { scrollIntoView: true });
810
+ const popularBox = await clickNodeCenter(client, popularLookup.candidate.node_id, {
811
+ ...DETERMINISTIC_CLICK_OPTIONS,
812
+ scrollIntoView: true
813
+ });
769
814
  await sleep(400);
770
815
  path.push({
771
816
  label: "热门",
@@ -801,7 +846,10 @@ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
801
846
  };
802
847
  }
803
848
 
804
- const nationalBox = await clickNodeCenter(client, nationalLookup.candidate.node_id, { scrollIntoView: true });
849
+ const nationalBox = await clickNodeCenter(client, nationalLookup.candidate.node_id, {
850
+ ...DETERMINISTIC_CLICK_OPTIONS,
851
+ scrollIntoView: true
852
+ });
805
853
  await sleep(700);
806
854
  path.push({
807
855
  label: "全国",
@@ -953,7 +1001,10 @@ export async function setRecruitCity(client, frameNodeId, city, {
953
1001
  };
954
1002
  }
955
1003
 
956
- const box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
1004
+ const box = await clickNodeCenter(client, candidate.node_id, {
1005
+ ...DETERMINISTIC_CLICK_OPTIONS,
1006
+ scrollIntoView: true
1007
+ });
957
1008
  await sleep(600);
958
1009
  return {
959
1010
  applied: true,
@@ -1057,6 +1108,7 @@ export async function applyRecruitSearchParams(client, {
1057
1108
  if (!controls.ok) {
1058
1109
  throw new Error(`Recruit search controls were not ready after navigation; counts=${JSON.stringify(controls.counts || {})}`);
1059
1110
  }
1111
+ const overlayDismissal = await dismissRecruitSearchOverlays(client);
1060
1112
  const initialRoots = await getRecruitRoots(client);
1061
1113
  let frameNodeId = initialRoots.iframe.documentNodeId;
1062
1114
  const initialFrameNodeId = frameNodeId;
@@ -1137,6 +1189,7 @@ export async function applyRecruitSearchParams(client, {
1137
1189
  applied: true,
1138
1190
  search_params: normalizedSearchParams,
1139
1191
  reset,
1192
+ overlay_dismissal: overlayDismissal,
1140
1193
  controls,
1141
1194
  initial_iframe: {
1142
1195
  selector: initialRoots.iframe.selector,