@reconcrap/boss-recommend-mcp 2.1.17 → 2.1.18

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.1.17",
3
+ "version": "2.1.18",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -1971,7 +1971,18 @@ export async function getOuterHTML(client, nodeId) {
1971
1971
  }
1972
1972
 
1973
1973
  export async function getNodeBox(client, nodeId) {
1974
- const result = await client.DOM.getBoxModel({ nodeId });
1974
+ let result;
1975
+ try {
1976
+ result = await client.DOM.getBoxModel({ nodeId });
1977
+ } catch (error) {
1978
+ const wrapped = new Error(error?.message || String(error));
1979
+ wrapped.name = error?.name || "Error";
1980
+ wrapped.node_id = nodeId;
1981
+ wrapped.cdp_method = "DOM.getBoxModel";
1982
+ wrapped.original_stack = error?.stack || "";
1983
+ wrapped.stack = `${new Error(`getNodeBox failed for nodeId=${nodeId}`).stack || wrapped.stack}\nCaused by: ${error?.stack || error}`;
1984
+ throw wrapped;
1985
+ }
1975
1986
  const model = result.model;
1976
1987
  const quad = model.border?.length ? model.border : model.content;
1977
1988
  const xs = [quad[0], quad[2], quad[4], quad[6]];
@@ -2171,7 +2182,17 @@ export async function clickPoint(client, x, y, {
2171
2182
  }
2172
2183
 
2173
2184
  export async function scrollNodeIntoView(client, nodeId) {
2174
- await client.DOM.scrollIntoViewIfNeeded({ nodeId });
2185
+ try {
2186
+ await client.DOM.scrollIntoViewIfNeeded({ nodeId });
2187
+ } catch (error) {
2188
+ const wrapped = new Error(error?.message || String(error));
2189
+ wrapped.name = error?.name || "Error";
2190
+ wrapped.node_id = nodeId;
2191
+ wrapped.cdp_method = "DOM.scrollIntoViewIfNeeded";
2192
+ wrapped.original_stack = error?.stack || "";
2193
+ wrapped.stack = `${new Error(`scrollNodeIntoView failed for nodeId=${nodeId}`).stack || wrapped.stack}\nCaused by: ${error?.stack || error}`;
2194
+ throw wrapped;
2195
+ }
2175
2196
  }
2176
2197
 
2177
2198
  export async function clickNodeCenter(client, nodeId, {
@@ -7,6 +7,7 @@ import {
7
7
  getOuterHTML,
8
8
  pressKey,
9
9
  querySelectorAll,
10
+ scrollNodeIntoView,
10
11
  sleep
11
12
  } from "../../core/browser/index.js";
12
13
  import {
@@ -27,6 +28,111 @@ import {
27
28
  getRecruitRoots
28
29
  } from "./roots.js";
29
30
 
31
+ function compactBox(box = null) {
32
+ if (!box) return null;
33
+ return {
34
+ center: box.center || null,
35
+ rect: box.rect || null
36
+ };
37
+ }
38
+
39
+ async function getViewportRect(client) {
40
+ if (typeof client?.Page?.getLayoutMetrics !== "function") return null;
41
+ try {
42
+ const metrics = await client.Page.getLayoutMetrics();
43
+ const viewport = metrics?.cssVisualViewport || metrics?.visualViewport || metrics?.layoutViewport || {};
44
+ const width = Number(viewport.clientWidth || viewport.width || metrics?.layoutViewport?.clientWidth || 0);
45
+ const height = Number(viewport.clientHeight || viewport.height || metrics?.layoutViewport?.clientHeight || 0);
46
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
47
+ return {
48
+ x: 0,
49
+ y: 0,
50
+ width,
51
+ height
52
+ };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function boxCenterIsInViewport(box, viewport, { marginPx = 16 } = {}) {
59
+ if (!box?.center || !viewport) return null;
60
+ const margin = Math.max(0, Number(marginPx) || 0);
61
+ return (
62
+ box.center.x >= viewport.x + margin
63
+ && box.center.x <= viewport.x + viewport.width - margin
64
+ && box.center.y >= viewport.y + margin
65
+ && box.center.y <= viewport.y + viewport.height - margin
66
+ );
67
+ }
68
+
69
+ function scrollDeltaForBox(box, viewport) {
70
+ if (!box?.center || !viewport) return 0;
71
+ const targetY = viewport.y + viewport.height * 0.48;
72
+ const delta = box.center.y - targetY;
73
+ if (Math.abs(delta) < 80) return 0;
74
+ return Math.max(-900, Math.min(900, delta));
75
+ }
76
+
77
+ export async function ensureRecruitCardInViewport(client, cardNodeId, {
78
+ maxScrollAttempts = 4,
79
+ marginPx = 16,
80
+ settleMs = 220
81
+ } = {}) {
82
+ const attempts = [];
83
+ await scrollNodeIntoView(client, cardNodeId);
84
+ if (settleMs > 0) await sleep(settleMs);
85
+
86
+ let finalBox = null;
87
+ for (let attempt = 0; attempt <= maxScrollAttempts; attempt += 1) {
88
+ const box = await getNodeBox(client, cardNodeId);
89
+ finalBox = box;
90
+ const viewport = await getViewportRect(client);
91
+ const inViewport = boxCenterIsInViewport(box, viewport, { marginPx });
92
+ const entry = {
93
+ attempt,
94
+ in_viewport: inViewport,
95
+ viewport,
96
+ box: compactBox(box)
97
+ };
98
+ attempts.push(entry);
99
+ if (inViewport === true || inViewport === null) {
100
+ return {
101
+ ok: inViewport !== false,
102
+ verified: inViewport === true,
103
+ box,
104
+ attempts
105
+ };
106
+ }
107
+ if (attempt >= maxScrollAttempts) break;
108
+
109
+ const deltaY = scrollDeltaForBox(box, viewport);
110
+ if (!deltaY) break;
111
+ const wheelPoint = {
112
+ x: viewport.x + viewport.width * 0.5,
113
+ y: viewport.y + viewport.height * 0.5
114
+ };
115
+ await client.Input.dispatchMouseEvent({
116
+ type: "mouseWheel",
117
+ x: wheelPoint.x,
118
+ y: wheelPoint.y,
119
+ deltaX: 0,
120
+ deltaY
121
+ });
122
+ entry.scroll = {
123
+ method: "mouseWheel",
124
+ delta_y: deltaY,
125
+ point: wheelPoint
126
+ };
127
+ if (settleMs > 0) await sleep(settleMs);
128
+ }
129
+
130
+ const error = new Error("Recruit candidate card is not inside the visible viewport before click");
131
+ error.card_viewport_attempts = attempts;
132
+ error.card_node_id = cardNodeId;
133
+ throw error;
134
+ }
135
+
30
136
  export function matchesRecruitDetailNetwork(url) {
31
137
  return RECRUIT_DETAIL_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
32
138
  }
@@ -256,13 +362,19 @@ export async function openRecruitCardDetail(client, cardNodeId, {
256
362
  const openedStarted = Date.now();
257
363
  const attempts = [];
258
364
  const clickStarted = Date.now();
365
+ const viewportGuard = await ensureRecruitCardInViewport(client, cardNodeId);
259
366
  const cardBox = await clickNodeCenter(client, cardNodeId, {
260
- scrollIntoView: true
367
+ scrollIntoView: false
261
368
  });
262
369
  let candidateClickMs = Date.now() - clickStarted;
263
370
  attempts.push({
264
371
  mode: "card-center",
265
- center: cardBox.center
372
+ center: cardBox.center,
373
+ viewport_guard: {
374
+ ok: viewportGuard.ok,
375
+ verified: viewportGuard.verified,
376
+ attempts: viewportGuard.attempts
377
+ }
266
378
  });
267
379
  const detailStarted = Date.now();
268
380
  let detailState = await waitForRecruitDetail(client, { timeoutMs });
@@ -44,7 +44,8 @@ const DEGREE_VALUES = new Set(["不限", "本科", "本科及以上", "硕士及
44
44
  const CITY_STOP_PATTERN = /(?:筛选|搜索|查找|找|做过|从事过|有过|相关|的人选|的人|并且|且|学历|学校|经验|性别|年龄|目标|必须|优先|,|。|;|;|,)/;
45
45
  const POST_ACTIONS = new Set(["none", "greet"]);
46
46
  const CRITERIA_MARKER_PATTERN = /(?:筛选条件|筛选标准|筛选要求|筛选规则|硬性条件|硬条件|criteria)\s*[::]/i;
47
- const CRITERIA_TRAILING_FIELD_PATTERN = /\n\s*(?:岗位|职位|关键词|城市|地点|工作地|学历|学校类型|院校标签|经验|经验要求|工作经验|工作年限|性别|年龄|年龄要求|年龄范围|只看未查看|目标筛选人数|目标人数|休息强度|后置动作|post_action|rest_level)\s*[::]/i;
47
+ const CRITERIA_TRAILING_FIELD_PATTERN = /\n\s*(?:岗位|职位|关键词|城市|地点|工作地|学历|学校类型|院校标签|经验|经验要求|工作经验|工作年限|性别|年龄|年龄要求|年龄范围|只看未查看|过滤已看|同事近期触达|近期同事触达|同事触达|同事联系|同事沟通|同事交换简历|目标筛选人数|目标人数|休息强度|后置动作|post_action|rest_level|filter_recent_colleague_contacted|recent_colleague_contacted|skip_recent_colleague_contacted)\s*[::]/i;
48
+ const INLINE_FIELD_BOUNDARY_PATTERN = /[;;]\s*(?:岗位|职位|关键词|城市|地点|工作地|学历|学校|学校类型|院校标签|经验|经验要求|工作经验|工作年限|性别|年龄|年龄要求|年龄范围|只看未查看|过滤已看|同事近期触达|近期同事触达|同事触达|同事联系|同事沟通|同事交换简历|目标筛选人数|目标人数|休息强度|后置动作|post_action|rest_level|filter_recent_colleague_contacted|recent_colleague_contacted|skip_recent_colleague_contacted)\s*(?:\([^)]*\))?\s*[::]/i;
48
49
 
49
50
  function normalizeText(input) {
50
51
  return String(input || "").replace(/\s+/g, " ").trim();
@@ -64,13 +65,15 @@ function escapeRegExp(input) {
64
65
  }
65
66
 
66
67
  function extractFieldLineValue(rawText, labels = []) {
67
- const lines = String(rawText || "").replace(/\r\n/g, "\n").split("\n");
68
+ const lines = String(rawText || "").replace(/\r\n/g, "\n").split(/\n|[;;]/);
68
69
  const labelPattern = labels.map(escapeRegExp).join("|");
69
70
  if (!labelPattern) return null;
70
71
  const pattern = new RegExp(`^\\s*(?:${labelPattern})(?:\\s*\\([^)]*\\))?\\s*[::]\\s*(.+?)\\s*$`, "i");
71
72
  for (const line of lines) {
72
73
  const match = line.match(pattern);
73
- const value = match?.[1]?.trim();
74
+ let value = match?.[1]?.trim();
75
+ const inlineBoundaryIndex = value ? value.search(INLINE_FIELD_BOUNDARY_PATTERN) : -1;
76
+ if (inlineBoundaryIndex >= 0) value = value.slice(0, inlineBoundaryIndex).trim();
74
77
  if (value) return value;
75
78
  }
76
79
  return null;
@@ -172,6 +175,56 @@ function normalizeRecentViewedOverride(value) {
172
175
  return null;
173
176
  }
174
177
 
178
+ function normalizeColleagueContactedFilterOverride(value) {
179
+ if (typeof value === "boolean") return value;
180
+ if (typeof value === "number") return value !== 0;
181
+ const normalized = normalizeText(value).toLowerCase();
182
+ const compact = normalized.replace(/\s+/g, "");
183
+ if (!compact) return null;
184
+ if ([
185
+ "true",
186
+ "yes",
187
+ "y",
188
+ "1",
189
+ "on",
190
+ "enable",
191
+ "enabled",
192
+ "需要",
193
+ "是",
194
+ "开启",
195
+ "过滤",
196
+ "需要过滤",
197
+ "跳过",
198
+ "排除",
199
+ "剔除",
200
+ "近30天未和同事交换简历",
201
+ "未和同事交换简历",
202
+ "只看未和同事交换简历"
203
+ ].includes(compact)) return true;
204
+ if ([
205
+ "false",
206
+ "no",
207
+ "n",
208
+ "0",
209
+ "off",
210
+ "disable",
211
+ "disabled",
212
+ "不需要",
213
+ "否",
214
+ "关闭",
215
+ "不限",
216
+ "不过滤",
217
+ "不跳过",
218
+ "不排除",
219
+ "保留",
220
+ "none",
221
+ "all"
222
+ ].includes(compact)) return false;
223
+ if (/(?:不|别|无需|不用|不要).{0,6}(?:过滤|排除|跳过|剔除).{0,8}(?:同事|交换简历|触达|联系|沟通)/i.test(normalized)) return false;
224
+ if (/(?:过滤|排除|跳过|剔除).{0,8}(?:同事|交换简历|触达|联系|沟通)/i.test(normalized)) return true;
225
+ return normalizeBooleanOverride(value);
226
+ }
227
+
175
228
  function normalizeBooleanOverride(value) {
176
229
  if (typeof value === "boolean") return value;
177
230
  if (typeof value === "number") return value !== 0;
@@ -217,6 +270,29 @@ function extractRecentViewedExplicit(rawText) {
217
270
  return value === null ? null : normalizeRecentViewedOverride(value);
218
271
  }
219
272
 
273
+ function extractColleagueContactedFilterExplicit(rawText) {
274
+ const value = extractFieldLineValue(rawText, [
275
+ "同事近期触达",
276
+ "近期同事触达",
277
+ "同事触达",
278
+ "同事近期联系",
279
+ "近期同事联系",
280
+ "同事联系",
281
+ "同事近期沟通",
282
+ "近期同事沟通",
283
+ "同事沟通",
284
+ "同事交换简历",
285
+ "近期同事交换简历",
286
+ "近30天未和同事交换简历",
287
+ "filter_recent_colleague_contacted",
288
+ "recent_colleague_contacted",
289
+ "colleague_contacted_filter",
290
+ "colleague_contacted",
291
+ "skip_recent_colleague_contacted"
292
+ ]);
293
+ return value === null ? null : normalizeColleagueContactedFilterOverride(value);
294
+ }
295
+
220
296
  function normalizeDegreesOverride(value) {
221
297
  if (Array.isArray(value)) return uniqueList(value.map(normalizeText));
222
298
  if (typeof value === "string") return uniqueList(value.split(/[,,、|/]/).map(normalizeText));
@@ -493,15 +569,21 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
493
569
  const rawInstruction = String(instruction || "");
494
570
  const text = normalizeText(rawInstruction);
495
571
  const finalConfirmed = confirmation?.final_confirmed === true;
496
- const hasSkipRecentColleagueOverride = Object.prototype.hasOwnProperty.call(
497
- overrides || {},
498
- "skip_recent_colleague_contacted"
499
- );
500
- const confirmationSkipRecentColleagueContacted = normalizeBooleanOverride(
501
- confirmation?.skip_recent_colleague_contacted_value
572
+ const hasSkipRecentColleagueOverride = [
573
+ "skip_recent_colleague_contacted",
574
+ "filter_recent_colleague_contacted",
575
+ "recent_colleague_contacted",
576
+ "colleague_contacted_filter",
577
+ "colleague_contacted"
578
+ ].some((key) => Object.prototype.hasOwnProperty.call(overrides || {}, key));
579
+ const confirmationSkipRecentColleagueContacted = normalizeColleagueContactedFilterOverride(
580
+ Object.prototype.hasOwnProperty.call(confirmation || {}, "filter_recent_colleague_contacted_value")
581
+ ? confirmation.filter_recent_colleague_contacted_value
582
+ : confirmation?.skip_recent_colleague_contacted_value
502
583
  );
503
584
  const explicitSchools = extractSchoolFilterExplicit(rawInstruction);
504
585
  const explicitRecentViewed = extractRecentViewedExplicit(rawInstruction);
586
+ const explicitColleagueContactedFilter = extractColleagueContactedFilterExplicit(rawInstruction);
505
587
  const explicitKeyword = extractFieldLineValue(rawInstruction, ["搜索关键词", "关键词", "keyword"]);
506
588
  const explicitJob = extractFieldLineValue(rawInstruction, ["岗位", "职位", "job"]);
507
589
  const explicitCity = extractFieldLineValue(rawInstruction, ["城市", "地点", "工作地", "base"]);
@@ -526,7 +608,7 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
526
608
  schools: explicitSchools.explicit ? explicitSchools.schools : extractSchools(text),
527
609
  schools_explicit: explicitSchools.explicit,
528
610
  filter_recent_viewed: explicitRecentViewed !== null ? explicitRecentViewed : extractRecentViewedFilter(text),
529
- skip_recent_colleague_contacted: confirmationSkipRecentColleagueContacted ?? true,
611
+ skip_recent_colleague_contacted: explicitColleagueContactedFilter ?? confirmationSkipRecentColleagueContacted,
530
612
  keyword_explicit: explicitKeyword || extractKeywordExplicit(text),
531
613
  keyword_auto: extractKeywordAuto(text),
532
614
  target_count: explicitTargetCount || extractTargetCount(text),
@@ -594,7 +676,17 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
594
676
  ? overrides.filter_recent_viewed
595
677
  : overrides.recent_not_view
596
678
  );
597
- const overrideSkipRecentColleagueContacted = normalizeBooleanOverride(overrides.skip_recent_colleague_contacted);
679
+ const overrideSkipRecentColleagueContacted = normalizeColleagueContactedFilterOverride(
680
+ Object.prototype.hasOwnProperty.call(overrides, "skip_recent_colleague_contacted")
681
+ ? overrides.skip_recent_colleague_contacted
682
+ : Object.prototype.hasOwnProperty.call(overrides, "filter_recent_colleague_contacted")
683
+ ? overrides.filter_recent_colleague_contacted
684
+ : Object.prototype.hasOwnProperty.call(overrides, "recent_colleague_contacted")
685
+ ? overrides.recent_colleague_contacted
686
+ : Object.prototype.hasOwnProperty.call(overrides, "colleague_contacted_filter")
687
+ ? overrides.colleague_contacted_filter
688
+ : overrides.colleague_contacted
689
+ );
598
690
  const overridePostAction = normalizePostAction(overrides.post_action);
599
691
  if (overrideCity) parsed.city = overrideCity;
600
692
  if (overrideDegree) parsed.degree = overrideDegree;
@@ -638,6 +730,11 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
638
730
  const postAction = resolvePostAction(parsed, confirmation);
639
731
  const maxGreetCount = resolveMaxGreetCount(parsed, confirmation);
640
732
  const confirmationCriteria = normalizeStringOverride(confirmation?.criteria_value);
733
+ const skipRecentColleagueContacted = typeof parsed.skip_recent_colleague_contacted === "boolean"
734
+ ? parsed.skip_recent_colleague_contacted
735
+ : finalConfirmed
736
+ ? false
737
+ : null;
641
738
  const baseSearchParams = {
642
739
  job,
643
740
  city: parsed.city,
@@ -648,7 +745,7 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
648
745
  gender: parsed.gender,
649
746
  age: parsed.age,
650
747
  filter_recent_viewed: parsed.filter_recent_viewed,
651
- skip_recent_colleague_contacted: parsed.skip_recent_colleague_contacted !== false,
748
+ skip_recent_colleague_contacted: skipRecentColleagueContacted,
652
749
  keyword: keywordResolution.keyword
653
750
  };
654
751
  const criteria = parsed.criteria_override || confirmationCriteria || parsed.criteria_explicit || null;
@@ -664,7 +761,7 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
664
761
  target_count: parsed.target_count,
665
762
  post_action: postAction,
666
763
  max_greet_count: maxGreetCount,
667
- skip_recent_colleague_contacted: parsed.skip_recent_colleague_contacted !== false,
764
+ skip_recent_colleague_contacted: skipRecentColleagueContacted === true,
668
765
  search_exchange_resume_filter_days: 30
669
766
  };
670
767
  const missingBeforeDefaults = collectMissingFields(baseSearchParams, baseScreenParams, parsed);
@@ -687,6 +784,7 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
687
784
  && !hasSkipRecentColleagueOverride
688
785
  && confirmationSkipRecentColleagueContacted === null
689
786
  && confirmation?.skip_recent_colleague_contacted_confirmed !== true
787
+ && confirmation?.filter_recent_colleague_contacted_confirmed !== true
690
788
  );
691
789
  const needs_criteria_confirmation = Boolean(screenParams.criteria) && !finalConfirmed && confirmation?.criteria_confirmed !== true;
692
790
  const pending_questions = [
@@ -703,12 +801,12 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
703
801
  : []),
704
802
  ...(needs_skip_recent_colleague_contacted_confirmation
705
803
  ? [{
706
- field: "skip_recent_colleague_contacted",
707
- question: "是否跳过近期已被同事触达的人选?搜索页会开启 Boss 的“近30天未和同事交换简历”过滤。",
804
+ field: "filter_recent_colleague_contacted",
805
+ question: "是否过滤近期已被同事触达的人选?开启后搜索页会勾选 Boss 的“近30天未和同事交换简历”。",
708
806
  value: true,
709
807
  options: [
710
- { label: "跳过(推荐)", value: true },
711
- { label: "不跳过", value: false }
808
+ { label: "过滤", value: true },
809
+ { label: "不过滤", value: false }
712
810
  ]
713
811
  }]
714
812
  : []),
@@ -109,6 +109,8 @@ function compactDetail(detailResult) {
109
109
  return {
110
110
  popup_text_length: detailResult.detail?.popup_text?.length || 0,
111
111
  resume_text_length: detailResult.detail?.resume_text?.length || 0,
112
+ card_box: detailResult.card_box || null,
113
+ open_attempts: detailResult.open_attempts || [],
112
114
  network_body_count: detailResult.network_bodies?.filter((item) => item.body).length || 0,
113
115
  parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
114
116
  cv_acquisition: detailResult.cv_acquisition || null,
@@ -593,7 +595,7 @@ export async function runRecruitWorkflow({
593
595
  const normalizedPostAction = normalizeRecruitPostAction(postAction);
594
596
  const postActionEnabled = normalizedPostAction !== "none";
595
597
  const useLlmScreening = normalizedScreeningMode !== "deterministic";
596
- const searchExchangeResumeFilterRequested = normalizedSearchParams.skip_recent_colleague_contacted !== false;
598
+ const searchExchangeResumeFilterRequested = normalizedSearchParams.skip_recent_colleague_contacted === true;
597
599
  let searchExchangeResumeFilterApplied = false;
598
600
  const limit = Math.max(1, Number(maxCandidates) || 1);
599
601
  const detailCountLimit = detailLimit == null ? limit : Math.max(0, Number(detailLimit) || 0);
@@ -1047,6 +1049,8 @@ export async function runRecruitWorkflow({
1047
1049
  networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
1048
1050
  networkParseIntervalMs: 250
1049
1051
  });
1052
+ detailResult.card_box = openedDetail.card_box || null;
1053
+ detailResult.open_attempts = openedDetail.open_attempts || [];
1050
1054
  addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
1051
1055
  const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
1052
1056
  let source = "network";
@@ -1484,7 +1488,7 @@ export function createRecruitRunService({
1484
1488
  const normalizedSearchParams = normalizeSearchParams(searchParams);
1485
1489
  const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
1486
1490
  const normalizedPostAction = normalizeRecruitPostAction(postAction);
1487
- const searchExchangeResumeFilterRequested = normalizedSearchParams.skip_recent_colleague_contacted !== false;
1491
+ const searchExchangeResumeFilterRequested = normalizedSearchParams.skip_recent_colleague_contacted === true;
1488
1492
  const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
1489
1493
  const normalizedDetailLimit = detailLimit == null ? candidateLimit : Math.max(0, Number(detailLimit) || 0);
1490
1494
  const effectiveHumanBehavior = normalizeHumanBehaviorOptions(humanBehavior, {
@@ -691,6 +691,9 @@ export function normalizeRecruitSearchParams(searchParams = {}) {
691
691
  const experience = normalizeRecruitExperienceFilter(pickRecruitExperienceSource(searchParams));
692
692
  const gender = normalizeRecruitGenderFilter(searchParams.gender);
693
693
  const age = normalizeRecruitAgeFilter(pickRecruitAgeSource(searchParams));
694
+ const skipRecentColleagueContacted = typeof searchParams.skip_recent_colleague_contacted === "boolean"
695
+ ? searchParams.skip_recent_colleague_contacted
696
+ : null;
694
697
  const normalized = {
695
698
  city: normalizeText(searchParams.city) || null,
696
699
  degree: degrees[0] || "不限",
@@ -700,7 +703,7 @@ export function normalizeRecruitSearchParams(searchParams = {}) {
700
703
  filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
701
704
  ? searchParams.filter_recent_viewed
702
705
  : null,
703
- skip_recent_colleague_contacted: searchParams.skip_recent_colleague_contacted !== false
706
+ skip_recent_colleague_contacted: skipRecentColleagueContacted
704
707
  };
705
708
  const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
706
709
  if (job) normalized.job = job;
@@ -733,6 +736,9 @@ export function hasRecruitSearchParams(searchParams = {}) {
733
736
  const experience = normalizeRecruitExperienceFilter(pickRecruitExperienceSource(searchParams));
734
737
  const gender = normalizeRecruitGenderFilter(searchParams.gender);
735
738
  const age = normalizeRecruitAgeFilter(pickRecruitAgeSource(searchParams));
739
+ const skipRecentColleagueContacted = typeof searchParams.skip_recent_colleague_contacted === "boolean"
740
+ ? searchParams.skip_recent_colleague_contacted
741
+ : null;
736
742
  const normalized = {
737
743
  city: normalizeText(searchParams.city) || null,
738
744
  degree: degrees[0] || "不限",
@@ -742,7 +748,7 @@ export function hasRecruitSearchParams(searchParams = {}) {
742
748
  filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
743
749
  ? searchParams.filter_recent_viewed
744
750
  : null,
745
- skip_recent_colleague_contacted: searchParams.skip_recent_colleague_contacted !== false
751
+ skip_recent_colleague_contacted: skipRecentColleagueContacted
746
752
  };
747
753
  return Boolean(
748
754
  job
@@ -2239,20 +2245,180 @@ function parseRecruitAgeCustomHiddenValue(value) {
2239
2245
  return parseAgeNumber(text, null);
2240
2246
  }
2241
2247
 
2248
+ async function listRecruitAgeCustomTriggerState(client, frameNodeId) {
2249
+ const triggerSources = [
2250
+ {
2251
+ source: "dropdown",
2252
+ node_ids: uniqueNodeIds(await querySelectorAll(
2253
+ client,
2254
+ frameNodeId,
2255
+ RECRUIT_SEARCH_SELECTORS.ageCustomDropdown.join(", ")
2256
+ ))
2257
+ },
2258
+ {
2259
+ source: "input",
2260
+ node_ids: uniqueNodeIds(await querySelectorAll(
2261
+ client,
2262
+ frameNodeId,
2263
+ RECRUIT_SEARCH_SELECTORS.ageCustomInput.join(", ")
2264
+ ))
2265
+ }
2266
+ ];
2267
+ const discovered = [];
2268
+ const seenTriggers = new Set();
2269
+ for (const { source, node_ids: nodeIds } of triggerSources) {
2270
+ for (const nodeId of nodeIds) {
2271
+ if (seenTriggers.has(nodeId)) continue;
2272
+ seenTriggers.add(nodeId);
2273
+ const attributes = await getAttributesMap(client, nodeId).catch(() => ({}));
2274
+ if (source === "input" && attributes.type === "hidden") {
2275
+ discovered.push({
2276
+ node_id: nodeId,
2277
+ source,
2278
+ visible: false,
2279
+ reason: "hidden_input"
2280
+ });
2281
+ continue;
2282
+ }
2283
+ let box = null;
2284
+ try {
2285
+ box = await getNodeBox(client, nodeId);
2286
+ } catch (error) {
2287
+ discovered.push({
2288
+ node_id: nodeId,
2289
+ source,
2290
+ visible: false,
2291
+ error: error?.message || String(error)
2292
+ });
2293
+ continue;
2294
+ }
2295
+ discovered.push({
2296
+ node_id: nodeId,
2297
+ source,
2298
+ visible: isVisibleBox(box),
2299
+ center: box.center,
2300
+ rect: box.rect
2301
+ });
2302
+ }
2303
+ }
2304
+ const sortTriggers = (items) => items
2305
+ .slice()
2306
+ .sort((left, right) => left.rect.x - right.rect.x || left.rect.y - right.rect.y);
2307
+ const visible_dropdowns = sortTriggers(discovered.filter((item) => item.visible && item.source === "dropdown"));
2308
+ const visible_inputs = sortTriggers(discovered.filter((item) => item.visible && item.source === "input"));
2309
+ return {
2310
+ discovered,
2311
+ visible_dropdowns,
2312
+ visible_inputs,
2313
+ preferred: visible_dropdowns.length >= 2 ? visible_dropdowns : visible_inputs
2314
+ };
2315
+ }
2316
+
2317
+ async function waitForRecruitAgeCustomTriggerState(client, frameNodeId, {
2318
+ timeoutMs = 1800,
2319
+ intervalMs = 150
2320
+ } = {}) {
2321
+ const started = Date.now();
2322
+ let state = null;
2323
+ while (Date.now() - started <= timeoutMs) {
2324
+ state = await listRecruitAgeCustomTriggerState(client, frameNodeId);
2325
+ if ((state.preferred || []).length >= 2) {
2326
+ return {
2327
+ ok: true,
2328
+ elapsed_ms: Date.now() - started,
2329
+ ...state
2330
+ };
2331
+ }
2332
+ await sleep(intervalMs);
2333
+ }
2334
+ return {
2335
+ ok: false,
2336
+ elapsed_ms: Date.now() - started,
2337
+ ...(state || { discovered: [], visible_dropdowns: [], visible_inputs: [], preferred: [] })
2338
+ };
2339
+ }
2340
+
2341
+ async function openRecruitAgeCustomControls(client, frameNodeId) {
2342
+ const alreadyOpen = await waitForRecruitAgeCustomTriggerState(client, frameNodeId, {
2343
+ timeoutMs: 200,
2344
+ intervalMs: 100
2345
+ });
2346
+ if (alreadyOpen.ok) {
2347
+ return {
2348
+ opened: true,
2349
+ already_open: true,
2350
+ trigger_state: alreadyOpen
2351
+ };
2352
+ }
2353
+
2354
+ const attempts = [];
2355
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
2356
+ const candidates = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.ageCustom, {
2357
+ includeBox: true
2358
+ });
2359
+ const visibleCandidates = candidates.filter((item) => item.visible);
2360
+ const labelCandidate = chooseRecruitTextCandidate(visibleCandidates, {
2361
+ label: "自定义",
2362
+ match: "exact"
2363
+ });
2364
+ const customClick = labelCandidate
2365
+ ? {
2366
+ clicked: true,
2367
+ selector: labelCandidate.selector,
2368
+ node_id: labelCandidate.node_id,
2369
+ box: await clickNodeCenter(client, labelCandidate.node_id, {
2370
+ ...DETERMINISTIC_CLICK_OPTIONS,
2371
+ scrollIntoView: true
2372
+ })
2373
+ }
2374
+ : await clickFirstNodeBySelectors(
2375
+ client,
2376
+ frameNodeId,
2377
+ RECRUIT_SEARCH_SELECTORS.ageCustom,
2378
+ { optional: false, scrollIntoView: true }
2379
+ );
2380
+ await sleep(500);
2381
+ const triggerState = await waitForRecruitAgeCustomTriggerState(client, frameNodeId);
2382
+ attempts.push({
2383
+ attempt,
2384
+ click: customClick,
2385
+ matched_label: labelCandidate ? compactRecruitTextCandidate(labelCandidate) : null,
2386
+ candidates: visibleCandidates.map(compactRecruitTextCandidate).slice(0, 10),
2387
+ trigger_state: triggerState
2388
+ });
2389
+ if (triggerState.ok) {
2390
+ return {
2391
+ opened: true,
2392
+ already_open: false,
2393
+ attempts,
2394
+ trigger_state: triggerState
2395
+ };
2396
+ }
2397
+ }
2398
+
2399
+ const error = new Error("Recruit age custom controls did not open after clicking 自定义");
2400
+ error.age_custom_attempts = attempts;
2401
+ error.discovered_dropdowns = attempts[attempts.length - 1]?.trigger_state?.discovered || [];
2402
+ throw error;
2403
+ }
2404
+
2242
2405
  async function selectRecruitAgeCustomDropdownValue(client, frameNodeId, {
2243
2406
  dropdownIndex,
2244
2407
  value
2245
2408
  }) {
2246
- const dropdownNodeIds = uniqueNodeIds(await querySelectorAll(
2247
- client,
2248
- frameNodeId,
2249
- RECRUIT_SEARCH_SELECTORS.ageCustomDropdown.join(", ")
2250
- ));
2251
- const dropdownNodeId = dropdownNodeIds[dropdownIndex];
2252
- if (!dropdownNodeId) {
2253
- throw new Error(`Recruit age custom dropdown was not found: index=${dropdownIndex}`);
2254
- }
2255
- const openBox = await clickNodeCenter(client, dropdownNodeId, {
2409
+ const triggerState = await listRecruitAgeCustomTriggerState(client, frameNodeId);
2410
+ const visibleDropdownWrappers = triggerState.visible_dropdowns || [];
2411
+ const visibleInputTriggers = triggerState.visible_inputs || [];
2412
+ const visibleDropdowns = visibleDropdownWrappers.length > dropdownIndex
2413
+ ? visibleDropdownWrappers
2414
+ : visibleInputTriggers;
2415
+ const dropdown = visibleDropdowns[dropdownIndex];
2416
+ if (!dropdown?.node_id) {
2417
+ const error = new Error(`Recruit age custom dropdown was not found: index=${dropdownIndex}`);
2418
+ error.discovered_dropdowns = triggerState.discovered;
2419
+ throw error;
2420
+ }
2421
+ const openBox = await clickNodeCenter(client, dropdown.node_id, {
2256
2422
  ...DETERMINISTIC_CLICK_OPTIONS,
2257
2423
  scrollIntoView: true
2258
2424
  });
@@ -2261,7 +2427,8 @@ async function selectRecruitAgeCustomDropdownValue(client, frameNodeId, {
2261
2427
  const options = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.ageCustomOption, {
2262
2428
  includeBox: true
2263
2429
  });
2264
- const option = chooseRecruitTextCandidate(options, { label, match: "exact" });
2430
+ const visibleOptions = options.filter((item) => item.visible);
2431
+ const option = chooseRecruitTextCandidate(visibleOptions, { label, match: "exact" });
2265
2432
  if (!option) {
2266
2433
  throw new Error(`Recruit age custom option was not found: ${label}`);
2267
2434
  }
@@ -2274,10 +2441,11 @@ async function selectRecruitAgeCustomDropdownValue(client, frameNodeId, {
2274
2441
  dropdown_index: dropdownIndex,
2275
2442
  requested_value: value,
2276
2443
  selected_label: option.text,
2277
- dropdown_node_id: dropdownNodeId,
2444
+ dropdown_node_id: dropdown.node_id,
2278
2445
  option_node_id: option.node_id,
2279
2446
  open_box: openBox,
2280
2447
  box,
2448
+ discovered_dropdowns: triggerState.discovered,
2281
2449
  discovered_options: summarizeTextCandidates(options, 40)
2282
2450
  };
2283
2451
  }
@@ -2320,12 +2488,7 @@ export async function setRecruitAge(client, frameNodeId, age) {
2320
2488
  };
2321
2489
  }
2322
2490
 
2323
- const customClick = await clickFirstNodeBySelectors(
2324
- client,
2325
- frameNodeId,
2326
- RECRUIT_SEARCH_SELECTORS.ageCustom,
2327
- { optional: false, scrollIntoView: true }
2328
- );
2491
+ const customOpen = await openRecruitAgeCustomControls(client, frameNodeId);
2329
2492
  const before = await readRecruitAgeCustomState(client, frameNodeId);
2330
2493
  const fixedBefore = await readRecruitAgeFixedOptionState(client, frameNodeId);
2331
2494
  const selected = [];
@@ -2354,7 +2517,7 @@ export async function setRecruitAge(client, frameNodeId, age) {
2354
2517
  min: filter.min,
2355
2518
  max: filter.max
2356
2519
  },
2357
- custom_click: customClick,
2520
+ custom_open: customOpen,
2358
2521
  before,
2359
2522
  after,
2360
2523
  fixed_options_before: fixedBefore,
package/src/index.js CHANGED
@@ -882,7 +882,7 @@ function createRunInputSchema() {
882
882
  },
883
883
  skip_recent_colleague_contacted: {
884
884
  type: "boolean",
885
- description: "默认 true。推荐页跳过近14天同事沟通过的人选;搜索页使用近30天未和同事交换简历过滤。"
885
+ description: "推荐页默认 true,用于跳过近14天同事沟通过的人选。搜索页请使用 recruit 工具的 filter_recent_colleague_contacted。"
886
886
  },
887
887
  criteria: { type: "string" },
888
888
  job: { type: "string" },
@@ -1390,7 +1390,12 @@ function createCompactRunInputSchema() {
1390
1390
  description: "用户完成总确认后传 true"
1391
1391
  },
1392
1392
  skip_recent_colleague_contacted_confirmed: { type: "boolean" },
1393
- skip_recent_colleague_contacted_value: { type: "boolean" }
1393
+ skip_recent_colleague_contacted_value: { type: "boolean" },
1394
+ filter_recent_colleague_contacted_confirmed: { type: "boolean" },
1395
+ filter_recent_colleague_contacted_value: {
1396
+ type: "boolean",
1397
+ description: "是否过滤近期已被同事触达的人选;true 会开启搜索页“近30天未和同事交换简历”。"
1398
+ }
1394
1399
  },
1395
1400
  additionalProperties: true
1396
1401
  },
@@ -1413,6 +1418,17 @@ function createCompactRunInputSchema() {
1413
1418
  gender: { type: "string" },
1414
1419
  recent_not_view: { type: "string" },
1415
1420
  skip_recent_colleague_contacted: { type: "boolean" },
1421
+ filter_recent_colleague_contacted: {
1422
+ type: "boolean",
1423
+ description: "是否过滤近期已被同事触达的人选;true 会开启搜索页“近30天未和同事交换简历”;false 会确保该过滤取消。"
1424
+ },
1425
+ recent_colleague_contacted: {
1426
+ anyOf: [
1427
+ { type: "boolean" },
1428
+ { type: "string" }
1429
+ ],
1430
+ description: "同事近期触达筛选别名;可填 不限/不过滤/过滤。"
1431
+ },
1416
1432
  criteria: { type: "string" },
1417
1433
  target_count: targetCountSchema,
1418
1434
  post_action: { type: "string", enum: ["greet", "none"] },
@@ -712,6 +712,11 @@ export function createRecruitPipelineInputSchema() {
712
712
  criteria_value: { type: "string" },
713
713
  skip_recent_colleague_contacted_confirmed: { type: "boolean" },
714
714
  skip_recent_colleague_contacted_value: { type: "boolean" },
715
+ filter_recent_colleague_contacted_confirmed: { type: "boolean" },
716
+ filter_recent_colleague_contacted_value: {
717
+ type: "boolean",
718
+ description: "是否过滤近期已被同事触达的人选;true 会开启搜索页“近30天未和同事交换简历”。"
719
+ },
715
720
  post_action_confirmed: { type: "boolean" },
716
721
  post_action_value: {
717
722
  type: "string",
@@ -744,7 +749,18 @@ export function createRecruitPipelineInputSchema() {
744
749
  filter_recent_viewed: { type: "boolean" },
745
750
  skip_recent_colleague_contacted: {
746
751
  type: "boolean",
747
- description: "默认 true。搜索页使用 Boss 的“近30天未和同事交换简历”过滤;false 会确保该过滤取消。"
752
+ description: "显式 true 时开启 Boss 的“近30天未和同事交换简历”过滤;false 会确保该过滤取消;未提供时不默认开启。"
753
+ },
754
+ filter_recent_colleague_contacted: {
755
+ type: "boolean",
756
+ description: "是否过滤近期已被同事触达的人选;true 会开启搜索页“近30天未和同事交换简历”;false 会确保该过滤取消。"
757
+ },
758
+ recent_colleague_contacted: {
759
+ anyOf: [
760
+ { type: "boolean" },
761
+ { type: "string" }
762
+ ],
763
+ description: "同事近期触达筛选别名;可填 不限/不过滤/过滤。"
748
764
  },
749
765
  recent_not_view: {
750
766
  anyOf: [
@@ -1049,7 +1065,7 @@ function buildRequiredConfirmations(parsedResult) {
1049
1065
  if (parsedResult.needs_search_params_confirmation) confirmations.push("search_params");
1050
1066
  if (parsedResult.needs_keyword_confirmation) confirmations.push("keyword");
1051
1067
  if (parsedResult.needs_recent_viewed_filter_confirmation) confirmations.push("filter_recent_viewed");
1052
- if (parsedResult.needs_skip_recent_colleague_contacted_confirmation) confirmations.push("skip_recent_colleague_contacted");
1068
+ if (parsedResult.needs_skip_recent_colleague_contacted_confirmation) confirmations.push("filter_recent_colleague_contacted");
1053
1069
  if (parsedResult.needs_criteria_confirmation) confirmations.push("criteria");
1054
1070
  if (parsedResult.has_unresolved_missing_fields) confirmations.push("missing_fields_or_defaults");
1055
1071
  if ((parsedResult.suspicious_fields || []).length) confirmations.push("suspicious_fields");