@reconcrap/boss-recommend-mcp 2.1.14 → 2.1.16

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.
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  clearFocusedInput,
3
3
  clickNodeCenter,
4
+ clickPoint,
4
5
  countSelectors,
5
6
  DETERMINISTIC_CLICK_OPTIONS,
6
7
  describeNode,
7
8
  findFirstNode,
8
9
  getAttributesMap,
10
+ getNodeBox,
9
11
  getOuterHTML,
10
12
  insertText,
11
13
  pressKey,
@@ -48,6 +50,20 @@ const DEGREE_LABEL_MAP = new Map([
48
50
  ["博士", "博士"]
49
51
  ]);
50
52
 
53
+ const SCHOOL_LABEL_ALIAS_MAP = new Map([
54
+ ["统招", ["统招本科"]],
55
+ ["统招本科", ["统招本科"]],
56
+ ["双一流", ["双一流院校"]],
57
+ ["双一流院校", ["双一流院校"]],
58
+ ["211", ["211院校"]],
59
+ ["211院校", ["211院校"]],
60
+ ["985", ["985院校"]],
61
+ ["985院校", ["985院校"]],
62
+ ["留学生", ["留学生"]],
63
+ ["qs100", ["QS 100", "QS100"]],
64
+ ["qs500", ["QS 500", "QS500"]]
65
+ ]);
66
+
51
67
  const NATIONAL_CITY_LABELS = new Set([
52
68
  "全国",
53
69
  "不限",
@@ -63,6 +79,156 @@ const CITY_NO_RESULT_LABELS = new Set([
63
79
  "暂无数据",
64
80
  "无结果"
65
81
  ]);
82
+ const EXPERIENCE_CUSTOM_MIN_VALUE = 1;
83
+ const EXPERIENCE_CUSTOM_MAX_VALUE = 12;
84
+ const EXPERIENCE_FIXED_LABEL_ALIASES = new Map([
85
+ ["不限", "不限"],
86
+ ["不限制", "不限"],
87
+ ["无限制", "不限"],
88
+ ["全部", "不限"],
89
+ ["所有", "不限"],
90
+ ["无", "不限"],
91
+ ["none", "不限"],
92
+ ["all", "不限"],
93
+ ["在校/应届", "在校/应届"],
94
+ ["在校应届", "在校/应届"],
95
+ ["在校", "在校/应届"],
96
+ ["应届", "在校/应届"],
97
+ ["应届生", "在校/应届"],
98
+ ["25年毕业", "25年毕业"],
99
+ ["2025年毕业", "25年毕业"],
100
+ ["25届", "25年毕业"],
101
+ ["2025届", "25年毕业"],
102
+ ["26年毕业", "26年毕业"],
103
+ ["2026年毕业", "26年毕业"],
104
+ ["26届", "26年毕业"],
105
+ ["2026届", "26年毕业"],
106
+ ["26年后毕业", "26年后毕业"],
107
+ ["2026年后毕业", "26年后毕业"],
108
+ ["26届后", "26年后毕业"],
109
+ ["1-3年", "1-3年"],
110
+ ["1到3年", "1-3年"],
111
+ ["1至3年", "1-3年"],
112
+ ["1~3年", "1-3年"],
113
+ ["3-5年", "3-5年"],
114
+ ["3到5年", "3-5年"],
115
+ ["3至5年", "3-5年"],
116
+ ["3~5年", "3-5年"],
117
+ ["5-10年", "5-10年"],
118
+ ["5到10年", "5-10年"],
119
+ ["5至10年", "5-10年"],
120
+ ["5~10年", "5-10年"]
121
+ ]);
122
+ const EXPERIENCE_CUSTOM_ENDPOINTS = new Map([
123
+ ["在校/应届", { value: 1, label: "在校/应届" }],
124
+ ["在校应届", { value: 1, label: "在校/应届" }],
125
+ ["在校", { value: 1, label: "在校/应届" }],
126
+ ["应届", { value: 1, label: "在校/应届" }],
127
+ ["应届生", { value: 1, label: "在校/应届" }],
128
+ ["1年以内", { value: 2, label: "1年以内" }],
129
+ ["一年以内", { value: 2, label: "1年以内" }],
130
+ ["1年", { value: 2, label: "1年" }],
131
+ ["一年", { value: 2, label: "1年" }],
132
+ ["2年", { value: 3, label: "2年" }],
133
+ ["二年", { value: 3, label: "2年" }],
134
+ ["两年", { value: 3, label: "2年" }],
135
+ ["3年", { value: 4, label: "3年" }],
136
+ ["三年", { value: 4, label: "3年" }],
137
+ ["4年", { value: 5, label: "4年" }],
138
+ ["四年", { value: 5, label: "4年" }],
139
+ ["5年", { value: 6, label: "5年" }],
140
+ ["五年", { value: 6, label: "5年" }],
141
+ ["6年", { value: 7, label: "6年" }],
142
+ ["六年", { value: 7, label: "6年" }],
143
+ ["7年", { value: 8, label: "7年" }],
144
+ ["七年", { value: 8, label: "7年" }],
145
+ ["8年", { value: 9, label: "8年" }],
146
+ ["八年", { value: 9, label: "8年" }],
147
+ ["9年", { value: 10, label: "9年" }],
148
+ ["九年", { value: 10, label: "9年" }],
149
+ ["10年", { value: 11, label: "10年" }],
150
+ ["十年", { value: 11, label: "10年" }],
151
+ ["10年以上", { value: 12, label: "10年以上" }],
152
+ ["十年以上", { value: 12, label: "10年以上" }],
153
+ ["10年+", { value: 12, label: "10年以上" }],
154
+ ["10年以上经验", { value: 12, label: "10年以上" }],
155
+ ["10+", { value: 12, label: "10年以上" }]
156
+ ]);
157
+ const EXPERIENCE_CUSTOM_LABELS_BY_VALUE = new Map([
158
+ [1, "在校/应届"],
159
+ [2, "1年以内"],
160
+ [3, "2年"],
161
+ [4, "3年"],
162
+ [5, "4年"],
163
+ [6, "5年"],
164
+ [7, "6年"],
165
+ [8, "7年"],
166
+ [9, "8年"],
167
+ [10, "9年"],
168
+ [11, "10年"],
169
+ [12, "10年以上"]
170
+ ]);
171
+ const GENDER_LABEL_ALIASES = new Map([
172
+ ["不限", "不限"],
173
+ ["不限制", "不限"],
174
+ ["无限制", "不限"],
175
+ ["全部", "不限"],
176
+ ["所有", "不限"],
177
+ ["无", "不限"],
178
+ ["none", "不限"],
179
+ ["all", "不限"],
180
+ ["男", "男"],
181
+ ["男性", "男"],
182
+ ["男生", "男"],
183
+ ["male", "男"],
184
+ ["m", "男"],
185
+ ["女", "女"],
186
+ ["女性", "女"],
187
+ ["女生", "女"],
188
+ ["female", "女"],
189
+ ["f", "女"]
190
+ ]);
191
+ const AGE_FIXED_LABEL_ALIASES = new Map([
192
+ ["不限", "不限"],
193
+ ["不限制", "不限"],
194
+ ["无限制", "不限"],
195
+ ["全部", "不限"],
196
+ ["所有", "不限"],
197
+ ["无", "不限"],
198
+ ["none", "不限"],
199
+ ["all", "不限"],
200
+ ["20-25", "20-25"],
201
+ ["20到25", "20-25"],
202
+ ["20至25", "20-25"],
203
+ ["20~25", "20-25"],
204
+ ["20-25岁", "20-25"],
205
+ ["25-30", "25-30"],
206
+ ["25到30", "25-30"],
207
+ ["25至30", "25-30"],
208
+ ["25~30", "25-30"],
209
+ ["25-30岁", "25-30"],
210
+ ["30-35", "30-35"],
211
+ ["30到35", "30-35"],
212
+ ["30至35", "30-35"],
213
+ ["30~35", "30-35"],
214
+ ["30-35岁", "30-35"],
215
+ ["35-40", "35-40"],
216
+ ["35到40", "35-40"],
217
+ ["35至40", "35-40"],
218
+ ["35~40", "35-40"],
219
+ ["35-40岁", "35-40"],
220
+ ["40-50", "40-50"],
221
+ ["40到50", "40-50"],
222
+ ["40至50", "40-50"],
223
+ ["40~50", "40-50"],
224
+ ["40-50岁", "40-50"],
225
+ ["50以上", "50以上"],
226
+ ["50岁以上", "50以上"],
227
+ ["50+", "50以上"],
228
+ ["50岁+", "50以上"]
229
+ ]);
230
+ const AGE_CUSTOM_MIN = 16;
231
+ const AGE_CUSTOM_MAX = 46;
66
232
 
67
233
  function uniqueNodeIds(nodeIds = []) {
68
234
  return Array.from(new Set(nodeIds.filter(Boolean)));
@@ -96,6 +262,34 @@ export function normalizeRecruitSearchLabel(label) {
96
262
  return normalizeText(label).replace(/\s+/g, "");
97
263
  }
98
264
 
265
+ function normalizeRecruitSchoolCompareKey(label) {
266
+ return normalizeRecruitSearchLabel(label).toLowerCase();
267
+ }
268
+
269
+ function resolveRecruitQsSchoolBucket(label) {
270
+ const compareKey = normalizeRecruitSchoolCompareKey(label);
271
+ const match = compareKey.match(/qs(?:世界大学排名)?(?:top)?(\d+)/i);
272
+ if (!match) return [];
273
+ const rank = Number(match[1]);
274
+ if (!Number.isFinite(rank)) return [];
275
+ return rank <= 100 ? ["QS 100", "QS100"] : ["QS 500", "QS500"];
276
+ }
277
+
278
+ export function buildRecruitSchoolSearchLabels(school) {
279
+ const raw = normalizeText(school);
280
+ if (!raw) return [];
281
+ const normalized = normalizeRecruitSearchLabel(raw);
282
+ const compareKey = normalizeRecruitSchoolCompareKey(raw);
283
+ const labels = new Set([raw, normalized]);
284
+ for (const alias of resolveRecruitQsSchoolBucket(raw)) {
285
+ labels.add(alias);
286
+ }
287
+ for (const alias of SCHOOL_LABEL_ALIAS_MAP.get(compareKey) || []) {
288
+ labels.add(alias);
289
+ }
290
+ return Array.from(labels).map(normalizeText).filter(Boolean);
291
+ }
292
+
99
293
  export function buildRecruitJobTitleSearchTerms(jobTitle) {
100
294
  const normalized = normalizeText(jobTitle);
101
295
  if (!normalized) return [];
@@ -134,47 +328,433 @@ export function normalizeRecruitDegreeLabels(value) {
134
328
  return uniqueLabels.length ? uniqueLabels : ["不限"];
135
329
  }
136
330
 
331
+ function normalizeRecruitSchoolList(value) {
332
+ const rawItems = Array.isArray(value)
333
+ ? value.flatMap((item) => (
334
+ typeof item === "string"
335
+ ? item.split(/[,,、|/]/)
336
+ : [item]
337
+ ))
338
+ : typeof value === "string"
339
+ ? value.split(/[,,、|/]/)
340
+ : [];
341
+ return uniqueNodeIds(rawItems.map(normalizeText).filter(Boolean));
342
+ }
343
+
344
+ function normalizeExperienceCompareKey(value) {
345
+ return normalizeRecruitSearchLabel(value).toLowerCase();
346
+ }
347
+
348
+ function resolveRecruitExperienceFixedLabel(value) {
349
+ const normalized = normalizeText(value);
350
+ if (!normalized) return null;
351
+ const direct = EXPERIENCE_FIXED_LABEL_ALIASES.get(normalized)
352
+ || EXPERIENCE_FIXED_LABEL_ALIASES.get(normalizeExperienceCompareKey(normalized));
353
+ if (direct) return direct;
354
+ const compactRange = normalized
355
+ .replace(/\s+/g, "")
356
+ .replace(/[-—–]/g, "-")
357
+ .replace(/(?:到|至)/g, "-")
358
+ .replace(/[~~]/g, "-");
359
+ return EXPERIENCE_FIXED_LABEL_ALIASES.get(compactRange) || null;
360
+ }
361
+
362
+ function normalizeRecruitExperienceCustomEndpoint(value, fallbackValue) {
363
+ if (typeof value === "number" && Number.isFinite(value)) {
364
+ const number = Math.min(
365
+ EXPERIENCE_CUSTOM_MAX_VALUE,
366
+ Math.max(EXPERIENCE_CUSTOM_MIN_VALUE, Math.round(value))
367
+ );
368
+ return {
369
+ value: number,
370
+ label: EXPERIENCE_CUSTOM_LABELS_BY_VALUE.get(number) || String(number)
371
+ };
372
+ }
373
+ const normalized = normalizeText(value);
374
+ if (!normalized) {
375
+ const number = fallbackValue;
376
+ return {
377
+ value: number,
378
+ label: EXPERIENCE_CUSTOM_LABELS_BY_VALUE.get(number) || String(number)
379
+ };
380
+ }
381
+ const direct = EXPERIENCE_CUSTOM_ENDPOINTS.get(normalized)
382
+ || EXPERIENCE_CUSTOM_ENDPOINTS.get(normalizeExperienceCompareKey(normalized));
383
+ if (direct) return { ...direct };
384
+ const numeric = normalized.match(/^(\d+)\s*(?:年)?$/);
385
+ if (numeric) {
386
+ const years = Number.parseInt(numeric[1], 10);
387
+ const valueByYears = new Map([
388
+ [0, 1],
389
+ [1, 2],
390
+ [2, 3],
391
+ [3, 4],
392
+ [4, 5],
393
+ [5, 6],
394
+ [6, 7],
395
+ [7, 8],
396
+ [8, 9],
397
+ [9, 10],
398
+ [10, 11]
399
+ ]);
400
+ const number = valueByYears.get(years);
401
+ if (number) {
402
+ return {
403
+ value: number,
404
+ label: EXPERIENCE_CUSTOM_LABELS_BY_VALUE.get(number) || `${years}年`
405
+ };
406
+ }
407
+ }
408
+ throw new Error(`Unsupported recruit experience custom endpoint: ${normalized}`);
409
+ }
410
+
411
+ function parseRecruitExperienceCustomRangeText(value) {
412
+ let text = normalizeText(value);
413
+ if (!text) return null;
414
+ text = text.replace(/^(?:自定义|custom)\s*[::]?\s*/i, "").trim();
415
+ if (!text) return {
416
+ start: "在校/应届",
417
+ end: "10年以上"
418
+ };
419
+ const parts = text
420
+ .replace(/[-—–]/g, "-")
421
+ .split(/\s*(?:到|至|~|~|-)\s*/)
422
+ .map(normalizeText)
423
+ .filter(Boolean);
424
+ if (parts.length >= 2) {
425
+ return {
426
+ start: parts[0],
427
+ end: parts[parts.length - 1]
428
+ };
429
+ }
430
+ return null;
431
+ }
432
+
433
+ export function normalizeRecruitExperienceFilter(value) {
434
+ if (value === null || value === undefined) return null;
435
+ if (Array.isArray(value)) {
436
+ const first = value.find((item) => normalizeText(item));
437
+ return first === undefined ? null : normalizeRecruitExperienceFilter(first);
438
+ }
439
+ if (typeof value === "object") {
440
+ const mode = normalizeText(value.mode).toLowerCase();
441
+ const label = normalizeText(value.label || value.option || value.value);
442
+ const hasCustomEndpoints = Object.prototype.hasOwnProperty.call(value, "start")
443
+ || Object.prototype.hasOwnProperty.call(value, "end")
444
+ || Object.prototype.hasOwnProperty.call(value, "min")
445
+ || Object.prototype.hasOwnProperty.call(value, "max")
446
+ || Object.prototype.hasOwnProperty.call(value, "from")
447
+ || Object.prototype.hasOwnProperty.call(value, "to")
448
+ || Object.prototype.hasOwnProperty.call(value, "start_value")
449
+ || Object.prototype.hasOwnProperty.call(value, "end_value");
450
+ if (mode === "custom" || hasCustomEndpoints) {
451
+ const startSource = value.start ?? value.min ?? value.from ?? value.start_label ?? value.start_value;
452
+ const endSource = value.end ?? value.max ?? value.to ?? value.end_label ?? value.end_value;
453
+ const start = normalizeRecruitExperienceCustomEndpoint(startSource, EXPERIENCE_CUSTOM_MIN_VALUE);
454
+ const end = normalizeRecruitExperienceCustomEndpoint(endSource, EXPERIENCE_CUSTOM_MAX_VALUE);
455
+ if (start.value > end.value) {
456
+ throw new Error(`Recruit experience custom start must be <= end: ${start.label} > ${end.label}`);
457
+ }
458
+ return {
459
+ mode: "custom",
460
+ start_label: start.label,
461
+ end_label: end.label,
462
+ start_value: start.value,
463
+ end_value: end.value,
464
+ label: `${start.label}-${end.label}`
465
+ };
466
+ }
467
+ return normalizeRecruitExperienceFilter(label);
468
+ }
469
+
470
+ const text = normalizeText(value);
471
+ if (!text) return null;
472
+ const fixedLabel = resolveRecruitExperienceFixedLabel(text);
473
+ if (fixedLabel) {
474
+ return {
475
+ mode: "option",
476
+ label: fixedLabel,
477
+ unlimited: fixedLabel === "不限"
478
+ };
479
+ }
480
+ const customRange = parseRecruitExperienceCustomRangeText(text);
481
+ if (customRange || /^(?:自定义|custom)$/i.test(text)) {
482
+ return normalizeRecruitExperienceFilter({
483
+ mode: "custom",
484
+ start: customRange?.start || "在校/应届",
485
+ end: customRange?.end || "10年以上"
486
+ });
487
+ }
488
+ throw new Error(`Unsupported recruit experience filter: ${text}`);
489
+ }
490
+
491
+ function normalizeGenderCompareKey(value) {
492
+ return normalizeRecruitSearchLabel(value).toLowerCase();
493
+ }
494
+
495
+ export function normalizeRecruitGenderFilter(value) {
496
+ if (value === null || value === undefined) return null;
497
+ if (Array.isArray(value)) {
498
+ const first = value.find((item) => normalizeText(item));
499
+ return first === undefined ? null : normalizeRecruitGenderFilter(first);
500
+ }
501
+ if (typeof value === "object") {
502
+ return normalizeRecruitGenderFilter(value.label || value.value || value.gender);
503
+ }
504
+ const text = normalizeText(value);
505
+ if (!text) return null;
506
+ const label = GENDER_LABEL_ALIASES.get(text) || GENDER_LABEL_ALIASES.get(normalizeGenderCompareKey(text));
507
+ if (!label) throw new Error(`Unsupported recruit gender filter: ${text}`);
508
+ return {
509
+ label,
510
+ unlimited: label === "不限"
511
+ };
512
+ }
513
+
514
+ function parseAgeNumber(value, fallback = null) {
515
+ if (typeof value === "number" && Number.isFinite(value)) return Math.round(value);
516
+ const text = normalizeText(value);
517
+ if (!text) return fallback;
518
+ if (/^(?:不限|不限制|无限制|全部|所有|无|none|all)$/i.test(text)) return fallback;
519
+ const match = text.match(/(\d+)/);
520
+ if (!match) return fallback;
521
+ return Number.parseInt(match[1], 10);
522
+ }
523
+
524
+ function normalizeAgeFixedLabel(value) {
525
+ const text = normalizeText(value);
526
+ if (!text) return null;
527
+ const compact = text
528
+ .replace(/\s+/g, "")
529
+ .replace(/[-—–]/g, "-")
530
+ .replace(/(?:到|至)/g, "-")
531
+ .replace(/[~~]/g, "-");
532
+ return AGE_FIXED_LABEL_ALIASES.get(text)
533
+ || AGE_FIXED_LABEL_ALIASES.get(compact)
534
+ || AGE_FIXED_LABEL_ALIASES.get(normalizeRecruitSearchLabel(compact));
535
+ }
536
+
537
+ function parseAgeCustomRangeText(value) {
538
+ let text = normalizeText(value);
539
+ if (!text) return null;
540
+ text = text.replace(/^(?:自定义|custom)\s*[::]?\s*/i, "").trim();
541
+ if (!text) return { min: null, max: null };
542
+ const rangeMatch = text
543
+ .replace(/[-—–]/g, "-")
544
+ .match(/(\d+)\s*(?:岁)?\s*(?:-|到|至|~|~)\s*(\d+)\s*(?:岁)?/);
545
+ if (rangeMatch) {
546
+ return {
547
+ min: Number.parseInt(rangeMatch[1], 10),
548
+ max: Number.parseInt(rangeMatch[2], 10)
549
+ };
550
+ }
551
+ return null;
552
+ }
553
+
554
+ function parseAgeCustomComparisonText(value) {
555
+ const text = normalizeText(value).replace(/\s+/g, "");
556
+ if (!text) return null;
557
+ const strictUpper = text.match(/^(?:低于|小于|少于|未满|不满|<)(\d+)(?:岁)?$/);
558
+ if (strictUpper) {
559
+ return {
560
+ min: null,
561
+ max: Number.parseInt(strictUpper[1], 10) - 1
562
+ };
563
+ }
564
+ const inclusiveUpper = text.match(/^(?:不超过|不大于|至多|最多|<=|≤)(\d+)(?:岁)?$/)
565
+ || text.match(/^(\d+)(?:岁)?(?:及以下|以下|以内)$/);
566
+ if (inclusiveUpper) {
567
+ return {
568
+ min: null,
569
+ max: Number.parseInt(inclusiveUpper[1], 10)
570
+ };
571
+ }
572
+ const strictLower = text.match(/^(?:高于|大于|超过|>)(\d+)(?:岁)?$/);
573
+ if (strictLower) {
574
+ return {
575
+ min: Number.parseInt(strictLower[1], 10) + 1,
576
+ max: null
577
+ };
578
+ }
579
+ const inclusiveLower = text.match(/^(?:不低于|不小于|至少|最低|>=|≥)(\d+)(?:岁)?$/)
580
+ || text.match(/^(\d+)(?:岁)?(?:及以上|以上)$/);
581
+ if (inclusiveLower) {
582
+ return {
583
+ min: Number.parseInt(inclusiveLower[1], 10),
584
+ max: null
585
+ };
586
+ }
587
+ return null;
588
+ }
589
+
590
+ export function normalizeRecruitAgeFilter(value) {
591
+ if (value === null || value === undefined) return null;
592
+ if (Array.isArray(value)) {
593
+ const first = value.find((item) => normalizeText(item));
594
+ return first === undefined ? null : normalizeRecruitAgeFilter(first);
595
+ }
596
+ if (typeof value === "object") {
597
+ const mode = normalizeText(value.mode).toLowerCase();
598
+ const label = normalizeText(value.label || value.option || value.value);
599
+ const hasCustom = Object.prototype.hasOwnProperty.call(value, "min")
600
+ || Object.prototype.hasOwnProperty.call(value, "max")
601
+ || Object.prototype.hasOwnProperty.call(value, "start")
602
+ || Object.prototype.hasOwnProperty.call(value, "end")
603
+ || Object.prototype.hasOwnProperty.call(value, "from")
604
+ || Object.prototype.hasOwnProperty.call(value, "to");
605
+ if (mode === "custom" || hasCustom) {
606
+ const min = parseAgeNumber(value.min ?? value.start ?? value.from, null);
607
+ const max = parseAgeNumber(value.max ?? value.end ?? value.to, null);
608
+ if (min !== null && (min < AGE_CUSTOM_MIN || min > AGE_CUSTOM_MAX)) {
609
+ throw new Error(`Recruit age custom min must be between ${AGE_CUSTOM_MIN} and ${AGE_CUSTOM_MAX}: ${min}`);
610
+ }
611
+ if (max !== null && (max < AGE_CUSTOM_MIN || max > AGE_CUSTOM_MAX)) {
612
+ throw new Error(`Recruit age custom max must be between ${AGE_CUSTOM_MIN} and ${AGE_CUSTOM_MAX}: ${max}`);
613
+ }
614
+ if (min !== null && max !== null && min > max) {
615
+ throw new Error(`Recruit age custom min must be <= max: ${min} > ${max}`);
616
+ }
617
+ return {
618
+ mode: "custom",
619
+ min,
620
+ max,
621
+ label: `${min ?? "不限"}-${max ?? "不限"}`
622
+ };
623
+ }
624
+ return normalizeRecruitAgeFilter(label);
625
+ }
626
+ const text = normalizeText(value);
627
+ if (!text) return null;
628
+ const fixedLabel = normalizeAgeFixedLabel(text);
629
+ if (fixedLabel) {
630
+ return {
631
+ mode: "option",
632
+ label: fixedLabel,
633
+ unlimited: fixedLabel === "不限"
634
+ };
635
+ }
636
+ const customRange = parseAgeCustomRangeText(text);
637
+ if (customRange || /^(?:自定义|custom)$/i.test(text)) {
638
+ return normalizeRecruitAgeFilter({
639
+ mode: "custom",
640
+ min: customRange?.min ?? null,
641
+ max: customRange?.max ?? null
642
+ });
643
+ }
644
+ const customComparison = parseAgeCustomComparisonText(text);
645
+ if (customComparison) {
646
+ return normalizeRecruitAgeFilter({
647
+ mode: "custom",
648
+ min: customComparison.min,
649
+ max: customComparison.max
650
+ });
651
+ }
652
+ throw new Error(`Unsupported recruit age filter: ${text}`);
653
+ }
654
+
655
+ function pickRecruitExperienceSource(searchParams = {}) {
656
+ if (Object.prototype.hasOwnProperty.call(searchParams, "experience")) return searchParams.experience;
657
+ if (Object.prototype.hasOwnProperty.call(searchParams, "experiences")) return searchParams.experiences;
658
+ if (Object.prototype.hasOwnProperty.call(searchParams, "experience_range")) return searchParams.experience_range;
659
+ if (
660
+ Object.prototype.hasOwnProperty.call(searchParams, "experience_start")
661
+ || Object.prototype.hasOwnProperty.call(searchParams, "experience_end")
662
+ ) {
663
+ return {
664
+ start: searchParams.experience_start,
665
+ end: searchParams.experience_end
666
+ };
667
+ }
668
+ return null;
669
+ }
670
+
671
+ function pickRecruitAgeSource(searchParams = {}) {
672
+ if (Object.prototype.hasOwnProperty.call(searchParams, "age")) return searchParams.age;
673
+ if (Object.prototype.hasOwnProperty.call(searchParams, "ages")) return searchParams.ages;
674
+ if (Object.prototype.hasOwnProperty.call(searchParams, "age_range")) return searchParams.age_range;
675
+ if (
676
+ Object.prototype.hasOwnProperty.call(searchParams, "age_min")
677
+ || Object.prototype.hasOwnProperty.call(searchParams, "age_max")
678
+ || Object.prototype.hasOwnProperty.call(searchParams, "min_age")
679
+ || Object.prototype.hasOwnProperty.call(searchParams, "max_age")
680
+ ) {
681
+ return {
682
+ min: searchParams.age_min ?? searchParams.min_age,
683
+ max: searchParams.age_max ?? searchParams.max_age
684
+ };
685
+ }
686
+ return null;
687
+ }
688
+
137
689
  export function normalizeRecruitSearchParams(searchParams = {}) {
138
690
  const degrees = normalizeRecruitDegreeLabels(searchParams.degrees || searchParams.degree || "不限");
691
+ const experience = normalizeRecruitExperienceFilter(pickRecruitExperienceSource(searchParams));
692
+ const gender = normalizeRecruitGenderFilter(searchParams.gender);
693
+ const age = normalizeRecruitAgeFilter(pickRecruitAgeSource(searchParams));
139
694
  const normalized = {
140
695
  city: normalizeText(searchParams.city) || null,
141
696
  degree: degrees[0] || "不限",
142
697
  degrees,
143
- schools: Array.isArray(searchParams.schools)
144
- ? searchParams.schools.map(normalizeText).filter(Boolean)
145
- : [],
698
+ schools: normalizeRecruitSchoolList(searchParams.schools),
146
699
  keyword: normalizeText(searchParams.keyword) || DEFAULT_RECRUIT_KEYWORD,
147
700
  filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
148
701
  ? searchParams.filter_recent_viewed
149
- : null
702
+ : null,
703
+ skip_recent_colleague_contacted: searchParams.skip_recent_colleague_contacted !== false
150
704
  };
151
705
  const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
152
706
  if (job) normalized.job = job;
707
+ if (experience) normalized.experience = experience;
708
+ if (gender) normalized.gender = gender;
709
+ if (age) normalized.age = age;
153
710
  return normalized;
154
711
  }
155
712
 
713
+ export function buildRecruitSearchApplicationStepNames(searchParams = {}) {
714
+ const normalized = normalizeRecruitSearchParams(searchParams);
715
+ const steps = [];
716
+ // Recruit search applies job first because job changes can reset other filters.
717
+ if (normalized.job) steps.push("job_title");
718
+ if (normalized.city) steps.push("city");
719
+ steps.push("degree", "schools");
720
+ if (normalized.experience) steps.push("experience");
721
+ if (normalized.gender) steps.push("gender");
722
+ if (normalized.age) steps.push("age");
723
+ if (typeof normalized.filter_recent_viewed === "boolean") steps.push("recent_viewed");
724
+ if (typeof normalized.skip_recent_colleague_contacted === "boolean") steps.push("exchange_resume");
725
+ // Keyword is the final filter before executing the search.
726
+ steps.push("keyword", "search");
727
+ return steps;
728
+ }
729
+
156
730
  export function hasRecruitSearchParams(searchParams = {}) {
157
731
  const degrees = normalizeRecruitDegreeLabels(searchParams.degrees || searchParams.degree || "不限");
158
732
  const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
733
+ const experience = normalizeRecruitExperienceFilter(pickRecruitExperienceSource(searchParams));
734
+ const gender = normalizeRecruitGenderFilter(searchParams.gender);
735
+ const age = normalizeRecruitAgeFilter(pickRecruitAgeSource(searchParams));
159
736
  const normalized = {
160
737
  city: normalizeText(searchParams.city) || null,
161
738
  degree: degrees[0] || "不限",
162
739
  degrees,
163
- schools: Array.isArray(searchParams.schools)
164
- ? searchParams.schools.map(normalizeText).filter(Boolean)
165
- : [],
740
+ schools: normalizeRecruitSchoolList(searchParams.schools),
166
741
  keyword: normalizeText(searchParams.keyword),
167
742
  filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
168
743
  ? searchParams.filter_recent_viewed
169
- : null
744
+ : null,
745
+ skip_recent_colleague_contacted: searchParams.skip_recent_colleague_contacted !== false
170
746
  };
171
747
  return Boolean(
172
748
  job
173
749
  || normalized.city
174
750
  || normalized.degrees.some((degree) => degree && degree !== "不限")
175
751
  || normalized.schools.length
752
+ || experience
753
+ || gender
754
+ || age
176
755
  || normalized.keyword
177
756
  || typeof normalized.filter_recent_viewed === "boolean"
757
+ || typeof normalized.skip_recent_colleague_contacted === "boolean"
178
758
  );
179
759
  }
180
760
 
@@ -186,14 +766,28 @@ function candidateIsActive(attributes = {}, outerHTML = "") {
186
766
  || /\bchecked(?:=["']?checked)?\b/i.test(openingTag);
187
767
  }
188
768
 
769
+ function isVisibleBox(box) {
770
+ return Boolean(box && box.rect.width > 4 && box.rect.height > 4);
771
+ }
772
+
189
773
  async function readTextCandidate(client, nodeId, {
190
774
  selector = "",
191
- index = 0
775
+ index = 0,
776
+ includeBox = false
192
777
  } = {}) {
193
778
  const [attributes, outerHTML] = await Promise.all([
194
779
  getAttributesMap(client, nodeId),
195
780
  getOuterHTML(client, nodeId)
196
781
  ]);
782
+ let box = null;
783
+ let boxError = "";
784
+ if (includeBox) {
785
+ try {
786
+ box = await getNodeBox(client, nodeId);
787
+ } catch (error) {
788
+ boxError = error?.message || String(error);
789
+ }
790
+ }
197
791
  const text = normalizeText(htmlToText(outerHTML));
198
792
  return {
199
793
  node_id: nodeId,
@@ -202,12 +796,16 @@ async function readTextCandidate(client, nodeId, {
202
796
  label: normalizeRecruitSearchLabel(text),
203
797
  text,
204
798
  active: candidateIsActive(attributes, outerHTML),
799
+ visible: includeBox ? isVisibleBox(box) : undefined,
800
+ center: box?.center || null,
801
+ rect: box?.rect || null,
802
+ box_error: boxError || undefined,
205
803
  class_name: attributes.class || "",
206
804
  attributes
207
805
  };
208
806
  }
209
807
 
210
- async function listTextCandidates(client, rootNodeId, selectors = []) {
808
+ async function listTextCandidates(client, rootNodeId, selectors = [], options = {}) {
211
809
  const candidates = [];
212
810
  const seen = new Set();
213
811
  for (const selector of selectors) {
@@ -216,7 +814,7 @@ async function listTextCandidates(client, rootNodeId, selectors = []) {
216
814
  const nodeId = nodeIds[index];
217
815
  if (seen.has(nodeId)) continue;
218
816
  seen.add(nodeId);
219
- candidates.push(await readTextCandidate(client, nodeId, { selector, index }));
817
+ candidates.push(await readTextCandidate(client, nodeId, { selector, index, ...options }));
220
818
  }
221
819
  }
222
820
  return candidates;
@@ -240,6 +838,12 @@ export function chooseRecruitTextCandidate(candidates = [], {
240
838
  return candidates.find((candidate) => candidate.label.includes(target) || target.includes(candidate.label)) || null;
241
839
  }
242
840
 
841
+ function chooseRecruitSchoolCandidate(candidates = [], school) {
842
+ const targetKeys = new Set(buildRecruitSchoolSearchLabels(school).map(normalizeRecruitSchoolCompareKey));
843
+ if (!targetKeys.size) return null;
844
+ return candidates.find((candidate) => targetKeys.has(normalizeRecruitSchoolCompareKey(candidate.text || candidate.label))) || null;
845
+ }
846
+
243
847
  async function findTextCandidate(client, rootNodeId, selectors, label, options = {}) {
244
848
  const candidates = await listTextCandidates(client, rootNodeId, selectors);
245
849
  return {
@@ -310,46 +914,82 @@ async function waitForRecruitJobTitleCandidate(client, rootNodeId, selectors, jo
310
914
  };
311
915
  }
312
916
 
313
- async function clickFirstNodeBySelectors(client, rootNodeId, selectors, {
314
- optional = false,
315
- scrollIntoView = true
316
- } = {}) {
317
- const found = await findFirstNode(client, rootNodeId, selectors);
318
- if (!found) {
319
- if (optional) return { clicked: false, reason: "not_found" };
320
- throw new Error(`Recruit search node was not found for selectors: ${selectors.join(", ")}`);
917
+ function compactRecruitTextCandidate(candidate = {}) {
918
+ return {
919
+ label: candidate.text || "",
920
+ normalized_label: candidate.label || "",
921
+ active: Boolean(candidate.active),
922
+ visible: Boolean(candidate.visible),
923
+ class_name: candidate.class_name || "",
924
+ node_id: candidate.node_id,
925
+ selector: candidate.selector,
926
+ center: candidate.center || null,
927
+ rect: candidate.rect || null,
928
+ box_error: candidate.box_error || null
929
+ };
930
+ }
931
+
932
+ async function listRecruitJobTitleOptions(client, frameNodeId) {
933
+ const candidates = await listTextCandidates(
934
+ client,
935
+ frameNodeId,
936
+ RECRUIT_SEARCH_SELECTORS.jobTitleOption,
937
+ { includeBox: true }
938
+ );
939
+ return candidates.filter((candidate) => candidate.text && candidate.text.length <= 160);
940
+ }
941
+
942
+ async function findVisibleRecruitJobTitleTrigger(client, frameNodeId) {
943
+ const candidates = [];
944
+ const seen = new Set();
945
+ for (const selector of RECRUIT_SEARCH_SELECTORS.jobTitleTrigger) {
946
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, frameNodeId, selector));
947
+ for (const nodeId of nodeIds) {
948
+ if (seen.has(nodeId)) continue;
949
+ seen.add(nodeId);
950
+ let box = null;
951
+ try {
952
+ box = await getNodeBox(client, nodeId);
953
+ } catch {}
954
+ if (!isVisibleBox(box)) continue;
955
+ candidates.push({
956
+ selector,
957
+ node_id: nodeId,
958
+ center: box.center,
959
+ rect: box.rect
960
+ });
961
+ }
321
962
  }
322
- try {
323
- const box = await clickNodeCenter(client, found.nodeId, {
324
- ...DETERMINISTIC_CLICK_OPTIONS,
325
- scrollIntoView
326
- });
327
- await sleep(250);
328
- return {
329
- clicked: true,
330
- selector: found.selector,
331
- node_id: found.nodeId,
332
- box
333
- };
334
- } catch (error) {
335
- if (optional) {
963
+ return candidates[0] || null;
964
+ }
965
+
966
+ async function waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
967
+ timeoutMs = 4000,
968
+ intervalMs = 200
969
+ } = {}) {
970
+ const started = Date.now();
971
+ let options = [];
972
+ while (Date.now() - started <= timeoutMs) {
973
+ options = await listRecruitJobTitleOptions(client, frameNodeId);
974
+ const visibleOptions = options.filter((option) => option.visible);
975
+ if (visibleOptions.length) {
336
976
  return {
337
- clicked: false,
338
- reason: "not_clickable",
339
- selector: found.selector,
340
- node_id: found.nodeId,
341
- error: error?.message || String(error)
977
+ options,
978
+ visible_options: visibleOptions
342
979
  };
343
980
  }
344
- throw error;
981
+ await sleep(intervalMs);
345
982
  }
983
+ return {
984
+ options,
985
+ visible_options: []
986
+ };
346
987
  }
347
988
 
348
- async function dismissRecruitSearchOverlays(client, settleMs = 250) {
989
+ async function closeRecruitJobTitleDropdown(client, settleMs = 300) {
349
990
  if (typeof client?.Input?.dispatchKeyEvent !== "function") {
350
991
  return {
351
- method: "Escape",
352
- skipped: true,
992
+ ok: false,
353
993
  reason: "dispatch_key_unavailable"
354
994
  };
355
995
  }
@@ -360,67 +1000,352 @@ async function dismissRecruitSearchOverlays(client, settleMs = 250) {
360
1000
  });
361
1001
  if (settleMs > 0) await sleep(settleMs);
362
1002
  return {
363
- method: "Escape",
364
- settle_ms: settleMs
1003
+ ok: true,
1004
+ reason: "escape"
365
1005
  };
366
1006
  }
367
1007
 
368
- export async function getRecruitSearchCounts(client, frameNodeId) {
369
- return countSelectors(client, frameNodeId, {
370
- keyword_input: RECRUIT_SEARCH_SELECTORS.keywordInput.join(", "),
371
- search_button: RECRUIT_SEARCH_SELECTORS.searchButton.join(", "),
372
- degree_option: RECRUIT_SEARCH_SELECTORS.degreeOption.join(", "),
373
- school_item: RECRUIT_SEARCH_SELECTORS.schoolItem.join(", "),
374
- recent_viewed_label: RECRUIT_SEARCH_SELECTORS.recentViewedLabel.join(", "),
375
- candidate_card: RECRUIT_CARD_SELECTOR,
376
- no_data: RECRUIT_NO_DATA_SELECTORS.join(", ")
1008
+ async function openRecruitJobTitleDropdown(client, frameNodeId, {
1009
+ timeoutMs = 4000,
1010
+ maxAttempts = 3
1011
+ } = {}) {
1012
+ const alreadyOpen = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1013
+ timeoutMs: 300,
1014
+ intervalMs: 100
377
1015
  });
1016
+ if (alreadyOpen.visible_options.length) {
1017
+ return {
1018
+ opened: true,
1019
+ already_open: true,
1020
+ options: alreadyOpen.options,
1021
+ visible_options: alreadyOpen.visible_options
1022
+ };
1023
+ }
1024
+
1025
+ await closeRecruitJobTitleDropdown(client);
1026
+ const attempts = [];
1027
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
1028
+ const trigger = await findVisibleRecruitJobTitleTrigger(client, frameNodeId);
1029
+ if (!trigger) {
1030
+ throw new Error("Recruit job trigger was not found");
1031
+ }
1032
+ if (attempt > 1) await closeRecruitJobTitleDropdown(client);
1033
+ const clickBox = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS);
1034
+ const opened = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1035
+ timeoutMs,
1036
+ intervalMs: 200
1037
+ });
1038
+ attempts.push({
1039
+ attempt,
1040
+ trigger,
1041
+ click_box: {
1042
+ center: clickBox.center,
1043
+ rect: clickBox.rect
1044
+ },
1045
+ option_count: opened.options.length,
1046
+ visible_option_count: opened.visible_options.length
1047
+ });
1048
+ if (opened.visible_options.length) {
1049
+ return {
1050
+ opened: true,
1051
+ already_open: false,
1052
+ trigger,
1053
+ options: opened.options,
1054
+ visible_options: opened.visible_options,
1055
+ attempts
1056
+ };
1057
+ }
1058
+ }
1059
+
1060
+ const error = new Error("Recruit job dropdown did not expose visible options after trigger click");
1061
+ error.job_dropdown_attempts = attempts;
1062
+ throw error;
378
1063
  }
379
1064
 
380
- export async function waitForRecruitSearchControls(client, {
381
- timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
382
- intervalMs = 300
1065
+ async function closeRecruitJobTitleDropdownFully(client, frameNodeId, {
1066
+ settleMs = 300,
1067
+ timeoutMs = 1500
383
1068
  } = {}) {
1069
+ const before = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1070
+ timeoutMs: 200,
1071
+ intervalMs: 100
1072
+ });
1073
+ const attempts = [];
1074
+ if (!before.visible_options.length) {
1075
+ return {
1076
+ ok: true,
1077
+ closed: false,
1078
+ reason: "already_closed",
1079
+ visible_before_count: 0,
1080
+ visible_after_count: 0,
1081
+ attempts
1082
+ };
1083
+ }
1084
+
384
1085
  const started = Date.now();
385
- let lastState = null;
386
- while (Date.now() - started <= timeoutMs) {
387
- const roots = await getRecruitRoots(client, { requireFrame: false });
388
- const frameNodeId = roots.iframe?.documentNodeId;
389
- if (frameNodeId) {
390
- const counts = await getRecruitSearchCounts(client, frameNodeId);
391
- lastState = {
392
- ok: counts.keyword_input > 0 && counts.search_button > 0,
393
- elapsed_ms: Date.now() - started,
394
- iframe_selector: roots.iframe.selector,
395
- iframe_document_node_id: frameNodeId,
396
- counts
1086
+ for (let attempt = 1; attempt <= 2 && Date.now() - started <= timeoutMs; attempt += 1) {
1087
+ const close = await closeRecruitJobTitleDropdown(client, settleMs);
1088
+ const after = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1089
+ timeoutMs: 250,
1090
+ intervalMs: 100
1091
+ });
1092
+ attempts.push({
1093
+ method: "escape",
1094
+ attempt,
1095
+ close,
1096
+ visible_after_count: after.visible_options.length
1097
+ });
1098
+ if (!after.visible_options.length) {
1099
+ return {
1100
+ ok: true,
1101
+ closed: true,
1102
+ reason: "escape",
1103
+ visible_before_count: before.visible_options.length,
1104
+ visible_after_count: 0,
1105
+ attempts
397
1106
  };
398
- if (lastState.ok) return lastState;
399
1107
  }
400
- await sleep(intervalMs);
401
1108
  }
1109
+
1110
+ const trigger = await findVisibleRecruitJobTitleTrigger(client, frameNodeId).catch(() => null);
1111
+ if (trigger?.node_id) {
1112
+ const click = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
1113
+ error: error?.message || String(error || "")
1114
+ }));
1115
+ if (settleMs > 0) await sleep(settleMs);
1116
+ const afterToggle = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1117
+ timeoutMs: 250,
1118
+ intervalMs: 100
1119
+ });
1120
+ attempts.push({
1121
+ method: "trigger_toggle",
1122
+ click,
1123
+ visible_after_count: afterToggle.visible_options.length
1124
+ });
1125
+ if (!afterToggle.visible_options.length) {
1126
+ return {
1127
+ ok: true,
1128
+ closed: true,
1129
+ reason: "trigger_toggle",
1130
+ visible_before_count: before.visible_options.length,
1131
+ visible_after_count: 0,
1132
+ attempts
1133
+ };
1134
+ }
1135
+ }
1136
+
1137
+ const outside = await clickPoint(client, 12, 12, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
1138
+ error: error?.message || String(error || "")
1139
+ }));
1140
+ if (settleMs > 0) await sleep(settleMs);
1141
+ const afterOutside = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1142
+ timeoutMs: 250,
1143
+ intervalMs: 100
1144
+ });
1145
+ attempts.push({
1146
+ method: "outside_click",
1147
+ click: outside,
1148
+ visible_after_count: afterOutside.visible_options.length
1149
+ });
1150
+ if (!afterOutside.visible_options.length) {
1151
+ return {
1152
+ ok: true,
1153
+ closed: true,
1154
+ reason: "outside_click",
1155
+ visible_before_count: before.visible_options.length,
1156
+ visible_after_count: 0,
1157
+ attempts
1158
+ };
1159
+ }
1160
+
402
1161
  return {
403
1162
  ok: false,
404
- elapsed_ms: Date.now() - started,
405
- ...(lastState || {})
1163
+ closed: false,
1164
+ reason: "still_visible_after_close_attempts",
1165
+ visible_before_count: before.visible_options.length,
1166
+ visible_after_count: afterOutside.visible_options.length,
1167
+ attempts
406
1168
  };
407
1169
  }
408
1170
 
409
- async function settleRecruitSearchAfterReset(client, {
410
- timeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS,
411
- settleMs = 5000
1171
+ async function verifyRecruitJobTitleSelection(client, frameNodeId, {
1172
+ jobTitle = "",
1173
+ delayMs = 1200,
1174
+ dropdownTimeoutMs = 4000
412
1175
  } = {}) {
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
- });
1176
+ const requested = normalizeText(jobTitle);
1177
+ if (delayMs > 0) await sleep(delayMs);
1178
+ let options = [];
1179
+ let openError = null;
1180
+ try {
1181
+ const opened = await openRecruitJobTitleDropdown(client, frameNodeId, {
1182
+ timeoutMs: dropdownTimeoutMs
1183
+ });
1184
+ options = opened.options || [];
1185
+ } catch (error) {
1186
+ openError = error;
1187
+ options = await listRecruitJobTitleOptions(client, frameNodeId).catch(() => []);
1188
+ }
1189
+ const current = options.find((option) => option.active) || null;
1190
+ const searchTerms = buildRecruitJobTitleSearchTerms(requested);
1191
+ const verified = Boolean(
1192
+ current
1193
+ && searchTerms.some((term) => chooseRecruitTextCandidate([current], {
1194
+ label: term,
1195
+ match: "contains"
1196
+ }))
1197
+ );
1198
+ const menuClose = await closeRecruitJobTitleDropdownFully(client, frameNodeId).catch((error) => ({
1199
+ ok: false,
1200
+ closed: false,
1201
+ reason: "close_failed",
1202
+ error: error?.message || String(error)
1203
+ }));
1204
+ return {
1205
+ verified,
1206
+ requested,
1207
+ search_terms: searchTerms,
1208
+ current_label: current?.text || "",
1209
+ current_option: current ? compactRecruitTextCandidate(current) : null,
1210
+ option_count: options.length,
1211
+ visible_option_count: options.filter((option) => option.visible).length,
1212
+ options: options.map(compactRecruitTextCandidate),
1213
+ open_error: openError ? (openError?.message || String(openError)) : null,
1214
+ menu_close: menuClose
1215
+ };
1216
+ }
1217
+
1218
+ async function clickFirstNodeBySelectors(client, rootNodeId, selectors, {
1219
+ optional = false,
1220
+ scrollIntoView = true
1221
+ } = {}) {
1222
+ const errors = [];
1223
+ const seen = new Set();
1224
+ let matched = false;
1225
+ for (const selector of selectors) {
1226
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, rootNodeId, selector));
1227
+ for (const nodeId of nodeIds) {
1228
+ if (seen.has(nodeId)) continue;
1229
+ seen.add(nodeId);
1230
+ matched = true;
1231
+ try {
1232
+ const box = await clickNodeCenter(client, nodeId, {
1233
+ ...DETERMINISTIC_CLICK_OPTIONS,
1234
+ scrollIntoView
1235
+ });
1236
+ await sleep(250);
1237
+ return {
1238
+ clicked: true,
1239
+ selector,
1240
+ node_id: nodeId,
1241
+ box,
1242
+ skipped_errors: errors
1243
+ };
1244
+ } catch (error) {
1245
+ errors.push({
1246
+ selector,
1247
+ node_id: nodeId,
1248
+ error: error?.message || String(error)
1249
+ });
1250
+ }
1251
+ }
1252
+ }
1253
+ if (!matched) {
1254
+ if (optional) return { clicked: false, reason: "not_found" };
1255
+ throw new Error(`Recruit search node was not found for selectors: ${selectors.join(", ")}`);
1256
+ }
1257
+ if (optional) {
1258
+ return {
1259
+ clicked: false,
1260
+ reason: "not_clickable",
1261
+ errors
1262
+ };
1263
+ }
1264
+ const detail = errors.map((item) => `${item.selector}#${item.node_id}: ${item.error}`).join("; ");
1265
+ throw new Error(`Recruit search nodes were found but none were clickable for selectors: ${selectors.join(", ")}; ${detail}`);
1266
+ }
1267
+
1268
+ async function dismissRecruitSearchOverlays(client, settleMs = 250) {
1269
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
1270
+ return {
1271
+ method: "Escape",
1272
+ skipped: true,
1273
+ reason: "dispatch_key_unavailable"
1274
+ };
1275
+ }
1276
+ await pressKey(client, "Escape", {
1277
+ code: "Escape",
1278
+ windowsVirtualKeyCode: 27,
1279
+ nativeVirtualKeyCode: 27
1280
+ });
1281
+ if (settleMs > 0) await sleep(settleMs);
1282
+ return {
1283
+ method: "Escape",
1284
+ settle_ms: settleMs
1285
+ };
1286
+ }
1287
+
1288
+ export async function getRecruitSearchCounts(client, frameNodeId) {
1289
+ return countSelectors(client, frameNodeId, {
1290
+ keyword_input: RECRUIT_SEARCH_SELECTORS.keywordInput.join(", "),
1291
+ search_button: RECRUIT_SEARCH_SELECTORS.searchButton.join(", "),
1292
+ degree_option: RECRUIT_SEARCH_SELECTORS.degreeOption.join(", "),
1293
+ school_item: RECRUIT_SEARCH_SELECTORS.schoolItem.join(", "),
1294
+ experience_option: RECRUIT_SEARCH_SELECTORS.experienceOption.join(", "),
1295
+ experience_custom: RECRUIT_SEARCH_SELECTORS.experienceCustom.join(", "),
1296
+ gender_dropdown: RECRUIT_SEARCH_SELECTORS.genderDropdown.join(", "),
1297
+ age_option: RECRUIT_SEARCH_SELECTORS.ageOption.join(", "),
1298
+ age_custom: RECRUIT_SEARCH_SELECTORS.ageCustom.join(", "),
1299
+ recent_viewed_label: RECRUIT_SEARCH_SELECTORS.recentViewedLabel.join(", "),
1300
+ candidate_card: RECRUIT_CARD_SELECTOR,
1301
+ no_data: RECRUIT_NO_DATA_SELECTORS.join(", ")
1302
+ });
1303
+ }
1304
+
1305
+ export async function waitForRecruitSearchControls(client, {
1306
+ timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
1307
+ intervalMs = 300
1308
+ } = {}) {
1309
+ const started = Date.now();
1310
+ let lastState = null;
1311
+ while (Date.now() - started <= timeoutMs) {
1312
+ const roots = await getRecruitRoots(client, { requireFrame: false });
1313
+ const frameNodeId = roots.iframe?.documentNodeId;
1314
+ if (frameNodeId) {
1315
+ const counts = await getRecruitSearchCounts(client, frameNodeId);
1316
+ lastState = {
1317
+ ok: counts.keyword_input > 0 && counts.search_button > 0,
1318
+ elapsed_ms: Date.now() - started,
1319
+ iframe_selector: roots.iframe.selector,
1320
+ iframe_document_node_id: frameNodeId,
1321
+ counts
1322
+ };
1323
+ if (lastState.ok) return lastState;
1324
+ }
1325
+ await sleep(intervalMs);
1326
+ }
1327
+ return {
1328
+ ok: false,
1329
+ elapsed_ms: Date.now() - started,
1330
+ ...(lastState || {})
1331
+ };
1332
+ }
1333
+
1334
+ async function settleRecruitSearchAfterReset(client, {
1335
+ timeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS,
1336
+ settleMs = 5000
1337
+ } = {}) {
1338
+ return waitForMiniFreshStartSettle(client, {
1339
+ domain: "search",
1340
+ timeoutMs,
1341
+ intervalMs: 500,
1342
+ settleMs: Math.max(0, Math.min(settleMs || 0, 5000)),
1343
+ readinessLabel: "search_controls_ready",
1344
+ checkReady: ({ remainingMs }) => waitForRecruitSearchControls(client, {
1345
+ timeoutMs: Math.min(Math.max(1, remainingMs), 1500),
1346
+ intervalMs: 300
1347
+ })
1348
+ });
424
1349
  }
425
1350
 
426
1351
  export async function resetRecruitSearchPage(client, {
@@ -534,15 +1459,102 @@ export async function setRecruitKeyword(client, frameNodeId, keyword) {
534
1459
  if (!normalizedKeyword) {
535
1460
  return { applied: false, reason: "empty_keyword" };
536
1461
  }
537
- const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.keywordInput);
538
- await clearFocusedInput(client);
539
- await sleep(120);
540
- await insertText(client, normalizedKeyword);
541
- await sleep(350);
1462
+ const attempts = [];
1463
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
1464
+ const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.keywordInput);
1465
+ await clearFocusedInput(client);
1466
+ await sleep(120);
1467
+ const textEntry = await insertText(client, normalizedKeyword);
1468
+ await sleep(350);
1469
+ const verification = await verifyRecruitKeywordInputValue(client, frameNodeId, normalizedKeyword, {
1470
+ settleMs: 350
1471
+ });
1472
+ attempts.push({
1473
+ attempt,
1474
+ input,
1475
+ text_entry: textEntry,
1476
+ verification
1477
+ });
1478
+ if (verification.verified !== false) {
1479
+ return {
1480
+ applied: true,
1481
+ keyword: normalizedKeyword,
1482
+ input,
1483
+ text_entry: textEntry,
1484
+ verification,
1485
+ attempts
1486
+ };
1487
+ }
1488
+ }
1489
+
1490
+ const last = attempts[attempts.length - 1]?.verification || {};
1491
+ throw new Error(`Recruit keyword input did not hold requested value: expected=${normalizedKeyword}; actual=${last.actual || "unknown"}`);
1492
+ }
1493
+
1494
+ export async function readRecruitKeywordInputValue(client, frameNodeId) {
1495
+ if (typeof client?.Accessibility?.getPartialAXTree !== "function") {
1496
+ return {
1497
+ available: false,
1498
+ reason: "accessibility_unavailable",
1499
+ value: null,
1500
+ normalized_value: ""
1501
+ };
1502
+ }
1503
+ for (const selector of RECRUIT_SEARCH_SELECTORS.keywordInput) {
1504
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, frameNodeId, selector));
1505
+ for (const nodeId of nodeIds) {
1506
+ const ax = await client.Accessibility.getPartialAXTree({
1507
+ nodeId,
1508
+ fetchRelatives: false
1509
+ });
1510
+ const node = ax?.nodes?.[0] || null;
1511
+ if (!node) continue;
1512
+ const value = typeof node?.value?.value === "string" ? node.value.value : "";
1513
+ return {
1514
+ available: true,
1515
+ selector,
1516
+ node_id: nodeId,
1517
+ value,
1518
+ normalized_value: normalizeText(value),
1519
+ role: node?.role?.value || ""
1520
+ };
1521
+ }
1522
+ }
542
1523
  return {
543
- applied: true,
544
- keyword: normalizedKeyword,
545
- input
1524
+ available: false,
1525
+ reason: "keyword_input_not_found",
1526
+ value: null,
1527
+ normalized_value: ""
1528
+ };
1529
+ }
1530
+
1531
+ export async function verifyRecruitKeywordInputValue(client, frameNodeId, expectedKeyword, {
1532
+ settleMs = 0
1533
+ } = {}) {
1534
+ const expected = normalizeText(expectedKeyword);
1535
+ const before = await readRecruitKeywordInputValue(client, frameNodeId);
1536
+ if (!before.available || !expected) {
1537
+ return {
1538
+ verified: null,
1539
+ reason: before.reason || "empty_expected_keyword",
1540
+ expected,
1541
+ actual: before.normalized_value || "",
1542
+ before,
1543
+ after: before
1544
+ };
1545
+ }
1546
+ let after = before;
1547
+ if (settleMs > 0) {
1548
+ await sleep(settleMs);
1549
+ after = await readRecruitKeywordInputValue(client, frameNodeId);
1550
+ }
1551
+ const actual = normalizeText(after.normalized_value || after.value);
1552
+ return {
1553
+ verified: actual === expected,
1554
+ expected,
1555
+ actual,
1556
+ before,
1557
+ after
546
1558
  };
547
1559
  }
548
1560
 
@@ -559,214 +1571,821 @@ export async function setRecruitJobTitle(client, frameNodeId, jobTitle, {
559
1571
  if (!normalizedJobTitle) {
560
1572
  return { applied: false, reason: "empty_job_title" };
561
1573
  }
562
- const lookup = await waitForRecruitJobTitleCandidate(
1574
+ const opened = await openRecruitJobTitleDropdown(client, frameNodeId, {
1575
+ timeoutMs: Math.min(optionTimeoutMs, 30000)
1576
+ });
1577
+ const options = opened.options.length
1578
+ ? opened.options
1579
+ : await listRecruitJobTitleOptions(client, frameNodeId);
1580
+ const terms = buildRecruitJobTitleSearchTerms(normalizedJobTitle);
1581
+ let match = null;
1582
+ let matchedTerm = "";
1583
+ const visibleOptions = options.filter((option) => option.visible);
1584
+ const hiddenMatches = [];
1585
+ for (const term of terms) {
1586
+ match = chooseRecruitTextCandidate(visibleOptions, { label: term, match: "contains" });
1587
+ if (match) {
1588
+ matchedTerm = term;
1589
+ break;
1590
+ }
1591
+ const hiddenMatch = chooseRecruitTextCandidate(
1592
+ options.filter((option) => !option.visible),
1593
+ { label: term, match: "contains" }
1594
+ );
1595
+ if (hiddenMatch) hiddenMatches.push(hiddenMatch);
1596
+ }
1597
+ if (!match) {
1598
+ await closeRecruitJobTitleDropdown(client);
1599
+ if (hiddenMatches.length) {
1600
+ const error = new Error(`Matched recruit job has no visible clickable option: ${hiddenMatches[0].text}`);
1601
+ error.hidden_job_matches = hiddenMatches.map(compactRecruitTextCandidate);
1602
+ throw error;
1603
+ }
1604
+ throw new Error(`Recruit job title option was not found: ${normalizedJobTitle}`);
1605
+ }
1606
+ let box = null;
1607
+ if (!match.active) {
1608
+ if (!match.center) {
1609
+ await closeRecruitJobTitleDropdown(client);
1610
+ throw new Error(`Matched recruit job has no clickable center: ${match.text}`);
1611
+ }
1612
+ box = await clickNodeCenter(client, match.node_id, {
1613
+ ...DETERMINISTIC_CLICK_OPTIONS,
1614
+ scrollIntoView: true
1615
+ });
1616
+ await sleep(500);
1617
+ }
1618
+ const stickyVerification = await verifyRecruitJobTitleSelection(client, frameNodeId, {
1619
+ jobTitle: normalizedJobTitle,
1620
+ delayMs: 1200,
1621
+ dropdownTimeoutMs: Math.min(optionTimeoutMs, 5000)
1622
+ });
1623
+ if (!stickyVerification.verified) {
1624
+ throw new Error(`Recruit job selection was not sticky after 1.2s: requested=${normalizedJobTitle}; current=${stickyVerification.current_label || "unknown"}`);
1625
+ }
1626
+ if (stickyVerification.menu_close && stickyVerification.menu_close.ok === false) {
1627
+ throw new Error(`Recruit job dropdown remained open after sticky verification: ${stickyVerification.menu_close.reason || "unknown"}`);
1628
+ }
1629
+ return {
1630
+ applied: true,
1631
+ requested_job: normalizedJobTitle,
1632
+ selected_label: match.text,
1633
+ matched_term: matchedTerm,
1634
+ search_terms: terms,
1635
+ selected_node_id: match.node_id,
1636
+ was_active: match.active,
1637
+ clicked: !match.active,
1638
+ box,
1639
+ opened_dropdown: {
1640
+ already_open: Boolean(opened.already_open),
1641
+ visible_option_count: visibleOptions.length,
1642
+ attempts: opened.attempts || []
1643
+ },
1644
+ sticky_verification: stickyVerification,
1645
+ discovered_options: options.map(compactRecruitTextCandidate).slice(0, 30)
1646
+ };
1647
+ }
1648
+
1649
+ export async function setRecruitDegree(client, frameNodeId, degree) {
1650
+ const degreeLabel = resolveRecruitDegreeLabel(degree);
1651
+ if (!degreeLabel || degreeLabel === "不限") {
1652
+ return { applied: false, reason: "unlimited_degree", degree: degreeLabel || "不限" };
1653
+ }
1654
+ const { candidate, candidates } = await findTextCandidate(
563
1655
  client,
564
1656
  frameNodeId,
565
- RECRUIT_SEARCH_SELECTORS.jobTitleOption,
566
- normalizedJobTitle,
567
- { timeoutMs: Math.min(optionTimeoutMs, 30000) }
1657
+ RECRUIT_SEARCH_SELECTORS.degreeOption,
1658
+ degreeLabel,
1659
+ { match: "prefix" }
568
1660
  );
569
- if (!lookup.candidate) {
570
- throw new Error(`Recruit job title option was not found: ${normalizedJobTitle}`);
1661
+ if (!candidate) {
1662
+ throw new Error(`Recruit degree option was not found: ${degreeLabel}`);
571
1663
  }
572
- let box = null;
573
- if (!lookup.candidate.active) {
574
- box = await clickNodeCenter(client, lookup.candidate.node_id, {
1664
+ const box = await clickNodeCenter(client, candidate.node_id, {
1665
+ ...DETERMINISTIC_CLICK_OPTIONS,
1666
+ scrollIntoView: true
1667
+ });
1668
+ await sleep(350);
1669
+ return {
1670
+ applied: true,
1671
+ requested_degree: degree,
1672
+ selected_label: candidate.text,
1673
+ selected_node_id: candidate.node_id,
1674
+ was_active: candidate.active,
1675
+ box,
1676
+ discovered_options: candidates.map((item) => ({
1677
+ label: item.text,
1678
+ active: item.active,
1679
+ node_id: item.node_id,
1680
+ selector: item.selector
1681
+ }))
1682
+ };
1683
+ }
1684
+
1685
+ export async function setRecruitDegrees(client, frameNodeId, degrees = []) {
1686
+ const labels = normalizeRecruitDegreeLabels(degrees).filter((label) => label && label !== "不限");
1687
+ if (!labels.length) {
1688
+ return { applied: false, reason: "unlimited_degree", degrees: ["不限"], selected: [] };
1689
+ }
1690
+
1691
+ const selected = [];
1692
+ let discoveredOptions = [];
1693
+ for (const label of labels) {
1694
+ const { candidate, candidates } = await findTextCandidate(
1695
+ client,
1696
+ frameNodeId,
1697
+ RECRUIT_SEARCH_SELECTORS.degreeOption,
1698
+ label,
1699
+ { match: "prefix" }
1700
+ );
1701
+ discoveredOptions = candidates.map((item) => ({
1702
+ label: item.text,
1703
+ active: item.active,
1704
+ node_id: item.node_id,
1705
+ selector: item.selector
1706
+ }));
1707
+ if (!candidate) {
1708
+ throw new Error(`Recruit degree option was not found: ${label}`);
1709
+ }
1710
+
1711
+ let box = null;
1712
+ if (!candidate.active) {
1713
+ box = await clickNodeCenter(client, candidate.node_id, {
1714
+ ...DETERMINISTIC_CLICK_OPTIONS,
1715
+ scrollIntoView: true
1716
+ });
1717
+ await sleep(350);
1718
+ }
1719
+ selected.push({
1720
+ requested_degree: label,
1721
+ selected_label: candidate.text,
1722
+ selected_node_id: candidate.node_id,
1723
+ was_active: candidate.active,
1724
+ clicked: !candidate.active,
1725
+ box
1726
+ });
1727
+ }
1728
+
1729
+ return {
1730
+ applied: true,
1731
+ requested_degrees: labels,
1732
+ selected,
1733
+ discovered_options: discoveredOptions
1734
+ };
1735
+ }
1736
+
1737
+ async function findClickableDescendant(client, nodeId, selectors) {
1738
+ for (const selector of selectors) {
1739
+ const childNodeId = await querySelector(client, nodeId, selector);
1740
+ if (childNodeId) return { node_id: childNodeId, selector };
1741
+ }
1742
+ return { node_id: nodeId, selector: null };
1743
+ }
1744
+
1745
+ export async function setRecruitSchools(client, frameNodeId, schools = []) {
1746
+ const targets = normalizeRecruitSchoolList(schools);
1747
+ const applied = [];
1748
+ const missing = [];
1749
+ if (!targets.length) {
1750
+ return { applied: false, schools: [], selected: [], missing: [] };
1751
+ }
1752
+
1753
+ for (const school of targets) {
1754
+ const candidates = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.schoolItem);
1755
+ const candidate = chooseRecruitSchoolCandidate(candidates, school);
1756
+ if (!candidate) {
1757
+ missing.push({
1758
+ school,
1759
+ exact_labels: buildRecruitSchoolSearchLabels(school),
1760
+ discovered: candidates.map((item) => item.text).slice(0, 20)
1761
+ });
1762
+ continue;
1763
+ }
1764
+
1765
+ const clickable = await findClickableDescendant(client, candidate.node_id, RECRUIT_SEARCH_SELECTORS.schoolClickable);
1766
+ let clickableActive = candidate.active;
1767
+ if (clickable.node_id !== candidate.node_id) {
1768
+ const clickableCandidate = await readTextCandidate(client, clickable.node_id, {
1769
+ selector: clickable.selector || "",
1770
+ index: 0
1771
+ });
1772
+ clickableActive = clickableActive || clickableCandidate.active;
1773
+ }
1774
+
1775
+ let box = null;
1776
+ if (!clickableActive) {
1777
+ box = await clickNodeCenter(client, clickable.node_id, {
1778
+ ...DETERMINISTIC_CLICK_OPTIONS,
1779
+ scrollIntoView: true
1780
+ });
1781
+ await sleep(350);
1782
+ }
1783
+
1784
+ applied.push({
1785
+ school,
1786
+ exact_labels: buildRecruitSchoolSearchLabels(school),
1787
+ selected_label: candidate.text,
1788
+ selected_node_id: candidate.node_id,
1789
+ clickable_node_id: clickable.node_id,
1790
+ clickable_selector: clickable.selector,
1791
+ was_active: clickableActive,
1792
+ clicked: !clickableActive,
1793
+ box
1794
+ });
1795
+ }
1796
+
1797
+ if (missing.length) {
1798
+ throw new Error(`Recruit school options were not found: ${missing.map((item) => item.school).join(", ")}`);
1799
+ }
1800
+
1801
+ return {
1802
+ applied: true,
1803
+ schools: targets,
1804
+ selected: applied,
1805
+ missing
1806
+ };
1807
+ }
1808
+
1809
+ async function findFirstRecruitSearchNode(client, rootNodeId, selectors = []) {
1810
+ const errors = [];
1811
+ for (const selector of selectors) {
1812
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, rootNodeId, selector));
1813
+ for (const nodeId of nodeIds) {
1814
+ try {
1815
+ const box = await getNodeBox(client, nodeId);
1816
+ if (!isVisibleBox(box)) continue;
1817
+ return { node_id: nodeId, selector, box };
1818
+ } catch (error) {
1819
+ errors.push({
1820
+ selector,
1821
+ node_id: nodeId,
1822
+ error: error?.message || String(error)
1823
+ });
1824
+ }
1825
+ }
1826
+ }
1827
+ return { node_id: 0, selector: "", box: null, errors };
1828
+ }
1829
+
1830
+ function parseRecruitExperienceHiddenValue(rawValue) {
1831
+ const [startRaw, endRaw] = String(rawValue || "").split(",");
1832
+ const startValue = Number.parseInt(startRaw, 10);
1833
+ const endValue = Number.parseInt(endRaw, 10);
1834
+ return {
1835
+ raw_value: String(rawValue || ""),
1836
+ start_value: Number.isFinite(startValue) ? startValue : null,
1837
+ end_value: Number.isFinite(endValue) ? endValue : null
1838
+ };
1839
+ }
1840
+
1841
+ async function readRecruitExperienceCustomState(client, frameNodeId) {
1842
+ let hidden = null;
1843
+ for (const selector of RECRUIT_SEARCH_SELECTORS.experienceCustomHiddenInput) {
1844
+ const nodeId = await querySelector(client, frameNodeId, selector);
1845
+ if (!nodeId) continue;
1846
+ const attributes = await getAttributesMap(client, nodeId);
1847
+ hidden = {
1848
+ node_id: nodeId,
1849
+ selector,
1850
+ value: attributes.value || ""
1851
+ };
1852
+ break;
1853
+ }
1854
+ const parsedHidden = parseRecruitExperienceHiddenValue(hidden?.value || "");
1855
+ const handleNodes = [];
1856
+ for (const selector of RECRUIT_SEARCH_SELECTORS.experienceCustomSliderHandle) {
1857
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, frameNodeId, selector));
1858
+ for (const nodeId of nodeIds) {
1859
+ if (handleNodes.some((item) => item.node_id === nodeId)) continue;
1860
+ try {
1861
+ const box = await getNodeBox(client, nodeId);
1862
+ if (!isVisibleBox(box)) continue;
1863
+ handleNodes.push({ node_id: nodeId, selector, box });
1864
+ } catch {
1865
+ // Ignore invisible handles; missing handles are reported by the caller.
1866
+ }
1867
+ }
1868
+ }
1869
+ handleNodes.sort((left, right) => left.box.center.x - right.box.center.x);
1870
+ return {
1871
+ hidden,
1872
+ ...parsedHidden,
1873
+ handles: handleNodes.map((item, index) => ({
1874
+ index,
1875
+ node_id: item.node_id,
1876
+ selector: item.selector,
1877
+ center: item.box.center,
1878
+ rect: item.box.rect
1879
+ }))
1880
+ };
1881
+ }
1882
+
1883
+ async function readRecruitExperienceFixedOptionState(client, frameNodeId) {
1884
+ const candidates = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.experienceOption);
1885
+ return {
1886
+ active_labels: candidates.filter((item) => item.active).map((item) => item.text),
1887
+ options: summarizeTextCandidates(candidates, 20)
1888
+ };
1889
+ }
1890
+
1891
+ async function dragRecruitExperienceSliderHandle(client, frameNodeId, {
1892
+ handleIndex,
1893
+ targetValue
1894
+ }) {
1895
+ const state = await readRecruitExperienceCustomState(client, frameNodeId);
1896
+ const handle = state.handles[handleIndex];
1897
+ if (!handle) {
1898
+ throw new Error(`Recruit experience custom slider handle was not found: index=${handleIndex}`);
1899
+ }
1900
+ const slider = await findFirstRecruitSearchNode(
1901
+ client,
1902
+ frameNodeId,
1903
+ RECRUIT_SEARCH_SELECTORS.experienceCustomSlider
1904
+ );
1905
+ let trackRect = slider.box?.rect || null;
1906
+ if (!trackRect && state.handles.length >= 2 && state.start_value !== null && state.end_value !== null && state.end_value !== state.start_value) {
1907
+ const leftHandle = state.handles[0];
1908
+ const rightHandle = state.handles[state.handles.length - 1];
1909
+ const valueSpan = state.end_value - state.start_value;
1910
+ const fullValueSpan = EXPERIENCE_CUSTOM_MAX_VALUE - EXPERIENCE_CUSTOM_MIN_VALUE;
1911
+ const inferredWidth = Math.abs(rightHandle.center.x - leftHandle.center.x) * (fullValueSpan / valueSpan);
1912
+ const inferredX = leftHandle.center.x - inferredWidth * ((state.start_value - EXPERIENCE_CUSTOM_MIN_VALUE) / fullValueSpan);
1913
+ trackRect = {
1914
+ x: inferredX,
1915
+ y: Math.min(leftHandle.rect.y, rightHandle.rect.y),
1916
+ width: inferredWidth,
1917
+ height: Math.max(leftHandle.rect.height, rightHandle.rect.height)
1918
+ };
1919
+ }
1920
+ if (!trackRect) {
1921
+ throw new Error("Recruit experience custom slider was not found");
1922
+ }
1923
+ const percent = (targetValue - EXPERIENCE_CUSTOM_MIN_VALUE)
1924
+ / (EXPERIENCE_CUSTOM_MAX_VALUE - EXPERIENCE_CUSTOM_MIN_VALUE);
1925
+ let targetX = trackRect.x + trackRect.width * Math.min(1, Math.max(0, percent));
1926
+ const endpointOvershootPx = Math.min(24, Math.max(8, trackRect.width * 0.04));
1927
+ const valueStepPx = trackRect.width / (EXPERIENCE_CUSTOM_MAX_VALUE - EXPERIENCE_CUSTOM_MIN_VALUE);
1928
+ if (targetValue > EXPERIENCE_CUSTOM_MIN_VALUE && targetValue < EXPERIENCE_CUSTOM_MAX_VALUE) {
1929
+ targetX += valueStepPx * 0.45;
1930
+ }
1931
+ if (targetValue === EXPERIENCE_CUSTOM_MIN_VALUE) targetX -= endpointOvershootPx;
1932
+ if (targetValue === EXPERIENCE_CUSTOM_MAX_VALUE) targetX += endpointOvershootPx;
1933
+ const targetY = handle.center.y || (trackRect.y + trackRect.height / 2);
1934
+ const startX = handle.center.x;
1935
+ const startY = handle.center.y;
1936
+ const steps = 8;
1937
+ await client.Input.dispatchMouseEvent({ type: "mouseMoved", x: startX, y: startY, button: "none" });
1938
+ await client.Input.dispatchMouseEvent({ type: "mousePressed", x: startX, y: startY, button: "left", clickCount: 1 });
1939
+ for (let step = 1; step <= steps; step += 1) {
1940
+ const ratio = step / steps;
1941
+ await client.Input.dispatchMouseEvent({
1942
+ type: "mouseMoved",
1943
+ x: startX + (targetX - startX) * ratio,
1944
+ y: startY + (targetY - startY) * ratio,
1945
+ button: "left"
1946
+ });
1947
+ await sleep(30);
1948
+ }
1949
+ await client.Input.dispatchMouseEvent({ type: "mouseReleased", x: targetX, y: targetY, button: "left", clickCount: 1 });
1950
+ await sleep(350);
1951
+ return {
1952
+ handle_index: handleIndex,
1953
+ target_value: targetValue,
1954
+ target_label: EXPERIENCE_CUSTOM_LABELS_BY_VALUE.get(targetValue) || String(targetValue),
1955
+ slider_node_id: slider.node_id || null,
1956
+ handle_node_id: handle.node_id,
1957
+ start: { x: startX, y: startY },
1958
+ target: { x: targetX, y: targetY },
1959
+ track: trackRect,
1960
+ inferred_track: !slider.box
1961
+ };
1962
+ }
1963
+
1964
+ async function nudgeRecruitExperienceCustomSelection(client, frameNodeId, filter) {
1965
+ if (filter.end_value > filter.start_value) {
1966
+ const intermediate = filter.end_value === EXPERIENCE_CUSTOM_MAX_VALUE
1967
+ ? filter.end_value - 1
1968
+ : filter.end_value + 1;
1969
+ return [
1970
+ await dragRecruitExperienceSliderHandle(client, frameNodeId, {
1971
+ handleIndex: 1,
1972
+ targetValue: intermediate
1973
+ }),
1974
+ await dragRecruitExperienceSliderHandle(client, frameNodeId, {
1975
+ handleIndex: 1,
1976
+ targetValue: filter.end_value
1977
+ })
1978
+ ];
1979
+ }
1980
+ const intermediate = filter.start_value === EXPERIENCE_CUSTOM_MIN_VALUE
1981
+ ? filter.start_value + 1
1982
+ : filter.start_value - 1;
1983
+ return [
1984
+ await dragRecruitExperienceSliderHandle(client, frameNodeId, {
1985
+ handleIndex: 0,
1986
+ targetValue: intermediate
1987
+ }),
1988
+ await dragRecruitExperienceSliderHandle(client, frameNodeId, {
1989
+ handleIndex: 0,
1990
+ targetValue: filter.start_value
1991
+ })
1992
+ ];
1993
+ }
1994
+
1995
+ export async function setRecruitExperience(client, frameNodeId, experience) {
1996
+ const filter = normalizeRecruitExperienceFilter(experience);
1997
+ if (!filter) {
1998
+ return { applied: false, reason: "not_requested" };
1999
+ }
2000
+
2001
+ if (filter.mode === "option") {
2002
+ const { candidate, candidates } = await findTextCandidate(
2003
+ client,
2004
+ frameNodeId,
2005
+ RECRUIT_SEARCH_SELECTORS.experienceOption,
2006
+ filter.label,
2007
+ { match: "exact" }
2008
+ );
2009
+ if (!candidate) {
2010
+ throw new Error(`Recruit experience option was not found: ${filter.label}`);
2011
+ }
2012
+ let box = null;
2013
+ if (!candidate.active) {
2014
+ box = await clickNodeCenter(client, candidate.node_id, {
2015
+ ...DETERMINISTIC_CLICK_OPTIONS,
2016
+ scrollIntoView: true
2017
+ });
2018
+ await sleep(500);
2019
+ }
2020
+ return {
2021
+ applied: true,
2022
+ mode: "option",
2023
+ requested_experience: experience,
2024
+ selected_label: candidate.text,
2025
+ selected_node_id: candidate.node_id,
2026
+ was_active: candidate.active,
2027
+ clicked: !candidate.active,
2028
+ box,
2029
+ discovered_options: summarizeTextCandidates(candidates, 20)
2030
+ };
2031
+ }
2032
+
2033
+ const customClick = await clickFirstNodeBySelectors(
2034
+ client,
2035
+ frameNodeId,
2036
+ RECRUIT_SEARCH_SELECTORS.experienceCustom,
2037
+ { optional: false, scrollIntoView: true }
2038
+ );
2039
+ const before = await readRecruitExperienceCustomState(client, frameNodeId);
2040
+ const fixedOptionsBefore = await readRecruitExperienceFixedOptionState(client, frameNodeId);
2041
+ const drags = [];
2042
+ if (before.start_value !== filter.start_value) {
2043
+ drags.push(await dragRecruitExperienceSliderHandle(client, frameNodeId, {
2044
+ handleIndex: 0,
2045
+ targetValue: filter.start_value
2046
+ }));
2047
+ }
2048
+ const afterStart = await readRecruitExperienceCustomState(client, frameNodeId);
2049
+ if (afterStart.end_value !== filter.end_value) {
2050
+ drags.push(await dragRecruitExperienceSliderHandle(client, frameNodeId, {
2051
+ handleIndex: 1,
2052
+ targetValue: filter.end_value
2053
+ }));
2054
+ }
2055
+ if (!drags.length && fixedOptionsBefore.active_labels.length) {
2056
+ drags.push(...await nudgeRecruitExperienceCustomSelection(client, frameNodeId, filter));
2057
+ }
2058
+ const after = await readRecruitExperienceCustomState(client, frameNodeId);
2059
+ const fixedOptionsAfter = await readRecruitExperienceFixedOptionState(client, frameNodeId);
2060
+ const verified = after.start_value === filter.start_value && after.end_value === filter.end_value;
2061
+ if (!verified) {
2062
+ throw new Error(
2063
+ `Recruit experience custom range did not stick: requested=${filter.start_value},${filter.end_value}; actual=${after.raw_value || "unknown"}`
2064
+ );
2065
+ }
2066
+ if (fixedOptionsAfter.active_labels.length) {
2067
+ throw new Error(
2068
+ `Recruit experience custom range still has fixed option active: ${fixedOptionsAfter.active_labels.join(", ")}`
2069
+ );
2070
+ }
2071
+ return {
2072
+ applied: true,
2073
+ mode: "custom",
2074
+ requested_experience: experience,
2075
+ requested_range: {
2076
+ start_label: filter.start_label,
2077
+ end_label: filter.end_label,
2078
+ start_value: filter.start_value,
2079
+ end_value: filter.end_value
2080
+ },
2081
+ custom_click: customClick,
2082
+ fixed_options_before: fixedOptionsBefore,
2083
+ before,
2084
+ after,
2085
+ fixed_options_after: fixedOptionsAfter,
2086
+ drags,
2087
+ verification: {
2088
+ verified,
2089
+ fixed_option_cleared: fixedOptionsAfter.active_labels.length === 0,
2090
+ expected: `${filter.start_value},${filter.end_value}`,
2091
+ actual: after.raw_value
2092
+ }
2093
+ };
2094
+ }
2095
+
2096
+ async function findRecruitGenderDropdown(client, frameNodeId) {
2097
+ const candidates = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.genderDropdown, {
2098
+ includeBox: true
2099
+ });
2100
+ const visible = candidates
2101
+ .filter((item) => item.visible && item.rect)
2102
+ .sort((left, right) => left.rect.x - right.rect.x);
2103
+ return {
2104
+ candidate: visible[0] || null,
2105
+ candidates
2106
+ };
2107
+ }
2108
+
2109
+ async function readRecruitGenderState(client, frameNodeId) {
2110
+ const { candidate, candidates } = await findRecruitGenderDropdown(client, frameNodeId);
2111
+ if (!candidate) {
2112
+ return {
2113
+ available: false,
2114
+ discovered: summarizeTextCandidates(candidates, 10)
2115
+ };
2116
+ }
2117
+ const hiddenNodeId = await querySelector(client, candidate.node_id, "input[type='hidden']");
2118
+ const hidden = hiddenNodeId ? await getAttributesMap(client, hiddenNodeId) : {};
2119
+ const hiddenSelectedLabel = hidden.value === "-1" ? "不限" : "";
2120
+ return {
2121
+ available: true,
2122
+ selected_label: hiddenSelectedLabel || normalizeText(candidate.text),
2123
+ selected_node_id: candidate.node_id,
2124
+ hidden_value: hidden.value || "",
2125
+ discovered: summarizeTextCandidates(candidates, 10)
2126
+ };
2127
+ }
2128
+
2129
+ export async function setRecruitGender(client, frameNodeId, gender) {
2130
+ const filter = normalizeRecruitGenderFilter(gender);
2131
+ if (!filter) {
2132
+ return { applied: false, reason: "not_requested" };
2133
+ }
2134
+ const beforeRoot = await findRecruitGenderDropdown(client, frameNodeId);
2135
+ if (!beforeRoot.candidate) {
2136
+ throw new Error("Recruit gender dropdown was not found");
2137
+ }
2138
+ const openBox = await clickNodeCenter(client, beforeRoot.candidate.node_id, {
2139
+ ...DETERMINISTIC_CLICK_OPTIONS,
2140
+ scrollIntoView: true
2141
+ });
2142
+ await sleep(350);
2143
+ const rootAfterOpen = await findRecruitGenderDropdown(client, frameNodeId);
2144
+ const rootNodeId = rootAfterOpen.candidate?.node_id || beforeRoot.candidate.node_id;
2145
+ const options = await listTextCandidates(client, rootNodeId, ["li"], { includeBox: true });
2146
+ const option = chooseRecruitTextCandidate(options, { label: filter.label, match: "exact" });
2147
+ if (!option) {
2148
+ throw new Error(`Recruit gender option was not found: ${filter.label}`);
2149
+ }
2150
+ let selectBox = null;
2151
+ if (!option.active) {
2152
+ selectBox = await clickNodeCenter(client, option.node_id, {
575
2153
  ...DETERMINISTIC_CLICK_OPTIONS,
576
2154
  scrollIntoView: true
577
2155
  });
578
- await sleep(500);
2156
+ await sleep(600);
2157
+ } else {
2158
+ await pressKey(client, "Escape", {
2159
+ code: "Escape",
2160
+ windowsVirtualKeyCode: 27,
2161
+ nativeVirtualKeyCode: 27
2162
+ });
2163
+ await sleep(250);
2164
+ }
2165
+ const after = await readRecruitGenderState(client, frameNodeId);
2166
+ const verified = after.selected_label === filter.label
2167
+ || (filter.label === "不限" && /^(?:性别|不限)$/.test(after.selected_label));
2168
+ if (!verified) {
2169
+ throw new Error(`Recruit gender selection did not stick: requested=${filter.label}; actual=${after.selected_label || "unknown"}`);
2170
+ }
2171
+ return {
2172
+ applied: true,
2173
+ requested_gender: gender,
2174
+ selected_label: filter.label,
2175
+ opened_dropdown: {
2176
+ node_id: beforeRoot.candidate.node_id,
2177
+ box: openBox
2178
+ },
2179
+ selected_node_id: option.node_id,
2180
+ option_was_active: option.active,
2181
+ clicked: !option.active,
2182
+ box: selectBox,
2183
+ verification: {
2184
+ verified,
2185
+ selected_label: after.selected_label,
2186
+ hidden_value: after.hidden_value
2187
+ },
2188
+ discovered_options: summarizeTextCandidates(options, 10)
2189
+ };
2190
+ }
2191
+
2192
+ async function readRecruitAgeCustomState(client, frameNodeId) {
2193
+ const inputNodeIds = uniqueNodeIds(await querySelectorAll(
2194
+ client,
2195
+ frameNodeId,
2196
+ RECRUIT_SEARCH_SELECTORS.ageCustomInput.join(", ")
2197
+ ));
2198
+ const inputs = [];
2199
+ for (const nodeId of inputNodeIds) {
2200
+ const attributes = await getAttributesMap(client, nodeId);
2201
+ let box = null;
2202
+ try {
2203
+ box = await getNodeBox(client, nodeId);
2204
+ } catch {}
2205
+ inputs.push({
2206
+ node_id: nodeId,
2207
+ type: attributes.type || "",
2208
+ value: attributes.value || "",
2209
+ placeholder: attributes.placeholder || "",
2210
+ visible: isVisibleBox(box),
2211
+ rect: box?.rect || null
2212
+ });
579
2213
  }
2214
+ const hidden = inputs.filter((item) => item.type === "hidden");
580
2215
  return {
581
- applied: true,
582
- requested_job: normalizedJobTitle,
583
- selected_label: lookup.candidate.text,
584
- matched_term: lookup.matched_term,
585
- search_terms: lookup.search_terms,
586
- selected_node_id: lookup.candidate.node_id,
587
- was_active: lookup.candidate.active,
588
- clicked: !lookup.candidate.active,
589
- box,
590
- discovered_options: summarizeTextCandidates(lookup.candidates, 30)
2216
+ inputs,
2217
+ min: parseRecruitAgeCustomHiddenValue(hidden[0]?.value),
2218
+ max: parseRecruitAgeCustomHiddenValue(hidden[1]?.value),
2219
+ raw_values: hidden.map((item) => item.value)
591
2220
  };
592
2221
  }
593
2222
 
594
- export async function setRecruitDegree(client, frameNodeId, degree) {
595
- const degreeLabel = resolveRecruitDegreeLabel(degree);
596
- if (!degreeLabel || degreeLabel === "不限") {
597
- return { applied: false, reason: "unlimited_degree", degree: degreeLabel || "不限" };
598
- }
599
- const { candidate, candidates } = await findTextCandidate(
2223
+ async function readRecruitAgeFixedOptionState(client, frameNodeId) {
2224
+ const candidates = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.ageOption);
2225
+ return {
2226
+ active_labels: candidates.filter((item) => item.active).map((item) => item.text),
2227
+ options: summarizeTextCandidates(candidates, 20)
2228
+ };
2229
+ }
2230
+
2231
+ function ageCustomOptionLabel(value) {
2232
+ if (value === null || value === undefined) return "不限";
2233
+ return `${value}岁`;
2234
+ }
2235
+
2236
+ function parseRecruitAgeCustomHiddenValue(value) {
2237
+ const text = normalizeText(value);
2238
+ if (!text || text === "0" || text === "-1") return null;
2239
+ return parseAgeNumber(text, null);
2240
+ }
2241
+
2242
+ async function selectRecruitAgeCustomDropdownValue(client, frameNodeId, {
2243
+ dropdownIndex,
2244
+ value
2245
+ }) {
2246
+ const dropdownNodeIds = uniqueNodeIds(await querySelectorAll(
600
2247
  client,
601
2248
  frameNodeId,
602
- RECRUIT_SEARCH_SELECTORS.degreeOption,
603
- degreeLabel,
604
- { match: "prefix" }
605
- );
606
- if (!candidate) {
607
- throw new Error(`Recruit degree option was not found: ${degreeLabel}`);
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}`);
608
2254
  }
609
- const box = await clickNodeCenter(client, candidate.node_id, {
2255
+ const openBox = await clickNodeCenter(client, dropdownNodeId, {
610
2256
  ...DETERMINISTIC_CLICK_OPTIONS,
611
2257
  scrollIntoView: true
612
2258
  });
613
2259
  await sleep(350);
2260
+ const label = ageCustomOptionLabel(value);
2261
+ const options = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.ageCustomOption, {
2262
+ includeBox: true
2263
+ });
2264
+ const option = chooseRecruitTextCandidate(options, { label, match: "exact" });
2265
+ if (!option) {
2266
+ throw new Error(`Recruit age custom option was not found: ${label}`);
2267
+ }
2268
+ const box = await clickNodeCenter(client, option.node_id, {
2269
+ ...DETERMINISTIC_CLICK_OPTIONS,
2270
+ scrollIntoView: true
2271
+ });
2272
+ await sleep(600);
614
2273
  return {
615
- applied: true,
616
- requested_degree: degree,
617
- selected_label: candidate.text,
618
- selected_node_id: candidate.node_id,
619
- was_active: candidate.active,
2274
+ dropdown_index: dropdownIndex,
2275
+ requested_value: value,
2276
+ selected_label: option.text,
2277
+ dropdown_node_id: dropdownNodeId,
2278
+ option_node_id: option.node_id,
2279
+ open_box: openBox,
620
2280
  box,
621
- discovered_options: candidates.map((item) => ({
622
- label: item.text,
623
- active: item.active,
624
- node_id: item.node_id,
625
- selector: item.selector
626
- }))
2281
+ discovered_options: summarizeTextCandidates(options, 40)
627
2282
  };
628
2283
  }
629
2284
 
630
- export async function setRecruitDegrees(client, frameNodeId, degrees = []) {
631
- const labels = normalizeRecruitDegreeLabels(degrees).filter((label) => label && label !== "不限");
632
- if (!labels.length) {
633
- return { applied: false, reason: "unlimited_degree", degrees: ["不限"], selected: [] };
2285
+ export async function setRecruitAge(client, frameNodeId, age) {
2286
+ const filter = normalizeRecruitAgeFilter(age);
2287
+ if (!filter) {
2288
+ return { applied: false, reason: "not_requested" };
634
2289
  }
635
2290
 
636
- const selected = [];
637
- let discoveredOptions = [];
638
- for (const label of labels) {
2291
+ if (filter.mode === "option") {
639
2292
  const { candidate, candidates } = await findTextCandidate(
640
2293
  client,
641
2294
  frameNodeId,
642
- RECRUIT_SEARCH_SELECTORS.degreeOption,
643
- label,
644
- { match: "prefix" }
2295
+ RECRUIT_SEARCH_SELECTORS.ageOption,
2296
+ filter.label,
2297
+ { match: "exact" }
645
2298
  );
646
- discoveredOptions = candidates.map((item) => ({
647
- label: item.text,
648
- active: item.active,
649
- node_id: item.node_id,
650
- selector: item.selector
651
- }));
652
2299
  if (!candidate) {
653
- throw new Error(`Recruit degree option was not found: ${label}`);
2300
+ throw new Error(`Recruit age option was not found: ${filter.label}`);
654
2301
  }
655
-
656
2302
  let box = null;
657
2303
  if (!candidate.active) {
658
2304
  box = await clickNodeCenter(client, candidate.node_id, {
659
2305
  ...DETERMINISTIC_CLICK_OPTIONS,
660
2306
  scrollIntoView: true
661
2307
  });
662
- await sleep(350);
2308
+ await sleep(500);
663
2309
  }
664
- selected.push({
665
- requested_degree: label,
2310
+ return {
2311
+ applied: true,
2312
+ mode: "option",
2313
+ requested_age: age,
666
2314
  selected_label: candidate.text,
667
2315
  selected_node_id: candidate.node_id,
668
2316
  was_active: candidate.active,
669
2317
  clicked: !candidate.active,
670
- box
671
- });
672
- }
673
-
674
- return {
675
- applied: true,
676
- requested_degrees: labels,
677
- selected,
678
- discovered_options: discoveredOptions
679
- };
680
- }
681
-
682
- async function findClickableDescendant(client, nodeId, selectors) {
683
- for (const selector of selectors) {
684
- const childNodeId = await querySelector(client, nodeId, selector);
685
- if (childNodeId) return { node_id: childNodeId, selector };
686
- }
687
- return { node_id: nodeId, selector: null };
688
- }
689
-
690
- export async function setRecruitSchools(client, frameNodeId, schools = []) {
691
- const targets = schools.map(normalizeText).filter(Boolean);
692
- const applied = [];
693
- const missing = [];
694
- if (!targets.length) {
695
- return { applied: false, schools: [], selected: [], missing: [] };
2318
+ box,
2319
+ discovered_options: summarizeTextCandidates(candidates, 20)
2320
+ };
696
2321
  }
697
2322
 
698
- for (const school of targets) {
699
- const { candidate, candidates } = await findTextCandidate(
700
- client,
701
- frameNodeId,
702
- RECRUIT_SEARCH_SELECTORS.schoolItem,
703
- school,
704
- { match: "contains" }
705
- );
706
- if (!candidate) {
707
- missing.push({
708
- school,
709
- discovered: candidates.map((item) => item.text).slice(0, 20)
710
- });
711
- continue;
712
- }
713
-
714
- const clickable = await findClickableDescendant(client, candidate.node_id, RECRUIT_SEARCH_SELECTORS.schoolClickable);
715
- let clickableActive = candidate.active;
716
- if (clickable.node_id !== candidate.node_id) {
717
- const clickableCandidate = await readTextCandidate(client, clickable.node_id, {
718
- selector: clickable.selector || "",
719
- index: 0
720
- });
721
- clickableActive = clickableActive || clickableCandidate.active;
722
- }
723
-
724
- let box = null;
725
- if (!clickableActive) {
726
- box = await clickNodeCenter(client, clickable.node_id, {
727
- ...DETERMINISTIC_CLICK_OPTIONS,
728
- scrollIntoView: true
729
- });
730
- await sleep(350);
731
- }
732
-
733
- applied.push({
734
- school,
735
- selected_label: candidate.text,
736
- selected_node_id: candidate.node_id,
737
- clickable_node_id: clickable.node_id,
738
- clickable_selector: clickable.selector,
739
- was_active: clickableActive,
740
- clicked: !clickableActive,
741
- box
742
- });
2323
+ const customClick = await clickFirstNodeBySelectors(
2324
+ client,
2325
+ frameNodeId,
2326
+ RECRUIT_SEARCH_SELECTORS.ageCustom,
2327
+ { optional: false, scrollIntoView: true }
2328
+ );
2329
+ const before = await readRecruitAgeCustomState(client, frameNodeId);
2330
+ const fixedBefore = await readRecruitAgeFixedOptionState(client, frameNodeId);
2331
+ const selected = [];
2332
+ selected.push(await selectRecruitAgeCustomDropdownValue(client, frameNodeId, {
2333
+ dropdownIndex: 0,
2334
+ value: filter.min
2335
+ }));
2336
+ selected.push(await selectRecruitAgeCustomDropdownValue(client, frameNodeId, {
2337
+ dropdownIndex: 1,
2338
+ value: filter.max
2339
+ }));
2340
+ const after = await readRecruitAgeCustomState(client, frameNodeId);
2341
+ const fixedAfter = await readRecruitAgeFixedOptionState(client, frameNodeId);
2342
+ const verified = after.min === filter.min && after.max === filter.max;
2343
+ if (!verified) {
2344
+ throw new Error(`Recruit age custom values did not stick: requested=${filter.min},${filter.max}; actual=${after.raw_values.join(",")}`);
743
2345
  }
744
-
745
- if (missing.length) {
746
- throw new Error(`Recruit school options were not found: ${missing.map((item) => item.school).join(", ")}`);
2346
+ if (fixedAfter.active_labels.length) {
2347
+ throw new Error(`Recruit age custom still has fixed option active: ${fixedAfter.active_labels.join(", ")}`);
747
2348
  }
748
-
749
2349
  return {
750
2350
  applied: true,
751
- schools: targets,
752
- selected: applied,
753
- missing
2351
+ mode: "custom",
2352
+ requested_age: age,
2353
+ requested_range: {
2354
+ min: filter.min,
2355
+ max: filter.max
2356
+ },
2357
+ custom_click: customClick,
2358
+ before,
2359
+ after,
2360
+ fixed_options_before: fixedBefore,
2361
+ fixed_options_after: fixedAfter,
2362
+ selected,
2363
+ verification: {
2364
+ verified,
2365
+ fixed_option_cleared: fixedAfter.active_labels.length === 0,
2366
+ expected: [filter.min, filter.max],
2367
+ actual: [after.min, after.max]
2368
+ }
754
2369
  };
755
2370
  }
756
2371
 
757
- export async function setRecruitRecentViewedFilter(client, frameNodeId, enabled) {
2372
+ async function setRecruitCheckboxFilter(client, frameNodeId, enabled, {
2373
+ selectors,
2374
+ label,
2375
+ errorLabel
2376
+ } = {}) {
758
2377
  if (typeof enabled !== "boolean") {
759
2378
  return { applied: false, reason: "not_requested" };
760
2379
  }
761
2380
  const { candidate, candidates } = await findTextCandidate(
762
2381
  client,
763
2382
  frameNodeId,
764
- RECRUIT_SEARCH_SELECTORS.recentViewedLabel,
765
- "过滤近14天查看",
2383
+ selectors,
2384
+ label,
766
2385
  { match: "contains" }
767
2386
  );
768
2387
  if (!candidate) {
769
- throw new Error("Recruit recent-viewed filter was not found");
2388
+ throw new Error(`${errorLabel || "Recruit checkbox filter"} was not found`);
770
2389
  }
771
2390
 
772
2391
  let box = null;
@@ -795,12 +2414,66 @@ export async function setRecruitRecentViewedFilter(client, frameNodeId, enabled)
795
2414
  };
796
2415
  }
797
2416
 
2417
+ export async function setRecruitRecentViewedFilter(client, frameNodeId, enabled) {
2418
+ return setRecruitCheckboxFilter(client, frameNodeId, enabled, {
2419
+ selectors: RECRUIT_SEARCH_SELECTORS.recentViewedLabel,
2420
+ label: "过滤近14天查看",
2421
+ errorLabel: "Recruit recent-viewed filter"
2422
+ });
2423
+ }
2424
+
2425
+ export async function setRecruitExchangeResumeFilter(client, frameNodeId, enabled) {
2426
+ return setRecruitCheckboxFilter(client, frameNodeId, enabled, {
2427
+ selectors: RECRUIT_SEARCH_SELECTORS.exchangeResumeLabel,
2428
+ label: "近30天未和同事交换简历",
2429
+ errorLabel: "Recruit exchange-resume filter"
2430
+ });
2431
+ }
2432
+
2433
+ async function openRecruitCityPicker(client, frameNodeId, {
2434
+ settleMs = 350
2435
+ } = {}) {
2436
+ const alreadyOpenInput = await clickFirstNodeBySelectors(
2437
+ client,
2438
+ frameNodeId,
2439
+ RECRUIT_SEARCH_SELECTORS.cityInput,
2440
+ { optional: true }
2441
+ );
2442
+ if (alreadyOpenInput.clicked) {
2443
+ return {
2444
+ opened: true,
2445
+ already_open: true,
2446
+ input: alreadyOpenInput,
2447
+ trigger: null
2448
+ };
2449
+ }
2450
+
2451
+ const trigger = await clickFirstNodeBySelectors(
2452
+ client,
2453
+ frameNodeId,
2454
+ RECRUIT_SEARCH_SELECTORS.cityTrigger,
2455
+ { scrollIntoView: false }
2456
+ );
2457
+ if (settleMs > 0) await sleep(settleMs);
2458
+ const input = await clickFirstNodeBySelectors(
2459
+ client,
2460
+ frameNodeId,
2461
+ RECRUIT_SEARCH_SELECTORS.cityInput
2462
+ );
2463
+ return {
2464
+ opened: true,
2465
+ already_open: false,
2466
+ trigger,
2467
+ input
2468
+ };
2469
+ }
2470
+
798
2471
  async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
799
2472
  requestedCity = "全国",
800
2473
  reason = "national_city_requested",
801
2474
  optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
802
2475
  } = {}) {
803
- const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
2476
+ const picker = await openRecruitCityPicker(client, frameNodeId);
804
2477
  await clearFocusedInput(client);
805
2478
  await sleep(500);
806
2479
 
@@ -854,7 +2527,8 @@ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
854
2527
  applied: false,
855
2528
  reason: "national_city_popular_not_found",
856
2529
  requested_city: requestedCity,
857
- input,
2530
+ input: picker.input,
2531
+ picker,
858
2532
  path,
859
2533
  discovered_options: summarizeTextCandidates(popularLookup.candidates)
860
2534
  };
@@ -892,7 +2566,8 @@ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
892
2566
  applied: false,
893
2567
  reason: "national_city_option_not_found",
894
2568
  requested_city: requestedCity,
895
- input,
2569
+ input: picker.input,
2570
+ picker,
896
2571
  path,
897
2572
  discovered_options: summarizeTextCandidates(nationalLookup.candidates)
898
2573
  };
@@ -917,7 +2592,8 @@ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
917
2592
  requested_city: requestedCity,
918
2593
  selected_label: nationalLookup.candidate.text,
919
2594
  selected_node_id: nationalLookup.candidate.node_id,
920
- input,
2595
+ input: picker.input,
2596
+ picker,
921
2597
  path,
922
2598
  box: nationalBox,
923
2599
  selection_mode: "city_picker",
@@ -972,7 +2648,7 @@ export async function setRecruitCity(client, frameNodeId, city, {
972
2648
  });
973
2649
  }
974
2650
 
975
- const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
2651
+ const picker = await openRecruitCityPicker(client, frameNodeId);
976
2652
  await clearFocusedInput(client);
977
2653
  await sleep(120);
978
2654
  await insertText(client, normalizedCity);
@@ -1016,7 +2692,8 @@ export async function setRecruitCity(client, frameNodeId, city, {
1016
2692
  requested_city: normalizedCity,
1017
2693
  requested_city_not_found: true,
1018
2694
  fallback_to_national: true,
1019
- original_input: input,
2695
+ original_input: picker.input,
2696
+ picker,
1020
2697
  elapsed_ms: Date.now() - started,
1021
2698
  discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
1022
2699
  };
@@ -1034,7 +2711,8 @@ export async function setRecruitCity(client, frameNodeId, city, {
1034
2711
  requested_city: normalizedCity,
1035
2712
  requested_city_not_found: true,
1036
2713
  fallback_to_national: true,
1037
- original_input: input,
2714
+ original_input: picker.input,
2715
+ picker,
1038
2716
  picker_fallback: nationalFallback,
1039
2717
  elapsed_ms: Date.now() - started,
1040
2718
  discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
@@ -1045,7 +2723,8 @@ export async function setRecruitCity(client, frameNodeId, city, {
1045
2723
  applied: false,
1046
2724
  reason: "city_result_not_found",
1047
2725
  city: normalizedCity,
1048
- input,
2726
+ input: picker.input,
2727
+ picker,
1049
2728
  elapsed_ms: Date.now() - started,
1050
2729
  discovered_options: candidates.map((item) => item.text).slice(0, 20),
1051
2730
  national_fallback: nationalFallback,
@@ -1063,7 +2742,8 @@ export async function setRecruitCity(client, frameNodeId, city, {
1063
2742
  city: normalizedCity,
1064
2743
  selected_label: candidate.text,
1065
2744
  selected_node_id: candidate.node_id,
1066
- input,
2745
+ input: picker.input,
2746
+ picker,
1067
2747
  elapsed_ms: Date.now() - started,
1068
2748
  box
1069
2749
  };
@@ -1095,6 +2775,86 @@ export async function clickRecruitSearch(client, frameNodeId) {
1095
2775
  };
1096
2776
  }
1097
2777
 
2778
+ export async function clickRecruitSearchWithKeywordGuard(client, frameNodeId, keyword, {
2779
+ maxAttempts = 2,
2780
+ postSearchSettleMs = 2200
2781
+ } = {}) {
2782
+ const normalizedKeyword = normalizeText(keyword);
2783
+ if (!normalizedKeyword) {
2784
+ return clickRecruitSearch(client, frameNodeId);
2785
+ }
2786
+
2787
+ const attempts = [];
2788
+ let currentFrameNodeId = frameNodeId;
2789
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
2790
+ let rootsBeforeAttempt = null;
2791
+ try {
2792
+ rootsBeforeAttempt = await getRecruitRoots(client, { requireFrame: false });
2793
+ if (rootsBeforeAttempt?.iframe?.documentNodeId) {
2794
+ currentFrameNodeId = rootsBeforeAttempt.iframe.documentNodeId;
2795
+ }
2796
+ } catch {}
2797
+ const before = await verifyRecruitKeywordInputValue(client, currentFrameNodeId, normalizedKeyword);
2798
+ let reapply = null;
2799
+ if (before.verified === false) {
2800
+ reapply = await setRecruitKeyword(client, currentFrameNodeId, normalizedKeyword);
2801
+ }
2802
+ const search = await clickRecruitSearch(client, currentFrameNodeId);
2803
+ let rootsAfterSearch = null;
2804
+ try {
2805
+ rootsAfterSearch = await getRecruitRoots(client, { requireFrame: false });
2806
+ if (rootsAfterSearch?.iframe?.documentNodeId) {
2807
+ currentFrameNodeId = rootsAfterSearch.iframe.documentNodeId;
2808
+ }
2809
+ } catch {}
2810
+ const after = await verifyRecruitKeywordInputValue(client, currentFrameNodeId, normalizedKeyword, {
2811
+ settleMs: postSearchSettleMs
2812
+ });
2813
+ attempts.push({
2814
+ attempt,
2815
+ before,
2816
+ reapply,
2817
+ search,
2818
+ after,
2819
+ frame_reacquired_before_attempt: rootsBeforeAttempt?.iframe?.documentNodeId
2820
+ ? {
2821
+ selector: rootsBeforeAttempt.iframe.selector,
2822
+ document_node_id: rootsBeforeAttempt.iframe.documentNodeId
2823
+ }
2824
+ : null,
2825
+ frame_reacquired: rootsAfterSearch?.iframe?.documentNodeId
2826
+ ? {
2827
+ selector: rootsAfterSearch.iframe.selector,
2828
+ document_node_id: rootsAfterSearch.iframe.documentNodeId
2829
+ }
2830
+ : null
2831
+ });
2832
+ if (after.verified !== false) {
2833
+ return {
2834
+ searched: true,
2835
+ mode: search.mode,
2836
+ search,
2837
+ keyword_guard: {
2838
+ verified: after.verified,
2839
+ expected: after.expected,
2840
+ actual: after.actual,
2841
+ attempts
2842
+ }
2843
+ };
2844
+ }
2845
+ }
2846
+
2847
+ const last = attempts[attempts.length - 1]?.after || {};
2848
+ const error = new Error(`Recruit keyword was not preserved after search: expected=${normalizedKeyword}; actual=${last.actual || "unknown"}`);
2849
+ error.keyword_guard = {
2850
+ verified: false,
2851
+ expected: normalizedKeyword,
2852
+ actual: last.actual || "",
2853
+ attempts
2854
+ };
2855
+ throw error;
2856
+ }
2857
+
1098
2858
  export async function waitForRecruitSearchResultState(client, {
1099
2859
  timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
1100
2860
  intervalMs = 500
@@ -1166,70 +2926,121 @@ export async function applyRecruitSearchParams(client, {
1166
2926
  const initialFrameNodeId = frameNodeId;
1167
2927
  const beforeCounts = await getRecruitSearchCounts(client, frameNodeId);
1168
2928
  const steps = [];
2929
+ const applicationStepNames = buildRecruitSearchApplicationStepNames(normalizedSearchParams);
1169
2930
 
1170
- if (normalizedSearchParams.job) {
1171
- steps.push({
1172
- step: "job_title",
1173
- result: await setRecruitJobTitle(client, frameNodeId, normalizedSearchParams.job, {
1174
- optionTimeoutMs: searchTimeoutMs
1175
- })
1176
- });
1177
- }
1178
-
1179
- if (normalizedSearchParams.city) {
1180
- const cityResult = await setRecruitCity(client, frameNodeId, normalizedSearchParams.city, {
1181
- optionTimeoutMs: cityOptionTimeoutMs
1182
- });
1183
- steps.push({
1184
- step: "city",
1185
- result: cityResult
1186
- });
1187
- if (cityResult?.reacquire_frame) {
1188
- const rootsAfterCity = await getRecruitRoots(client);
1189
- frameNodeId = rootsAfterCity.iframe.documentNodeId;
2931
+ for (const stepName of applicationStepNames) {
2932
+ if (stepName === "job_title") {
2933
+ steps.push({
2934
+ step: "job_title",
2935
+ result: await setRecruitJobTitle(client, frameNodeId, normalizedSearchParams.job, {
2936
+ optionTimeoutMs: searchTimeoutMs
2937
+ })
2938
+ });
2939
+ const rootsAfterJob = await getRecruitRoots(client);
2940
+ frameNodeId = rootsAfterJob.iframe.documentNodeId;
2941
+ steps.push({
2942
+ step: "reacquire_after_job",
2943
+ result: {
2944
+ selector: rootsAfterJob.iframe.selector,
2945
+ document_node_id: frameNodeId
2946
+ }
2947
+ });
2948
+ } else if (stepName === "city") {
2949
+ const cityResult = await setRecruitCity(client, frameNodeId, normalizedSearchParams.city, {
2950
+ optionTimeoutMs: cityOptionTimeoutMs
2951
+ });
2952
+ steps.push({
2953
+ step: "city",
2954
+ result: cityResult
2955
+ });
2956
+ if (cityResult?.reacquire_frame) {
2957
+ const rootsAfterCity = await getRecruitRoots(client);
2958
+ frameNodeId = rootsAfterCity.iframe.documentNodeId;
2959
+ steps.push({
2960
+ step: "reacquire_after_city",
2961
+ result: {
2962
+ selector: rootsAfterCity.iframe.selector,
2963
+ document_node_id: frameNodeId,
2964
+ reason: cityResult.reason
2965
+ }
2966
+ });
2967
+ }
2968
+ } else if (stepName === "degree") {
2969
+ steps.push({
2970
+ step: "degree",
2971
+ result: await setRecruitDegrees(client, frameNodeId, normalizedSearchParams.degrees)
2972
+ });
2973
+ } else if (stepName === "schools") {
2974
+ steps.push({
2975
+ step: "schools",
2976
+ result: await setRecruitSchools(client, frameNodeId, normalizedSearchParams.schools)
2977
+ });
2978
+ } else if (stepName === "experience") {
2979
+ steps.push({
2980
+ step: "experience",
2981
+ result: await setRecruitExperience(client, frameNodeId, normalizedSearchParams.experience)
2982
+ });
2983
+ } else if (stepName === "gender") {
2984
+ steps.push({
2985
+ step: "gender",
2986
+ result: await setRecruitGender(client, frameNodeId, normalizedSearchParams.gender)
2987
+ });
2988
+ } else if (stepName === "age") {
2989
+ steps.push({
2990
+ step: "age",
2991
+ result: await setRecruitAge(client, frameNodeId, normalizedSearchParams.age)
2992
+ });
2993
+ } else if (stepName === "keyword") {
2994
+ const rootsBeforeKeyword = await getRecruitRoots(client);
2995
+ frameNodeId = rootsBeforeKeyword.iframe.documentNodeId;
2996
+ steps.push({
2997
+ step: "reacquire_before_keyword",
2998
+ result: {
2999
+ selector: rootsBeforeKeyword.iframe.selector,
3000
+ document_node_id: frameNodeId
3001
+ }
3002
+ });
3003
+ steps.push({
3004
+ step: "keyword",
3005
+ result: await setRecruitKeyword(client, frameNodeId, normalizedSearchParams.keyword)
3006
+ });
3007
+ } else if (stepName === "search") {
3008
+ const rootsBeforeSearch = await getRecruitRoots(client);
3009
+ frameNodeId = rootsBeforeSearch.iframe.documentNodeId;
1190
3010
  steps.push({
1191
- step: "reacquire_after_city",
3011
+ step: "reacquire_before_search",
1192
3012
  result: {
1193
- selector: rootsAfterCity.iframe.selector,
1194
- document_node_id: frameNodeId,
1195
- reason: cityResult.reason
3013
+ selector: rootsBeforeSearch.iframe.selector,
3014
+ document_node_id: frameNodeId
1196
3015
  }
1197
3016
  });
3017
+ steps.push({
3018
+ step: "search",
3019
+ result: await clickRecruitSearchWithKeywordGuard(client, frameNodeId, normalizedSearchParams.keyword)
3020
+ });
3021
+ } else if (stepName === "recent_viewed") {
3022
+ const recentFilterRoots = await getRecruitRoots(client);
3023
+ steps.push({
3024
+ step: "recent_viewed",
3025
+ result: await setRecruitRecentViewedFilter(
3026
+ client,
3027
+ recentFilterRoots.iframe.documentNodeId,
3028
+ normalizedSearchParams.filter_recent_viewed
3029
+ )
3030
+ });
3031
+ } else if (stepName === "exchange_resume") {
3032
+ const exchangeFilterRoots = await getRecruitRoots(client);
3033
+ steps.push({
3034
+ step: "exchange_resume",
3035
+ result: await setRecruitExchangeResumeFilter(
3036
+ client,
3037
+ exchangeFilterRoots.iframe.documentNodeId,
3038
+ normalizedSearchParams.skip_recent_colleague_contacted
3039
+ )
3040
+ });
1198
3041
  }
1199
3042
  }
1200
3043
 
1201
- steps.push({
1202
- step: "degree",
1203
- result: await setRecruitDegrees(client, frameNodeId, normalizedSearchParams.degrees)
1204
- });
1205
-
1206
- steps.push({
1207
- step: "schools",
1208
- result: await setRecruitSchools(client, frameNodeId, normalizedSearchParams.schools)
1209
- });
1210
-
1211
- steps.push({
1212
- step: "keyword",
1213
- result: await setRecruitKeyword(client, frameNodeId, normalizedSearchParams.keyword)
1214
- });
1215
-
1216
- steps.push({
1217
- step: "search",
1218
- result: await clickRecruitSearch(client, frameNodeId)
1219
- });
1220
-
1221
- if (typeof normalizedSearchParams.filter_recent_viewed === "boolean") {
1222
- const postSearchRoots = await getRecruitRoots(client);
1223
- steps.push({
1224
- step: "recent_viewed",
1225
- result: await setRecruitRecentViewedFilter(
1226
- client,
1227
- postSearchRoots.iframe.documentNodeId,
1228
- normalizedSearchParams.filter_recent_viewed
1229
- )
1230
- });
1231
- }
1232
-
1233
3044
  const postSearchState = await waitForRecruitSearchResultState(client, {
1234
3045
  timeoutMs: searchTimeoutMs
1235
3046
  });
@@ -1243,6 +3054,7 @@ export async function applyRecruitSearchParams(client, {
1243
3054
  reset,
1244
3055
  overlay_dismissal: overlayDismissal,
1245
3056
  controls,
3057
+ application_step_names: applicationStepNames,
1246
3058
  initial_iframe: {
1247
3059
  selector: initialRoots.iframe.selector,
1248
3060
  document_node_id: initialFrameNodeId