@reconcrap/boss-recommend-mcp 2.1.23 → 2.1.25

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