@reconcrap/boss-recommend-mcp 2.1.21 → 2.1.22

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 (67) hide show
  1. package/README.md +5 -2
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/config/screening-config.example.json +33 -33
  4. package/package.json +8 -8
  5. package/scripts/install-macos.sh +280 -280
  6. package/scripts/postinstall.cjs +44 -44
  7. package/skills/boss-chat/README.md +42 -42
  8. package/skills/boss-chat/SKILL.md +106 -106
  9. package/skills/boss-recommend-pipeline/README.md +13 -13
  10. package/skills/boss-recommend-pipeline/SKILL.md +219 -214
  11. package/skills/boss-recruit-pipeline/README.md +19 -19
  12. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  13. package/src/chat-mcp.js +127 -127
  14. package/src/chat-runtime-config.js +775 -775
  15. package/src/cli.js +573 -573
  16. package/src/core/boss-cards/index.js +199 -199
  17. package/src/core/browser/index.js +2419 -2415
  18. package/src/core/capture/index.js +1201 -1201
  19. package/src/core/cv-acquisition/index.js +238 -238
  20. package/src/core/cv-capture-target/index.js +299 -299
  21. package/src/core/greet-quota/index.js +71 -71
  22. package/src/core/infinite-list/index.js +1326 -1326
  23. package/src/core/reporting/legacy-csv.js +334 -332
  24. package/src/core/run/index.js +32 -32
  25. package/src/core/run/timing.js +33 -33
  26. package/src/core/screening/index.js +2135 -2135
  27. package/src/core/self-heal/index.js +973 -973
  28. package/src/core/self-heal/viewport.js +564 -564
  29. package/src/detached-worker.js +99 -99
  30. package/src/domains/chat/cards.js +137 -137
  31. package/src/domains/chat/constants.js +9 -9
  32. package/src/domains/chat/detail.js +113 -113
  33. package/src/domains/chat/index.js +7 -7
  34. package/src/domains/chat/jobs.js +620 -620
  35. package/src/domains/chat/page-guard.js +122 -122
  36. package/src/domains/chat/roots.js +56 -56
  37. package/src/domains/chat/run-service.js +571 -571
  38. package/src/domains/common/account-rights-panel.js +314 -314
  39. package/src/domains/common/recovery-settle.js +159 -159
  40. package/src/domains/recommend/actions.js +472 -472
  41. package/src/domains/recommend/cards.js +243 -243
  42. package/src/domains/recommend/colleague-contact.js +333 -333
  43. package/src/domains/recommend/constants.js +228 -159
  44. package/src/domains/recommend/detail.js +650 -650
  45. package/src/domains/recommend/filters.js +748 -377
  46. package/src/domains/recommend/index.js +4 -3
  47. package/src/domains/recommend/jobs.js +542 -542
  48. package/src/domains/recommend/location.js +736 -0
  49. package/src/domains/recommend/refresh.js +504 -361
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +987 -854
  52. package/src/domains/recommend/scopes.js +246 -246
  53. package/src/domains/recruit/actions.js +277 -277
  54. package/src/domains/recruit/cards.js +74 -74
  55. package/src/domains/recruit/constants.js +236 -236
  56. package/src/domains/recruit/detail.js +588 -588
  57. package/src/domains/recruit/index.js +9 -9
  58. package/src/domains/recruit/instruction-parser.js +866 -866
  59. package/src/domains/recruit/refresh.js +45 -45
  60. package/src/domains/recruit/roots.js +68 -68
  61. package/src/domains/recruit/run-service.js +1620 -1620
  62. package/src/domains/recruit/search.js +3229 -3229
  63. package/src/index.js +13 -0
  64. package/src/parser.js +376 -8
  65. package/src/recommend-mcp.js +929 -915
  66. package/src/recommend-scheduler.js +496 -496
  67. package/src/recruit-mcp.js +2121 -2121
@@ -1,542 +1,542 @@
1
- import {
2
- clickNodeCenter,
3
- clickPoint,
4
- DETERMINISTIC_CLICK_OPTIONS,
5
- getAttributesMap,
6
- getNodeBox,
7
- getOuterHTML,
8
- pressKey,
9
- querySelectorAll,
10
- sleep
11
- } from "../../core/browser/index.js";
12
- import {
13
- htmlToText,
14
- normalizeText
15
- } from "../../core/screening/index.js";
16
- import { isStaleRecommendNodeError } from "./detail.js";
17
-
18
- export const RECOMMEND_JOB_SELECTORS = Object.freeze({
19
- trigger: ".job-selecter-wrap, [class*=\"job-selecter-wrap\"], .ui-dropmenu",
20
- option: ".job-selecter-options .job-item, .job-list .job-item, .job-item",
21
- current: ".job-selecter-options .job-item.curr, .job-list .job-item.curr, .job-item.curr"
22
- });
23
-
24
- function normalizeJobText(value) {
25
- return normalizeText(value).replace(/\s+/g, "");
26
- }
27
-
28
- function stripSalaryText(label) {
29
- return normalizeText(label)
30
- .replace(/\s*[((]\s*(?:\d+(?:-\d+)?K|面议|\d+-\d+元\/天)\s*[))]\s*$/i, "")
31
- .replace(/\s+(?:\d+(?:-\d+)?K|面议|\d+-\d+元\/天)\s*$/i, "")
32
- .trim();
33
- }
34
-
35
- function trimSalarySuffix(label) {
36
- return stripSalaryText(label);
37
- }
38
-
39
- export function jobLabelMatches(optionLabel, targetLabel) {
40
- const option = normalizeJobText(optionLabel);
41
- const target = normalizeJobText(targetLabel);
42
- const optionWithoutSalary = normalizeJobText(stripSalaryText(optionLabel));
43
- const targetWithoutSalary = normalizeJobText(stripSalaryText(targetLabel));
44
- if (!option || !target) return false;
45
- return option === target
46
- || option.startsWith(target)
47
- || optionWithoutSalary === target
48
- || option === targetWithoutSalary
49
- || optionWithoutSalary === targetWithoutSalary;
50
- }
51
-
52
- function isVisibleBox(box) {
53
- return Boolean(box && box.rect.width > 4 && box.rect.height > 4);
54
- }
55
-
56
- async function readJobOption(client, nodeId, index) {
57
- let attributes = null;
58
- let outerHTML = "";
59
- try {
60
- [attributes, outerHTML] = await Promise.all([
61
- getAttributesMap(client, nodeId),
62
- getOuterHTML(client, nodeId)
63
- ]);
64
- } catch (error) {
65
- if (isStaleRecommendNodeError(error)) {
66
- return null;
67
- }
68
- throw error;
69
- }
70
- const label = normalizeText(htmlToText(outerHTML));
71
- let box = null;
72
- try {
73
- box = await getNodeBox(client, nodeId);
74
- } catch (error) {
75
- if (!isStaleRecommendNodeError(error)) throw error;
76
- }
77
- const className = attributes.class || "";
78
- return {
79
- node_id: nodeId,
80
- index,
81
- label,
82
- label_without_salary: trimSalarySuffix(label),
83
- class_name: className,
84
- current: /\bcurr\b|\bactive\b|\bselected\b/.test(className),
85
- visible: isVisibleBox(box),
86
- center: box?.center || null,
87
- rect: box?.rect || null
88
- };
89
- }
90
-
91
- async function readJobTrigger(client, nodeId) {
92
- let box = null;
93
- try {
94
- box = await getNodeBox(client, nodeId);
95
- } catch {}
96
- if (!isVisibleBox(box)) return null;
97
-
98
- let label = "";
99
- let className = "";
100
- try {
101
- const outerHTML = await getOuterHTML(client, nodeId);
102
- label = normalizeText(htmlToText(outerHTML));
103
- } catch {}
104
- try {
105
- const attributes = await getAttributesMap(client, nodeId);
106
- className = attributes.class || "";
107
- } catch {}
108
-
109
- return {
110
- node_id: nodeId,
111
- center: box.center,
112
- rect: box.rect,
113
- label,
114
- label_without_salary: trimSalarySuffix(label),
115
- class_name: className,
116
- visible: true
117
- };
118
- }
119
-
120
- export async function findRecommendJobTrigger(client, frameNodeId) {
121
- const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_JOB_SELECTORS.trigger);
122
- for (const nodeId of nodeIds) {
123
- const trigger = await readJobTrigger(client, nodeId);
124
- if (trigger) return trigger;
125
- }
126
- return null;
127
- }
128
-
129
- export async function waitForRecommendJobTrigger(client, frameNodeId, {
130
- timeoutMs = 8000,
131
- intervalMs = 250
132
- } = {}) {
133
- const started = Date.now();
134
- while (Date.now() - started <= timeoutMs) {
135
- const trigger = await findRecommendJobTrigger(client, frameNodeId);
136
- if (trigger) return trigger;
137
- await sleep(intervalMs);
138
- }
139
- return null;
140
- }
141
-
142
- export async function openRecommendJobDropdown(client, frameNodeId, {
143
- timeoutMs = 4000,
144
- triggerTimeoutMs = Math.max(8000, timeoutMs),
145
- triggerIntervalMs = 250,
146
- dismissBeforeOpen = true,
147
- maxAttempts = 3
148
- } = {}) {
149
- const trigger = await waitForRecommendJobTrigger(client, frameNodeId, {
150
- timeoutMs: triggerTimeoutMs,
151
- intervalMs: triggerIntervalMs
152
- });
153
- if (!trigger) {
154
- throw new Error("Recommend job trigger was not found");
155
- }
156
-
157
- const alreadyOpen = await waitForVisibleRecommendJobOptions(client, frameNodeId, {
158
- timeoutMs: 300,
159
- intervalMs: 100
160
- });
161
- if (alreadyOpen.visible_options.length) {
162
- return {
163
- opened: true,
164
- already_open: true,
165
- trigger,
166
- options: alreadyOpen.options
167
- };
168
- }
169
-
170
- const attempts = [];
171
- const attemptLimit = Math.max(1, Math.floor(Number(maxAttempts) || 1));
172
- if (dismissBeforeOpen) {
173
- await closeRecommendJobDropdown(client);
174
- }
175
- for (let attempt = 1; attempt <= attemptLimit; attempt += 1) {
176
- if (attempt > 1) await closeRecommendJobDropdown(client);
177
- const triggerBox = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS);
178
- const opened = await waitForVisibleRecommendJobOptions(client, frameNodeId, {
179
- timeoutMs,
180
- intervalMs: 200
181
- });
182
- attempts.push({
183
- attempt,
184
- trigger_box: triggerBox,
185
- option_count: opened.options.length,
186
- visible_option_count: opened.visible_options.length
187
- });
188
- if (opened.visible_options.length) {
189
- return {
190
- opened: true,
191
- already_open: false,
192
- trigger,
193
- options: opened.options,
194
- attempts
195
- };
196
- }
197
- }
198
- const error = new Error("Recommend job dropdown did not expose visible options after trigger click");
199
- error.trigger = trigger;
200
- error.job_dropdown_attempts = attempts;
201
- throw error;
202
- }
203
-
204
- async function waitForVisibleRecommendJobOptions(client, frameNodeId, {
205
- timeoutMs = 4000,
206
- intervalMs = 200
207
- } = {}) {
208
- const started = Date.now();
209
- let lastOptions = [];
210
- while (Date.now() - started <= timeoutMs) {
211
- lastOptions = await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
212
- const visibleOptions = lastOptions.filter((option) => option.visible);
213
- if (visibleOptions.length) {
214
- return {
215
- options: lastOptions,
216
- visible_options: visibleOptions
217
- };
218
- }
219
- await sleep(intervalMs);
220
- }
221
- return {
222
- options: lastOptions,
223
- visible_options: []
224
- };
225
- }
226
-
227
- export async function listRecommendJobOptions(client, frameNodeId, {
228
- openDropdown = true
229
- } = {}) {
230
- if (openDropdown) {
231
- await openRecommendJobDropdown(client, frameNodeId);
232
- }
233
-
234
- const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_JOB_SELECTORS.option);
235
- const options = [];
236
- const seen = new Set();
237
- for (let index = 0; index < nodeIds.length; index += 1) {
238
- const nodeId = nodeIds[index];
239
- if (seen.has(nodeId)) continue;
240
- seen.add(nodeId);
241
- const option = await readJobOption(client, nodeId, index);
242
- if (!option) continue;
243
- if (!option.label) continue;
244
- if (option.label.length > 120) continue;
245
- options.push(option);
246
- }
247
- return options;
248
- }
249
-
250
- export async function closeRecommendJobDropdown(client) {
251
- if (typeof client?.Input?.dispatchKeyEvent !== "function") {
252
- return {
253
- ok: false,
254
- reason: "dispatch_key_unavailable"
255
- };
256
- }
257
- await pressKey(client, "Escape", {
258
- code: "Escape",
259
- windowsVirtualKeyCode: 27,
260
- nativeVirtualKeyCode: 27
261
- });
262
- await sleep(300);
263
- return {
264
- ok: true,
265
- reason: "escape"
266
- };
267
- }
268
-
269
- async function readVisibleRecommendJobOptions(client, frameNodeId) {
270
- const options = await listRecommendJobOptions(client, frameNodeId, {
271
- openDropdown: false
272
- }).catch(() => []);
273
- return {
274
- options,
275
- visible_options: options.filter((option) => option.visible)
276
- };
277
- }
278
-
279
- export async function closeRecommendJobDropdownFully(client, frameNodeId, {
280
- settleMs = 300,
281
- timeoutMs = 1200
282
- } = {}) {
283
- const before = await readVisibleRecommendJobOptions(client, frameNodeId);
284
- const attempts = [];
285
- if (!before.visible_options.length) {
286
- return {
287
- ok: true,
288
- closed: false,
289
- reason: "already_closed",
290
- visible_before_count: 0,
291
- visible_after_count: 0,
292
- attempts
293
- };
294
- }
295
-
296
- const started = Date.now();
297
- for (let attempt = 1; attempt <= 2 && Date.now() - started <= timeoutMs; attempt += 1) {
298
- const close = await closeRecommendJobDropdown(client);
299
- if (settleMs > 0) await sleep(settleMs);
300
- const afterEscape = await readVisibleRecommendJobOptions(client, frameNodeId);
301
- attempts.push({
302
- method: "escape",
303
- attempt,
304
- ok: afterEscape.visible_options.length === 0,
305
- visible_after_count: afterEscape.visible_options.length,
306
- close
307
- });
308
- if (!afterEscape.visible_options.length) {
309
- return {
310
- ok: true,
311
- closed: true,
312
- reason: "escape",
313
- visible_before_count: before.visible_options.length,
314
- visible_after_count: 0,
315
- attempts
316
- };
317
- }
318
- }
319
-
320
- const trigger = await findRecommendJobTrigger(client, frameNodeId).catch(() => null);
321
- if (trigger?.node_id) {
322
- const click = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
323
- error: error?.message || String(error || "")
324
- }));
325
- if (settleMs > 0) await sleep(settleMs);
326
- const afterToggle = await readVisibleRecommendJobOptions(client, frameNodeId);
327
- attempts.push({
328
- method: "trigger_toggle",
329
- ok: afterToggle.visible_options.length === 0,
330
- visible_after_count: afterToggle.visible_options.length,
331
- click
332
- });
333
- if (!afterToggle.visible_options.length) {
334
- return {
335
- ok: true,
336
- closed: true,
337
- reason: "trigger_toggle",
338
- visible_before_count: before.visible_options.length,
339
- visible_after_count: 0,
340
- attempts
341
- };
342
- }
343
- }
344
-
345
- const outside = await clickPoint(client, 12, 12, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
346
- error: error?.message || String(error || "")
347
- }));
348
- if (settleMs > 0) await sleep(settleMs);
349
- const afterOutside = await readVisibleRecommendJobOptions(client, frameNodeId);
350
- attempts.push({
351
- method: "outside_click",
352
- ok: afterOutside.visible_options.length === 0,
353
- visible_after_count: afterOutside.visible_options.length,
354
- click: outside
355
- });
356
-
357
- return {
358
- ok: afterOutside.visible_options.length === 0,
359
- closed: afterOutside.visible_options.length === 0,
360
- reason: afterOutside.visible_options.length ? "still_visible_after_close_attempts" : "outside_click",
361
- visible_before_count: before.visible_options.length,
362
- visible_after_count: afterOutside.visible_options.length,
363
- attempts,
364
- first_visible_after: afterOutside.visible_options[0] ? compactJobOption(afterOutside.visible_options[0]) : null
365
- };
366
- }
367
-
368
- export async function verifyRecommendJobSelection(client, frameNodeId, {
369
- jobLabel = "",
370
- delayMs = 2000,
371
- dropdownTimeoutMs = 4000,
372
- closeSettleMs = 300
373
- } = {}) {
374
- const requested = normalizeText(jobLabel);
375
- if (delayMs > 0) await sleep(delayMs);
376
- let options = [];
377
- let openError = null;
378
- try {
379
- options = await listRecommendJobOptions(client, frameNodeId, {
380
- openDropdown: true
381
- });
382
- } catch (error) {
383
- openError = error;
384
- options = await listRecommendJobOptions(client, frameNodeId, {
385
- openDropdown: false
386
- }).catch(() => []);
387
- }
388
- const current = options.find((option) => option.current) || null;
389
- const verified = Boolean(current && jobLabelMatches(current.label, requested));
390
- const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId, {
391
- settleMs: closeSettleMs,
392
- timeoutMs: Math.max(1200, Math.min(4000, dropdownTimeoutMs))
393
- }).catch((error) => ({
394
- ok: false,
395
- closed: false,
396
- reason: "close_failed",
397
- error: error?.message || String(error || "")
398
- }));
399
- return {
400
- verified,
401
- requested,
402
- current_label: current?.label || "",
403
- current_label_without_salary: current?.label_without_salary || "",
404
- current_option: current ? compactJobOption(current) : null,
405
- option_count: options.length,
406
- visible_option_count: options.filter((option) => option.visible).length,
407
- options: options.map(compactJobOption),
408
- open_error: openError ? (openError?.message || String(openError)) : null,
409
- menu_close: menuClose
410
- };
411
- }
412
-
413
- export async function selectRecommendJob(client, frameNodeId, {
414
- jobLabel = "",
415
- settleMs = 6000,
416
- dropdownTimeoutMs = Math.max(8000, settleMs)
417
- } = {}) {
418
- const target = normalizeText(jobLabel);
419
- if (!target) {
420
- return {
421
- requested: "",
422
- selected: false,
423
- reason: "no_job_requested",
424
- options: []
425
- };
426
- }
427
-
428
- let opened = null;
429
- try {
430
- opened = await openRecommendJobDropdown(client, frameNodeId, {
431
- timeoutMs: dropdownTimeoutMs,
432
- triggerTimeoutMs: dropdownTimeoutMs
433
- });
434
- } catch (error) {
435
- const currentOptions = await listRecommendJobOptions(client, frameNodeId, {
436
- openDropdown: false
437
- }).catch(() => []);
438
- const currentMatch = currentOptions.find((option) => (
439
- option.current && jobLabelMatches(option.label, target)
440
- ));
441
- if (currentMatch) {
442
- const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId).catch((closeError) => ({
443
- ok: false,
444
- closed: false,
445
- reason: "close_failed",
446
- error: closeError?.message || String(closeError || "")
447
- }));
448
- return {
449
- requested: target,
450
- selected: true,
451
- already_current: true,
452
- selected_option: compactJobOption({
453
- ...currentMatch,
454
- source: "current_option_without_visible_dropdown"
455
- }),
456
- options: currentOptions.map(compactJobOption),
457
- dropdown_error: error?.message || String(error),
458
- job_dropdown_attempts: error?.job_dropdown_attempts || [],
459
- menu_close: menuClose
460
- };
461
- }
462
- throw error;
463
- }
464
- const options = opened.options.length
465
- ? opened.options
466
- : await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
467
- const visibleOptions = options.filter((option) => option.visible);
468
- const hiddenMatches = options.filter((option) => !option.visible && jobLabelMatches(option.label, target));
469
- const match = visibleOptions.find((option) => jobLabelMatches(option.label, target));
470
-
471
- if (!match) {
472
- await closeRecommendJobDropdown(client);
473
- if (hiddenMatches.length) {
474
- const error = new Error(`Matched recommend job has no visible clickable option: ${hiddenMatches[0].label}`);
475
- error.hidden_job_matches = hiddenMatches.map(compactJobOption);
476
- throw error;
477
- }
478
- return {
479
- requested: target,
480
- selected: false,
481
- reason: "job_not_found",
482
- options: options.map(compactJobOption)
483
- };
484
- }
485
-
486
- if (match.current) {
487
- const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId).catch((error) => ({
488
- ok: false,
489
- closed: false,
490
- reason: "close_failed",
491
- error: error?.message || String(error || "")
492
- }));
493
- return {
494
- requested: target,
495
- selected: true,
496
- already_current: true,
497
- selected_option: compactJobOption(match),
498
- options: options.map(compactJobOption),
499
- menu_close: menuClose
500
- };
501
- }
502
-
503
- if (!match.center) {
504
- await closeRecommendJobDropdown(client);
505
- throw new Error(`Matched recommend job has no clickable center: ${match.label}`);
506
- }
507
-
508
- const clickedBox = await clickNodeCenter(client, match.node_id, DETERMINISTIC_CLICK_OPTIONS);
509
- if (settleMs > 0) await sleep(settleMs);
510
- const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId).catch((error) => ({
511
- ok: false,
512
- closed: false,
513
- reason: "close_failed",
514
- error: error?.message || String(error || "")
515
- }));
516
- return {
517
- requested: target,
518
- selected: true,
519
- already_current: false,
520
- selected_option: compactJobOption(match),
521
- click_box: {
522
- center: clickedBox.center,
523
- rect: clickedBox.rect
524
- },
525
- options: options.map(compactJobOption),
526
- menu_close: menuClose
527
- };
528
- }
529
-
530
- function compactJobOption(option) {
531
- return {
532
- label: option.label,
533
- label_without_salary: option.label_without_salary,
534
- current: Boolean(option.current),
535
- visible: Boolean(option.visible),
536
- class_name: option.class_name,
537
- node_id: option.node_id,
538
- center: option.center,
539
- rect: option.rect,
540
- source: option.source || null
541
- };
542
- }
1
+ import {
2
+ clickNodeCenter,
3
+ clickPoint,
4
+ DETERMINISTIC_CLICK_OPTIONS,
5
+ getAttributesMap,
6
+ getNodeBox,
7
+ getOuterHTML,
8
+ pressKey,
9
+ querySelectorAll,
10
+ sleep
11
+ } from "../../core/browser/index.js";
12
+ import {
13
+ htmlToText,
14
+ normalizeText
15
+ } from "../../core/screening/index.js";
16
+ import { isStaleRecommendNodeError } from "./detail.js";
17
+
18
+ export const RECOMMEND_JOB_SELECTORS = Object.freeze({
19
+ trigger: ".job-selecter-wrap, [class*=\"job-selecter-wrap\"], .ui-dropmenu",
20
+ option: ".job-selecter-options .job-item, .job-list .job-item, .job-item",
21
+ current: ".job-selecter-options .job-item.curr, .job-list .job-item.curr, .job-item.curr"
22
+ });
23
+
24
+ function normalizeJobText(value) {
25
+ return normalizeText(value).replace(/\s+/g, "");
26
+ }
27
+
28
+ function stripSalaryText(label) {
29
+ return normalizeText(label)
30
+ .replace(/\s*[((]\s*(?:\d+(?:-\d+)?K|面议|\d+-\d+元\/天)\s*[))]\s*$/i, "")
31
+ .replace(/\s+(?:\d+(?:-\d+)?K|面议|\d+-\d+元\/天)\s*$/i, "")
32
+ .trim();
33
+ }
34
+
35
+ function trimSalarySuffix(label) {
36
+ return stripSalaryText(label);
37
+ }
38
+
39
+ export function jobLabelMatches(optionLabel, targetLabel) {
40
+ const option = normalizeJobText(optionLabel);
41
+ const target = normalizeJobText(targetLabel);
42
+ const optionWithoutSalary = normalizeJobText(stripSalaryText(optionLabel));
43
+ const targetWithoutSalary = normalizeJobText(stripSalaryText(targetLabel));
44
+ if (!option || !target) return false;
45
+ return option === target
46
+ || option.startsWith(target)
47
+ || optionWithoutSalary === target
48
+ || option === targetWithoutSalary
49
+ || optionWithoutSalary === targetWithoutSalary;
50
+ }
51
+
52
+ function isVisibleBox(box) {
53
+ return Boolean(box && box.rect.width > 4 && box.rect.height > 4);
54
+ }
55
+
56
+ async function readJobOption(client, nodeId, index) {
57
+ let attributes = null;
58
+ let outerHTML = "";
59
+ try {
60
+ [attributes, outerHTML] = await Promise.all([
61
+ getAttributesMap(client, nodeId),
62
+ getOuterHTML(client, nodeId)
63
+ ]);
64
+ } catch (error) {
65
+ if (isStaleRecommendNodeError(error)) {
66
+ return null;
67
+ }
68
+ throw error;
69
+ }
70
+ const label = normalizeText(htmlToText(outerHTML));
71
+ let box = null;
72
+ try {
73
+ box = await getNodeBox(client, nodeId);
74
+ } catch (error) {
75
+ if (!isStaleRecommendNodeError(error)) throw error;
76
+ }
77
+ const className = attributes.class || "";
78
+ return {
79
+ node_id: nodeId,
80
+ index,
81
+ label,
82
+ label_without_salary: trimSalarySuffix(label),
83
+ class_name: className,
84
+ current: /\bcurr\b|\bactive\b|\bselected\b/.test(className),
85
+ visible: isVisibleBox(box),
86
+ center: box?.center || null,
87
+ rect: box?.rect || null
88
+ };
89
+ }
90
+
91
+ async function readJobTrigger(client, nodeId) {
92
+ let box = null;
93
+ try {
94
+ box = await getNodeBox(client, nodeId);
95
+ } catch {}
96
+ if (!isVisibleBox(box)) return null;
97
+
98
+ let label = "";
99
+ let className = "";
100
+ try {
101
+ const outerHTML = await getOuterHTML(client, nodeId);
102
+ label = normalizeText(htmlToText(outerHTML));
103
+ } catch {}
104
+ try {
105
+ const attributes = await getAttributesMap(client, nodeId);
106
+ className = attributes.class || "";
107
+ } catch {}
108
+
109
+ return {
110
+ node_id: nodeId,
111
+ center: box.center,
112
+ rect: box.rect,
113
+ label,
114
+ label_without_salary: trimSalarySuffix(label),
115
+ class_name: className,
116
+ visible: true
117
+ };
118
+ }
119
+
120
+ export async function findRecommendJobTrigger(client, frameNodeId) {
121
+ const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_JOB_SELECTORS.trigger);
122
+ for (const nodeId of nodeIds) {
123
+ const trigger = await readJobTrigger(client, nodeId);
124
+ if (trigger) return trigger;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ export async function waitForRecommendJobTrigger(client, frameNodeId, {
130
+ timeoutMs = 8000,
131
+ intervalMs = 250
132
+ } = {}) {
133
+ const started = Date.now();
134
+ while (Date.now() - started <= timeoutMs) {
135
+ const trigger = await findRecommendJobTrigger(client, frameNodeId);
136
+ if (trigger) return trigger;
137
+ await sleep(intervalMs);
138
+ }
139
+ return null;
140
+ }
141
+
142
+ export async function openRecommendJobDropdown(client, frameNodeId, {
143
+ timeoutMs = 4000,
144
+ triggerTimeoutMs = Math.max(8000, timeoutMs),
145
+ triggerIntervalMs = 250,
146
+ dismissBeforeOpen = true,
147
+ maxAttempts = 3
148
+ } = {}) {
149
+ const trigger = await waitForRecommendJobTrigger(client, frameNodeId, {
150
+ timeoutMs: triggerTimeoutMs,
151
+ intervalMs: triggerIntervalMs
152
+ });
153
+ if (!trigger) {
154
+ throw new Error("Recommend job trigger was not found");
155
+ }
156
+
157
+ const alreadyOpen = await waitForVisibleRecommendJobOptions(client, frameNodeId, {
158
+ timeoutMs: 300,
159
+ intervalMs: 100
160
+ });
161
+ if (alreadyOpen.visible_options.length) {
162
+ return {
163
+ opened: true,
164
+ already_open: true,
165
+ trigger,
166
+ options: alreadyOpen.options
167
+ };
168
+ }
169
+
170
+ const attempts = [];
171
+ const attemptLimit = Math.max(1, Math.floor(Number(maxAttempts) || 1));
172
+ if (dismissBeforeOpen) {
173
+ await closeRecommendJobDropdown(client);
174
+ }
175
+ for (let attempt = 1; attempt <= attemptLimit; attempt += 1) {
176
+ if (attempt > 1) await closeRecommendJobDropdown(client);
177
+ const triggerBox = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS);
178
+ const opened = await waitForVisibleRecommendJobOptions(client, frameNodeId, {
179
+ timeoutMs,
180
+ intervalMs: 200
181
+ });
182
+ attempts.push({
183
+ attempt,
184
+ trigger_box: triggerBox,
185
+ option_count: opened.options.length,
186
+ visible_option_count: opened.visible_options.length
187
+ });
188
+ if (opened.visible_options.length) {
189
+ return {
190
+ opened: true,
191
+ already_open: false,
192
+ trigger,
193
+ options: opened.options,
194
+ attempts
195
+ };
196
+ }
197
+ }
198
+ const error = new Error("Recommend job dropdown did not expose visible options after trigger click");
199
+ error.trigger = trigger;
200
+ error.job_dropdown_attempts = attempts;
201
+ throw error;
202
+ }
203
+
204
+ async function waitForVisibleRecommendJobOptions(client, frameNodeId, {
205
+ timeoutMs = 4000,
206
+ intervalMs = 200
207
+ } = {}) {
208
+ const started = Date.now();
209
+ let lastOptions = [];
210
+ while (Date.now() - started <= timeoutMs) {
211
+ lastOptions = await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
212
+ const visibleOptions = lastOptions.filter((option) => option.visible);
213
+ if (visibleOptions.length) {
214
+ return {
215
+ options: lastOptions,
216
+ visible_options: visibleOptions
217
+ };
218
+ }
219
+ await sleep(intervalMs);
220
+ }
221
+ return {
222
+ options: lastOptions,
223
+ visible_options: []
224
+ };
225
+ }
226
+
227
+ export async function listRecommendJobOptions(client, frameNodeId, {
228
+ openDropdown = true
229
+ } = {}) {
230
+ if (openDropdown) {
231
+ await openRecommendJobDropdown(client, frameNodeId);
232
+ }
233
+
234
+ const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_JOB_SELECTORS.option);
235
+ const options = [];
236
+ const seen = new Set();
237
+ for (let index = 0; index < nodeIds.length; index += 1) {
238
+ const nodeId = nodeIds[index];
239
+ if (seen.has(nodeId)) continue;
240
+ seen.add(nodeId);
241
+ const option = await readJobOption(client, nodeId, index);
242
+ if (!option) continue;
243
+ if (!option.label) continue;
244
+ if (option.label.length > 120) continue;
245
+ options.push(option);
246
+ }
247
+ return options;
248
+ }
249
+
250
+ export async function closeRecommendJobDropdown(client) {
251
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
252
+ return {
253
+ ok: false,
254
+ reason: "dispatch_key_unavailable"
255
+ };
256
+ }
257
+ await pressKey(client, "Escape", {
258
+ code: "Escape",
259
+ windowsVirtualKeyCode: 27,
260
+ nativeVirtualKeyCode: 27
261
+ });
262
+ await sleep(300);
263
+ return {
264
+ ok: true,
265
+ reason: "escape"
266
+ };
267
+ }
268
+
269
+ async function readVisibleRecommendJobOptions(client, frameNodeId) {
270
+ const options = await listRecommendJobOptions(client, frameNodeId, {
271
+ openDropdown: false
272
+ }).catch(() => []);
273
+ return {
274
+ options,
275
+ visible_options: options.filter((option) => option.visible)
276
+ };
277
+ }
278
+
279
+ export async function closeRecommendJobDropdownFully(client, frameNodeId, {
280
+ settleMs = 300,
281
+ timeoutMs = 1200
282
+ } = {}) {
283
+ const before = await readVisibleRecommendJobOptions(client, frameNodeId);
284
+ const attempts = [];
285
+ if (!before.visible_options.length) {
286
+ return {
287
+ ok: true,
288
+ closed: false,
289
+ reason: "already_closed",
290
+ visible_before_count: 0,
291
+ visible_after_count: 0,
292
+ attempts
293
+ };
294
+ }
295
+
296
+ const started = Date.now();
297
+ for (let attempt = 1; attempt <= 2 && Date.now() - started <= timeoutMs; attempt += 1) {
298
+ const close = await closeRecommendJobDropdown(client);
299
+ if (settleMs > 0) await sleep(settleMs);
300
+ const afterEscape = await readVisibleRecommendJobOptions(client, frameNodeId);
301
+ attempts.push({
302
+ method: "escape",
303
+ attempt,
304
+ ok: afterEscape.visible_options.length === 0,
305
+ visible_after_count: afterEscape.visible_options.length,
306
+ close
307
+ });
308
+ if (!afterEscape.visible_options.length) {
309
+ return {
310
+ ok: true,
311
+ closed: true,
312
+ reason: "escape",
313
+ visible_before_count: before.visible_options.length,
314
+ visible_after_count: 0,
315
+ attempts
316
+ };
317
+ }
318
+ }
319
+
320
+ const trigger = await findRecommendJobTrigger(client, frameNodeId).catch(() => null);
321
+ if (trigger?.node_id) {
322
+ const click = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
323
+ error: error?.message || String(error || "")
324
+ }));
325
+ if (settleMs > 0) await sleep(settleMs);
326
+ const afterToggle = await readVisibleRecommendJobOptions(client, frameNodeId);
327
+ attempts.push({
328
+ method: "trigger_toggle",
329
+ ok: afterToggle.visible_options.length === 0,
330
+ visible_after_count: afterToggle.visible_options.length,
331
+ click
332
+ });
333
+ if (!afterToggle.visible_options.length) {
334
+ return {
335
+ ok: true,
336
+ closed: true,
337
+ reason: "trigger_toggle",
338
+ visible_before_count: before.visible_options.length,
339
+ visible_after_count: 0,
340
+ attempts
341
+ };
342
+ }
343
+ }
344
+
345
+ const outside = await clickPoint(client, 12, 12, DETERMINISTIC_CLICK_OPTIONS).catch((error) => ({
346
+ error: error?.message || String(error || "")
347
+ }));
348
+ if (settleMs > 0) await sleep(settleMs);
349
+ const afterOutside = await readVisibleRecommendJobOptions(client, frameNodeId);
350
+ attempts.push({
351
+ method: "outside_click",
352
+ ok: afterOutside.visible_options.length === 0,
353
+ visible_after_count: afterOutside.visible_options.length,
354
+ click: outside
355
+ });
356
+
357
+ return {
358
+ ok: afterOutside.visible_options.length === 0,
359
+ closed: afterOutside.visible_options.length === 0,
360
+ reason: afterOutside.visible_options.length ? "still_visible_after_close_attempts" : "outside_click",
361
+ visible_before_count: before.visible_options.length,
362
+ visible_after_count: afterOutside.visible_options.length,
363
+ attempts,
364
+ first_visible_after: afterOutside.visible_options[0] ? compactJobOption(afterOutside.visible_options[0]) : null
365
+ };
366
+ }
367
+
368
+ export async function verifyRecommendJobSelection(client, frameNodeId, {
369
+ jobLabel = "",
370
+ delayMs = 2000,
371
+ dropdownTimeoutMs = 4000,
372
+ closeSettleMs = 300
373
+ } = {}) {
374
+ const requested = normalizeText(jobLabel);
375
+ if (delayMs > 0) await sleep(delayMs);
376
+ let options = [];
377
+ let openError = null;
378
+ try {
379
+ options = await listRecommendJobOptions(client, frameNodeId, {
380
+ openDropdown: true
381
+ });
382
+ } catch (error) {
383
+ openError = error;
384
+ options = await listRecommendJobOptions(client, frameNodeId, {
385
+ openDropdown: false
386
+ }).catch(() => []);
387
+ }
388
+ const current = options.find((option) => option.current) || null;
389
+ const verified = Boolean(current && jobLabelMatches(current.label, requested));
390
+ const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId, {
391
+ settleMs: closeSettleMs,
392
+ timeoutMs: Math.max(1200, Math.min(4000, dropdownTimeoutMs))
393
+ }).catch((error) => ({
394
+ ok: false,
395
+ closed: false,
396
+ reason: "close_failed",
397
+ error: error?.message || String(error || "")
398
+ }));
399
+ return {
400
+ verified,
401
+ requested,
402
+ current_label: current?.label || "",
403
+ current_label_without_salary: current?.label_without_salary || "",
404
+ current_option: current ? compactJobOption(current) : null,
405
+ option_count: options.length,
406
+ visible_option_count: options.filter((option) => option.visible).length,
407
+ options: options.map(compactJobOption),
408
+ open_error: openError ? (openError?.message || String(openError)) : null,
409
+ menu_close: menuClose
410
+ };
411
+ }
412
+
413
+ export async function selectRecommendJob(client, frameNodeId, {
414
+ jobLabel = "",
415
+ settleMs = 6000,
416
+ dropdownTimeoutMs = Math.max(8000, settleMs)
417
+ } = {}) {
418
+ const target = normalizeText(jobLabel);
419
+ if (!target) {
420
+ return {
421
+ requested: "",
422
+ selected: false,
423
+ reason: "no_job_requested",
424
+ options: []
425
+ };
426
+ }
427
+
428
+ let opened = null;
429
+ try {
430
+ opened = await openRecommendJobDropdown(client, frameNodeId, {
431
+ timeoutMs: dropdownTimeoutMs,
432
+ triggerTimeoutMs: dropdownTimeoutMs
433
+ });
434
+ } catch (error) {
435
+ const currentOptions = await listRecommendJobOptions(client, frameNodeId, {
436
+ openDropdown: false
437
+ }).catch(() => []);
438
+ const currentMatch = currentOptions.find((option) => (
439
+ option.current && jobLabelMatches(option.label, target)
440
+ ));
441
+ if (currentMatch) {
442
+ const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId).catch((closeError) => ({
443
+ ok: false,
444
+ closed: false,
445
+ reason: "close_failed",
446
+ error: closeError?.message || String(closeError || "")
447
+ }));
448
+ return {
449
+ requested: target,
450
+ selected: true,
451
+ already_current: true,
452
+ selected_option: compactJobOption({
453
+ ...currentMatch,
454
+ source: "current_option_without_visible_dropdown"
455
+ }),
456
+ options: currentOptions.map(compactJobOption),
457
+ dropdown_error: error?.message || String(error),
458
+ job_dropdown_attempts: error?.job_dropdown_attempts || [],
459
+ menu_close: menuClose
460
+ };
461
+ }
462
+ throw error;
463
+ }
464
+ const options = opened.options.length
465
+ ? opened.options
466
+ : await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
467
+ const visibleOptions = options.filter((option) => option.visible);
468
+ const hiddenMatches = options.filter((option) => !option.visible && jobLabelMatches(option.label, target));
469
+ const match = visibleOptions.find((option) => jobLabelMatches(option.label, target));
470
+
471
+ if (!match) {
472
+ await closeRecommendJobDropdown(client);
473
+ if (hiddenMatches.length) {
474
+ const error = new Error(`Matched recommend job has no visible clickable option: ${hiddenMatches[0].label}`);
475
+ error.hidden_job_matches = hiddenMatches.map(compactJobOption);
476
+ throw error;
477
+ }
478
+ return {
479
+ requested: target,
480
+ selected: false,
481
+ reason: "job_not_found",
482
+ options: options.map(compactJobOption)
483
+ };
484
+ }
485
+
486
+ if (match.current) {
487
+ const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId).catch((error) => ({
488
+ ok: false,
489
+ closed: false,
490
+ reason: "close_failed",
491
+ error: error?.message || String(error || "")
492
+ }));
493
+ return {
494
+ requested: target,
495
+ selected: true,
496
+ already_current: true,
497
+ selected_option: compactJobOption(match),
498
+ options: options.map(compactJobOption),
499
+ menu_close: menuClose
500
+ };
501
+ }
502
+
503
+ if (!match.center) {
504
+ await closeRecommendJobDropdown(client);
505
+ throw new Error(`Matched recommend job has no clickable center: ${match.label}`);
506
+ }
507
+
508
+ const clickedBox = await clickNodeCenter(client, match.node_id, DETERMINISTIC_CLICK_OPTIONS);
509
+ if (settleMs > 0) await sleep(settleMs);
510
+ const menuClose = await closeRecommendJobDropdownFully(client, frameNodeId).catch((error) => ({
511
+ ok: false,
512
+ closed: false,
513
+ reason: "close_failed",
514
+ error: error?.message || String(error || "")
515
+ }));
516
+ return {
517
+ requested: target,
518
+ selected: true,
519
+ already_current: false,
520
+ selected_option: compactJobOption(match),
521
+ click_box: {
522
+ center: clickedBox.center,
523
+ rect: clickedBox.rect
524
+ },
525
+ options: options.map(compactJobOption),
526
+ menu_close: menuClose
527
+ };
528
+ }
529
+
530
+ function compactJobOption(option) {
531
+ return {
532
+ label: option.label,
533
+ label_without_salary: option.label_without_salary,
534
+ current: Boolean(option.current),
535
+ visible: Boolean(option.visible),
536
+ class_name: option.class_name,
537
+ node_id: option.node_id,
538
+ center: option.center,
539
+ rect: option.rect,
540
+ source: option.source || null
541
+ };
542
+ }