@reconcrap/boss-recommend-mcp 2.1.14 → 2.1.15

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,15 +328,374 @@ 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
@@ -150,19 +703,39 @@ export function normalizeRecruitSearchParams(searchParams = {}) {
150
703
  };
151
704
  const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
152
705
  if (job) normalized.job = job;
706
+ if (experience) normalized.experience = experience;
707
+ if (gender) normalized.gender = gender;
708
+ if (age) normalized.age = age;
153
709
  return normalized;
154
710
  }
155
711
 
712
+ export function buildRecruitSearchApplicationStepNames(searchParams = {}) {
713
+ const normalized = normalizeRecruitSearchParams(searchParams);
714
+ const steps = [];
715
+ // Recruit search applies job first because job changes can reset other filters.
716
+ if (normalized.job) steps.push("job_title");
717
+ if (normalized.city) steps.push("city");
718
+ steps.push("degree", "schools");
719
+ if (normalized.experience) steps.push("experience");
720
+ if (normalized.gender) steps.push("gender");
721
+ if (normalized.age) steps.push("age");
722
+ if (typeof normalized.filter_recent_viewed === "boolean") steps.push("recent_viewed");
723
+ // Keyword is the final filter before executing the search.
724
+ steps.push("keyword", "search");
725
+ return steps;
726
+ }
727
+
156
728
  export function hasRecruitSearchParams(searchParams = {}) {
157
729
  const degrees = normalizeRecruitDegreeLabels(searchParams.degrees || searchParams.degree || "不限");
158
730
  const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
731
+ const experience = normalizeRecruitExperienceFilter(pickRecruitExperienceSource(searchParams));
732
+ const gender = normalizeRecruitGenderFilter(searchParams.gender);
733
+ const age = normalizeRecruitAgeFilter(pickRecruitAgeSource(searchParams));
159
734
  const normalized = {
160
735
  city: normalizeText(searchParams.city) || null,
161
736
  degree: degrees[0] || "不限",
162
737
  degrees,
163
- schools: Array.isArray(searchParams.schools)
164
- ? searchParams.schools.map(normalizeText).filter(Boolean)
165
- : [],
738
+ schools: normalizeRecruitSchoolList(searchParams.schools),
166
739
  keyword: normalizeText(searchParams.keyword),
167
740
  filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
168
741
  ? searchParams.filter_recent_viewed
@@ -173,6 +746,9 @@ export function hasRecruitSearchParams(searchParams = {}) {
173
746
  || normalized.city
174
747
  || normalized.degrees.some((degree) => degree && degree !== "不限")
175
748
  || normalized.schools.length
749
+ || experience
750
+ || gender
751
+ || age
176
752
  || normalized.keyword
177
753
  || typeof normalized.filter_recent_viewed === "boolean"
178
754
  );
@@ -186,14 +762,28 @@ function candidateIsActive(attributes = {}, outerHTML = "") {
186
762
  || /\bchecked(?:=["']?checked)?\b/i.test(openingTag);
187
763
  }
188
764
 
765
+ function isVisibleBox(box) {
766
+ return Boolean(box && box.rect.width > 4 && box.rect.height > 4);
767
+ }
768
+
189
769
  async function readTextCandidate(client, nodeId, {
190
770
  selector = "",
191
- index = 0
771
+ index = 0,
772
+ includeBox = false
192
773
  } = {}) {
193
774
  const [attributes, outerHTML] = await Promise.all([
194
775
  getAttributesMap(client, nodeId),
195
776
  getOuterHTML(client, nodeId)
196
777
  ]);
778
+ let box = null;
779
+ let boxError = "";
780
+ if (includeBox) {
781
+ try {
782
+ box = await getNodeBox(client, nodeId);
783
+ } catch (error) {
784
+ boxError = error?.message || String(error);
785
+ }
786
+ }
197
787
  const text = normalizeText(htmlToText(outerHTML));
198
788
  return {
199
789
  node_id: nodeId,
@@ -202,12 +792,16 @@ async function readTextCandidate(client, nodeId, {
202
792
  label: normalizeRecruitSearchLabel(text),
203
793
  text,
204
794
  active: candidateIsActive(attributes, outerHTML),
795
+ visible: includeBox ? isVisibleBox(box) : undefined,
796
+ center: box?.center || null,
797
+ rect: box?.rect || null,
798
+ box_error: boxError || undefined,
205
799
  class_name: attributes.class || "",
206
800
  attributes
207
801
  };
208
802
  }
209
803
 
210
- async function listTextCandidates(client, rootNodeId, selectors = []) {
804
+ async function listTextCandidates(client, rootNodeId, selectors = [], options = {}) {
211
805
  const candidates = [];
212
806
  const seen = new Set();
213
807
  for (const selector of selectors) {
@@ -216,7 +810,7 @@ async function listTextCandidates(client, rootNodeId, selectors = []) {
216
810
  const nodeId = nodeIds[index];
217
811
  if (seen.has(nodeId)) continue;
218
812
  seen.add(nodeId);
219
- candidates.push(await readTextCandidate(client, nodeId, { selector, index }));
813
+ candidates.push(await readTextCandidate(client, nodeId, { selector, index, ...options }));
220
814
  }
221
815
  }
222
816
  return candidates;
@@ -240,6 +834,12 @@ export function chooseRecruitTextCandidate(candidates = [], {
240
834
  return candidates.find((candidate) => candidate.label.includes(target) || target.includes(candidate.label)) || null;
241
835
  }
242
836
 
837
+ function chooseRecruitSchoolCandidate(candidates = [], school) {
838
+ const targetKeys = new Set(buildRecruitSchoolSearchLabels(school).map(normalizeRecruitSchoolCompareKey));
839
+ if (!targetKeys.size) return null;
840
+ return candidates.find((candidate) => targetKeys.has(normalizeRecruitSchoolCompareKey(candidate.text || candidate.label))) || null;
841
+ }
842
+
243
843
  async function findTextCandidate(client, rootNodeId, selectors, label, options = {}) {
244
844
  const candidates = await listTextCandidates(client, rootNodeId, selectors);
245
845
  return {
@@ -310,46 +910,82 @@ async function waitForRecruitJobTitleCandidate(client, rootNodeId, selectors, jo
310
910
  };
311
911
  }
312
912
 
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(", ")}`);
913
+ function compactRecruitTextCandidate(candidate = {}) {
914
+ return {
915
+ label: candidate.text || "",
916
+ normalized_label: candidate.label || "",
917
+ active: Boolean(candidate.active),
918
+ visible: Boolean(candidate.visible),
919
+ class_name: candidate.class_name || "",
920
+ node_id: candidate.node_id,
921
+ selector: candidate.selector,
922
+ center: candidate.center || null,
923
+ rect: candidate.rect || null,
924
+ box_error: candidate.box_error || null
925
+ };
926
+ }
927
+
928
+ async function listRecruitJobTitleOptions(client, frameNodeId) {
929
+ const candidates = await listTextCandidates(
930
+ client,
931
+ frameNodeId,
932
+ RECRUIT_SEARCH_SELECTORS.jobTitleOption,
933
+ { includeBox: true }
934
+ );
935
+ return candidates.filter((candidate) => candidate.text && candidate.text.length <= 160);
936
+ }
937
+
938
+ async function findVisibleRecruitJobTitleTrigger(client, frameNodeId) {
939
+ const candidates = [];
940
+ const seen = new Set();
941
+ for (const selector of RECRUIT_SEARCH_SELECTORS.jobTitleTrigger) {
942
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, frameNodeId, selector));
943
+ for (const nodeId of nodeIds) {
944
+ if (seen.has(nodeId)) continue;
945
+ seen.add(nodeId);
946
+ let box = null;
947
+ try {
948
+ box = await getNodeBox(client, nodeId);
949
+ } catch {}
950
+ if (!isVisibleBox(box)) continue;
951
+ candidates.push({
952
+ selector,
953
+ node_id: nodeId,
954
+ center: box.center,
955
+ rect: box.rect
956
+ });
957
+ }
321
958
  }
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) {
959
+ return candidates[0] || null;
960
+ }
961
+
962
+ async function waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
963
+ timeoutMs = 4000,
964
+ intervalMs = 200
965
+ } = {}) {
966
+ const started = Date.now();
967
+ let options = [];
968
+ while (Date.now() - started <= timeoutMs) {
969
+ options = await listRecruitJobTitleOptions(client, frameNodeId);
970
+ const visibleOptions = options.filter((option) => option.visible);
971
+ if (visibleOptions.length) {
336
972
  return {
337
- clicked: false,
338
- reason: "not_clickable",
339
- selector: found.selector,
340
- node_id: found.nodeId,
341
- error: error?.message || String(error)
973
+ options,
974
+ visible_options: visibleOptions
342
975
  };
343
976
  }
344
- throw error;
977
+ await sleep(intervalMs);
345
978
  }
979
+ return {
980
+ options,
981
+ visible_options: []
982
+ };
346
983
  }
347
984
 
348
- async function dismissRecruitSearchOverlays(client, settleMs = 250) {
985
+ async function closeRecruitJobTitleDropdown(client, settleMs = 300) {
349
986
  if (typeof client?.Input?.dispatchKeyEvent !== "function") {
350
987
  return {
351
- method: "Escape",
352
- skipped: true,
988
+ ok: false,
353
989
  reason: "dispatch_key_unavailable"
354
990
  };
355
991
  }
@@ -360,67 +996,352 @@ async function dismissRecruitSearchOverlays(client, settleMs = 250) {
360
996
  });
361
997
  if (settleMs > 0) await sleep(settleMs);
362
998
  return {
363
- method: "Escape",
364
- settle_ms: settleMs
999
+ ok: true,
1000
+ reason: "escape"
365
1001
  };
366
1002
  }
367
1003
 
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(", ")
1004
+ async function openRecruitJobTitleDropdown(client, frameNodeId, {
1005
+ timeoutMs = 4000,
1006
+ maxAttempts = 3
1007
+ } = {}) {
1008
+ const alreadyOpen = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1009
+ timeoutMs: 300,
1010
+ intervalMs: 100
377
1011
  });
1012
+ if (alreadyOpen.visible_options.length) {
1013
+ return {
1014
+ opened: true,
1015
+ already_open: true,
1016
+ options: alreadyOpen.options,
1017
+ visible_options: alreadyOpen.visible_options
1018
+ };
1019
+ }
1020
+
1021
+ await closeRecruitJobTitleDropdown(client);
1022
+ const attempts = [];
1023
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
1024
+ const trigger = await findVisibleRecruitJobTitleTrigger(client, frameNodeId);
1025
+ if (!trigger) {
1026
+ throw new Error("Recruit job trigger was not found");
1027
+ }
1028
+ if (attempt > 1) await closeRecruitJobTitleDropdown(client);
1029
+ const clickBox = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS);
1030
+ const opened = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1031
+ timeoutMs,
1032
+ intervalMs: 200
1033
+ });
1034
+ attempts.push({
1035
+ attempt,
1036
+ trigger,
1037
+ click_box: {
1038
+ center: clickBox.center,
1039
+ rect: clickBox.rect
1040
+ },
1041
+ option_count: opened.options.length,
1042
+ visible_option_count: opened.visible_options.length
1043
+ });
1044
+ if (opened.visible_options.length) {
1045
+ return {
1046
+ opened: true,
1047
+ already_open: false,
1048
+ trigger,
1049
+ options: opened.options,
1050
+ visible_options: opened.visible_options,
1051
+ attempts
1052
+ };
1053
+ }
1054
+ }
1055
+
1056
+ const error = new Error("Recruit job dropdown did not expose visible options after trigger click");
1057
+ error.job_dropdown_attempts = attempts;
1058
+ throw error;
378
1059
  }
379
1060
 
380
- export async function waitForRecruitSearchControls(client, {
381
- timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
382
- intervalMs = 300
1061
+ async function closeRecruitJobTitleDropdownFully(client, frameNodeId, {
1062
+ settleMs = 300,
1063
+ timeoutMs = 1500
383
1064
  } = {}) {
1065
+ const before = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1066
+ timeoutMs: 200,
1067
+ intervalMs: 100
1068
+ });
1069
+ const attempts = [];
1070
+ if (!before.visible_options.length) {
1071
+ return {
1072
+ ok: true,
1073
+ closed: false,
1074
+ reason: "already_closed",
1075
+ visible_before_count: 0,
1076
+ visible_after_count: 0,
1077
+ attempts
1078
+ };
1079
+ }
1080
+
384
1081
  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
1082
+ for (let attempt = 1; attempt <= 2 && Date.now() - started <= timeoutMs; attempt += 1) {
1083
+ const close = await closeRecruitJobTitleDropdown(client, settleMs);
1084
+ const after = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1085
+ timeoutMs: 250,
1086
+ intervalMs: 100
1087
+ });
1088
+ attempts.push({
1089
+ method: "escape",
1090
+ attempt,
1091
+ close,
1092
+ visible_after_count: after.visible_options.length
1093
+ });
1094
+ if (!after.visible_options.length) {
1095
+ return {
1096
+ ok: true,
1097
+ closed: true,
1098
+ reason: "escape",
1099
+ visible_before_count: before.visible_options.length,
1100
+ visible_after_count: 0,
1101
+ attempts
397
1102
  };
398
- if (lastState.ok) return lastState;
399
1103
  }
400
- await sleep(intervalMs);
401
1104
  }
1105
+
1106
+ const trigger = await findVisibleRecruitJobTitleTrigger(client, frameNodeId).catch(() => null);
1107
+ if (trigger?.node_id) {
1108
+ const click = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
1109
+ error: error?.message || String(error || "")
1110
+ }));
1111
+ if (settleMs > 0) await sleep(settleMs);
1112
+ const afterToggle = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1113
+ timeoutMs: 250,
1114
+ intervalMs: 100
1115
+ });
1116
+ attempts.push({
1117
+ method: "trigger_toggle",
1118
+ click,
1119
+ visible_after_count: afterToggle.visible_options.length
1120
+ });
1121
+ if (!afterToggle.visible_options.length) {
1122
+ return {
1123
+ ok: true,
1124
+ closed: true,
1125
+ reason: "trigger_toggle",
1126
+ visible_before_count: before.visible_options.length,
1127
+ visible_after_count: 0,
1128
+ attempts
1129
+ };
1130
+ }
1131
+ }
1132
+
1133
+ const outside = await clickPoint(client, 12, 12, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
1134
+ error: error?.message || String(error || "")
1135
+ }));
1136
+ if (settleMs > 0) await sleep(settleMs);
1137
+ const afterOutside = await waitForVisibleRecruitJobTitleOptions(client, frameNodeId, {
1138
+ timeoutMs: 250,
1139
+ intervalMs: 100
1140
+ });
1141
+ attempts.push({
1142
+ method: "outside_click",
1143
+ click: outside,
1144
+ visible_after_count: afterOutside.visible_options.length
1145
+ });
1146
+ if (!afterOutside.visible_options.length) {
1147
+ return {
1148
+ ok: true,
1149
+ closed: true,
1150
+ reason: "outside_click",
1151
+ visible_before_count: before.visible_options.length,
1152
+ visible_after_count: 0,
1153
+ attempts
1154
+ };
1155
+ }
1156
+
402
1157
  return {
403
1158
  ok: false,
404
- elapsed_ms: Date.now() - started,
405
- ...(lastState || {})
1159
+ closed: false,
1160
+ reason: "still_visible_after_close_attempts",
1161
+ visible_before_count: before.visible_options.length,
1162
+ visible_after_count: afterOutside.visible_options.length,
1163
+ attempts
406
1164
  };
407
1165
  }
408
1166
 
409
- async function settleRecruitSearchAfterReset(client, {
410
- timeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS,
411
- settleMs = 5000
1167
+ async function verifyRecruitJobTitleSelection(client, frameNodeId, {
1168
+ jobTitle = "",
1169
+ delayMs = 1200,
1170
+ dropdownTimeoutMs = 4000
412
1171
  } = {}) {
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
- });
1172
+ const requested = normalizeText(jobTitle);
1173
+ if (delayMs > 0) await sleep(delayMs);
1174
+ let options = [];
1175
+ let openError = null;
1176
+ try {
1177
+ const opened = await openRecruitJobTitleDropdown(client, frameNodeId, {
1178
+ timeoutMs: dropdownTimeoutMs
1179
+ });
1180
+ options = opened.options || [];
1181
+ } catch (error) {
1182
+ openError = error;
1183
+ options = await listRecruitJobTitleOptions(client, frameNodeId).catch(() => []);
1184
+ }
1185
+ const current = options.find((option) => option.active) || null;
1186
+ const searchTerms = buildRecruitJobTitleSearchTerms(requested);
1187
+ const verified = Boolean(
1188
+ current
1189
+ && searchTerms.some((term) => chooseRecruitTextCandidate([current], {
1190
+ label: term,
1191
+ match: "contains"
1192
+ }))
1193
+ );
1194
+ const menuClose = await closeRecruitJobTitleDropdownFully(client, frameNodeId).catch((error) => ({
1195
+ ok: false,
1196
+ closed: false,
1197
+ reason: "close_failed",
1198
+ error: error?.message || String(error)
1199
+ }));
1200
+ return {
1201
+ verified,
1202
+ requested,
1203
+ search_terms: searchTerms,
1204
+ current_label: current?.text || "",
1205
+ current_option: current ? compactRecruitTextCandidate(current) : null,
1206
+ option_count: options.length,
1207
+ visible_option_count: options.filter((option) => option.visible).length,
1208
+ options: options.map(compactRecruitTextCandidate),
1209
+ open_error: openError ? (openError?.message || String(openError)) : null,
1210
+ menu_close: menuClose
1211
+ };
1212
+ }
1213
+
1214
+ async function clickFirstNodeBySelectors(client, rootNodeId, selectors, {
1215
+ optional = false,
1216
+ scrollIntoView = true
1217
+ } = {}) {
1218
+ const errors = [];
1219
+ const seen = new Set();
1220
+ let matched = false;
1221
+ for (const selector of selectors) {
1222
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, rootNodeId, selector));
1223
+ for (const nodeId of nodeIds) {
1224
+ if (seen.has(nodeId)) continue;
1225
+ seen.add(nodeId);
1226
+ matched = true;
1227
+ try {
1228
+ const box = await clickNodeCenter(client, nodeId, {
1229
+ ...DETERMINISTIC_CLICK_OPTIONS,
1230
+ scrollIntoView
1231
+ });
1232
+ await sleep(250);
1233
+ return {
1234
+ clicked: true,
1235
+ selector,
1236
+ node_id: nodeId,
1237
+ box,
1238
+ skipped_errors: errors
1239
+ };
1240
+ } catch (error) {
1241
+ errors.push({
1242
+ selector,
1243
+ node_id: nodeId,
1244
+ error: error?.message || String(error)
1245
+ });
1246
+ }
1247
+ }
1248
+ }
1249
+ if (!matched) {
1250
+ if (optional) return { clicked: false, reason: "not_found" };
1251
+ throw new Error(`Recruit search node was not found for selectors: ${selectors.join(", ")}`);
1252
+ }
1253
+ if (optional) {
1254
+ return {
1255
+ clicked: false,
1256
+ reason: "not_clickable",
1257
+ errors
1258
+ };
1259
+ }
1260
+ const detail = errors.map((item) => `${item.selector}#${item.node_id}: ${item.error}`).join("; ");
1261
+ throw new Error(`Recruit search nodes were found but none were clickable for selectors: ${selectors.join(", ")}; ${detail}`);
1262
+ }
1263
+
1264
+ async function dismissRecruitSearchOverlays(client, settleMs = 250) {
1265
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
1266
+ return {
1267
+ method: "Escape",
1268
+ skipped: true,
1269
+ reason: "dispatch_key_unavailable"
1270
+ };
1271
+ }
1272
+ await pressKey(client, "Escape", {
1273
+ code: "Escape",
1274
+ windowsVirtualKeyCode: 27,
1275
+ nativeVirtualKeyCode: 27
1276
+ });
1277
+ if (settleMs > 0) await sleep(settleMs);
1278
+ return {
1279
+ method: "Escape",
1280
+ settle_ms: settleMs
1281
+ };
1282
+ }
1283
+
1284
+ export async function getRecruitSearchCounts(client, frameNodeId) {
1285
+ return countSelectors(client, frameNodeId, {
1286
+ keyword_input: RECRUIT_SEARCH_SELECTORS.keywordInput.join(", "),
1287
+ search_button: RECRUIT_SEARCH_SELECTORS.searchButton.join(", "),
1288
+ degree_option: RECRUIT_SEARCH_SELECTORS.degreeOption.join(", "),
1289
+ school_item: RECRUIT_SEARCH_SELECTORS.schoolItem.join(", "),
1290
+ experience_option: RECRUIT_SEARCH_SELECTORS.experienceOption.join(", "),
1291
+ experience_custom: RECRUIT_SEARCH_SELECTORS.experienceCustom.join(", "),
1292
+ gender_dropdown: RECRUIT_SEARCH_SELECTORS.genderDropdown.join(", "),
1293
+ age_option: RECRUIT_SEARCH_SELECTORS.ageOption.join(", "),
1294
+ age_custom: RECRUIT_SEARCH_SELECTORS.ageCustom.join(", "),
1295
+ recent_viewed_label: RECRUIT_SEARCH_SELECTORS.recentViewedLabel.join(", "),
1296
+ candidate_card: RECRUIT_CARD_SELECTOR,
1297
+ no_data: RECRUIT_NO_DATA_SELECTORS.join(", ")
1298
+ });
1299
+ }
1300
+
1301
+ export async function waitForRecruitSearchControls(client, {
1302
+ timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
1303
+ intervalMs = 300
1304
+ } = {}) {
1305
+ const started = Date.now();
1306
+ let lastState = null;
1307
+ while (Date.now() - started <= timeoutMs) {
1308
+ const roots = await getRecruitRoots(client, { requireFrame: false });
1309
+ const frameNodeId = roots.iframe?.documentNodeId;
1310
+ if (frameNodeId) {
1311
+ const counts = await getRecruitSearchCounts(client, frameNodeId);
1312
+ lastState = {
1313
+ ok: counts.keyword_input > 0 && counts.search_button > 0,
1314
+ elapsed_ms: Date.now() - started,
1315
+ iframe_selector: roots.iframe.selector,
1316
+ iframe_document_node_id: frameNodeId,
1317
+ counts
1318
+ };
1319
+ if (lastState.ok) return lastState;
1320
+ }
1321
+ await sleep(intervalMs);
1322
+ }
1323
+ return {
1324
+ ok: false,
1325
+ elapsed_ms: Date.now() - started,
1326
+ ...(lastState || {})
1327
+ };
1328
+ }
1329
+
1330
+ async function settleRecruitSearchAfterReset(client, {
1331
+ timeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS,
1332
+ settleMs = 5000
1333
+ } = {}) {
1334
+ return waitForMiniFreshStartSettle(client, {
1335
+ domain: "search",
1336
+ timeoutMs,
1337
+ intervalMs: 500,
1338
+ settleMs: Math.max(0, Math.min(settleMs || 0, 5000)),
1339
+ readinessLabel: "search_controls_ready",
1340
+ checkReady: ({ remainingMs }) => waitForRecruitSearchControls(client, {
1341
+ timeoutMs: Math.min(Math.max(1, remainingMs), 1500),
1342
+ intervalMs: 300
1343
+ })
1344
+ });
424
1345
  }
425
1346
 
426
1347
  export async function resetRecruitSearchPage(client, {
@@ -534,15 +1455,102 @@ export async function setRecruitKeyword(client, frameNodeId, keyword) {
534
1455
  if (!normalizedKeyword) {
535
1456
  return { applied: false, reason: "empty_keyword" };
536
1457
  }
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);
1458
+ const attempts = [];
1459
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
1460
+ const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.keywordInput);
1461
+ await clearFocusedInput(client);
1462
+ await sleep(120);
1463
+ const textEntry = await insertText(client, normalizedKeyword);
1464
+ await sleep(350);
1465
+ const verification = await verifyRecruitKeywordInputValue(client, frameNodeId, normalizedKeyword, {
1466
+ settleMs: 350
1467
+ });
1468
+ attempts.push({
1469
+ attempt,
1470
+ input,
1471
+ text_entry: textEntry,
1472
+ verification
1473
+ });
1474
+ if (verification.verified !== false) {
1475
+ return {
1476
+ applied: true,
1477
+ keyword: normalizedKeyword,
1478
+ input,
1479
+ text_entry: textEntry,
1480
+ verification,
1481
+ attempts
1482
+ };
1483
+ }
1484
+ }
1485
+
1486
+ const last = attempts[attempts.length - 1]?.verification || {};
1487
+ throw new Error(`Recruit keyword input did not hold requested value: expected=${normalizedKeyword}; actual=${last.actual || "unknown"}`);
1488
+ }
1489
+
1490
+ export async function readRecruitKeywordInputValue(client, frameNodeId) {
1491
+ if (typeof client?.Accessibility?.getPartialAXTree !== "function") {
1492
+ return {
1493
+ available: false,
1494
+ reason: "accessibility_unavailable",
1495
+ value: null,
1496
+ normalized_value: ""
1497
+ };
1498
+ }
1499
+ for (const selector of RECRUIT_SEARCH_SELECTORS.keywordInput) {
1500
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, frameNodeId, selector));
1501
+ for (const nodeId of nodeIds) {
1502
+ const ax = await client.Accessibility.getPartialAXTree({
1503
+ nodeId,
1504
+ fetchRelatives: false
1505
+ });
1506
+ const node = ax?.nodes?.[0] || null;
1507
+ if (!node) continue;
1508
+ const value = typeof node?.value?.value === "string" ? node.value.value : "";
1509
+ return {
1510
+ available: true,
1511
+ selector,
1512
+ node_id: nodeId,
1513
+ value,
1514
+ normalized_value: normalizeText(value),
1515
+ role: node?.role?.value || ""
1516
+ };
1517
+ }
1518
+ }
542
1519
  return {
543
- applied: true,
544
- keyword: normalizedKeyword,
545
- input
1520
+ available: false,
1521
+ reason: "keyword_input_not_found",
1522
+ value: null,
1523
+ normalized_value: ""
1524
+ };
1525
+ }
1526
+
1527
+ export async function verifyRecruitKeywordInputValue(client, frameNodeId, expectedKeyword, {
1528
+ settleMs = 0
1529
+ } = {}) {
1530
+ const expected = normalizeText(expectedKeyword);
1531
+ const before = await readRecruitKeywordInputValue(client, frameNodeId);
1532
+ if (!before.available || !expected) {
1533
+ return {
1534
+ verified: null,
1535
+ reason: before.reason || "empty_expected_keyword",
1536
+ expected,
1537
+ actual: before.normalized_value || "",
1538
+ before,
1539
+ after: before
1540
+ };
1541
+ }
1542
+ let after = before;
1543
+ if (settleMs > 0) {
1544
+ await sleep(settleMs);
1545
+ after = await readRecruitKeywordInputValue(client, frameNodeId);
1546
+ }
1547
+ const actual = normalizeText(after.normalized_value || after.value);
1548
+ return {
1549
+ verified: actual === expected,
1550
+ expected,
1551
+ actual,
1552
+ before,
1553
+ after
546
1554
  };
547
1555
  }
548
1556
 
@@ -559,198 +1567,801 @@ export async function setRecruitJobTitle(client, frameNodeId, jobTitle, {
559
1567
  if (!normalizedJobTitle) {
560
1568
  return { applied: false, reason: "empty_job_title" };
561
1569
  }
562
- const lookup = await waitForRecruitJobTitleCandidate(
1570
+ const opened = await openRecruitJobTitleDropdown(client, frameNodeId, {
1571
+ timeoutMs: Math.min(optionTimeoutMs, 30000)
1572
+ });
1573
+ const options = opened.options.length
1574
+ ? opened.options
1575
+ : await listRecruitJobTitleOptions(client, frameNodeId);
1576
+ const terms = buildRecruitJobTitleSearchTerms(normalizedJobTitle);
1577
+ let match = null;
1578
+ let matchedTerm = "";
1579
+ const visibleOptions = options.filter((option) => option.visible);
1580
+ const hiddenMatches = [];
1581
+ for (const term of terms) {
1582
+ match = chooseRecruitTextCandidate(visibleOptions, { label: term, match: "contains" });
1583
+ if (match) {
1584
+ matchedTerm = term;
1585
+ break;
1586
+ }
1587
+ const hiddenMatch = chooseRecruitTextCandidate(
1588
+ options.filter((option) => !option.visible),
1589
+ { label: term, match: "contains" }
1590
+ );
1591
+ if (hiddenMatch) hiddenMatches.push(hiddenMatch);
1592
+ }
1593
+ if (!match) {
1594
+ await closeRecruitJobTitleDropdown(client);
1595
+ if (hiddenMatches.length) {
1596
+ const error = new Error(`Matched recruit job has no visible clickable option: ${hiddenMatches[0].text}`);
1597
+ error.hidden_job_matches = hiddenMatches.map(compactRecruitTextCandidate);
1598
+ throw error;
1599
+ }
1600
+ throw new Error(`Recruit job title option was not found: ${normalizedJobTitle}`);
1601
+ }
1602
+ let box = null;
1603
+ if (!match.active) {
1604
+ if (!match.center) {
1605
+ await closeRecruitJobTitleDropdown(client);
1606
+ throw new Error(`Matched recruit job has no clickable center: ${match.text}`);
1607
+ }
1608
+ box = await clickNodeCenter(client, match.node_id, {
1609
+ ...DETERMINISTIC_CLICK_OPTIONS,
1610
+ scrollIntoView: true
1611
+ });
1612
+ await sleep(500);
1613
+ }
1614
+ const stickyVerification = await verifyRecruitJobTitleSelection(client, frameNodeId, {
1615
+ jobTitle: normalizedJobTitle,
1616
+ delayMs: 1200,
1617
+ dropdownTimeoutMs: Math.min(optionTimeoutMs, 5000)
1618
+ });
1619
+ if (!stickyVerification.verified) {
1620
+ throw new Error(`Recruit job selection was not sticky after 1.2s: requested=${normalizedJobTitle}; current=${stickyVerification.current_label || "unknown"}`);
1621
+ }
1622
+ if (stickyVerification.menu_close && stickyVerification.menu_close.ok === false) {
1623
+ throw new Error(`Recruit job dropdown remained open after sticky verification: ${stickyVerification.menu_close.reason || "unknown"}`);
1624
+ }
1625
+ return {
1626
+ applied: true,
1627
+ requested_job: normalizedJobTitle,
1628
+ selected_label: match.text,
1629
+ matched_term: matchedTerm,
1630
+ search_terms: terms,
1631
+ selected_node_id: match.node_id,
1632
+ was_active: match.active,
1633
+ clicked: !match.active,
1634
+ box,
1635
+ opened_dropdown: {
1636
+ already_open: Boolean(opened.already_open),
1637
+ visible_option_count: visibleOptions.length,
1638
+ attempts: opened.attempts || []
1639
+ },
1640
+ sticky_verification: stickyVerification,
1641
+ discovered_options: options.map(compactRecruitTextCandidate).slice(0, 30)
1642
+ };
1643
+ }
1644
+
1645
+ export async function setRecruitDegree(client, frameNodeId, degree) {
1646
+ const degreeLabel = resolveRecruitDegreeLabel(degree);
1647
+ if (!degreeLabel || degreeLabel === "不限") {
1648
+ return { applied: false, reason: "unlimited_degree", degree: degreeLabel || "不限" };
1649
+ }
1650
+ const { candidate, candidates } = await findTextCandidate(
563
1651
  client,
564
1652
  frameNodeId,
565
- RECRUIT_SEARCH_SELECTORS.jobTitleOption,
566
- normalizedJobTitle,
567
- { timeoutMs: Math.min(optionTimeoutMs, 30000) }
1653
+ RECRUIT_SEARCH_SELECTORS.degreeOption,
1654
+ degreeLabel,
1655
+ { match: "prefix" }
568
1656
  );
569
- if (!lookup.candidate) {
570
- throw new Error(`Recruit job title option was not found: ${normalizedJobTitle}`);
1657
+ if (!candidate) {
1658
+ throw new Error(`Recruit degree option was not found: ${degreeLabel}`);
571
1659
  }
572
- let box = null;
573
- if (!lookup.candidate.active) {
574
- box = await clickNodeCenter(client, lookup.candidate.node_id, {
1660
+ const box = await clickNodeCenter(client, candidate.node_id, {
1661
+ ...DETERMINISTIC_CLICK_OPTIONS,
1662
+ scrollIntoView: true
1663
+ });
1664
+ await sleep(350);
1665
+ return {
1666
+ applied: true,
1667
+ requested_degree: degree,
1668
+ selected_label: candidate.text,
1669
+ selected_node_id: candidate.node_id,
1670
+ was_active: candidate.active,
1671
+ box,
1672
+ discovered_options: candidates.map((item) => ({
1673
+ label: item.text,
1674
+ active: item.active,
1675
+ node_id: item.node_id,
1676
+ selector: item.selector
1677
+ }))
1678
+ };
1679
+ }
1680
+
1681
+ export async function setRecruitDegrees(client, frameNodeId, degrees = []) {
1682
+ const labels = normalizeRecruitDegreeLabels(degrees).filter((label) => label && label !== "不限");
1683
+ if (!labels.length) {
1684
+ return { applied: false, reason: "unlimited_degree", degrees: ["不限"], selected: [] };
1685
+ }
1686
+
1687
+ const selected = [];
1688
+ let discoveredOptions = [];
1689
+ for (const label of labels) {
1690
+ const { candidate, candidates } = await findTextCandidate(
1691
+ client,
1692
+ frameNodeId,
1693
+ RECRUIT_SEARCH_SELECTORS.degreeOption,
1694
+ label,
1695
+ { match: "prefix" }
1696
+ );
1697
+ discoveredOptions = candidates.map((item) => ({
1698
+ label: item.text,
1699
+ active: item.active,
1700
+ node_id: item.node_id,
1701
+ selector: item.selector
1702
+ }));
1703
+ if (!candidate) {
1704
+ throw new Error(`Recruit degree option was not found: ${label}`);
1705
+ }
1706
+
1707
+ let box = null;
1708
+ if (!candidate.active) {
1709
+ box = await clickNodeCenter(client, candidate.node_id, {
1710
+ ...DETERMINISTIC_CLICK_OPTIONS,
1711
+ scrollIntoView: true
1712
+ });
1713
+ await sleep(350);
1714
+ }
1715
+ selected.push({
1716
+ requested_degree: label,
1717
+ selected_label: candidate.text,
1718
+ selected_node_id: candidate.node_id,
1719
+ was_active: candidate.active,
1720
+ clicked: !candidate.active,
1721
+ box
1722
+ });
1723
+ }
1724
+
1725
+ return {
1726
+ applied: true,
1727
+ requested_degrees: labels,
1728
+ selected,
1729
+ discovered_options: discoveredOptions
1730
+ };
1731
+ }
1732
+
1733
+ async function findClickableDescendant(client, nodeId, selectors) {
1734
+ for (const selector of selectors) {
1735
+ const childNodeId = await querySelector(client, nodeId, selector);
1736
+ if (childNodeId) return { node_id: childNodeId, selector };
1737
+ }
1738
+ return { node_id: nodeId, selector: null };
1739
+ }
1740
+
1741
+ export async function setRecruitSchools(client, frameNodeId, schools = []) {
1742
+ const targets = normalizeRecruitSchoolList(schools);
1743
+ const applied = [];
1744
+ const missing = [];
1745
+ if (!targets.length) {
1746
+ return { applied: false, schools: [], selected: [], missing: [] };
1747
+ }
1748
+
1749
+ for (const school of targets) {
1750
+ const candidates = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.schoolItem);
1751
+ const candidate = chooseRecruitSchoolCandidate(candidates, school);
1752
+ if (!candidate) {
1753
+ missing.push({
1754
+ school,
1755
+ exact_labels: buildRecruitSchoolSearchLabels(school),
1756
+ discovered: candidates.map((item) => item.text).slice(0, 20)
1757
+ });
1758
+ continue;
1759
+ }
1760
+
1761
+ const clickable = await findClickableDescendant(client, candidate.node_id, RECRUIT_SEARCH_SELECTORS.schoolClickable);
1762
+ let clickableActive = candidate.active;
1763
+ if (clickable.node_id !== candidate.node_id) {
1764
+ const clickableCandidate = await readTextCandidate(client, clickable.node_id, {
1765
+ selector: clickable.selector || "",
1766
+ index: 0
1767
+ });
1768
+ clickableActive = clickableActive || clickableCandidate.active;
1769
+ }
1770
+
1771
+ let box = null;
1772
+ if (!clickableActive) {
1773
+ box = await clickNodeCenter(client, clickable.node_id, {
1774
+ ...DETERMINISTIC_CLICK_OPTIONS,
1775
+ scrollIntoView: true
1776
+ });
1777
+ await sleep(350);
1778
+ }
1779
+
1780
+ applied.push({
1781
+ school,
1782
+ exact_labels: buildRecruitSchoolSearchLabels(school),
1783
+ selected_label: candidate.text,
1784
+ selected_node_id: candidate.node_id,
1785
+ clickable_node_id: clickable.node_id,
1786
+ clickable_selector: clickable.selector,
1787
+ was_active: clickableActive,
1788
+ clicked: !clickableActive,
1789
+ box
1790
+ });
1791
+ }
1792
+
1793
+ if (missing.length) {
1794
+ throw new Error(`Recruit school options were not found: ${missing.map((item) => item.school).join(", ")}`);
1795
+ }
1796
+
1797
+ return {
1798
+ applied: true,
1799
+ schools: targets,
1800
+ selected: applied,
1801
+ missing
1802
+ };
1803
+ }
1804
+
1805
+ async function findFirstRecruitSearchNode(client, rootNodeId, selectors = []) {
1806
+ const errors = [];
1807
+ for (const selector of selectors) {
1808
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, rootNodeId, selector));
1809
+ for (const nodeId of nodeIds) {
1810
+ try {
1811
+ const box = await getNodeBox(client, nodeId);
1812
+ if (!isVisibleBox(box)) continue;
1813
+ return { node_id: nodeId, selector, box };
1814
+ } catch (error) {
1815
+ errors.push({
1816
+ selector,
1817
+ node_id: nodeId,
1818
+ error: error?.message || String(error)
1819
+ });
1820
+ }
1821
+ }
1822
+ }
1823
+ return { node_id: 0, selector: "", box: null, errors };
1824
+ }
1825
+
1826
+ function parseRecruitExperienceHiddenValue(rawValue) {
1827
+ const [startRaw, endRaw] = String(rawValue || "").split(",");
1828
+ const startValue = Number.parseInt(startRaw, 10);
1829
+ const endValue = Number.parseInt(endRaw, 10);
1830
+ return {
1831
+ raw_value: String(rawValue || ""),
1832
+ start_value: Number.isFinite(startValue) ? startValue : null,
1833
+ end_value: Number.isFinite(endValue) ? endValue : null
1834
+ };
1835
+ }
1836
+
1837
+ async function readRecruitExperienceCustomState(client, frameNodeId) {
1838
+ let hidden = null;
1839
+ for (const selector of RECRUIT_SEARCH_SELECTORS.experienceCustomHiddenInput) {
1840
+ const nodeId = await querySelector(client, frameNodeId, selector);
1841
+ if (!nodeId) continue;
1842
+ const attributes = await getAttributesMap(client, nodeId);
1843
+ hidden = {
1844
+ node_id: nodeId,
1845
+ selector,
1846
+ value: attributes.value || ""
1847
+ };
1848
+ break;
1849
+ }
1850
+ const parsedHidden = parseRecruitExperienceHiddenValue(hidden?.value || "");
1851
+ const handleNodes = [];
1852
+ for (const selector of RECRUIT_SEARCH_SELECTORS.experienceCustomSliderHandle) {
1853
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, frameNodeId, selector));
1854
+ for (const nodeId of nodeIds) {
1855
+ if (handleNodes.some((item) => item.node_id === nodeId)) continue;
1856
+ try {
1857
+ const box = await getNodeBox(client, nodeId);
1858
+ if (!isVisibleBox(box)) continue;
1859
+ handleNodes.push({ node_id: nodeId, selector, box });
1860
+ } catch {
1861
+ // Ignore invisible handles; missing handles are reported by the caller.
1862
+ }
1863
+ }
1864
+ }
1865
+ handleNodes.sort((left, right) => left.box.center.x - right.box.center.x);
1866
+ return {
1867
+ hidden,
1868
+ ...parsedHidden,
1869
+ handles: handleNodes.map((item, index) => ({
1870
+ index,
1871
+ node_id: item.node_id,
1872
+ selector: item.selector,
1873
+ center: item.box.center,
1874
+ rect: item.box.rect
1875
+ }))
1876
+ };
1877
+ }
1878
+
1879
+ async function readRecruitExperienceFixedOptionState(client, frameNodeId) {
1880
+ const candidates = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.experienceOption);
1881
+ return {
1882
+ active_labels: candidates.filter((item) => item.active).map((item) => item.text),
1883
+ options: summarizeTextCandidates(candidates, 20)
1884
+ };
1885
+ }
1886
+
1887
+ async function dragRecruitExperienceSliderHandle(client, frameNodeId, {
1888
+ handleIndex,
1889
+ targetValue
1890
+ }) {
1891
+ const state = await readRecruitExperienceCustomState(client, frameNodeId);
1892
+ const handle = state.handles[handleIndex];
1893
+ if (!handle) {
1894
+ throw new Error(`Recruit experience custom slider handle was not found: index=${handleIndex}`);
1895
+ }
1896
+ const slider = await findFirstRecruitSearchNode(
1897
+ client,
1898
+ frameNodeId,
1899
+ RECRUIT_SEARCH_SELECTORS.experienceCustomSlider
1900
+ );
1901
+ let trackRect = slider.box?.rect || null;
1902
+ if (!trackRect && state.handles.length >= 2 && state.start_value !== null && state.end_value !== null && state.end_value !== state.start_value) {
1903
+ const leftHandle = state.handles[0];
1904
+ const rightHandle = state.handles[state.handles.length - 1];
1905
+ const valueSpan = state.end_value - state.start_value;
1906
+ const fullValueSpan = EXPERIENCE_CUSTOM_MAX_VALUE - EXPERIENCE_CUSTOM_MIN_VALUE;
1907
+ const inferredWidth = Math.abs(rightHandle.center.x - leftHandle.center.x) * (fullValueSpan / valueSpan);
1908
+ const inferredX = leftHandle.center.x - inferredWidth * ((state.start_value - EXPERIENCE_CUSTOM_MIN_VALUE) / fullValueSpan);
1909
+ trackRect = {
1910
+ x: inferredX,
1911
+ y: Math.min(leftHandle.rect.y, rightHandle.rect.y),
1912
+ width: inferredWidth,
1913
+ height: Math.max(leftHandle.rect.height, rightHandle.rect.height)
1914
+ };
1915
+ }
1916
+ if (!trackRect) {
1917
+ throw new Error("Recruit experience custom slider was not found");
1918
+ }
1919
+ const percent = (targetValue - EXPERIENCE_CUSTOM_MIN_VALUE)
1920
+ / (EXPERIENCE_CUSTOM_MAX_VALUE - EXPERIENCE_CUSTOM_MIN_VALUE);
1921
+ let targetX = trackRect.x + trackRect.width * Math.min(1, Math.max(0, percent));
1922
+ const endpointOvershootPx = Math.min(24, Math.max(8, trackRect.width * 0.04));
1923
+ const valueStepPx = trackRect.width / (EXPERIENCE_CUSTOM_MAX_VALUE - EXPERIENCE_CUSTOM_MIN_VALUE);
1924
+ if (targetValue > EXPERIENCE_CUSTOM_MIN_VALUE && targetValue < EXPERIENCE_CUSTOM_MAX_VALUE) {
1925
+ targetX += valueStepPx * 0.45;
1926
+ }
1927
+ if (targetValue === EXPERIENCE_CUSTOM_MIN_VALUE) targetX -= endpointOvershootPx;
1928
+ if (targetValue === EXPERIENCE_CUSTOM_MAX_VALUE) targetX += endpointOvershootPx;
1929
+ const targetY = handle.center.y || (trackRect.y + trackRect.height / 2);
1930
+ const startX = handle.center.x;
1931
+ const startY = handle.center.y;
1932
+ const steps = 8;
1933
+ await client.Input.dispatchMouseEvent({ type: "mouseMoved", x: startX, y: startY, button: "none" });
1934
+ await client.Input.dispatchMouseEvent({ type: "mousePressed", x: startX, y: startY, button: "left", clickCount: 1 });
1935
+ for (let step = 1; step <= steps; step += 1) {
1936
+ const ratio = step / steps;
1937
+ await client.Input.dispatchMouseEvent({
1938
+ type: "mouseMoved",
1939
+ x: startX + (targetX - startX) * ratio,
1940
+ y: startY + (targetY - startY) * ratio,
1941
+ button: "left"
1942
+ });
1943
+ await sleep(30);
1944
+ }
1945
+ await client.Input.dispatchMouseEvent({ type: "mouseReleased", x: targetX, y: targetY, button: "left", clickCount: 1 });
1946
+ await sleep(350);
1947
+ return {
1948
+ handle_index: handleIndex,
1949
+ target_value: targetValue,
1950
+ target_label: EXPERIENCE_CUSTOM_LABELS_BY_VALUE.get(targetValue) || String(targetValue),
1951
+ slider_node_id: slider.node_id || null,
1952
+ handle_node_id: handle.node_id,
1953
+ start: { x: startX, y: startY },
1954
+ target: { x: targetX, y: targetY },
1955
+ track: trackRect,
1956
+ inferred_track: !slider.box
1957
+ };
1958
+ }
1959
+
1960
+ async function nudgeRecruitExperienceCustomSelection(client, frameNodeId, filter) {
1961
+ if (filter.end_value > filter.start_value) {
1962
+ const intermediate = filter.end_value === EXPERIENCE_CUSTOM_MAX_VALUE
1963
+ ? filter.end_value - 1
1964
+ : filter.end_value + 1;
1965
+ return [
1966
+ await dragRecruitExperienceSliderHandle(client, frameNodeId, {
1967
+ handleIndex: 1,
1968
+ targetValue: intermediate
1969
+ }),
1970
+ await dragRecruitExperienceSliderHandle(client, frameNodeId, {
1971
+ handleIndex: 1,
1972
+ targetValue: filter.end_value
1973
+ })
1974
+ ];
1975
+ }
1976
+ const intermediate = filter.start_value === EXPERIENCE_CUSTOM_MIN_VALUE
1977
+ ? filter.start_value + 1
1978
+ : filter.start_value - 1;
1979
+ return [
1980
+ await dragRecruitExperienceSliderHandle(client, frameNodeId, {
1981
+ handleIndex: 0,
1982
+ targetValue: intermediate
1983
+ }),
1984
+ await dragRecruitExperienceSliderHandle(client, frameNodeId, {
1985
+ handleIndex: 0,
1986
+ targetValue: filter.start_value
1987
+ })
1988
+ ];
1989
+ }
1990
+
1991
+ export async function setRecruitExperience(client, frameNodeId, experience) {
1992
+ const filter = normalizeRecruitExperienceFilter(experience);
1993
+ if (!filter) {
1994
+ return { applied: false, reason: "not_requested" };
1995
+ }
1996
+
1997
+ if (filter.mode === "option") {
1998
+ const { candidate, candidates } = await findTextCandidate(
1999
+ client,
2000
+ frameNodeId,
2001
+ RECRUIT_SEARCH_SELECTORS.experienceOption,
2002
+ filter.label,
2003
+ { match: "exact" }
2004
+ );
2005
+ if (!candidate) {
2006
+ throw new Error(`Recruit experience option was not found: ${filter.label}`);
2007
+ }
2008
+ let box = null;
2009
+ if (!candidate.active) {
2010
+ box = await clickNodeCenter(client, candidate.node_id, {
2011
+ ...DETERMINISTIC_CLICK_OPTIONS,
2012
+ scrollIntoView: true
2013
+ });
2014
+ await sleep(500);
2015
+ }
2016
+ return {
2017
+ applied: true,
2018
+ mode: "option",
2019
+ requested_experience: experience,
2020
+ selected_label: candidate.text,
2021
+ selected_node_id: candidate.node_id,
2022
+ was_active: candidate.active,
2023
+ clicked: !candidate.active,
2024
+ box,
2025
+ discovered_options: summarizeTextCandidates(candidates, 20)
2026
+ };
2027
+ }
2028
+
2029
+ const customClick = await clickFirstNodeBySelectors(
2030
+ client,
2031
+ frameNodeId,
2032
+ RECRUIT_SEARCH_SELECTORS.experienceCustom,
2033
+ { optional: false, scrollIntoView: true }
2034
+ );
2035
+ const before = await readRecruitExperienceCustomState(client, frameNodeId);
2036
+ const fixedOptionsBefore = await readRecruitExperienceFixedOptionState(client, frameNodeId);
2037
+ const drags = [];
2038
+ if (before.start_value !== filter.start_value) {
2039
+ drags.push(await dragRecruitExperienceSliderHandle(client, frameNodeId, {
2040
+ handleIndex: 0,
2041
+ targetValue: filter.start_value
2042
+ }));
2043
+ }
2044
+ const afterStart = await readRecruitExperienceCustomState(client, frameNodeId);
2045
+ if (afterStart.end_value !== filter.end_value) {
2046
+ drags.push(await dragRecruitExperienceSliderHandle(client, frameNodeId, {
2047
+ handleIndex: 1,
2048
+ targetValue: filter.end_value
2049
+ }));
2050
+ }
2051
+ if (!drags.length && fixedOptionsBefore.active_labels.length) {
2052
+ drags.push(...await nudgeRecruitExperienceCustomSelection(client, frameNodeId, filter));
2053
+ }
2054
+ const after = await readRecruitExperienceCustomState(client, frameNodeId);
2055
+ const fixedOptionsAfter = await readRecruitExperienceFixedOptionState(client, frameNodeId);
2056
+ const verified = after.start_value === filter.start_value && after.end_value === filter.end_value;
2057
+ if (!verified) {
2058
+ throw new Error(
2059
+ `Recruit experience custom range did not stick: requested=${filter.start_value},${filter.end_value}; actual=${after.raw_value || "unknown"}`
2060
+ );
2061
+ }
2062
+ if (fixedOptionsAfter.active_labels.length) {
2063
+ throw new Error(
2064
+ `Recruit experience custom range still has fixed option active: ${fixedOptionsAfter.active_labels.join(", ")}`
2065
+ );
2066
+ }
2067
+ return {
2068
+ applied: true,
2069
+ mode: "custom",
2070
+ requested_experience: experience,
2071
+ requested_range: {
2072
+ start_label: filter.start_label,
2073
+ end_label: filter.end_label,
2074
+ start_value: filter.start_value,
2075
+ end_value: filter.end_value
2076
+ },
2077
+ custom_click: customClick,
2078
+ fixed_options_before: fixedOptionsBefore,
2079
+ before,
2080
+ after,
2081
+ fixed_options_after: fixedOptionsAfter,
2082
+ drags,
2083
+ verification: {
2084
+ verified,
2085
+ fixed_option_cleared: fixedOptionsAfter.active_labels.length === 0,
2086
+ expected: `${filter.start_value},${filter.end_value}`,
2087
+ actual: after.raw_value
2088
+ }
2089
+ };
2090
+ }
2091
+
2092
+ async function findRecruitGenderDropdown(client, frameNodeId) {
2093
+ const candidates = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.genderDropdown, {
2094
+ includeBox: true
2095
+ });
2096
+ const visible = candidates
2097
+ .filter((item) => item.visible && item.rect)
2098
+ .sort((left, right) => left.rect.x - right.rect.x);
2099
+ return {
2100
+ candidate: visible[0] || null,
2101
+ candidates
2102
+ };
2103
+ }
2104
+
2105
+ async function readRecruitGenderState(client, frameNodeId) {
2106
+ const { candidate, candidates } = await findRecruitGenderDropdown(client, frameNodeId);
2107
+ if (!candidate) {
2108
+ return {
2109
+ available: false,
2110
+ discovered: summarizeTextCandidates(candidates, 10)
2111
+ };
2112
+ }
2113
+ const hiddenNodeId = await querySelector(client, candidate.node_id, "input[type='hidden']");
2114
+ const hidden = hiddenNodeId ? await getAttributesMap(client, hiddenNodeId) : {};
2115
+ const hiddenSelectedLabel = hidden.value === "-1" ? "不限" : "";
2116
+ return {
2117
+ available: true,
2118
+ selected_label: hiddenSelectedLabel || normalizeText(candidate.text),
2119
+ selected_node_id: candidate.node_id,
2120
+ hidden_value: hidden.value || "",
2121
+ discovered: summarizeTextCandidates(candidates, 10)
2122
+ };
2123
+ }
2124
+
2125
+ export async function setRecruitGender(client, frameNodeId, gender) {
2126
+ const filter = normalizeRecruitGenderFilter(gender);
2127
+ if (!filter) {
2128
+ return { applied: false, reason: "not_requested" };
2129
+ }
2130
+ const beforeRoot = await findRecruitGenderDropdown(client, frameNodeId);
2131
+ if (!beforeRoot.candidate) {
2132
+ throw new Error("Recruit gender dropdown was not found");
2133
+ }
2134
+ const openBox = await clickNodeCenter(client, beforeRoot.candidate.node_id, {
2135
+ ...DETERMINISTIC_CLICK_OPTIONS,
2136
+ scrollIntoView: true
2137
+ });
2138
+ await sleep(350);
2139
+ const rootAfterOpen = await findRecruitGenderDropdown(client, frameNodeId);
2140
+ const rootNodeId = rootAfterOpen.candidate?.node_id || beforeRoot.candidate.node_id;
2141
+ const options = await listTextCandidates(client, rootNodeId, ["li"], { includeBox: true });
2142
+ const option = chooseRecruitTextCandidate(options, { label: filter.label, match: "exact" });
2143
+ if (!option) {
2144
+ throw new Error(`Recruit gender option was not found: ${filter.label}`);
2145
+ }
2146
+ let selectBox = null;
2147
+ if (!option.active) {
2148
+ selectBox = await clickNodeCenter(client, option.node_id, {
575
2149
  ...DETERMINISTIC_CLICK_OPTIONS,
576
2150
  scrollIntoView: true
577
2151
  });
578
- await sleep(500);
2152
+ await sleep(600);
2153
+ } else {
2154
+ await pressKey(client, "Escape", {
2155
+ code: "Escape",
2156
+ windowsVirtualKeyCode: 27,
2157
+ nativeVirtualKeyCode: 27
2158
+ });
2159
+ await sleep(250);
2160
+ }
2161
+ const after = await readRecruitGenderState(client, frameNodeId);
2162
+ const verified = after.selected_label === filter.label
2163
+ || (filter.label === "不限" && /^(?:性别|不限)$/.test(after.selected_label));
2164
+ if (!verified) {
2165
+ throw new Error(`Recruit gender selection did not stick: requested=${filter.label}; actual=${after.selected_label || "unknown"}`);
579
2166
  }
580
2167
  return {
581
2168
  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)
2169
+ requested_gender: gender,
2170
+ selected_label: filter.label,
2171
+ opened_dropdown: {
2172
+ node_id: beforeRoot.candidate.node_id,
2173
+ box: openBox
2174
+ },
2175
+ selected_node_id: option.node_id,
2176
+ option_was_active: option.active,
2177
+ clicked: !option.active,
2178
+ box: selectBox,
2179
+ verification: {
2180
+ verified,
2181
+ selected_label: after.selected_label,
2182
+ hidden_value: after.hidden_value
2183
+ },
2184
+ discovered_options: summarizeTextCandidates(options, 10)
591
2185
  };
592
2186
  }
593
2187
 
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 || "不限" };
2188
+ async function readRecruitAgeCustomState(client, frameNodeId) {
2189
+ const inputNodeIds = uniqueNodeIds(await querySelectorAll(
2190
+ client,
2191
+ frameNodeId,
2192
+ RECRUIT_SEARCH_SELECTORS.ageCustomInput.join(", ")
2193
+ ));
2194
+ const inputs = [];
2195
+ for (const nodeId of inputNodeIds) {
2196
+ const attributes = await getAttributesMap(client, nodeId);
2197
+ let box = null;
2198
+ try {
2199
+ box = await getNodeBox(client, nodeId);
2200
+ } catch {}
2201
+ inputs.push({
2202
+ node_id: nodeId,
2203
+ type: attributes.type || "",
2204
+ value: attributes.value || "",
2205
+ placeholder: attributes.placeholder || "",
2206
+ visible: isVisibleBox(box),
2207
+ rect: box?.rect || null
2208
+ });
598
2209
  }
599
- const { candidate, candidates } = await findTextCandidate(
2210
+ const hidden = inputs.filter((item) => item.type === "hidden");
2211
+ return {
2212
+ inputs,
2213
+ min: parseRecruitAgeCustomHiddenValue(hidden[0]?.value),
2214
+ max: parseRecruitAgeCustomHiddenValue(hidden[1]?.value),
2215
+ raw_values: hidden.map((item) => item.value)
2216
+ };
2217
+ }
2218
+
2219
+ async function readRecruitAgeFixedOptionState(client, frameNodeId) {
2220
+ const candidates = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.ageOption);
2221
+ return {
2222
+ active_labels: candidates.filter((item) => item.active).map((item) => item.text),
2223
+ options: summarizeTextCandidates(candidates, 20)
2224
+ };
2225
+ }
2226
+
2227
+ function ageCustomOptionLabel(value) {
2228
+ if (value === null || value === undefined) return "不限";
2229
+ return `${value}岁`;
2230
+ }
2231
+
2232
+ function parseRecruitAgeCustomHiddenValue(value) {
2233
+ const text = normalizeText(value);
2234
+ if (!text || text === "0" || text === "-1") return null;
2235
+ return parseAgeNumber(text, null);
2236
+ }
2237
+
2238
+ async function selectRecruitAgeCustomDropdownValue(client, frameNodeId, {
2239
+ dropdownIndex,
2240
+ value
2241
+ }) {
2242
+ const dropdownNodeIds = uniqueNodeIds(await querySelectorAll(
600
2243
  client,
601
2244
  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}`);
2245
+ RECRUIT_SEARCH_SELECTORS.ageCustomDropdown.join(", ")
2246
+ ));
2247
+ const dropdownNodeId = dropdownNodeIds[dropdownIndex];
2248
+ if (!dropdownNodeId) {
2249
+ throw new Error(`Recruit age custom dropdown was not found: index=${dropdownIndex}`);
608
2250
  }
609
- const box = await clickNodeCenter(client, candidate.node_id, {
2251
+ const openBox = await clickNodeCenter(client, dropdownNodeId, {
610
2252
  ...DETERMINISTIC_CLICK_OPTIONS,
611
2253
  scrollIntoView: true
612
2254
  });
613
2255
  await sleep(350);
2256
+ const label = ageCustomOptionLabel(value);
2257
+ const options = await listTextCandidates(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.ageCustomOption, {
2258
+ includeBox: true
2259
+ });
2260
+ const option = chooseRecruitTextCandidate(options, { label, match: "exact" });
2261
+ if (!option) {
2262
+ throw new Error(`Recruit age custom option was not found: ${label}`);
2263
+ }
2264
+ const box = await clickNodeCenter(client, option.node_id, {
2265
+ ...DETERMINISTIC_CLICK_OPTIONS,
2266
+ scrollIntoView: true
2267
+ });
2268
+ await sleep(600);
614
2269
  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,
2270
+ dropdown_index: dropdownIndex,
2271
+ requested_value: value,
2272
+ selected_label: option.text,
2273
+ dropdown_node_id: dropdownNodeId,
2274
+ option_node_id: option.node_id,
2275
+ open_box: openBox,
620
2276
  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
- }))
2277
+ discovered_options: summarizeTextCandidates(options, 40)
627
2278
  };
628
2279
  }
629
2280
 
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: [] };
2281
+ export async function setRecruitAge(client, frameNodeId, age) {
2282
+ const filter = normalizeRecruitAgeFilter(age);
2283
+ if (!filter) {
2284
+ return { applied: false, reason: "not_requested" };
634
2285
  }
635
2286
 
636
- const selected = [];
637
- let discoveredOptions = [];
638
- for (const label of labels) {
2287
+ if (filter.mode === "option") {
639
2288
  const { candidate, candidates } = await findTextCandidate(
640
2289
  client,
641
2290
  frameNodeId,
642
- RECRUIT_SEARCH_SELECTORS.degreeOption,
643
- label,
644
- { match: "prefix" }
2291
+ RECRUIT_SEARCH_SELECTORS.ageOption,
2292
+ filter.label,
2293
+ { match: "exact" }
645
2294
  );
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
2295
  if (!candidate) {
653
- throw new Error(`Recruit degree option was not found: ${label}`);
2296
+ throw new Error(`Recruit age option was not found: ${filter.label}`);
654
2297
  }
655
-
656
2298
  let box = null;
657
2299
  if (!candidate.active) {
658
2300
  box = await clickNodeCenter(client, candidate.node_id, {
659
2301
  ...DETERMINISTIC_CLICK_OPTIONS,
660
2302
  scrollIntoView: true
661
2303
  });
662
- await sleep(350);
2304
+ await sleep(500);
663
2305
  }
664
- selected.push({
665
- requested_degree: label,
2306
+ return {
2307
+ applied: true,
2308
+ mode: "option",
2309
+ requested_age: age,
666
2310
  selected_label: candidate.text,
667
2311
  selected_node_id: candidate.node_id,
668
2312
  was_active: candidate.active,
669
2313
  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: [] };
2314
+ box,
2315
+ discovered_options: summarizeTextCandidates(candidates, 20)
2316
+ };
696
2317
  }
697
2318
 
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
- });
2319
+ const customClick = await clickFirstNodeBySelectors(
2320
+ client,
2321
+ frameNodeId,
2322
+ RECRUIT_SEARCH_SELECTORS.ageCustom,
2323
+ { optional: false, scrollIntoView: true }
2324
+ );
2325
+ const before = await readRecruitAgeCustomState(client, frameNodeId);
2326
+ const fixedBefore = await readRecruitAgeFixedOptionState(client, frameNodeId);
2327
+ const selected = [];
2328
+ selected.push(await selectRecruitAgeCustomDropdownValue(client, frameNodeId, {
2329
+ dropdownIndex: 0,
2330
+ value: filter.min
2331
+ }));
2332
+ selected.push(await selectRecruitAgeCustomDropdownValue(client, frameNodeId, {
2333
+ dropdownIndex: 1,
2334
+ value: filter.max
2335
+ }));
2336
+ const after = await readRecruitAgeCustomState(client, frameNodeId);
2337
+ const fixedAfter = await readRecruitAgeFixedOptionState(client, frameNodeId);
2338
+ const verified = after.min === filter.min && after.max === filter.max;
2339
+ if (!verified) {
2340
+ throw new Error(`Recruit age custom values did not stick: requested=${filter.min},${filter.max}; actual=${after.raw_values.join(",")}`);
743
2341
  }
744
-
745
- if (missing.length) {
746
- throw new Error(`Recruit school options were not found: ${missing.map((item) => item.school).join(", ")}`);
2342
+ if (fixedAfter.active_labels.length) {
2343
+ throw new Error(`Recruit age custom still has fixed option active: ${fixedAfter.active_labels.join(", ")}`);
747
2344
  }
748
-
749
2345
  return {
750
2346
  applied: true,
751
- schools: targets,
752
- selected: applied,
753
- missing
2347
+ mode: "custom",
2348
+ requested_age: age,
2349
+ requested_range: {
2350
+ min: filter.min,
2351
+ max: filter.max
2352
+ },
2353
+ custom_click: customClick,
2354
+ before,
2355
+ after,
2356
+ fixed_options_before: fixedBefore,
2357
+ fixed_options_after: fixedAfter,
2358
+ selected,
2359
+ verification: {
2360
+ verified,
2361
+ fixed_option_cleared: fixedAfter.active_labels.length === 0,
2362
+ expected: [filter.min, filter.max],
2363
+ actual: [after.min, after.max]
2364
+ }
754
2365
  };
755
2366
  }
756
2367
 
@@ -795,12 +2406,50 @@ export async function setRecruitRecentViewedFilter(client, frameNodeId, enabled)
795
2406
  };
796
2407
  }
797
2408
 
2409
+ async function openRecruitCityPicker(client, frameNodeId, {
2410
+ settleMs = 350
2411
+ } = {}) {
2412
+ const alreadyOpenInput = await clickFirstNodeBySelectors(
2413
+ client,
2414
+ frameNodeId,
2415
+ RECRUIT_SEARCH_SELECTORS.cityInput,
2416
+ { optional: true }
2417
+ );
2418
+ if (alreadyOpenInput.clicked) {
2419
+ return {
2420
+ opened: true,
2421
+ already_open: true,
2422
+ input: alreadyOpenInput,
2423
+ trigger: null
2424
+ };
2425
+ }
2426
+
2427
+ const trigger = await clickFirstNodeBySelectors(
2428
+ client,
2429
+ frameNodeId,
2430
+ RECRUIT_SEARCH_SELECTORS.cityTrigger,
2431
+ { scrollIntoView: false }
2432
+ );
2433
+ if (settleMs > 0) await sleep(settleMs);
2434
+ const input = await clickFirstNodeBySelectors(
2435
+ client,
2436
+ frameNodeId,
2437
+ RECRUIT_SEARCH_SELECTORS.cityInput
2438
+ );
2439
+ return {
2440
+ opened: true,
2441
+ already_open: false,
2442
+ trigger,
2443
+ input
2444
+ };
2445
+ }
2446
+
798
2447
  async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
799
2448
  requestedCity = "全国",
800
2449
  reason = "national_city_requested",
801
2450
  optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
802
2451
  } = {}) {
803
- const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
2452
+ const picker = await openRecruitCityPicker(client, frameNodeId);
804
2453
  await clearFocusedInput(client);
805
2454
  await sleep(500);
806
2455
 
@@ -854,7 +2503,8 @@ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
854
2503
  applied: false,
855
2504
  reason: "national_city_popular_not_found",
856
2505
  requested_city: requestedCity,
857
- input,
2506
+ input: picker.input,
2507
+ picker,
858
2508
  path,
859
2509
  discovered_options: summarizeTextCandidates(popularLookup.candidates)
860
2510
  };
@@ -892,7 +2542,8 @@ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
892
2542
  applied: false,
893
2543
  reason: "national_city_option_not_found",
894
2544
  requested_city: requestedCity,
895
- input,
2545
+ input: picker.input,
2546
+ picker,
896
2547
  path,
897
2548
  discovered_options: summarizeTextCandidates(nationalLookup.candidates)
898
2549
  };
@@ -917,7 +2568,8 @@ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
917
2568
  requested_city: requestedCity,
918
2569
  selected_label: nationalLookup.candidate.text,
919
2570
  selected_node_id: nationalLookup.candidate.node_id,
920
- input,
2571
+ input: picker.input,
2572
+ picker,
921
2573
  path,
922
2574
  box: nationalBox,
923
2575
  selection_mode: "city_picker",
@@ -972,7 +2624,7 @@ export async function setRecruitCity(client, frameNodeId, city, {
972
2624
  });
973
2625
  }
974
2626
 
975
- const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
2627
+ const picker = await openRecruitCityPicker(client, frameNodeId);
976
2628
  await clearFocusedInput(client);
977
2629
  await sleep(120);
978
2630
  await insertText(client, normalizedCity);
@@ -1016,7 +2668,8 @@ export async function setRecruitCity(client, frameNodeId, city, {
1016
2668
  requested_city: normalizedCity,
1017
2669
  requested_city_not_found: true,
1018
2670
  fallback_to_national: true,
1019
- original_input: input,
2671
+ original_input: picker.input,
2672
+ picker,
1020
2673
  elapsed_ms: Date.now() - started,
1021
2674
  discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
1022
2675
  };
@@ -1034,7 +2687,8 @@ export async function setRecruitCity(client, frameNodeId, city, {
1034
2687
  requested_city: normalizedCity,
1035
2688
  requested_city_not_found: true,
1036
2689
  fallback_to_national: true,
1037
- original_input: input,
2690
+ original_input: picker.input,
2691
+ picker,
1038
2692
  picker_fallback: nationalFallback,
1039
2693
  elapsed_ms: Date.now() - started,
1040
2694
  discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
@@ -1045,7 +2699,8 @@ export async function setRecruitCity(client, frameNodeId, city, {
1045
2699
  applied: false,
1046
2700
  reason: "city_result_not_found",
1047
2701
  city: normalizedCity,
1048
- input,
2702
+ input: picker.input,
2703
+ picker,
1049
2704
  elapsed_ms: Date.now() - started,
1050
2705
  discovered_options: candidates.map((item) => item.text).slice(0, 20),
1051
2706
  national_fallback: nationalFallback,
@@ -1063,7 +2718,8 @@ export async function setRecruitCity(client, frameNodeId, city, {
1063
2718
  city: normalizedCity,
1064
2719
  selected_label: candidate.text,
1065
2720
  selected_node_id: candidate.node_id,
1066
- input,
2721
+ input: picker.input,
2722
+ picker,
1067
2723
  elapsed_ms: Date.now() - started,
1068
2724
  box
1069
2725
  };
@@ -1095,6 +2751,86 @@ export async function clickRecruitSearch(client, frameNodeId) {
1095
2751
  };
1096
2752
  }
1097
2753
 
2754
+ export async function clickRecruitSearchWithKeywordGuard(client, frameNodeId, keyword, {
2755
+ maxAttempts = 2,
2756
+ postSearchSettleMs = 2200
2757
+ } = {}) {
2758
+ const normalizedKeyword = normalizeText(keyword);
2759
+ if (!normalizedKeyword) {
2760
+ return clickRecruitSearch(client, frameNodeId);
2761
+ }
2762
+
2763
+ const attempts = [];
2764
+ let currentFrameNodeId = frameNodeId;
2765
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
2766
+ let rootsBeforeAttempt = null;
2767
+ try {
2768
+ rootsBeforeAttempt = await getRecruitRoots(client, { requireFrame: false });
2769
+ if (rootsBeforeAttempt?.iframe?.documentNodeId) {
2770
+ currentFrameNodeId = rootsBeforeAttempt.iframe.documentNodeId;
2771
+ }
2772
+ } catch {}
2773
+ const before = await verifyRecruitKeywordInputValue(client, currentFrameNodeId, normalizedKeyword);
2774
+ let reapply = null;
2775
+ if (before.verified === false) {
2776
+ reapply = await setRecruitKeyword(client, currentFrameNodeId, normalizedKeyword);
2777
+ }
2778
+ const search = await clickRecruitSearch(client, currentFrameNodeId);
2779
+ let rootsAfterSearch = null;
2780
+ try {
2781
+ rootsAfterSearch = await getRecruitRoots(client, { requireFrame: false });
2782
+ if (rootsAfterSearch?.iframe?.documentNodeId) {
2783
+ currentFrameNodeId = rootsAfterSearch.iframe.documentNodeId;
2784
+ }
2785
+ } catch {}
2786
+ const after = await verifyRecruitKeywordInputValue(client, currentFrameNodeId, normalizedKeyword, {
2787
+ settleMs: postSearchSettleMs
2788
+ });
2789
+ attempts.push({
2790
+ attempt,
2791
+ before,
2792
+ reapply,
2793
+ search,
2794
+ after,
2795
+ frame_reacquired_before_attempt: rootsBeforeAttempt?.iframe?.documentNodeId
2796
+ ? {
2797
+ selector: rootsBeforeAttempt.iframe.selector,
2798
+ document_node_id: rootsBeforeAttempt.iframe.documentNodeId
2799
+ }
2800
+ : null,
2801
+ frame_reacquired: rootsAfterSearch?.iframe?.documentNodeId
2802
+ ? {
2803
+ selector: rootsAfterSearch.iframe.selector,
2804
+ document_node_id: rootsAfterSearch.iframe.documentNodeId
2805
+ }
2806
+ : null
2807
+ });
2808
+ if (after.verified !== false) {
2809
+ return {
2810
+ searched: true,
2811
+ mode: search.mode,
2812
+ search,
2813
+ keyword_guard: {
2814
+ verified: after.verified,
2815
+ expected: after.expected,
2816
+ actual: after.actual,
2817
+ attempts
2818
+ }
2819
+ };
2820
+ }
2821
+ }
2822
+
2823
+ const last = attempts[attempts.length - 1]?.after || {};
2824
+ const error = new Error(`Recruit keyword was not preserved after search: expected=${normalizedKeyword}; actual=${last.actual || "unknown"}`);
2825
+ error.keyword_guard = {
2826
+ verified: false,
2827
+ expected: normalizedKeyword,
2828
+ actual: last.actual || "",
2829
+ attempts
2830
+ };
2831
+ throw error;
2832
+ }
2833
+
1098
2834
  export async function waitForRecruitSearchResultState(client, {
1099
2835
  timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
1100
2836
  intervalMs = 500
@@ -1166,70 +2902,111 @@ export async function applyRecruitSearchParams(client, {
1166
2902
  const initialFrameNodeId = frameNodeId;
1167
2903
  const beforeCounts = await getRecruitSearchCounts(client, frameNodeId);
1168
2904
  const steps = [];
2905
+ const applicationStepNames = buildRecruitSearchApplicationStepNames(normalizedSearchParams);
1169
2906
 
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;
2907
+ for (const stepName of applicationStepNames) {
2908
+ if (stepName === "job_title") {
2909
+ steps.push({
2910
+ step: "job_title",
2911
+ result: await setRecruitJobTitle(client, frameNodeId, normalizedSearchParams.job, {
2912
+ optionTimeoutMs: searchTimeoutMs
2913
+ })
2914
+ });
2915
+ const rootsAfterJob = await getRecruitRoots(client);
2916
+ frameNodeId = rootsAfterJob.iframe.documentNodeId;
2917
+ steps.push({
2918
+ step: "reacquire_after_job",
2919
+ result: {
2920
+ selector: rootsAfterJob.iframe.selector,
2921
+ document_node_id: frameNodeId
2922
+ }
2923
+ });
2924
+ } else if (stepName === "city") {
2925
+ const cityResult = await setRecruitCity(client, frameNodeId, normalizedSearchParams.city, {
2926
+ optionTimeoutMs: cityOptionTimeoutMs
2927
+ });
2928
+ steps.push({
2929
+ step: "city",
2930
+ result: cityResult
2931
+ });
2932
+ if (cityResult?.reacquire_frame) {
2933
+ const rootsAfterCity = await getRecruitRoots(client);
2934
+ frameNodeId = rootsAfterCity.iframe.documentNodeId;
2935
+ steps.push({
2936
+ step: "reacquire_after_city",
2937
+ result: {
2938
+ selector: rootsAfterCity.iframe.selector,
2939
+ document_node_id: frameNodeId,
2940
+ reason: cityResult.reason
2941
+ }
2942
+ });
2943
+ }
2944
+ } else if (stepName === "degree") {
2945
+ steps.push({
2946
+ step: "degree",
2947
+ result: await setRecruitDegrees(client, frameNodeId, normalizedSearchParams.degrees)
2948
+ });
2949
+ } else if (stepName === "schools") {
2950
+ steps.push({
2951
+ step: "schools",
2952
+ result: await setRecruitSchools(client, frameNodeId, normalizedSearchParams.schools)
2953
+ });
2954
+ } else if (stepName === "experience") {
2955
+ steps.push({
2956
+ step: "experience",
2957
+ result: await setRecruitExperience(client, frameNodeId, normalizedSearchParams.experience)
2958
+ });
2959
+ } else if (stepName === "gender") {
2960
+ steps.push({
2961
+ step: "gender",
2962
+ result: await setRecruitGender(client, frameNodeId, normalizedSearchParams.gender)
2963
+ });
2964
+ } else if (stepName === "age") {
2965
+ steps.push({
2966
+ step: "age",
2967
+ result: await setRecruitAge(client, frameNodeId, normalizedSearchParams.age)
2968
+ });
2969
+ } else if (stepName === "keyword") {
2970
+ const rootsBeforeKeyword = await getRecruitRoots(client);
2971
+ frameNodeId = rootsBeforeKeyword.iframe.documentNodeId;
2972
+ steps.push({
2973
+ step: "reacquire_before_keyword",
2974
+ result: {
2975
+ selector: rootsBeforeKeyword.iframe.selector,
2976
+ document_node_id: frameNodeId
2977
+ }
2978
+ });
2979
+ steps.push({
2980
+ step: "keyword",
2981
+ result: await setRecruitKeyword(client, frameNodeId, normalizedSearchParams.keyword)
2982
+ });
2983
+ } else if (stepName === "search") {
2984
+ const rootsBeforeSearch = await getRecruitRoots(client);
2985
+ frameNodeId = rootsBeforeSearch.iframe.documentNodeId;
1190
2986
  steps.push({
1191
- step: "reacquire_after_city",
2987
+ step: "reacquire_before_search",
1192
2988
  result: {
1193
- selector: rootsAfterCity.iframe.selector,
1194
- document_node_id: frameNodeId,
1195
- reason: cityResult.reason
2989
+ selector: rootsBeforeSearch.iframe.selector,
2990
+ document_node_id: frameNodeId
1196
2991
  }
1197
2992
  });
2993
+ steps.push({
2994
+ step: "search",
2995
+ result: await clickRecruitSearchWithKeywordGuard(client, frameNodeId, normalizedSearchParams.keyword)
2996
+ });
2997
+ } else if (stepName === "recent_viewed") {
2998
+ const recentFilterRoots = await getRecruitRoots(client);
2999
+ steps.push({
3000
+ step: "recent_viewed",
3001
+ result: await setRecruitRecentViewedFilter(
3002
+ client,
3003
+ recentFilterRoots.iframe.documentNodeId,
3004
+ normalizedSearchParams.filter_recent_viewed
3005
+ )
3006
+ });
1198
3007
  }
1199
3008
  }
1200
3009
 
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
3010
  const postSearchState = await waitForRecruitSearchResultState(client, {
1234
3011
  timeoutMs: searchTimeoutMs
1235
3012
  });
@@ -1243,6 +3020,7 @@ export async function applyRecruitSearchParams(client, {
1243
3020
  reset,
1244
3021
  overlay_dismissal: overlayDismissal,
1245
3022
  controls,
3023
+ application_step_names: applicationStepNames,
1246
3024
  initial_iframe: {
1247
3025
  selector: initialRoots.iframe.selector,
1248
3026
  document_node_id: initialFrameNodeId