@reconcrap/boss-recommend-mcp 2.0.46 → 2.0.47

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.
Files changed (56) hide show
  1. package/bin/boss-recommend-mcp.js +4 -4
  2. package/config/screening-config.example.json +27 -27
  3. package/package.json +1 -1
  4. package/scripts/postinstall.cjs +44 -44
  5. package/skills/boss-chat/README.md +39 -39
  6. package/skills/boss-chat/SKILL.md +93 -93
  7. package/skills/boss-recommend-pipeline/README.md +12 -12
  8. package/skills/boss-recommend-pipeline/SKILL.md +180 -180
  9. package/skills/boss-recruit-pipeline/README.md +17 -17
  10. package/skills/boss-recruit-pipeline/SKILL.md +58 -58
  11. package/src/chat-mcp.js +1780 -1780
  12. package/src/chat-runtime-config.js +749 -749
  13. package/src/cli.js +3054 -3054
  14. package/src/core/boss-cards/index.js +199 -199
  15. package/src/core/browser/index.js +1453 -1453
  16. package/src/core/capture/index.js +1201 -1201
  17. package/src/core/cv-acquisition/index.js +238 -238
  18. package/src/core/cv-capture-target/index.js +299 -299
  19. package/src/core/greet-quota/index.js +54 -54
  20. package/src/core/infinite-list/index.js +1326 -1326
  21. package/src/core/reporting/legacy-csv.js +341 -341
  22. package/src/core/run/timing.js +33 -33
  23. package/src/core/screening/index.js +50 -3
  24. package/src/core/self-heal/index.js +973 -973
  25. package/src/core/self-heal/viewport.js +564 -564
  26. package/src/domains/chat/cards.js +137 -137
  27. package/src/domains/chat/constants.js +221 -221
  28. package/src/domains/chat/detail.js +1668 -1668
  29. package/src/domains/chat/index.js +7 -7
  30. package/src/domains/chat/jobs.js +592 -592
  31. package/src/domains/chat/page-guard.js +98 -98
  32. package/src/domains/chat/roots.js +56 -56
  33. package/src/domains/chat/run-service.js +1977 -1977
  34. package/src/domains/recommend/actions.js +457 -457
  35. package/src/domains/recommend/cards.js +243 -243
  36. package/src/domains/recommend/constants.js +165 -165
  37. package/src/domains/recommend/detail.js +25 -18
  38. package/src/domains/recommend/filters.js +610 -610
  39. package/src/domains/recommend/index.js +10 -10
  40. package/src/domains/recommend/jobs.js +316 -316
  41. package/src/domains/recommend/refresh.js +472 -472
  42. package/src/domains/recommend/roots.js +80 -80
  43. package/src/domains/recommend/run-service.js +27 -20
  44. package/src/domains/recommend/scopes.js +246 -246
  45. package/src/domains/recruit/actions.js +277 -277
  46. package/src/domains/recruit/cards.js +74 -74
  47. package/src/domains/recruit/constants.js +167 -167
  48. package/src/domains/recruit/detail.js +461 -461
  49. package/src/domains/recruit/index.js +9 -9
  50. package/src/domains/recruit/instruction-parser.js +451 -451
  51. package/src/domains/recruit/refresh.js +44 -44
  52. package/src/domains/recruit/roots.js +68 -68
  53. package/src/domains/recruit/run-service.js +1207 -1207
  54. package/src/domains/recruit/search.js +1202 -1202
  55. package/src/recommend-mcp.js +22 -22
  56. package/src/recruit-mcp.js +1338 -1338
@@ -1,1202 +1,1202 @@
1
- import {
2
- clearFocusedInput,
3
- clickNodeCenter,
4
- countSelectors,
5
- DETERMINISTIC_CLICK_OPTIONS,
6
- describeNode,
7
- findFirstNode,
8
- getAttributesMap,
9
- getOuterHTML,
10
- insertText,
11
- pressKey,
12
- querySelector,
13
- querySelectorAll,
14
- sleep
15
- } from "../../core/browser/index.js";
16
- import {
17
- htmlToText,
18
- normalizeText
19
- } from "../../core/screening/index.js";
20
- import {
21
- RECRUIT_CARD_SELECTOR,
22
- RECRUIT_TARGET_URL,
23
- RECRUIT_NO_DATA_SELECTORS,
24
- RECRUIT_SEARCH_SELECTORS
25
- } from "./constants.js";
26
- import {
27
- getRecruitRoots,
28
- waitForRecruitRoots
29
- } from "./roots.js";
30
-
31
- const DEFAULT_RECRUIT_KEYWORD = "算法工程师";
32
- const ACTIVE_CLASS_PATTERN = /\b(active|selected|checked|cur|current)\b/i;
33
- const DEFAULT_RECRUIT_RESET_TIMEOUT_MS = 180000;
34
- const DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS = 90000;
35
- const DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS = 30000;
36
- const DEFAULT_RECRUIT_CITY_NO_RESULT_FALLBACK_MS = 15000;
37
-
38
- const DEGREE_LABEL_MAP = new Map([
39
- ["不限", "不限"],
40
- ["本科", "本科"],
41
- ["本科及以上", "本科"],
42
- ["硕士", "硕士"],
43
- ["硕士及以上", "硕士"],
44
- ["博士", "博士"]
45
- ]);
46
-
47
- const NATIONAL_CITY_LABELS = new Set([
48
- "全国",
49
- "不限",
50
- "不限城市",
51
- "全部",
52
- "All",
53
- "ALL",
54
- "all"
55
- ]);
56
-
57
- const CITY_NO_RESULT_LABELS = new Set([
58
- "暂无结果",
59
- "暂无数据",
60
- "无结果"
61
- ]);
62
-
63
- function uniqueNodeIds(nodeIds = []) {
64
- return Array.from(new Set(nodeIds.filter(Boolean)));
65
- }
66
-
67
- function buildRecruitSearchFrameUrl(pageUrl = RECRUIT_TARGET_URL) {
68
- const origin = new URL(pageUrl).origin;
69
- return `${origin}/web/frame/search/?jobId=&keywords=&t=${Date.now()}&source=&city=`;
70
- }
71
-
72
- async function navigateRecruitSearchFrame(client, iframeNodeId, {
73
- pageUrl = RECRUIT_TARGET_URL,
74
- reason = "reset_frame"
75
- } = {}) {
76
- if (!iframeNodeId || typeof client?.Page?.navigate !== "function") return null;
77
- const iframeNode = await describeNode(client, iframeNodeId, { depth: 1, pierce: true });
78
- const frameId = iframeNode?.frameId;
79
- if (!frameId) return null;
80
- const frameUrl = buildRecruitSearchFrameUrl(pageUrl);
81
- await client.Page.navigate({ frameId, url: frameUrl });
82
- return {
83
- method: "Page.navigate",
84
- scope: "frame",
85
- frame_id: frameId,
86
- url: frameUrl,
87
- reason
88
- };
89
- }
90
-
91
- export function normalizeRecruitSearchLabel(label) {
92
- return normalizeText(label).replace(/\s+/g, "");
93
- }
94
-
95
- export function buildRecruitJobTitleSearchTerms(jobTitle) {
96
- const normalized = normalizeText(jobTitle);
97
- if (!normalized) return [];
98
- const variants = [
99
- normalized,
100
- normalized.replace(/\s*[__]\s*/g, " "),
101
- normalized.replace(/\s*[||]\s*/g, " ")
102
- ].map(normalizeText).filter(Boolean);
103
- const separatorParts = normalized.split(/\s*[__||]\s*/).map(normalizeText).filter(Boolean);
104
- if (separatorParts.length > 1) {
105
- variants.push(separatorParts.join(" "));
106
- variants.push(separatorParts[0]);
107
- }
108
- return Array.from(new Set(variants));
109
- }
110
-
111
- export function isRecruitNationalCity(city) {
112
- return NATIONAL_CITY_LABELS.has(normalizeRecruitSearchLabel(city));
113
- }
114
-
115
- export function resolveRecruitDegreeLabel(degree) {
116
- const normalized = normalizeRecruitSearchLabel(degree || "不限");
117
- return DEGREE_LABEL_MAP.get(normalized) || normalized || "不限";
118
- }
119
-
120
- export function normalizeRecruitDegreeLabels(value) {
121
- const rawItems = Array.isArray(value)
122
- ? value
123
- : typeof value === "string"
124
- ? value.split(/[,,、|/]/)
125
- : [];
126
- const labels = rawItems
127
- .map(resolveRecruitDegreeLabel)
128
- .filter(Boolean);
129
- const uniqueLabels = uniqueNodeIds(labels);
130
- return uniqueLabels.length ? uniqueLabels : ["不限"];
131
- }
132
-
133
- export function normalizeRecruitSearchParams(searchParams = {}) {
134
- const degrees = normalizeRecruitDegreeLabels(searchParams.degrees || searchParams.degree || "不限");
135
- const normalized = {
136
- city: normalizeText(searchParams.city) || null,
137
- degree: degrees[0] || "不限",
138
- degrees,
139
- schools: Array.isArray(searchParams.schools)
140
- ? searchParams.schools.map(normalizeText).filter(Boolean)
141
- : [],
142
- keyword: normalizeText(searchParams.keyword) || DEFAULT_RECRUIT_KEYWORD,
143
- filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
144
- ? searchParams.filter_recent_viewed
145
- : null
146
- };
147
- const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
148
- if (job) normalized.job = job;
149
- return normalized;
150
- }
151
-
152
- export function hasRecruitSearchParams(searchParams = {}) {
153
- const degrees = normalizeRecruitDegreeLabels(searchParams.degrees || searchParams.degree || "不限");
154
- const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
155
- const normalized = {
156
- city: normalizeText(searchParams.city) || null,
157
- degree: degrees[0] || "不限",
158
- degrees,
159
- schools: Array.isArray(searchParams.schools)
160
- ? searchParams.schools.map(normalizeText).filter(Boolean)
161
- : [],
162
- keyword: normalizeText(searchParams.keyword),
163
- filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
164
- ? searchParams.filter_recent_viewed
165
- : null
166
- };
167
- return Boolean(
168
- job
169
- || normalized.city
170
- || normalized.degrees.some((degree) => degree && degree !== "不限")
171
- || normalized.schools.length
172
- || normalized.keyword
173
- || typeof normalized.filter_recent_viewed === "boolean"
174
- );
175
- }
176
-
177
- function candidateIsActive(attributes = {}, outerHTML = "") {
178
- const className = attributes.class || "";
179
- const openingTag = String(outerHTML || "").split(">")[0] || "";
180
- return ACTIVE_CLASS_PATTERN.test(className)
181
- || ACTIVE_CLASS_PATTERN.test(openingTag)
182
- || /\bchecked(?:=["']?checked)?\b/i.test(openingTag);
183
- }
184
-
185
- async function readTextCandidate(client, nodeId, {
186
- selector = "",
187
- index = 0
188
- } = {}) {
189
- const [attributes, outerHTML] = await Promise.all([
190
- getAttributesMap(client, nodeId),
191
- getOuterHTML(client, nodeId)
192
- ]);
193
- const text = normalizeText(htmlToText(outerHTML));
194
- return {
195
- node_id: nodeId,
196
- selector,
197
- index,
198
- label: normalizeRecruitSearchLabel(text),
199
- text,
200
- active: candidateIsActive(attributes, outerHTML),
201
- class_name: attributes.class || "",
202
- attributes
203
- };
204
- }
205
-
206
- async function listTextCandidates(client, rootNodeId, selectors = []) {
207
- const candidates = [];
208
- const seen = new Set();
209
- for (const selector of selectors) {
210
- const nodeIds = uniqueNodeIds(await querySelectorAll(client, rootNodeId, selector));
211
- for (let index = 0; index < nodeIds.length; index += 1) {
212
- const nodeId = nodeIds[index];
213
- if (seen.has(nodeId)) continue;
214
- seen.add(nodeId);
215
- candidates.push(await readTextCandidate(client, nodeId, { selector, index }));
216
- }
217
- }
218
- return candidates;
219
- }
220
-
221
- export function chooseRecruitTextCandidate(candidates = [], {
222
- label = "",
223
- match = "exact"
224
- } = {}) {
225
- const target = normalizeRecruitSearchLabel(label);
226
- if (!target) return null;
227
- const byExact = candidates.find((candidate) => candidate.label === target);
228
- if (byExact) return byExact;
229
- if (match === "exact") return null;
230
- const byPrefix = candidates.find((candidate) => (
231
- candidate.label.startsWith(target)
232
- || target.startsWith(candidate.label)
233
- ));
234
- if (byPrefix) return byPrefix;
235
- if (match === "prefix") return null;
236
- return candidates.find((candidate) => candidate.label.includes(target) || target.includes(candidate.label)) || null;
237
- }
238
-
239
- async function findTextCandidate(client, rootNodeId, selectors, label, options = {}) {
240
- const candidates = await listTextCandidates(client, rootNodeId, selectors);
241
- return {
242
- candidate: chooseRecruitTextCandidate(candidates, { label, ...options }),
243
- candidates
244
- };
245
- }
246
-
247
- function summarizeTextCandidates(candidates = [], limit = 20) {
248
- return candidates.map((item) => ({
249
- label: item.text,
250
- active: item.active,
251
- node_id: item.node_id,
252
- selector: item.selector
253
- })).slice(0, limit);
254
- }
255
-
256
- async function waitForRecruitTextCandidate(client, rootNodeId, selectors, label, {
257
- timeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS,
258
- intervalMs = 300,
259
- match = "exact"
260
- } = {}) {
261
- const started = Date.now();
262
- let candidate = null;
263
- let candidates = [];
264
- while (Date.now() - started <= timeoutMs) {
265
- const found = await findTextCandidate(client, rootNodeId, selectors, label, { match });
266
- candidate = found.candidate;
267
- candidates = found.candidates;
268
- if (candidate) break;
269
- await sleep(intervalMs);
270
- }
271
- return {
272
- candidate,
273
- candidates,
274
- elapsed_ms: Date.now() - started
275
- };
276
- }
277
-
278
- async function waitForRecruitJobTitleCandidate(client, rootNodeId, selectors, jobTitle, {
279
- timeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS,
280
- intervalMs = 300
281
- } = {}) {
282
- const terms = buildRecruitJobTitleSearchTerms(jobTitle);
283
- const started = Date.now();
284
- let candidate = null;
285
- let candidates = [];
286
- let matchedTerm = "";
287
- while (Date.now() - started <= timeoutMs) {
288
- candidates = await listTextCandidates(client, rootNodeId, selectors);
289
- for (const term of terms) {
290
- const found = chooseRecruitTextCandidate(candidates, { label: term, match: "contains" });
291
- if (found) {
292
- candidate = found;
293
- matchedTerm = term;
294
- break;
295
- }
296
- }
297
- if (candidate) break;
298
- await sleep(intervalMs);
299
- }
300
- return {
301
- candidate,
302
- candidates,
303
- matched_term: matchedTerm,
304
- search_terms: terms,
305
- elapsed_ms: Date.now() - started
306
- };
307
- }
308
-
309
- async function clickFirstNodeBySelectors(client, rootNodeId, selectors, {
310
- optional = false,
311
- scrollIntoView = true
312
- } = {}) {
313
- const found = await findFirstNode(client, rootNodeId, selectors);
314
- if (!found) {
315
- if (optional) return { clicked: false, reason: "not_found" };
316
- throw new Error(`Recruit search node was not found for selectors: ${selectors.join(", ")}`);
317
- }
318
- try {
319
- const box = await clickNodeCenter(client, found.nodeId, {
320
- ...DETERMINISTIC_CLICK_OPTIONS,
321
- scrollIntoView
322
- });
323
- await sleep(250);
324
- return {
325
- clicked: true,
326
- selector: found.selector,
327
- node_id: found.nodeId,
328
- box
329
- };
330
- } catch (error) {
331
- if (optional) {
332
- return {
333
- clicked: false,
334
- reason: "not_clickable",
335
- selector: found.selector,
336
- node_id: found.nodeId,
337
- error: error?.message || String(error)
338
- };
339
- }
340
- throw error;
341
- }
342
- }
343
-
344
- async function dismissRecruitSearchOverlays(client, settleMs = 250) {
345
- if (typeof client?.Input?.dispatchKeyEvent !== "function") {
346
- return {
347
- method: "Escape",
348
- skipped: true,
349
- reason: "dispatch_key_unavailable"
350
- };
351
- }
352
- await pressKey(client, "Escape", {
353
- code: "Escape",
354
- windowsVirtualKeyCode: 27,
355
- nativeVirtualKeyCode: 27
356
- });
357
- if (settleMs > 0) await sleep(settleMs);
358
- return {
359
- method: "Escape",
360
- settle_ms: settleMs
361
- };
362
- }
363
-
364
- export async function getRecruitSearchCounts(client, frameNodeId) {
365
- return countSelectors(client, frameNodeId, {
366
- keyword_input: RECRUIT_SEARCH_SELECTORS.keywordInput.join(", "),
367
- search_button: RECRUIT_SEARCH_SELECTORS.searchButton.join(", "),
368
- degree_option: RECRUIT_SEARCH_SELECTORS.degreeOption.join(", "),
369
- school_item: RECRUIT_SEARCH_SELECTORS.schoolItem.join(", "),
370
- recent_viewed_label: RECRUIT_SEARCH_SELECTORS.recentViewedLabel.join(", "),
371
- candidate_card: RECRUIT_CARD_SELECTOR,
372
- no_data: RECRUIT_NO_DATA_SELECTORS.join(", ")
373
- });
374
- }
375
-
376
- export async function waitForRecruitSearchControls(client, {
377
- timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
378
- intervalMs = 300
379
- } = {}) {
380
- const started = Date.now();
381
- let lastState = null;
382
- while (Date.now() - started <= timeoutMs) {
383
- const roots = await getRecruitRoots(client, { requireFrame: false });
384
- const frameNodeId = roots.iframe?.documentNodeId;
385
- if (frameNodeId) {
386
- const counts = await getRecruitSearchCounts(client, frameNodeId);
387
- lastState = {
388
- ok: counts.keyword_input > 0 && counts.search_button > 0,
389
- elapsed_ms: Date.now() - started,
390
- iframe_selector: roots.iframe.selector,
391
- iframe_document_node_id: frameNodeId,
392
- counts
393
- };
394
- if (lastState.ok) return lastState;
395
- }
396
- await sleep(intervalMs);
397
- }
398
- return {
399
- ok: false,
400
- elapsed_ms: Date.now() - started,
401
- ...(lastState || {})
402
- };
403
- }
404
-
405
- export async function resetRecruitSearchPage(client, {
406
- url = RECRUIT_TARGET_URL,
407
- settleMs = 5000,
408
- timeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS
409
- } = {}) {
410
- const actions = [];
411
- const rootTimeoutMs = Math.min(timeoutMs, 90000);
412
- async function waitForRootsAfterSettle() {
413
- await sleep(settleMs);
414
- return waitForRecruitRoots(client, {
415
- timeoutMs: rootTimeoutMs,
416
- intervalMs: 300
417
- });
418
- }
419
-
420
- async function waitForControls() {
421
- return waitForRecruitSearchControls(client, {
422
- timeoutMs,
423
- intervalMs: 300
424
- });
425
- }
426
-
427
- if (typeof client?.Page?.reload === "function") {
428
- await client.Page.reload({ ignoreCache: true });
429
- actions.push({ method: "Page.reload" });
430
- } else {
431
- await client.Page.navigate({ url });
432
- actions.push({ method: "Page.navigate", url });
433
- }
434
-
435
- let roots = await waitForRootsAfterSettle();
436
- const frameReset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, {
437
- pageUrl: url,
438
- reason: "reset_frame_after_page_reload"
439
- });
440
- if (frameReset) {
441
- actions.push(frameReset);
442
- await sleep(settleMs);
443
- }
444
-
445
- let controls = await waitForControls();
446
- if (!controls.ok && typeof client?.Page?.navigate === "function") {
447
- await client.Page.navigate({ url });
448
- actions.push({
449
- method: "Page.navigate",
450
- url,
451
- reason: roots?.iframe?.documentNodeId ? "controls_not_ready" : "iframe_not_ready"
452
- });
453
- roots = await waitForRootsAfterSettle();
454
- const fallbackFrameReset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, {
455
- pageUrl: url,
456
- reason: "reset_frame_after_page_navigate"
457
- });
458
- if (fallbackFrameReset) {
459
- actions.push(fallbackFrameReset);
460
- await sleep(settleMs);
461
- }
462
- controls = await waitForControls();
463
- }
464
- roots = await getRecruitRoots(client, { requireFrame: false });
465
- if (!controls.ok && !roots?.iframe?.documentNodeId) {
466
- throw new Error("Recruit search page reset did not expose searchFrame iframe");
467
- }
468
- if (!controls.ok) {
469
- throw new Error("Recruit search page reset exposed iframe but search controls were not ready");
470
- }
471
- return {
472
- actions,
473
- target_url: url,
474
- iframe_selector: controls.iframe_selector || roots.iframe.selector,
475
- iframe_document_node_id: controls.iframe_document_node_id || roots.iframe.documentNodeId,
476
- controls
477
- };
478
- }
479
-
480
- export async function setRecruitKeyword(client, frameNodeId, keyword) {
481
- const normalizedKeyword = normalizeText(keyword);
482
- if (!normalizedKeyword) {
483
- return { applied: false, reason: "empty_keyword" };
484
- }
485
- const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.keywordInput);
486
- await clearFocusedInput(client);
487
- await sleep(120);
488
- await insertText(client, normalizedKeyword);
489
- await sleep(350);
490
- return {
491
- applied: true,
492
- keyword: normalizedKeyword,
493
- input
494
- };
495
- }
496
-
497
- export async function selectRecruitDefaultJobTitle(client, frameNodeId) {
498
- return clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.jobTitleOption, {
499
- optional: true
500
- });
501
- }
502
-
503
- export async function setRecruitJobTitle(client, frameNodeId, jobTitle, {
504
- optionTimeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS
505
- } = {}) {
506
- const normalizedJobTitle = normalizeText(jobTitle);
507
- if (!normalizedJobTitle) {
508
- return { applied: false, reason: "empty_job_title" };
509
- }
510
- const lookup = await waitForRecruitJobTitleCandidate(
511
- client,
512
- frameNodeId,
513
- RECRUIT_SEARCH_SELECTORS.jobTitleOption,
514
- normalizedJobTitle,
515
- { timeoutMs: Math.min(optionTimeoutMs, 30000) }
516
- );
517
- if (!lookup.candidate) {
518
- throw new Error(`Recruit job title option was not found: ${normalizedJobTitle}`);
519
- }
520
- let box = null;
521
- if (!lookup.candidate.active) {
522
- box = await clickNodeCenter(client, lookup.candidate.node_id, {
523
- ...DETERMINISTIC_CLICK_OPTIONS,
524
- scrollIntoView: true
525
- });
526
- await sleep(500);
527
- }
528
- return {
529
- applied: true,
530
- requested_job: normalizedJobTitle,
531
- selected_label: lookup.candidate.text,
532
- matched_term: lookup.matched_term,
533
- search_terms: lookup.search_terms,
534
- selected_node_id: lookup.candidate.node_id,
535
- was_active: lookup.candidate.active,
536
- clicked: !lookup.candidate.active,
537
- box,
538
- discovered_options: summarizeTextCandidates(lookup.candidates, 30)
539
- };
540
- }
541
-
542
- export async function setRecruitDegree(client, frameNodeId, degree) {
543
- const degreeLabel = resolveRecruitDegreeLabel(degree);
544
- if (!degreeLabel || degreeLabel === "不限") {
545
- return { applied: false, reason: "unlimited_degree", degree: degreeLabel || "不限" };
546
- }
547
- const { candidate, candidates } = await findTextCandidate(
548
- client,
549
- frameNodeId,
550
- RECRUIT_SEARCH_SELECTORS.degreeOption,
551
- degreeLabel,
552
- { match: "prefix" }
553
- );
554
- if (!candidate) {
555
- throw new Error(`Recruit degree option was not found: ${degreeLabel}`);
556
- }
557
- const box = await clickNodeCenter(client, candidate.node_id, {
558
- ...DETERMINISTIC_CLICK_OPTIONS,
559
- scrollIntoView: true
560
- });
561
- await sleep(350);
562
- return {
563
- applied: true,
564
- requested_degree: degree,
565
- selected_label: candidate.text,
566
- selected_node_id: candidate.node_id,
567
- was_active: candidate.active,
568
- box,
569
- discovered_options: candidates.map((item) => ({
570
- label: item.text,
571
- active: item.active,
572
- node_id: item.node_id,
573
- selector: item.selector
574
- }))
575
- };
576
- }
577
-
578
- export async function setRecruitDegrees(client, frameNodeId, degrees = []) {
579
- const labels = normalizeRecruitDegreeLabels(degrees).filter((label) => label && label !== "不限");
580
- if (!labels.length) {
581
- return { applied: false, reason: "unlimited_degree", degrees: ["不限"], selected: [] };
582
- }
583
-
584
- const selected = [];
585
- let discoveredOptions = [];
586
- for (const label of labels) {
587
- const { candidate, candidates } = await findTextCandidate(
588
- client,
589
- frameNodeId,
590
- RECRUIT_SEARCH_SELECTORS.degreeOption,
591
- label,
592
- { match: "prefix" }
593
- );
594
- discoveredOptions = candidates.map((item) => ({
595
- label: item.text,
596
- active: item.active,
597
- node_id: item.node_id,
598
- selector: item.selector
599
- }));
600
- if (!candidate) {
601
- throw new Error(`Recruit degree option was not found: ${label}`);
602
- }
603
-
604
- let box = null;
605
- if (!candidate.active) {
606
- box = await clickNodeCenter(client, candidate.node_id, {
607
- ...DETERMINISTIC_CLICK_OPTIONS,
608
- scrollIntoView: true
609
- });
610
- await sleep(350);
611
- }
612
- selected.push({
613
- requested_degree: label,
614
- selected_label: candidate.text,
615
- selected_node_id: candidate.node_id,
616
- was_active: candidate.active,
617
- clicked: !candidate.active,
618
- box
619
- });
620
- }
621
-
622
- return {
623
- applied: true,
624
- requested_degrees: labels,
625
- selected,
626
- discovered_options: discoveredOptions
627
- };
628
- }
629
-
630
- async function findClickableDescendant(client, nodeId, selectors) {
631
- for (const selector of selectors) {
632
- const childNodeId = await querySelector(client, nodeId, selector);
633
- if (childNodeId) return { node_id: childNodeId, selector };
634
- }
635
- return { node_id: nodeId, selector: null };
636
- }
637
-
638
- export async function setRecruitSchools(client, frameNodeId, schools = []) {
639
- const targets = schools.map(normalizeText).filter(Boolean);
640
- const applied = [];
641
- const missing = [];
642
- if (!targets.length) {
643
- return { applied: false, schools: [], selected: [], missing: [] };
644
- }
645
-
646
- for (const school of targets) {
647
- const { candidate, candidates } = await findTextCandidate(
648
- client,
649
- frameNodeId,
650
- RECRUIT_SEARCH_SELECTORS.schoolItem,
651
- school,
652
- { match: "contains" }
653
- );
654
- if (!candidate) {
655
- missing.push({
656
- school,
657
- discovered: candidates.map((item) => item.text).slice(0, 20)
658
- });
659
- continue;
660
- }
661
-
662
- const clickable = await findClickableDescendant(client, candidate.node_id, RECRUIT_SEARCH_SELECTORS.schoolClickable);
663
- let clickableActive = candidate.active;
664
- if (clickable.node_id !== candidate.node_id) {
665
- const clickableCandidate = await readTextCandidate(client, clickable.node_id, {
666
- selector: clickable.selector || "",
667
- index: 0
668
- });
669
- clickableActive = clickableActive || clickableCandidate.active;
670
- }
671
-
672
- let box = null;
673
- if (!clickableActive) {
674
- box = await clickNodeCenter(client, clickable.node_id, {
675
- ...DETERMINISTIC_CLICK_OPTIONS,
676
- scrollIntoView: true
677
- });
678
- await sleep(350);
679
- }
680
-
681
- applied.push({
682
- school,
683
- selected_label: candidate.text,
684
- selected_node_id: candidate.node_id,
685
- clickable_node_id: clickable.node_id,
686
- clickable_selector: clickable.selector,
687
- was_active: clickableActive,
688
- clicked: !clickableActive,
689
- box
690
- });
691
- }
692
-
693
- if (missing.length) {
694
- throw new Error(`Recruit school options were not found: ${missing.map((item) => item.school).join(", ")}`);
695
- }
696
-
697
- return {
698
- applied: true,
699
- schools: targets,
700
- selected: applied,
701
- missing
702
- };
703
- }
704
-
705
- export async function setRecruitRecentViewedFilter(client, frameNodeId, enabled) {
706
- if (typeof enabled !== "boolean") {
707
- return { applied: false, reason: "not_requested" };
708
- }
709
- const { candidate, candidates } = await findTextCandidate(
710
- client,
711
- frameNodeId,
712
- RECRUIT_SEARCH_SELECTORS.recentViewedLabel,
713
- "过滤近14天查看",
714
- { match: "contains" }
715
- );
716
- if (!candidate) {
717
- throw new Error("Recruit recent-viewed filter was not found");
718
- }
719
-
720
- let box = null;
721
- if (candidate.active !== enabled) {
722
- box = await clickNodeCenter(client, candidate.node_id, {
723
- ...DETERMINISTIC_CLICK_OPTIONS,
724
- scrollIntoView: true
725
- });
726
- await sleep(900);
727
- }
728
-
729
- return {
730
- applied: true,
731
- requested: enabled,
732
- was_active: candidate.active,
733
- changed: candidate.active !== enabled,
734
- selected_label: candidate.text,
735
- selected_node_id: candidate.node_id,
736
- box,
737
- discovered_options: candidates.map((item) => ({
738
- label: item.text,
739
- active: item.active,
740
- node_id: item.node_id,
741
- selector: item.selector
742
- }))
743
- };
744
- }
745
-
746
- async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
747
- requestedCity = "全国",
748
- reason = "national_city_requested",
749
- optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
750
- } = {}) {
751
- const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
752
- await clearFocusedInput(client);
753
- await sleep(500);
754
-
755
- const path = [];
756
- const categoryLookup = await waitForRecruitTextCandidate(
757
- client,
758
- frameNodeId,
759
- RECRUIT_SEARCH_SELECTORS.citySearchResult,
760
- "城市",
761
- { match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
762
- );
763
- if (categoryLookup.candidate) {
764
- const box = await clickNodeCenter(client, categoryLookup.candidate.node_id, {
765
- ...DETERMINISTIC_CLICK_OPTIONS,
766
- scrollIntoView: true
767
- });
768
- await sleep(400);
769
- path.push({
770
- label: "城市",
771
- selected_label: categoryLookup.candidate.text,
772
- node_id: categoryLookup.candidate.node_id,
773
- box
774
- });
775
- } else {
776
- path.push({
777
- label: "城市",
778
- skipped: true,
779
- reason: "not_found_or_already_expanded",
780
- discovered_options: summarizeTextCandidates(categoryLookup.candidates)
781
- });
782
- }
783
-
784
- let popularLookup = await waitForRecruitTextCandidate(
785
- client,
786
- frameNodeId,
787
- RECRUIT_SEARCH_SELECTORS.cityProvinceItem,
788
- "热门",
789
- { match: "exact", timeoutMs: optionTimeoutMs }
790
- );
791
- if (!popularLookup.candidate) {
792
- popularLookup = await waitForRecruitTextCandidate(
793
- client,
794
- frameNodeId,
795
- RECRUIT_SEARCH_SELECTORS.citySearchResult,
796
- "热门",
797
- { match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
798
- );
799
- }
800
- if (!popularLookup.candidate) {
801
- return {
802
- applied: false,
803
- reason: "national_city_popular_not_found",
804
- requested_city: requestedCity,
805
- input,
806
- path,
807
- discovered_options: summarizeTextCandidates(popularLookup.candidates)
808
- };
809
- }
810
- const popularBox = await clickNodeCenter(client, popularLookup.candidate.node_id, {
811
- ...DETERMINISTIC_CLICK_OPTIONS,
812
- scrollIntoView: true
813
- });
814
- await sleep(400);
815
- path.push({
816
- label: "热门",
817
- selected_label: popularLookup.candidate.text,
818
- node_id: popularLookup.candidate.node_id,
819
- box: popularBox
820
- });
821
-
822
- let nationalLookup = await waitForRecruitTextCandidate(
823
- client,
824
- frameNodeId,
825
- RECRUIT_SEARCH_SELECTORS.cityDropdownItem,
826
- "全国",
827
- { match: "exact", timeoutMs: optionTimeoutMs }
828
- );
829
- if (!nationalLookup.candidate) {
830
- nationalLookup = await waitForRecruitTextCandidate(
831
- client,
832
- frameNodeId,
833
- RECRUIT_SEARCH_SELECTORS.citySearchResult,
834
- "全国",
835
- { match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
836
- );
837
- }
838
- if (!nationalLookup.candidate) {
839
- return {
840
- applied: false,
841
- reason: "national_city_option_not_found",
842
- requested_city: requestedCity,
843
- input,
844
- path,
845
- discovered_options: summarizeTextCandidates(nationalLookup.candidates)
846
- };
847
- }
848
-
849
- const nationalBox = await clickNodeCenter(client, nationalLookup.candidate.node_id, {
850
- ...DETERMINISTIC_CLICK_OPTIONS,
851
- scrollIntoView: true
852
- });
853
- await sleep(700);
854
- path.push({
855
- label: "全国",
856
- selected_label: nationalLookup.candidate.text,
857
- node_id: nationalLookup.candidate.node_id,
858
- box: nationalBox
859
- });
860
-
861
- return {
862
- applied: true,
863
- reason,
864
- city: "全国",
865
- requested_city: requestedCity,
866
- selected_label: nationalLookup.candidate.text,
867
- selected_node_id: nationalLookup.candidate.node_id,
868
- input,
869
- path,
870
- box: nationalBox,
871
- selection_mode: "city_picker",
872
- picker_path: ["城市", "热门", "全国"]
873
- };
874
- }
875
-
876
- async function resetRecruitCityToNational(client, {
877
- requestedCity = "",
878
- reason = "national_city_frame_reset",
879
- optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
880
- } = {}) {
881
- const roots = await getRecruitRoots(client, { requireFrame: false });
882
- const reset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, { reason });
883
- if (!reset) {
884
- return {
885
- applied: false,
886
- reason: "national_city_frame_reset_unavailable",
887
- requested_city: requestedCity
888
- };
889
- }
890
- await sleep(1500);
891
- const controls = await waitForRecruitSearchControls(client, {
892
- timeoutMs: Math.max(optionTimeoutMs, DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS),
893
- intervalMs: 300
894
- });
895
- return {
896
- applied: controls.ok,
897
- reason,
898
- city: "全国",
899
- requested_city: requestedCity,
900
- selected_label: "全国",
901
- selection_mode: "frame_reset",
902
- reset,
903
- controls,
904
- reacquire_frame: true
905
- };
906
- }
907
-
908
- export async function setRecruitCity(client, frameNodeId, city, {
909
- optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
910
- } = {}) {
911
- const normalizedCity = normalizeText(city);
912
- if (!normalizedCity) {
913
- return { applied: false, reason: "empty_city" };
914
- }
915
- if (isRecruitNationalCity(normalizedCity)) {
916
- return selectRecruitNationalCityThroughPicker(client, frameNodeId, {
917
- requestedCity: normalizedCity,
918
- reason: "national_city_requested",
919
- optionTimeoutMs
920
- });
921
- }
922
-
923
- const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
924
- await clearFocusedInput(client);
925
- await sleep(120);
926
- await insertText(client, normalizedCity);
927
- await sleep(500);
928
-
929
- const started = Date.now();
930
- const noResultFallbackMs = Math.min(DEFAULT_RECRUIT_CITY_NO_RESULT_FALLBACK_MS, optionTimeoutMs);
931
- let candidate = null;
932
- let candidates = [];
933
- let noResultFirstSeenAt = 0;
934
- while (Date.now() - started <= optionTimeoutMs) {
935
- const found = await findTextCandidate(
936
- client,
937
- frameNodeId,
938
- RECRUIT_SEARCH_SELECTORS.citySearchResult,
939
- normalizedCity,
940
- { match: "contains" }
941
- );
942
- candidate = found.candidate;
943
- candidates = found.candidates;
944
- if (candidate) break;
945
- const hasNoResult = candidates.some((item) => CITY_NO_RESULT_LABELS.has(item.label));
946
- if (hasNoResult) {
947
- if (!noResultFirstSeenAt) noResultFirstSeenAt = Date.now();
948
- if (Date.now() - noResultFirstSeenAt >= noResultFallbackMs) break;
949
- } else {
950
- noResultFirstSeenAt = 0;
951
- }
952
- await sleep(300);
953
- }
954
- if (!candidate) {
955
- const nationalFallback = await selectRecruitNationalCityThroughPicker(client, frameNodeId, {
956
- requestedCity: normalizedCity,
957
- reason: "city_result_not_found",
958
- optionTimeoutMs
959
- });
960
- if (nationalFallback.applied) {
961
- return {
962
- ...nationalFallback,
963
- reason: "city_result_not_found",
964
- requested_city: normalizedCity,
965
- requested_city_not_found: true,
966
- fallback_to_national: true,
967
- original_input: input,
968
- elapsed_ms: Date.now() - started,
969
- discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
970
- };
971
- }
972
-
973
- const resetFallback = await resetRecruitCityToNational(client, {
974
- requestedCity: normalizedCity,
975
- reason: "city_result_not_found_frame_reset",
976
- optionTimeoutMs
977
- });
978
- if (resetFallback.applied) {
979
- return {
980
- ...resetFallback,
981
- reason: "city_result_not_found",
982
- requested_city: normalizedCity,
983
- requested_city_not_found: true,
984
- fallback_to_national: true,
985
- original_input: input,
986
- picker_fallback: nationalFallback,
987
- elapsed_ms: Date.now() - started,
988
- discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
989
- };
990
- }
991
-
992
- return {
993
- applied: false,
994
- reason: "city_result_not_found",
995
- city: normalizedCity,
996
- input,
997
- elapsed_ms: Date.now() - started,
998
- discovered_options: candidates.map((item) => item.text).slice(0, 20),
999
- national_fallback: nationalFallback,
1000
- reset_fallback: resetFallback
1001
- };
1002
- }
1003
-
1004
- const box = await clickNodeCenter(client, candidate.node_id, {
1005
- ...DETERMINISTIC_CLICK_OPTIONS,
1006
- scrollIntoView: true
1007
- });
1008
- await sleep(600);
1009
- return {
1010
- applied: true,
1011
- city: normalizedCity,
1012
- selected_label: candidate.text,
1013
- selected_node_id: candidate.node_id,
1014
- input,
1015
- elapsed_ms: Date.now() - started,
1016
- box
1017
- };
1018
- }
1019
-
1020
- export async function clickRecruitSearch(client, frameNodeId) {
1021
- const buttonResult = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.searchButton, {
1022
- optional: true,
1023
- scrollIntoView: false
1024
- });
1025
- if (buttonResult.clicked) {
1026
- await sleep(1500);
1027
- return {
1028
- searched: true,
1029
- mode: "button",
1030
- button: buttonResult
1031
- };
1032
- }
1033
-
1034
- await pressKey(client, "Enter", {
1035
- code: "Enter",
1036
- windowsVirtualKeyCode: 13,
1037
- nativeVirtualKeyCode: 13
1038
- });
1039
- await sleep(1500);
1040
- return {
1041
- searched: true,
1042
- mode: "enter"
1043
- };
1044
- }
1045
-
1046
- export async function waitForRecruitSearchResultState(client, {
1047
- timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
1048
- intervalMs = 500
1049
- } = {}) {
1050
- const started = Date.now();
1051
- let lastState = null;
1052
- while (Date.now() - started <= timeoutMs) {
1053
- try {
1054
- const roots = await getRecruitRoots(client, { requireFrame: false });
1055
- const frameNodeId = roots.iframe?.documentNodeId;
1056
- if (frameNodeId) {
1057
- const counts = await countSelectors(client, frameNodeId, {
1058
- candidate_card: RECRUIT_CARD_SELECTOR,
1059
- no_data: RECRUIT_NO_DATA_SELECTORS.join(", ")
1060
- });
1061
- lastState = {
1062
- ok: counts.candidate_card > 0 || counts.no_data > 0,
1063
- elapsed_ms: Date.now() - started,
1064
- iframe_selector: roots.iframe.selector,
1065
- iframe_document_node_id: frameNodeId,
1066
- counts
1067
- };
1068
- if (lastState.ok) return lastState;
1069
- }
1070
- } catch (error) {
1071
- lastState = {
1072
- ok: false,
1073
- elapsed_ms: Date.now() - started,
1074
- error: error?.message || String(error)
1075
- };
1076
- }
1077
- await sleep(intervalMs);
1078
- }
1079
- return {
1080
- ok: false,
1081
- elapsed_ms: Date.now() - started,
1082
- ...(lastState || {})
1083
- };
1084
- }
1085
-
1086
- export async function applyRecruitSearchParams(client, {
1087
- searchParams = {},
1088
- requireCards = true,
1089
- resetBeforeApply = false,
1090
- searchTimeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
1091
- resetTimeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS,
1092
- resetSettleMs = 5000,
1093
- cityOptionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
1094
- } = {}) {
1095
- const normalizedSearchParams = normalizeRecruitSearchParams(searchParams);
1096
- const reset = resetBeforeApply
1097
- ? await resetRecruitSearchPage(client, {
1098
- timeoutMs: resetTimeoutMs,
1099
- settleMs: resetSettleMs
1100
- })
1101
- : null;
1102
- const controls = reset?.controls?.ok
1103
- ? reset.controls
1104
- : await waitForRecruitSearchControls(client, {
1105
- timeoutMs: searchTimeoutMs,
1106
- intervalMs: 500
1107
- });
1108
- if (!controls.ok) {
1109
- throw new Error(`Recruit search controls were not ready after navigation; counts=${JSON.stringify(controls.counts || {})}`);
1110
- }
1111
- const overlayDismissal = await dismissRecruitSearchOverlays(client);
1112
- const initialRoots = await getRecruitRoots(client);
1113
- let frameNodeId = initialRoots.iframe.documentNodeId;
1114
- const initialFrameNodeId = frameNodeId;
1115
- const beforeCounts = await getRecruitSearchCounts(client, frameNodeId);
1116
- const steps = [];
1117
-
1118
- if (normalizedSearchParams.job) {
1119
- steps.push({
1120
- step: "job_title",
1121
- result: await setRecruitJobTitle(client, frameNodeId, normalizedSearchParams.job, {
1122
- optionTimeoutMs: searchTimeoutMs
1123
- })
1124
- });
1125
- }
1126
-
1127
- if (normalizedSearchParams.city) {
1128
- const cityResult = await setRecruitCity(client, frameNodeId, normalizedSearchParams.city, {
1129
- optionTimeoutMs: cityOptionTimeoutMs
1130
- });
1131
- steps.push({
1132
- step: "city",
1133
- result: cityResult
1134
- });
1135
- if (cityResult?.reacquire_frame) {
1136
- const rootsAfterCity = await getRecruitRoots(client);
1137
- frameNodeId = rootsAfterCity.iframe.documentNodeId;
1138
- steps.push({
1139
- step: "reacquire_after_city",
1140
- result: {
1141
- selector: rootsAfterCity.iframe.selector,
1142
- document_node_id: frameNodeId,
1143
- reason: cityResult.reason
1144
- }
1145
- });
1146
- }
1147
- }
1148
-
1149
- steps.push({
1150
- step: "degree",
1151
- result: await setRecruitDegrees(client, frameNodeId, normalizedSearchParams.degrees)
1152
- });
1153
-
1154
- steps.push({
1155
- step: "schools",
1156
- result: await setRecruitSchools(client, frameNodeId, normalizedSearchParams.schools)
1157
- });
1158
-
1159
- steps.push({
1160
- step: "keyword",
1161
- result: await setRecruitKeyword(client, frameNodeId, normalizedSearchParams.keyword)
1162
- });
1163
-
1164
- steps.push({
1165
- step: "search",
1166
- result: await clickRecruitSearch(client, frameNodeId)
1167
- });
1168
-
1169
- if (typeof normalizedSearchParams.filter_recent_viewed === "boolean") {
1170
- const postSearchRoots = await getRecruitRoots(client);
1171
- steps.push({
1172
- step: "recent_viewed",
1173
- result: await setRecruitRecentViewedFilter(
1174
- client,
1175
- postSearchRoots.iframe.documentNodeId,
1176
- normalizedSearchParams.filter_recent_viewed
1177
- )
1178
- });
1179
- }
1180
-
1181
- const postSearchState = await waitForRecruitSearchResultState(client, {
1182
- timeoutMs: searchTimeoutMs
1183
- });
1184
- if (requireCards && (postSearchState.counts?.candidate_card || 0) === 0) {
1185
- throw new Error(`Recruit search did not produce candidate cards; no_data=${postSearchState.counts?.no_data || 0}`);
1186
- }
1187
-
1188
- return {
1189
- applied: true,
1190
- search_params: normalizedSearchParams,
1191
- reset,
1192
- overlay_dismissal: overlayDismissal,
1193
- controls,
1194
- initial_iframe: {
1195
- selector: initialRoots.iframe.selector,
1196
- document_node_id: initialFrameNodeId
1197
- },
1198
- before_counts: beforeCounts,
1199
- steps,
1200
- post_search_state: postSearchState
1201
- };
1202
- }
1
+ import {
2
+ clearFocusedInput,
3
+ clickNodeCenter,
4
+ countSelectors,
5
+ DETERMINISTIC_CLICK_OPTIONS,
6
+ describeNode,
7
+ findFirstNode,
8
+ getAttributesMap,
9
+ getOuterHTML,
10
+ insertText,
11
+ pressKey,
12
+ querySelector,
13
+ querySelectorAll,
14
+ sleep
15
+ } from "../../core/browser/index.js";
16
+ import {
17
+ htmlToText,
18
+ normalizeText
19
+ } from "../../core/screening/index.js";
20
+ import {
21
+ RECRUIT_CARD_SELECTOR,
22
+ RECRUIT_TARGET_URL,
23
+ RECRUIT_NO_DATA_SELECTORS,
24
+ RECRUIT_SEARCH_SELECTORS
25
+ } from "./constants.js";
26
+ import {
27
+ getRecruitRoots,
28
+ waitForRecruitRoots
29
+ } from "./roots.js";
30
+
31
+ const DEFAULT_RECRUIT_KEYWORD = "算法工程师";
32
+ const ACTIVE_CLASS_PATTERN = /\b(active|selected|checked|cur|current)\b/i;
33
+ const DEFAULT_RECRUIT_RESET_TIMEOUT_MS = 180000;
34
+ const DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS = 90000;
35
+ const DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS = 30000;
36
+ const DEFAULT_RECRUIT_CITY_NO_RESULT_FALLBACK_MS = 15000;
37
+
38
+ const DEGREE_LABEL_MAP = new Map([
39
+ ["不限", "不限"],
40
+ ["本科", "本科"],
41
+ ["本科及以上", "本科"],
42
+ ["硕士", "硕士"],
43
+ ["硕士及以上", "硕士"],
44
+ ["博士", "博士"]
45
+ ]);
46
+
47
+ const NATIONAL_CITY_LABELS = new Set([
48
+ "全国",
49
+ "不限",
50
+ "不限城市",
51
+ "全部",
52
+ "All",
53
+ "ALL",
54
+ "all"
55
+ ]);
56
+
57
+ const CITY_NO_RESULT_LABELS = new Set([
58
+ "暂无结果",
59
+ "暂无数据",
60
+ "无结果"
61
+ ]);
62
+
63
+ function uniqueNodeIds(nodeIds = []) {
64
+ return Array.from(new Set(nodeIds.filter(Boolean)));
65
+ }
66
+
67
+ function buildRecruitSearchFrameUrl(pageUrl = RECRUIT_TARGET_URL) {
68
+ const origin = new URL(pageUrl).origin;
69
+ return `${origin}/web/frame/search/?jobId=&keywords=&t=${Date.now()}&source=&city=`;
70
+ }
71
+
72
+ async function navigateRecruitSearchFrame(client, iframeNodeId, {
73
+ pageUrl = RECRUIT_TARGET_URL,
74
+ reason = "reset_frame"
75
+ } = {}) {
76
+ if (!iframeNodeId || typeof client?.Page?.navigate !== "function") return null;
77
+ const iframeNode = await describeNode(client, iframeNodeId, { depth: 1, pierce: true });
78
+ const frameId = iframeNode?.frameId;
79
+ if (!frameId) return null;
80
+ const frameUrl = buildRecruitSearchFrameUrl(pageUrl);
81
+ await client.Page.navigate({ frameId, url: frameUrl });
82
+ return {
83
+ method: "Page.navigate",
84
+ scope: "frame",
85
+ frame_id: frameId,
86
+ url: frameUrl,
87
+ reason
88
+ };
89
+ }
90
+
91
+ export function normalizeRecruitSearchLabel(label) {
92
+ return normalizeText(label).replace(/\s+/g, "");
93
+ }
94
+
95
+ export function buildRecruitJobTitleSearchTerms(jobTitle) {
96
+ const normalized = normalizeText(jobTitle);
97
+ if (!normalized) return [];
98
+ const variants = [
99
+ normalized,
100
+ normalized.replace(/\s*[__]\s*/g, " "),
101
+ normalized.replace(/\s*[||]\s*/g, " ")
102
+ ].map(normalizeText).filter(Boolean);
103
+ const separatorParts = normalized.split(/\s*[__||]\s*/).map(normalizeText).filter(Boolean);
104
+ if (separatorParts.length > 1) {
105
+ variants.push(separatorParts.join(" "));
106
+ variants.push(separatorParts[0]);
107
+ }
108
+ return Array.from(new Set(variants));
109
+ }
110
+
111
+ export function isRecruitNationalCity(city) {
112
+ return NATIONAL_CITY_LABELS.has(normalizeRecruitSearchLabel(city));
113
+ }
114
+
115
+ export function resolveRecruitDegreeLabel(degree) {
116
+ const normalized = normalizeRecruitSearchLabel(degree || "不限");
117
+ return DEGREE_LABEL_MAP.get(normalized) || normalized || "不限";
118
+ }
119
+
120
+ export function normalizeRecruitDegreeLabels(value) {
121
+ const rawItems = Array.isArray(value)
122
+ ? value
123
+ : typeof value === "string"
124
+ ? value.split(/[,,、|/]/)
125
+ : [];
126
+ const labels = rawItems
127
+ .map(resolveRecruitDegreeLabel)
128
+ .filter(Boolean);
129
+ const uniqueLabels = uniqueNodeIds(labels);
130
+ return uniqueLabels.length ? uniqueLabels : ["不限"];
131
+ }
132
+
133
+ export function normalizeRecruitSearchParams(searchParams = {}) {
134
+ const degrees = normalizeRecruitDegreeLabels(searchParams.degrees || searchParams.degree || "不限");
135
+ const normalized = {
136
+ city: normalizeText(searchParams.city) || null,
137
+ degree: degrees[0] || "不限",
138
+ degrees,
139
+ schools: Array.isArray(searchParams.schools)
140
+ ? searchParams.schools.map(normalizeText).filter(Boolean)
141
+ : [],
142
+ keyword: normalizeText(searchParams.keyword) || DEFAULT_RECRUIT_KEYWORD,
143
+ filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
144
+ ? searchParams.filter_recent_viewed
145
+ : null
146
+ };
147
+ const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
148
+ if (job) normalized.job = job;
149
+ return normalized;
150
+ }
151
+
152
+ export function hasRecruitSearchParams(searchParams = {}) {
153
+ const degrees = normalizeRecruitDegreeLabels(searchParams.degrees || searchParams.degree || "不限");
154
+ const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
155
+ const normalized = {
156
+ city: normalizeText(searchParams.city) || null,
157
+ degree: degrees[0] || "不限",
158
+ degrees,
159
+ schools: Array.isArray(searchParams.schools)
160
+ ? searchParams.schools.map(normalizeText).filter(Boolean)
161
+ : [],
162
+ keyword: normalizeText(searchParams.keyword),
163
+ filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
164
+ ? searchParams.filter_recent_viewed
165
+ : null
166
+ };
167
+ return Boolean(
168
+ job
169
+ || normalized.city
170
+ || normalized.degrees.some((degree) => degree && degree !== "不限")
171
+ || normalized.schools.length
172
+ || normalized.keyword
173
+ || typeof normalized.filter_recent_viewed === "boolean"
174
+ );
175
+ }
176
+
177
+ function candidateIsActive(attributes = {}, outerHTML = "") {
178
+ const className = attributes.class || "";
179
+ const openingTag = String(outerHTML || "").split(">")[0] || "";
180
+ return ACTIVE_CLASS_PATTERN.test(className)
181
+ || ACTIVE_CLASS_PATTERN.test(openingTag)
182
+ || /\bchecked(?:=["']?checked)?\b/i.test(openingTag);
183
+ }
184
+
185
+ async function readTextCandidate(client, nodeId, {
186
+ selector = "",
187
+ index = 0
188
+ } = {}) {
189
+ const [attributes, outerHTML] = await Promise.all([
190
+ getAttributesMap(client, nodeId),
191
+ getOuterHTML(client, nodeId)
192
+ ]);
193
+ const text = normalizeText(htmlToText(outerHTML));
194
+ return {
195
+ node_id: nodeId,
196
+ selector,
197
+ index,
198
+ label: normalizeRecruitSearchLabel(text),
199
+ text,
200
+ active: candidateIsActive(attributes, outerHTML),
201
+ class_name: attributes.class || "",
202
+ attributes
203
+ };
204
+ }
205
+
206
+ async function listTextCandidates(client, rootNodeId, selectors = []) {
207
+ const candidates = [];
208
+ const seen = new Set();
209
+ for (const selector of selectors) {
210
+ const nodeIds = uniqueNodeIds(await querySelectorAll(client, rootNodeId, selector));
211
+ for (let index = 0; index < nodeIds.length; index += 1) {
212
+ const nodeId = nodeIds[index];
213
+ if (seen.has(nodeId)) continue;
214
+ seen.add(nodeId);
215
+ candidates.push(await readTextCandidate(client, nodeId, { selector, index }));
216
+ }
217
+ }
218
+ return candidates;
219
+ }
220
+
221
+ export function chooseRecruitTextCandidate(candidates = [], {
222
+ label = "",
223
+ match = "exact"
224
+ } = {}) {
225
+ const target = normalizeRecruitSearchLabel(label);
226
+ if (!target) return null;
227
+ const byExact = candidates.find((candidate) => candidate.label === target);
228
+ if (byExact) return byExact;
229
+ if (match === "exact") return null;
230
+ const byPrefix = candidates.find((candidate) => (
231
+ candidate.label.startsWith(target)
232
+ || target.startsWith(candidate.label)
233
+ ));
234
+ if (byPrefix) return byPrefix;
235
+ if (match === "prefix") return null;
236
+ return candidates.find((candidate) => candidate.label.includes(target) || target.includes(candidate.label)) || null;
237
+ }
238
+
239
+ async function findTextCandidate(client, rootNodeId, selectors, label, options = {}) {
240
+ const candidates = await listTextCandidates(client, rootNodeId, selectors);
241
+ return {
242
+ candidate: chooseRecruitTextCandidate(candidates, { label, ...options }),
243
+ candidates
244
+ };
245
+ }
246
+
247
+ function summarizeTextCandidates(candidates = [], limit = 20) {
248
+ return candidates.map((item) => ({
249
+ label: item.text,
250
+ active: item.active,
251
+ node_id: item.node_id,
252
+ selector: item.selector
253
+ })).slice(0, limit);
254
+ }
255
+
256
+ async function waitForRecruitTextCandidate(client, rootNodeId, selectors, label, {
257
+ timeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS,
258
+ intervalMs = 300,
259
+ match = "exact"
260
+ } = {}) {
261
+ const started = Date.now();
262
+ let candidate = null;
263
+ let candidates = [];
264
+ while (Date.now() - started <= timeoutMs) {
265
+ const found = await findTextCandidate(client, rootNodeId, selectors, label, { match });
266
+ candidate = found.candidate;
267
+ candidates = found.candidates;
268
+ if (candidate) break;
269
+ await sleep(intervalMs);
270
+ }
271
+ return {
272
+ candidate,
273
+ candidates,
274
+ elapsed_ms: Date.now() - started
275
+ };
276
+ }
277
+
278
+ async function waitForRecruitJobTitleCandidate(client, rootNodeId, selectors, jobTitle, {
279
+ timeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS,
280
+ intervalMs = 300
281
+ } = {}) {
282
+ const terms = buildRecruitJobTitleSearchTerms(jobTitle);
283
+ const started = Date.now();
284
+ let candidate = null;
285
+ let candidates = [];
286
+ let matchedTerm = "";
287
+ while (Date.now() - started <= timeoutMs) {
288
+ candidates = await listTextCandidates(client, rootNodeId, selectors);
289
+ for (const term of terms) {
290
+ const found = chooseRecruitTextCandidate(candidates, { label: term, match: "contains" });
291
+ if (found) {
292
+ candidate = found;
293
+ matchedTerm = term;
294
+ break;
295
+ }
296
+ }
297
+ if (candidate) break;
298
+ await sleep(intervalMs);
299
+ }
300
+ return {
301
+ candidate,
302
+ candidates,
303
+ matched_term: matchedTerm,
304
+ search_terms: terms,
305
+ elapsed_ms: Date.now() - started
306
+ };
307
+ }
308
+
309
+ async function clickFirstNodeBySelectors(client, rootNodeId, selectors, {
310
+ optional = false,
311
+ scrollIntoView = true
312
+ } = {}) {
313
+ const found = await findFirstNode(client, rootNodeId, selectors);
314
+ if (!found) {
315
+ if (optional) return { clicked: false, reason: "not_found" };
316
+ throw new Error(`Recruit search node was not found for selectors: ${selectors.join(", ")}`);
317
+ }
318
+ try {
319
+ const box = await clickNodeCenter(client, found.nodeId, {
320
+ ...DETERMINISTIC_CLICK_OPTIONS,
321
+ scrollIntoView
322
+ });
323
+ await sleep(250);
324
+ return {
325
+ clicked: true,
326
+ selector: found.selector,
327
+ node_id: found.nodeId,
328
+ box
329
+ };
330
+ } catch (error) {
331
+ if (optional) {
332
+ return {
333
+ clicked: false,
334
+ reason: "not_clickable",
335
+ selector: found.selector,
336
+ node_id: found.nodeId,
337
+ error: error?.message || String(error)
338
+ };
339
+ }
340
+ throw error;
341
+ }
342
+ }
343
+
344
+ async function dismissRecruitSearchOverlays(client, settleMs = 250) {
345
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
346
+ return {
347
+ method: "Escape",
348
+ skipped: true,
349
+ reason: "dispatch_key_unavailable"
350
+ };
351
+ }
352
+ await pressKey(client, "Escape", {
353
+ code: "Escape",
354
+ windowsVirtualKeyCode: 27,
355
+ nativeVirtualKeyCode: 27
356
+ });
357
+ if (settleMs > 0) await sleep(settleMs);
358
+ return {
359
+ method: "Escape",
360
+ settle_ms: settleMs
361
+ };
362
+ }
363
+
364
+ export async function getRecruitSearchCounts(client, frameNodeId) {
365
+ return countSelectors(client, frameNodeId, {
366
+ keyword_input: RECRUIT_SEARCH_SELECTORS.keywordInput.join(", "),
367
+ search_button: RECRUIT_SEARCH_SELECTORS.searchButton.join(", "),
368
+ degree_option: RECRUIT_SEARCH_SELECTORS.degreeOption.join(", "),
369
+ school_item: RECRUIT_SEARCH_SELECTORS.schoolItem.join(", "),
370
+ recent_viewed_label: RECRUIT_SEARCH_SELECTORS.recentViewedLabel.join(", "),
371
+ candidate_card: RECRUIT_CARD_SELECTOR,
372
+ no_data: RECRUIT_NO_DATA_SELECTORS.join(", ")
373
+ });
374
+ }
375
+
376
+ export async function waitForRecruitSearchControls(client, {
377
+ timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
378
+ intervalMs = 300
379
+ } = {}) {
380
+ const started = Date.now();
381
+ let lastState = null;
382
+ while (Date.now() - started <= timeoutMs) {
383
+ const roots = await getRecruitRoots(client, { requireFrame: false });
384
+ const frameNodeId = roots.iframe?.documentNodeId;
385
+ if (frameNodeId) {
386
+ const counts = await getRecruitSearchCounts(client, frameNodeId);
387
+ lastState = {
388
+ ok: counts.keyword_input > 0 && counts.search_button > 0,
389
+ elapsed_ms: Date.now() - started,
390
+ iframe_selector: roots.iframe.selector,
391
+ iframe_document_node_id: frameNodeId,
392
+ counts
393
+ };
394
+ if (lastState.ok) return lastState;
395
+ }
396
+ await sleep(intervalMs);
397
+ }
398
+ return {
399
+ ok: false,
400
+ elapsed_ms: Date.now() - started,
401
+ ...(lastState || {})
402
+ };
403
+ }
404
+
405
+ export async function resetRecruitSearchPage(client, {
406
+ url = RECRUIT_TARGET_URL,
407
+ settleMs = 5000,
408
+ timeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS
409
+ } = {}) {
410
+ const actions = [];
411
+ const rootTimeoutMs = Math.min(timeoutMs, 90000);
412
+ async function waitForRootsAfterSettle() {
413
+ await sleep(settleMs);
414
+ return waitForRecruitRoots(client, {
415
+ timeoutMs: rootTimeoutMs,
416
+ intervalMs: 300
417
+ });
418
+ }
419
+
420
+ async function waitForControls() {
421
+ return waitForRecruitSearchControls(client, {
422
+ timeoutMs,
423
+ intervalMs: 300
424
+ });
425
+ }
426
+
427
+ if (typeof client?.Page?.reload === "function") {
428
+ await client.Page.reload({ ignoreCache: true });
429
+ actions.push({ method: "Page.reload" });
430
+ } else {
431
+ await client.Page.navigate({ url });
432
+ actions.push({ method: "Page.navigate", url });
433
+ }
434
+
435
+ let roots = await waitForRootsAfterSettle();
436
+ const frameReset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, {
437
+ pageUrl: url,
438
+ reason: "reset_frame_after_page_reload"
439
+ });
440
+ if (frameReset) {
441
+ actions.push(frameReset);
442
+ await sleep(settleMs);
443
+ }
444
+
445
+ let controls = await waitForControls();
446
+ if (!controls.ok && typeof client?.Page?.navigate === "function") {
447
+ await client.Page.navigate({ url });
448
+ actions.push({
449
+ method: "Page.navigate",
450
+ url,
451
+ reason: roots?.iframe?.documentNodeId ? "controls_not_ready" : "iframe_not_ready"
452
+ });
453
+ roots = await waitForRootsAfterSettle();
454
+ const fallbackFrameReset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, {
455
+ pageUrl: url,
456
+ reason: "reset_frame_after_page_navigate"
457
+ });
458
+ if (fallbackFrameReset) {
459
+ actions.push(fallbackFrameReset);
460
+ await sleep(settleMs);
461
+ }
462
+ controls = await waitForControls();
463
+ }
464
+ roots = await getRecruitRoots(client, { requireFrame: false });
465
+ if (!controls.ok && !roots?.iframe?.documentNodeId) {
466
+ throw new Error("Recruit search page reset did not expose searchFrame iframe");
467
+ }
468
+ if (!controls.ok) {
469
+ throw new Error("Recruit search page reset exposed iframe but search controls were not ready");
470
+ }
471
+ return {
472
+ actions,
473
+ target_url: url,
474
+ iframe_selector: controls.iframe_selector || roots.iframe.selector,
475
+ iframe_document_node_id: controls.iframe_document_node_id || roots.iframe.documentNodeId,
476
+ controls
477
+ };
478
+ }
479
+
480
+ export async function setRecruitKeyword(client, frameNodeId, keyword) {
481
+ const normalizedKeyword = normalizeText(keyword);
482
+ if (!normalizedKeyword) {
483
+ return { applied: false, reason: "empty_keyword" };
484
+ }
485
+ const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.keywordInput);
486
+ await clearFocusedInput(client);
487
+ await sleep(120);
488
+ await insertText(client, normalizedKeyword);
489
+ await sleep(350);
490
+ return {
491
+ applied: true,
492
+ keyword: normalizedKeyword,
493
+ input
494
+ };
495
+ }
496
+
497
+ export async function selectRecruitDefaultJobTitle(client, frameNodeId) {
498
+ return clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.jobTitleOption, {
499
+ optional: true
500
+ });
501
+ }
502
+
503
+ export async function setRecruitJobTitle(client, frameNodeId, jobTitle, {
504
+ optionTimeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS
505
+ } = {}) {
506
+ const normalizedJobTitle = normalizeText(jobTitle);
507
+ if (!normalizedJobTitle) {
508
+ return { applied: false, reason: "empty_job_title" };
509
+ }
510
+ const lookup = await waitForRecruitJobTitleCandidate(
511
+ client,
512
+ frameNodeId,
513
+ RECRUIT_SEARCH_SELECTORS.jobTitleOption,
514
+ normalizedJobTitle,
515
+ { timeoutMs: Math.min(optionTimeoutMs, 30000) }
516
+ );
517
+ if (!lookup.candidate) {
518
+ throw new Error(`Recruit job title option was not found: ${normalizedJobTitle}`);
519
+ }
520
+ let box = null;
521
+ if (!lookup.candidate.active) {
522
+ box = await clickNodeCenter(client, lookup.candidate.node_id, {
523
+ ...DETERMINISTIC_CLICK_OPTIONS,
524
+ scrollIntoView: true
525
+ });
526
+ await sleep(500);
527
+ }
528
+ return {
529
+ applied: true,
530
+ requested_job: normalizedJobTitle,
531
+ selected_label: lookup.candidate.text,
532
+ matched_term: lookup.matched_term,
533
+ search_terms: lookup.search_terms,
534
+ selected_node_id: lookup.candidate.node_id,
535
+ was_active: lookup.candidate.active,
536
+ clicked: !lookup.candidate.active,
537
+ box,
538
+ discovered_options: summarizeTextCandidates(lookup.candidates, 30)
539
+ };
540
+ }
541
+
542
+ export async function setRecruitDegree(client, frameNodeId, degree) {
543
+ const degreeLabel = resolveRecruitDegreeLabel(degree);
544
+ if (!degreeLabel || degreeLabel === "不限") {
545
+ return { applied: false, reason: "unlimited_degree", degree: degreeLabel || "不限" };
546
+ }
547
+ const { candidate, candidates } = await findTextCandidate(
548
+ client,
549
+ frameNodeId,
550
+ RECRUIT_SEARCH_SELECTORS.degreeOption,
551
+ degreeLabel,
552
+ { match: "prefix" }
553
+ );
554
+ if (!candidate) {
555
+ throw new Error(`Recruit degree option was not found: ${degreeLabel}`);
556
+ }
557
+ const box = await clickNodeCenter(client, candidate.node_id, {
558
+ ...DETERMINISTIC_CLICK_OPTIONS,
559
+ scrollIntoView: true
560
+ });
561
+ await sleep(350);
562
+ return {
563
+ applied: true,
564
+ requested_degree: degree,
565
+ selected_label: candidate.text,
566
+ selected_node_id: candidate.node_id,
567
+ was_active: candidate.active,
568
+ box,
569
+ discovered_options: candidates.map((item) => ({
570
+ label: item.text,
571
+ active: item.active,
572
+ node_id: item.node_id,
573
+ selector: item.selector
574
+ }))
575
+ };
576
+ }
577
+
578
+ export async function setRecruitDegrees(client, frameNodeId, degrees = []) {
579
+ const labels = normalizeRecruitDegreeLabels(degrees).filter((label) => label && label !== "不限");
580
+ if (!labels.length) {
581
+ return { applied: false, reason: "unlimited_degree", degrees: ["不限"], selected: [] };
582
+ }
583
+
584
+ const selected = [];
585
+ let discoveredOptions = [];
586
+ for (const label of labels) {
587
+ const { candidate, candidates } = await findTextCandidate(
588
+ client,
589
+ frameNodeId,
590
+ RECRUIT_SEARCH_SELECTORS.degreeOption,
591
+ label,
592
+ { match: "prefix" }
593
+ );
594
+ discoveredOptions = candidates.map((item) => ({
595
+ label: item.text,
596
+ active: item.active,
597
+ node_id: item.node_id,
598
+ selector: item.selector
599
+ }));
600
+ if (!candidate) {
601
+ throw new Error(`Recruit degree option was not found: ${label}`);
602
+ }
603
+
604
+ let box = null;
605
+ if (!candidate.active) {
606
+ box = await clickNodeCenter(client, candidate.node_id, {
607
+ ...DETERMINISTIC_CLICK_OPTIONS,
608
+ scrollIntoView: true
609
+ });
610
+ await sleep(350);
611
+ }
612
+ selected.push({
613
+ requested_degree: label,
614
+ selected_label: candidate.text,
615
+ selected_node_id: candidate.node_id,
616
+ was_active: candidate.active,
617
+ clicked: !candidate.active,
618
+ box
619
+ });
620
+ }
621
+
622
+ return {
623
+ applied: true,
624
+ requested_degrees: labels,
625
+ selected,
626
+ discovered_options: discoveredOptions
627
+ };
628
+ }
629
+
630
+ async function findClickableDescendant(client, nodeId, selectors) {
631
+ for (const selector of selectors) {
632
+ const childNodeId = await querySelector(client, nodeId, selector);
633
+ if (childNodeId) return { node_id: childNodeId, selector };
634
+ }
635
+ return { node_id: nodeId, selector: null };
636
+ }
637
+
638
+ export async function setRecruitSchools(client, frameNodeId, schools = []) {
639
+ const targets = schools.map(normalizeText).filter(Boolean);
640
+ const applied = [];
641
+ const missing = [];
642
+ if (!targets.length) {
643
+ return { applied: false, schools: [], selected: [], missing: [] };
644
+ }
645
+
646
+ for (const school of targets) {
647
+ const { candidate, candidates } = await findTextCandidate(
648
+ client,
649
+ frameNodeId,
650
+ RECRUIT_SEARCH_SELECTORS.schoolItem,
651
+ school,
652
+ { match: "contains" }
653
+ );
654
+ if (!candidate) {
655
+ missing.push({
656
+ school,
657
+ discovered: candidates.map((item) => item.text).slice(0, 20)
658
+ });
659
+ continue;
660
+ }
661
+
662
+ const clickable = await findClickableDescendant(client, candidate.node_id, RECRUIT_SEARCH_SELECTORS.schoolClickable);
663
+ let clickableActive = candidate.active;
664
+ if (clickable.node_id !== candidate.node_id) {
665
+ const clickableCandidate = await readTextCandidate(client, clickable.node_id, {
666
+ selector: clickable.selector || "",
667
+ index: 0
668
+ });
669
+ clickableActive = clickableActive || clickableCandidate.active;
670
+ }
671
+
672
+ let box = null;
673
+ if (!clickableActive) {
674
+ box = await clickNodeCenter(client, clickable.node_id, {
675
+ ...DETERMINISTIC_CLICK_OPTIONS,
676
+ scrollIntoView: true
677
+ });
678
+ await sleep(350);
679
+ }
680
+
681
+ applied.push({
682
+ school,
683
+ selected_label: candidate.text,
684
+ selected_node_id: candidate.node_id,
685
+ clickable_node_id: clickable.node_id,
686
+ clickable_selector: clickable.selector,
687
+ was_active: clickableActive,
688
+ clicked: !clickableActive,
689
+ box
690
+ });
691
+ }
692
+
693
+ if (missing.length) {
694
+ throw new Error(`Recruit school options were not found: ${missing.map((item) => item.school).join(", ")}`);
695
+ }
696
+
697
+ return {
698
+ applied: true,
699
+ schools: targets,
700
+ selected: applied,
701
+ missing
702
+ };
703
+ }
704
+
705
+ export async function setRecruitRecentViewedFilter(client, frameNodeId, enabled) {
706
+ if (typeof enabled !== "boolean") {
707
+ return { applied: false, reason: "not_requested" };
708
+ }
709
+ const { candidate, candidates } = await findTextCandidate(
710
+ client,
711
+ frameNodeId,
712
+ RECRUIT_SEARCH_SELECTORS.recentViewedLabel,
713
+ "过滤近14天查看",
714
+ { match: "contains" }
715
+ );
716
+ if (!candidate) {
717
+ throw new Error("Recruit recent-viewed filter was not found");
718
+ }
719
+
720
+ let box = null;
721
+ if (candidate.active !== enabled) {
722
+ box = await clickNodeCenter(client, candidate.node_id, {
723
+ ...DETERMINISTIC_CLICK_OPTIONS,
724
+ scrollIntoView: true
725
+ });
726
+ await sleep(900);
727
+ }
728
+
729
+ return {
730
+ applied: true,
731
+ requested: enabled,
732
+ was_active: candidate.active,
733
+ changed: candidate.active !== enabled,
734
+ selected_label: candidate.text,
735
+ selected_node_id: candidate.node_id,
736
+ box,
737
+ discovered_options: candidates.map((item) => ({
738
+ label: item.text,
739
+ active: item.active,
740
+ node_id: item.node_id,
741
+ selector: item.selector
742
+ }))
743
+ };
744
+ }
745
+
746
+ async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
747
+ requestedCity = "全国",
748
+ reason = "national_city_requested",
749
+ optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
750
+ } = {}) {
751
+ const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
752
+ await clearFocusedInput(client);
753
+ await sleep(500);
754
+
755
+ const path = [];
756
+ const categoryLookup = await waitForRecruitTextCandidate(
757
+ client,
758
+ frameNodeId,
759
+ RECRUIT_SEARCH_SELECTORS.citySearchResult,
760
+ "城市",
761
+ { match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
762
+ );
763
+ if (categoryLookup.candidate) {
764
+ const box = await clickNodeCenter(client, categoryLookup.candidate.node_id, {
765
+ ...DETERMINISTIC_CLICK_OPTIONS,
766
+ scrollIntoView: true
767
+ });
768
+ await sleep(400);
769
+ path.push({
770
+ label: "城市",
771
+ selected_label: categoryLookup.candidate.text,
772
+ node_id: categoryLookup.candidate.node_id,
773
+ box
774
+ });
775
+ } else {
776
+ path.push({
777
+ label: "城市",
778
+ skipped: true,
779
+ reason: "not_found_or_already_expanded",
780
+ discovered_options: summarizeTextCandidates(categoryLookup.candidates)
781
+ });
782
+ }
783
+
784
+ let popularLookup = await waitForRecruitTextCandidate(
785
+ client,
786
+ frameNodeId,
787
+ RECRUIT_SEARCH_SELECTORS.cityProvinceItem,
788
+ "热门",
789
+ { match: "exact", timeoutMs: optionTimeoutMs }
790
+ );
791
+ if (!popularLookup.candidate) {
792
+ popularLookup = await waitForRecruitTextCandidate(
793
+ client,
794
+ frameNodeId,
795
+ RECRUIT_SEARCH_SELECTORS.citySearchResult,
796
+ "热门",
797
+ { match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
798
+ );
799
+ }
800
+ if (!popularLookup.candidate) {
801
+ return {
802
+ applied: false,
803
+ reason: "national_city_popular_not_found",
804
+ requested_city: requestedCity,
805
+ input,
806
+ path,
807
+ discovered_options: summarizeTextCandidates(popularLookup.candidates)
808
+ };
809
+ }
810
+ const popularBox = await clickNodeCenter(client, popularLookup.candidate.node_id, {
811
+ ...DETERMINISTIC_CLICK_OPTIONS,
812
+ scrollIntoView: true
813
+ });
814
+ await sleep(400);
815
+ path.push({
816
+ label: "热门",
817
+ selected_label: popularLookup.candidate.text,
818
+ node_id: popularLookup.candidate.node_id,
819
+ box: popularBox
820
+ });
821
+
822
+ let nationalLookup = await waitForRecruitTextCandidate(
823
+ client,
824
+ frameNodeId,
825
+ RECRUIT_SEARCH_SELECTORS.cityDropdownItem,
826
+ "全国",
827
+ { match: "exact", timeoutMs: optionTimeoutMs }
828
+ );
829
+ if (!nationalLookup.candidate) {
830
+ nationalLookup = await waitForRecruitTextCandidate(
831
+ client,
832
+ frameNodeId,
833
+ RECRUIT_SEARCH_SELECTORS.citySearchResult,
834
+ "全国",
835
+ { match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
836
+ );
837
+ }
838
+ if (!nationalLookup.candidate) {
839
+ return {
840
+ applied: false,
841
+ reason: "national_city_option_not_found",
842
+ requested_city: requestedCity,
843
+ input,
844
+ path,
845
+ discovered_options: summarizeTextCandidates(nationalLookup.candidates)
846
+ };
847
+ }
848
+
849
+ const nationalBox = await clickNodeCenter(client, nationalLookup.candidate.node_id, {
850
+ ...DETERMINISTIC_CLICK_OPTIONS,
851
+ scrollIntoView: true
852
+ });
853
+ await sleep(700);
854
+ path.push({
855
+ label: "全国",
856
+ selected_label: nationalLookup.candidate.text,
857
+ node_id: nationalLookup.candidate.node_id,
858
+ box: nationalBox
859
+ });
860
+
861
+ return {
862
+ applied: true,
863
+ reason,
864
+ city: "全国",
865
+ requested_city: requestedCity,
866
+ selected_label: nationalLookup.candidate.text,
867
+ selected_node_id: nationalLookup.candidate.node_id,
868
+ input,
869
+ path,
870
+ box: nationalBox,
871
+ selection_mode: "city_picker",
872
+ picker_path: ["城市", "热门", "全国"]
873
+ };
874
+ }
875
+
876
+ async function resetRecruitCityToNational(client, {
877
+ requestedCity = "",
878
+ reason = "national_city_frame_reset",
879
+ optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
880
+ } = {}) {
881
+ const roots = await getRecruitRoots(client, { requireFrame: false });
882
+ const reset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, { reason });
883
+ if (!reset) {
884
+ return {
885
+ applied: false,
886
+ reason: "national_city_frame_reset_unavailable",
887
+ requested_city: requestedCity
888
+ };
889
+ }
890
+ await sleep(1500);
891
+ const controls = await waitForRecruitSearchControls(client, {
892
+ timeoutMs: Math.max(optionTimeoutMs, DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS),
893
+ intervalMs: 300
894
+ });
895
+ return {
896
+ applied: controls.ok,
897
+ reason,
898
+ city: "全国",
899
+ requested_city: requestedCity,
900
+ selected_label: "全国",
901
+ selection_mode: "frame_reset",
902
+ reset,
903
+ controls,
904
+ reacquire_frame: true
905
+ };
906
+ }
907
+
908
+ export async function setRecruitCity(client, frameNodeId, city, {
909
+ optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
910
+ } = {}) {
911
+ const normalizedCity = normalizeText(city);
912
+ if (!normalizedCity) {
913
+ return { applied: false, reason: "empty_city" };
914
+ }
915
+ if (isRecruitNationalCity(normalizedCity)) {
916
+ return selectRecruitNationalCityThroughPicker(client, frameNodeId, {
917
+ requestedCity: normalizedCity,
918
+ reason: "national_city_requested",
919
+ optionTimeoutMs
920
+ });
921
+ }
922
+
923
+ const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
924
+ await clearFocusedInput(client);
925
+ await sleep(120);
926
+ await insertText(client, normalizedCity);
927
+ await sleep(500);
928
+
929
+ const started = Date.now();
930
+ const noResultFallbackMs = Math.min(DEFAULT_RECRUIT_CITY_NO_RESULT_FALLBACK_MS, optionTimeoutMs);
931
+ let candidate = null;
932
+ let candidates = [];
933
+ let noResultFirstSeenAt = 0;
934
+ while (Date.now() - started <= optionTimeoutMs) {
935
+ const found = await findTextCandidate(
936
+ client,
937
+ frameNodeId,
938
+ RECRUIT_SEARCH_SELECTORS.citySearchResult,
939
+ normalizedCity,
940
+ { match: "contains" }
941
+ );
942
+ candidate = found.candidate;
943
+ candidates = found.candidates;
944
+ if (candidate) break;
945
+ const hasNoResult = candidates.some((item) => CITY_NO_RESULT_LABELS.has(item.label));
946
+ if (hasNoResult) {
947
+ if (!noResultFirstSeenAt) noResultFirstSeenAt = Date.now();
948
+ if (Date.now() - noResultFirstSeenAt >= noResultFallbackMs) break;
949
+ } else {
950
+ noResultFirstSeenAt = 0;
951
+ }
952
+ await sleep(300);
953
+ }
954
+ if (!candidate) {
955
+ const nationalFallback = await selectRecruitNationalCityThroughPicker(client, frameNodeId, {
956
+ requestedCity: normalizedCity,
957
+ reason: "city_result_not_found",
958
+ optionTimeoutMs
959
+ });
960
+ if (nationalFallback.applied) {
961
+ return {
962
+ ...nationalFallback,
963
+ reason: "city_result_not_found",
964
+ requested_city: normalizedCity,
965
+ requested_city_not_found: true,
966
+ fallback_to_national: true,
967
+ original_input: input,
968
+ elapsed_ms: Date.now() - started,
969
+ discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
970
+ };
971
+ }
972
+
973
+ const resetFallback = await resetRecruitCityToNational(client, {
974
+ requestedCity: normalizedCity,
975
+ reason: "city_result_not_found_frame_reset",
976
+ optionTimeoutMs
977
+ });
978
+ if (resetFallback.applied) {
979
+ return {
980
+ ...resetFallback,
981
+ reason: "city_result_not_found",
982
+ requested_city: normalizedCity,
983
+ requested_city_not_found: true,
984
+ fallback_to_national: true,
985
+ original_input: input,
986
+ picker_fallback: nationalFallback,
987
+ elapsed_ms: Date.now() - started,
988
+ discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
989
+ };
990
+ }
991
+
992
+ return {
993
+ applied: false,
994
+ reason: "city_result_not_found",
995
+ city: normalizedCity,
996
+ input,
997
+ elapsed_ms: Date.now() - started,
998
+ discovered_options: candidates.map((item) => item.text).slice(0, 20),
999
+ national_fallback: nationalFallback,
1000
+ reset_fallback: resetFallback
1001
+ };
1002
+ }
1003
+
1004
+ const box = await clickNodeCenter(client, candidate.node_id, {
1005
+ ...DETERMINISTIC_CLICK_OPTIONS,
1006
+ scrollIntoView: true
1007
+ });
1008
+ await sleep(600);
1009
+ return {
1010
+ applied: true,
1011
+ city: normalizedCity,
1012
+ selected_label: candidate.text,
1013
+ selected_node_id: candidate.node_id,
1014
+ input,
1015
+ elapsed_ms: Date.now() - started,
1016
+ box
1017
+ };
1018
+ }
1019
+
1020
+ export async function clickRecruitSearch(client, frameNodeId) {
1021
+ const buttonResult = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.searchButton, {
1022
+ optional: true,
1023
+ scrollIntoView: false
1024
+ });
1025
+ if (buttonResult.clicked) {
1026
+ await sleep(1500);
1027
+ return {
1028
+ searched: true,
1029
+ mode: "button",
1030
+ button: buttonResult
1031
+ };
1032
+ }
1033
+
1034
+ await pressKey(client, "Enter", {
1035
+ code: "Enter",
1036
+ windowsVirtualKeyCode: 13,
1037
+ nativeVirtualKeyCode: 13
1038
+ });
1039
+ await sleep(1500);
1040
+ return {
1041
+ searched: true,
1042
+ mode: "enter"
1043
+ };
1044
+ }
1045
+
1046
+ export async function waitForRecruitSearchResultState(client, {
1047
+ timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
1048
+ intervalMs = 500
1049
+ } = {}) {
1050
+ const started = Date.now();
1051
+ let lastState = null;
1052
+ while (Date.now() - started <= timeoutMs) {
1053
+ try {
1054
+ const roots = await getRecruitRoots(client, { requireFrame: false });
1055
+ const frameNodeId = roots.iframe?.documentNodeId;
1056
+ if (frameNodeId) {
1057
+ const counts = await countSelectors(client, frameNodeId, {
1058
+ candidate_card: RECRUIT_CARD_SELECTOR,
1059
+ no_data: RECRUIT_NO_DATA_SELECTORS.join(", ")
1060
+ });
1061
+ lastState = {
1062
+ ok: counts.candidate_card > 0 || counts.no_data > 0,
1063
+ elapsed_ms: Date.now() - started,
1064
+ iframe_selector: roots.iframe.selector,
1065
+ iframe_document_node_id: frameNodeId,
1066
+ counts
1067
+ };
1068
+ if (lastState.ok) return lastState;
1069
+ }
1070
+ } catch (error) {
1071
+ lastState = {
1072
+ ok: false,
1073
+ elapsed_ms: Date.now() - started,
1074
+ error: error?.message || String(error)
1075
+ };
1076
+ }
1077
+ await sleep(intervalMs);
1078
+ }
1079
+ return {
1080
+ ok: false,
1081
+ elapsed_ms: Date.now() - started,
1082
+ ...(lastState || {})
1083
+ };
1084
+ }
1085
+
1086
+ export async function applyRecruitSearchParams(client, {
1087
+ searchParams = {},
1088
+ requireCards = true,
1089
+ resetBeforeApply = false,
1090
+ searchTimeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
1091
+ resetTimeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS,
1092
+ resetSettleMs = 5000,
1093
+ cityOptionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
1094
+ } = {}) {
1095
+ const normalizedSearchParams = normalizeRecruitSearchParams(searchParams);
1096
+ const reset = resetBeforeApply
1097
+ ? await resetRecruitSearchPage(client, {
1098
+ timeoutMs: resetTimeoutMs,
1099
+ settleMs: resetSettleMs
1100
+ })
1101
+ : null;
1102
+ const controls = reset?.controls?.ok
1103
+ ? reset.controls
1104
+ : await waitForRecruitSearchControls(client, {
1105
+ timeoutMs: searchTimeoutMs,
1106
+ intervalMs: 500
1107
+ });
1108
+ if (!controls.ok) {
1109
+ throw new Error(`Recruit search controls were not ready after navigation; counts=${JSON.stringify(controls.counts || {})}`);
1110
+ }
1111
+ const overlayDismissal = await dismissRecruitSearchOverlays(client);
1112
+ const initialRoots = await getRecruitRoots(client);
1113
+ let frameNodeId = initialRoots.iframe.documentNodeId;
1114
+ const initialFrameNodeId = frameNodeId;
1115
+ const beforeCounts = await getRecruitSearchCounts(client, frameNodeId);
1116
+ const steps = [];
1117
+
1118
+ if (normalizedSearchParams.job) {
1119
+ steps.push({
1120
+ step: "job_title",
1121
+ result: await setRecruitJobTitle(client, frameNodeId, normalizedSearchParams.job, {
1122
+ optionTimeoutMs: searchTimeoutMs
1123
+ })
1124
+ });
1125
+ }
1126
+
1127
+ if (normalizedSearchParams.city) {
1128
+ const cityResult = await setRecruitCity(client, frameNodeId, normalizedSearchParams.city, {
1129
+ optionTimeoutMs: cityOptionTimeoutMs
1130
+ });
1131
+ steps.push({
1132
+ step: "city",
1133
+ result: cityResult
1134
+ });
1135
+ if (cityResult?.reacquire_frame) {
1136
+ const rootsAfterCity = await getRecruitRoots(client);
1137
+ frameNodeId = rootsAfterCity.iframe.documentNodeId;
1138
+ steps.push({
1139
+ step: "reacquire_after_city",
1140
+ result: {
1141
+ selector: rootsAfterCity.iframe.selector,
1142
+ document_node_id: frameNodeId,
1143
+ reason: cityResult.reason
1144
+ }
1145
+ });
1146
+ }
1147
+ }
1148
+
1149
+ steps.push({
1150
+ step: "degree",
1151
+ result: await setRecruitDegrees(client, frameNodeId, normalizedSearchParams.degrees)
1152
+ });
1153
+
1154
+ steps.push({
1155
+ step: "schools",
1156
+ result: await setRecruitSchools(client, frameNodeId, normalizedSearchParams.schools)
1157
+ });
1158
+
1159
+ steps.push({
1160
+ step: "keyword",
1161
+ result: await setRecruitKeyword(client, frameNodeId, normalizedSearchParams.keyword)
1162
+ });
1163
+
1164
+ steps.push({
1165
+ step: "search",
1166
+ result: await clickRecruitSearch(client, frameNodeId)
1167
+ });
1168
+
1169
+ if (typeof normalizedSearchParams.filter_recent_viewed === "boolean") {
1170
+ const postSearchRoots = await getRecruitRoots(client);
1171
+ steps.push({
1172
+ step: "recent_viewed",
1173
+ result: await setRecruitRecentViewedFilter(
1174
+ client,
1175
+ postSearchRoots.iframe.documentNodeId,
1176
+ normalizedSearchParams.filter_recent_viewed
1177
+ )
1178
+ });
1179
+ }
1180
+
1181
+ const postSearchState = await waitForRecruitSearchResultState(client, {
1182
+ timeoutMs: searchTimeoutMs
1183
+ });
1184
+ if (requireCards && (postSearchState.counts?.candidate_card || 0) === 0) {
1185
+ throw new Error(`Recruit search did not produce candidate cards; no_data=${postSearchState.counts?.no_data || 0}`);
1186
+ }
1187
+
1188
+ return {
1189
+ applied: true,
1190
+ search_params: normalizedSearchParams,
1191
+ reset,
1192
+ overlay_dismissal: overlayDismissal,
1193
+ controls,
1194
+ initial_iframe: {
1195
+ selector: initialRoots.iframe.selector,
1196
+ document_node_id: initialFrameNodeId
1197
+ },
1198
+ before_counts: beforeCounts,
1199
+ steps,
1200
+ post_search_state: postSearchState
1201
+ };
1202
+ }