@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,592 +1,592 @@
1
- import {
2
- clickNodeCenter,
3
- clickPoint,
4
- DETERMINISTIC_CLICK_OPTIONS,
5
- getAttributesMap,
6
- getNodeBox,
7
- getOuterHTML,
8
- pressKey,
9
- querySelector,
10
- querySelectorAll,
11
- sleep
12
- } from "../../core/browser/index.js";
13
- import {
14
- htmlToText,
15
- normalizeText
16
- } from "../../core/screening/index.js";
17
- import {
18
- CHAT_JOB_FALLBACK_SELECTORS,
19
- CHAT_JOB_LABEL_SELECTORS,
20
- CHAT_JOB_OPTION_SELECTORS,
21
- CHAT_JOB_TRIGGER_SELECTORS
22
- } from "./constants.js";
23
- import { getChatRoots } from "./roots.js";
24
-
25
- function isActiveClass(className = "") {
26
- return /\b(active|selected|current)\b/i.test(String(className || ""));
27
- }
28
-
29
- function normalizeJobText(value) {
30
- return normalizeText(value).replace(/\s+_/g, " _").replace(/_\s+/g, "_ ");
31
- }
32
-
33
- async function freshTopRootNodeId(client, fallbackNodeId) {
34
- try {
35
- const rootState = await getChatRoots(client);
36
- return rootState.rootNodes.top || fallbackNodeId;
37
- } catch {
38
- return fallbackNodeId;
39
- }
40
- }
41
-
42
- async function safeQuerySelector(client, rootNodeId, selector) {
43
- try {
44
- return await querySelector(client, rootNodeId, selector);
45
- } catch {
46
- return 0;
47
- }
48
- }
49
-
50
- async function safeQuerySelectorAll(client, rootNodeId, selector) {
51
- try {
52
- return await querySelectorAll(client, rootNodeId, selector);
53
- } catch {
54
- return [];
55
- }
56
- }
57
-
58
- async function readNodeText(client, nodeId) {
59
- const outerHTML = await getOuterHTML(client, nodeId);
60
- return {
61
- outerHTML,
62
- text: normalizeJobText(htmlToText(outerHTML))
63
- };
64
- }
65
-
66
- async function readSelectedJobLabel(client, rootNodeId) {
67
- for (const selector of CHAT_JOB_LABEL_SELECTORS) {
68
- const nodeId = await safeQuerySelector(client, rootNodeId, selector);
69
- if (!nodeId) continue;
70
- try {
71
- const { text } = await readNodeText(client, nodeId);
72
- if (text) return { selector, label: text };
73
- } catch {
74
- continue;
75
- }
76
- }
77
- return { selector: "", label: "" };
78
- }
79
-
80
- async function readOptionNode(client, nodeId, index, { selector, source }) {
81
- const [attributes, textResult] = await Promise.all([
82
- getAttributesMap(client, nodeId),
83
- readNodeText(client, nodeId)
84
- ]);
85
- const label = normalizeJobText(attributes.title || textResult.text);
86
- if (!label) return null;
87
- const rawValue = normalizeText(attributes.value || attributes["data-value"] || attributes["data-id"] || "");
88
- return {
89
- node_id: nodeId,
90
- index,
91
- label,
92
- title: label,
93
- value: rawValue || label,
94
- active: isActiveClass(attributes.class),
95
- is_all: rawValue === "-1" || /^(全部职位|全部岗位|全部)$/u.test(label),
96
- source,
97
- selector
98
- };
99
- }
100
-
101
- async function readClickableOptionNode(client, nodeId, index, { selector, source }) {
102
- const option = await readOptionNode(client, nodeId, index, { selector, source });
103
- if (!option) return null;
104
- try {
105
- const box = await getNodeBox(client, nodeId);
106
- option.center = box.center;
107
- option.rect = box.rect;
108
- option.visible = box.rect.width > 2 && box.rect.height > 2;
109
- } catch {
110
- option.center = null;
111
- option.rect = null;
112
- option.visible = false;
113
- }
114
- return option;
115
- }
116
-
117
- async function readOptionsForSelector(client, rootNodeId, selector, { source }) {
118
- const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
119
- const options = [];
120
- for (const nodeId of nodeIds) {
121
- let option = null;
122
- try {
123
- option = await readClickableOptionNode(client, nodeId, options.length + 1, {
124
- selector,
125
- source
126
- });
127
- } catch {
128
- option = null;
129
- }
130
- if (option) options.push(option);
131
- }
132
- return options;
133
- }
134
-
135
- function dedupeJobOptions(options = []) {
136
- const seen = new Set();
137
- const deduped = [];
138
- for (const option of options) {
139
- const key = `${normalizeText(option.value).toLowerCase()}|${normalizeText(option.label).toLowerCase()}`;
140
- if (seen.has(key)) continue;
141
- seen.add(key);
142
- deduped.push({
143
- ...option,
144
- index: deduped.length + 1
145
- });
146
- }
147
- return deduped;
148
- }
149
-
150
- export async function readChatJobOptions(client, rootNodeId, {
151
- timeoutMs = 12000,
152
- intervalMs = 300
153
- } = {}) {
154
- const started = Date.now();
155
- let selected = { selector: "", label: "" };
156
- let lastPrimary = {
157
- selector: "",
158
- source: "chat-job-list",
159
- options: []
160
- };
161
-
162
- while (Date.now() - started <= timeoutMs) {
163
- selected = await readSelectedJobLabel(client, rootNodeId);
164
- for (const selector of CHAT_JOB_OPTION_SELECTORS) {
165
- const options = await readOptionsForSelector(client, rootNodeId, selector, {
166
- source: "chat-job-list"
167
- });
168
- if (options.length) {
169
- lastPrimary = {
170
- selector,
171
- source: "chat-job-list",
172
- options: dedupeJobOptions(options)
173
- };
174
- return {
175
- selector,
176
- source: "chat-job-list",
177
- selected_label: selected.label || "",
178
- selected_selector: selected.selector || "",
179
- job_options: lastPrimary.options
180
- };
181
- }
182
- }
183
- await sleep(intervalMs);
184
- }
185
-
186
- const fallbackOptions = [];
187
- for (const selector of CHAT_JOB_FALLBACK_SELECTORS) {
188
- const options = await readOptionsForSelector(client, rootNodeId, selector, {
189
- source: "conversation-source-job"
190
- });
191
- fallbackOptions.push(...options);
192
- }
193
-
194
- const dedupedFallback = dedupeJobOptions(fallbackOptions);
195
- if (dedupedFallback.length) {
196
- return {
197
- selector: CHAT_JOB_FALLBACK_SELECTORS.join(", "),
198
- source: "conversation-source-job",
199
- selected_label: selected.label || "",
200
- selected_selector: selected.selector || "",
201
- job_options: dedupedFallback
202
- };
203
- }
204
-
205
- return {
206
- selector: lastPrimary.selector,
207
- source: lastPrimary.source,
208
- selected_label: selected.label || "",
209
- selected_selector: selected.selector || "",
210
- job_options: []
211
- };
212
- }
213
-
214
- function matchJobOption(option, jobLabel = "") {
215
- const requested = normalizeJobText(jobLabel).toLowerCase();
216
- if (!requested) return false;
217
- return [
218
- option.value,
219
- option.label,
220
- option.title
221
- ].map((value) => normalizeJobText(value).toLowerCase()).some((value) => (
222
- value === requested
223
- || value.includes(requested)
224
- || requested.includes(value)
225
- ));
226
- }
227
-
228
- function activeMatchingJobOption(options = [], jobLabel = "") {
229
- return (options || []).find((option) => option.active && matchJobOption(option, jobLabel)) || null;
230
- }
231
-
232
- function selectedLabelMatches(label = "", jobLabel = "") {
233
- const normalized = normalizeJobText(label);
234
- return Boolean(normalized && matchJobOption({ label: normalized, value: normalized, title: normalized }, jobLabel));
235
- }
236
-
237
- async function clickFirstVisible(client, rootNodeId, selectors = []) {
238
- for (const selector of selectors) {
239
- const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
240
- for (const nodeId of nodeIds) {
241
- try {
242
- const box = await getNodeBox(client, nodeId);
243
- if (box.rect.width <= 2 || box.rect.height <= 2) continue;
244
- await clickPoint(client, box.center.x, box.center.y, DETERMINISTIC_CLICK_OPTIONS);
245
- return {
246
- clicked: true,
247
- selector,
248
- node_id: nodeId,
249
- center: box.center
250
- };
251
- } catch {}
252
- }
253
- }
254
- return {
255
- clicked: false,
256
- selector: "",
257
- node_id: 0
258
- };
259
- }
260
-
261
- async function openChatJobDropdown(client, rootNodeId, {
262
- timeoutMs = 12000,
263
- intervalMs = 300,
264
- settleMs = 800
265
- } = {}) {
266
- const started = Date.now();
267
- const triedPoints = new Set();
268
- const attempts = [];
269
- const initialClose = await closeChatJobDropdownQuietly(client, rootNodeId, Math.min(settleMs, 300));
270
- for (const selector of CHAT_JOB_TRIGGER_SELECTORS) {
271
- const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
272
- const nodeIds = await safeQuerySelectorAll(client, currentRootNodeId, selector);
273
- for (const nodeId of nodeIds) {
274
- try {
275
- const box = await getNodeBox(client, nodeId);
276
- if (box.rect.width <= 2 || box.rect.height <= 2) continue;
277
- const y = box.center.y;
278
- const xCandidates = [
279
- ["center", box.center.x],
280
- ["right_12", box.rect.x + box.rect.width - 12],
281
- ["right_44", box.rect.x + box.rect.width - 44],
282
- ["right_64", box.rect.x + box.rect.width - 64]
283
- ].filter(([, x]) => x > box.rect.x + 4 && x < box.rect.x + box.rect.width - 4);
284
- for (const [pointName, x] of xCandidates) {
285
- const pointKey = `${nodeId}:${Math.round(x)}:${Math.round(y)}`;
286
- if (triedPoints.has(pointKey)) continue;
287
- triedPoints.add(pointKey);
288
- await clickPoint(client, x, y, DETERMINISTIC_CLICK_OPTIONS);
289
- if (settleMs > 0) await sleep(Math.min(settleMs, 800));
290
- const remaining = Math.max(300, timeoutMs - (Date.now() - started));
291
- const optionsResult = await waitForChatJobOptions(client, currentRootNodeId, {
292
- timeoutMs: Math.min(remaining, 1800),
293
- intervalMs,
294
- requireVisible: true
295
- });
296
- const visibleCount = (optionsResult.job_options || []).filter((option) => option.visible).length;
297
- const attempt = {
298
- clicked: true,
299
- selector,
300
- node_id: nodeId,
301
- point: pointName,
302
- center: { x, y },
303
- visible_option_count: visibleCount,
304
- initial_close: initialClose
305
- };
306
- attempts.push(attempt);
307
- if (visibleCount > 0) {
308
- return {
309
- ...attempt,
310
- attempts,
311
- options_result: optionsResult
312
- };
313
- }
314
- if (Date.now() - started > timeoutMs) break;
315
- }
316
- } catch (error) {
317
- attempts.push({
318
- clicked: false,
319
- selector,
320
- node_id: nodeId,
321
- error: error?.message || String(error)
322
- });
323
- }
324
- if (Date.now() - started > timeoutMs) break;
325
- }
326
- if (Date.now() - started > timeoutMs) break;
327
- }
328
- return {
329
- clicked: attempts.some((attempt) => attempt.clicked),
330
- selector: attempts.find((attempt) => attempt.clicked)?.selector || "",
331
- node_id: attempts.find((attempt) => attempt.clicked)?.node_id || 0,
332
- attempts,
333
- options_result: null
334
- };
335
- }
336
-
337
- async function waitForChatJobOptions(client, rootNodeId, {
338
- timeoutMs = 12000,
339
- intervalMs = 300,
340
- requireVisible = false
341
- } = {}) {
342
- const started = Date.now();
343
- let latest = null;
344
- while (Date.now() - started <= timeoutMs) {
345
- const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
346
- latest = await readChatJobOptions(client, currentRootNodeId, {
347
- timeoutMs: Math.min(intervalMs, 300),
348
- intervalMs
349
- });
350
- const options = latest.job_options || [];
351
- if (options.length && (!requireVisible || options.some((option) => option.visible))) {
352
- return latest;
353
- }
354
- await sleep(intervalMs);
355
- }
356
- return latest || {
357
- selector: "",
358
- source: "chat-job-list",
359
- selected_label: "",
360
- job_options: []
361
- };
362
- }
363
-
364
- async function waitForSelectedChatJob(client, rootNodeId, jobLabel = "", {
365
- timeoutMs = 5000,
366
- intervalMs = 300
367
- } = {}) {
368
- const started = Date.now();
369
- let latest = null;
370
- while (Date.now() - started <= timeoutMs) {
371
- const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
372
- latest = await readChatJobOptions(client, currentRootNodeId, {
373
- timeoutMs: Math.min(intervalMs, 300),
374
- intervalMs
375
- });
376
- if (
377
- selectedLabelMatches(latest.selected_label, jobLabel)
378
- || activeMatchingJobOption(latest.job_options || [], jobLabel)
379
- ) {
380
- return {
381
- verified: true,
382
- result: latest
383
- };
384
- }
385
- await sleep(intervalMs);
386
- }
387
- return {
388
- verified: false,
389
- result: latest
390
- };
391
- }
392
-
393
- async function visibleChatJobOptions(client, rootNodeId) {
394
- const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
395
- const visible = [];
396
- for (const selector of CHAT_JOB_OPTION_SELECTORS) {
397
- const nodeIds = await safeQuerySelectorAll(client, currentRootNodeId, selector);
398
- for (const nodeId of nodeIds) {
399
- try {
400
- const box = await getNodeBox(client, nodeId);
401
- if (box.rect.width > 2 && box.rect.height > 2) {
402
- visible.push({
403
- selector,
404
- node_id: nodeId,
405
- center: box.center,
406
- rect: box.rect
407
- });
408
- }
409
- } catch {
410
- // Hidden job options are normal when the dropdown is closed.
411
- }
412
- }
413
- }
414
- return visible;
415
- }
416
-
417
- export async function closeChatJobDropdown(client, rootNodeId, {
418
- settleMs = 180
419
- } = {}) {
420
- const before = await visibleChatJobOptions(client, rootNodeId);
421
- if (!before.length) {
422
- return {
423
- ok: true,
424
- closed: false,
425
- reason: "already_closed",
426
- visible_before_count: 0,
427
- visible_after_count: 0
428
- };
429
- }
430
- if (typeof client?.Input?.dispatchKeyEvent !== "function") {
431
- return {
432
- ok: false,
433
- closed: false,
434
- reason: "dispatch_key_unavailable",
435
- visible_before_count: before.length,
436
- visible_after_count: before.length
437
- };
438
- }
439
- await pressKey(client, "Escape", {
440
- code: "Escape",
441
- windowsVirtualKeyCode: 27,
442
- nativeVirtualKeyCode: 27
443
- });
444
- if (settleMs > 0) await sleep(settleMs);
445
- const after = await visibleChatJobOptions(client, rootNodeId);
446
- return {
447
- ok: after.length === 0,
448
- closed: after.length === 0,
449
- reason: after.length ? "still_visible_after_escape" : "escape",
450
- visible_before_count: before.length,
451
- visible_after_count: after.length,
452
- first_visible_before: before[0] || null,
453
- first_visible_after: after[0] || null
454
- };
455
- }
456
-
457
- async function closeChatJobDropdownQuietly(client, rootNodeId, settleMs = 180) {
458
- try {
459
- return await closeChatJobDropdown(client, rootNodeId, { settleMs });
460
- } catch (error) {
461
- return {
462
- ok: false,
463
- closed: false,
464
- reason: "close_failed",
465
- error: error?.message || String(error)
466
- };
467
- }
468
- }
469
-
470
- export async function selectChatJob(client, rootNodeId, {
471
- jobLabel = "",
472
- timeoutMs = 12000,
473
- intervalMs = 300,
474
- settleMs = 800
475
- } = {}) {
476
- const requested = normalizeJobText(jobLabel);
477
- if (!requested) {
478
- return {
479
- selected: false,
480
- reason: "missing_job_label"
481
- };
482
- }
483
-
484
- let currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
485
- let optionsResult = await readChatJobOptions(client, currentRootNodeId, {
486
- timeoutMs: Math.min(timeoutMs, 1500),
487
- intervalMs
488
- });
489
- let matched = (optionsResult.job_options || []).find((option) => matchJobOption(option, requested)) || null;
490
- if (
491
- matched
492
- && (
493
- matched.active
494
- || selectedLabelMatches(optionsResult.selected_label, matched.label)
495
- || selectedLabelMatches(optionsResult.selected_label, requested)
496
- )
497
- ) {
498
- const menuClose = await closeChatJobDropdownQuietly(client, currentRootNodeId, Math.min(settleMs, 300));
499
- return {
500
- selected: true,
501
- verified: true,
502
- already_current: true,
503
- requested,
504
- selected_option: matched,
505
- options: optionsResult.job_options || [],
506
- selected_label: optionsResult.selected_label || matched.label,
507
- menu_close: menuClose
508
- };
509
- }
510
-
511
- if (!matched || !matched.visible) {
512
- const triggerRootNodeId = await freshTopRootNodeId(client, currentRootNodeId);
513
- const trigger = await openChatJobDropdown(client, triggerRootNodeId, {
514
- timeoutMs,
515
- intervalMs,
516
- settleMs
517
- });
518
- currentRootNodeId = await freshTopRootNodeId(client, triggerRootNodeId);
519
- optionsResult = trigger.options_result || await waitForChatJobOptions(client, currentRootNodeId, {
520
- timeoutMs,
521
- intervalMs,
522
- requireVisible: true
523
- });
524
- matched = (optionsResult.job_options || []).find((option) => matchJobOption(option, requested)) || null;
525
- if (!matched || !matched.visible) {
526
- const menuClose = await closeChatJobDropdownQuietly(client, currentRootNodeId, Math.min(settleMs, 300));
527
- return {
528
- selected: false,
529
- reason: matched ? "job_option_not_visible" : "job_option_not_found",
530
- requested,
531
- trigger,
532
- options: optionsResult.job_options || [],
533
- selected_label_before: optionsResult.selected_label || "",
534
- menu_close: menuClose
535
- };
536
- }
537
- }
538
-
539
- if (matched.active || normalizeJobText(optionsResult.selected_label).toLowerCase() === normalizeJobText(matched.label).toLowerCase()) {
540
- const menuClose = await closeChatJobDropdownQuietly(client, currentRootNodeId, Math.min(settleMs, 300));
541
- return {
542
- selected: true,
543
- verified: true,
544
- already_current: true,
545
- requested,
546
- selected_option: matched,
547
- options: optionsResult.job_options || [],
548
- selected_label: optionsResult.selected_label || matched.label,
549
- menu_close: menuClose
550
- };
551
- }
552
-
553
- if (matched.center) {
554
- await clickPoint(client, matched.center.x, matched.center.y, DETERMINISTIC_CLICK_OPTIONS);
555
- } else {
556
- await clickNodeCenter(client, matched.node_id, {
557
- ...DETERMINISTIC_CLICK_OPTIONS,
558
- scrollIntoView: true
559
- });
560
- }
561
- if (settleMs > 0) await sleep(settleMs);
562
-
563
- const afterRootNodeId = await freshTopRootNodeId(client, currentRootNodeId);
564
- const verification = await waitForSelectedChatJob(client, afterRootNodeId, matched.label, {
565
- timeoutMs: Math.min(timeoutMs, 5000),
566
- intervalMs
567
- });
568
- const after = verification.result || {
569
- selected_label: "",
570
- job_options: []
571
- };
572
- const afterMatch = (after.job_options || []).find((option) => matchJobOption(option, matched.label)) || matched;
573
- const selectedLabel = normalizeJobText(after.selected_label || "");
574
- const activeMatch = activeMatchingJobOption(after.job_options || [], matched.label);
575
- const verified = Boolean(verification.verified || selectedLabelMatches(selectedLabel, matched.label) || activeMatch);
576
- const menuClose = await closeChatJobDropdownQuietly(client, afterRootNodeId, Math.min(settleMs, 300));
577
-
578
- return {
579
- selected: verified,
580
- verified,
581
- already_current: false,
582
- reason: verified ? "verified" : "job_selection_not_verified",
583
- requested,
584
- selected_option: afterMatch,
585
- active_option: activeMatch,
586
- options: after.job_options || optionsResult.job_options || [],
587
- selected_label: selectedLabel,
588
- before: optionsResult,
589
- after,
590
- menu_close: menuClose
591
- };
592
- }
1
+ import {
2
+ clickNodeCenter,
3
+ clickPoint,
4
+ DETERMINISTIC_CLICK_OPTIONS,
5
+ getAttributesMap,
6
+ getNodeBox,
7
+ getOuterHTML,
8
+ pressKey,
9
+ querySelector,
10
+ querySelectorAll,
11
+ sleep
12
+ } from "../../core/browser/index.js";
13
+ import {
14
+ htmlToText,
15
+ normalizeText
16
+ } from "../../core/screening/index.js";
17
+ import {
18
+ CHAT_JOB_FALLBACK_SELECTORS,
19
+ CHAT_JOB_LABEL_SELECTORS,
20
+ CHAT_JOB_OPTION_SELECTORS,
21
+ CHAT_JOB_TRIGGER_SELECTORS
22
+ } from "./constants.js";
23
+ import { getChatRoots } from "./roots.js";
24
+
25
+ function isActiveClass(className = "") {
26
+ return /\b(active|selected|current)\b/i.test(String(className || ""));
27
+ }
28
+
29
+ function normalizeJobText(value) {
30
+ return normalizeText(value).replace(/\s+_/g, " _").replace(/_\s+/g, "_ ");
31
+ }
32
+
33
+ async function freshTopRootNodeId(client, fallbackNodeId) {
34
+ try {
35
+ const rootState = await getChatRoots(client);
36
+ return rootState.rootNodes.top || fallbackNodeId;
37
+ } catch {
38
+ return fallbackNodeId;
39
+ }
40
+ }
41
+
42
+ async function safeQuerySelector(client, rootNodeId, selector) {
43
+ try {
44
+ return await querySelector(client, rootNodeId, selector);
45
+ } catch {
46
+ return 0;
47
+ }
48
+ }
49
+
50
+ async function safeQuerySelectorAll(client, rootNodeId, selector) {
51
+ try {
52
+ return await querySelectorAll(client, rootNodeId, selector);
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ async function readNodeText(client, nodeId) {
59
+ const outerHTML = await getOuterHTML(client, nodeId);
60
+ return {
61
+ outerHTML,
62
+ text: normalizeJobText(htmlToText(outerHTML))
63
+ };
64
+ }
65
+
66
+ async function readSelectedJobLabel(client, rootNodeId) {
67
+ for (const selector of CHAT_JOB_LABEL_SELECTORS) {
68
+ const nodeId = await safeQuerySelector(client, rootNodeId, selector);
69
+ if (!nodeId) continue;
70
+ try {
71
+ const { text } = await readNodeText(client, nodeId);
72
+ if (text) return { selector, label: text };
73
+ } catch {
74
+ continue;
75
+ }
76
+ }
77
+ return { selector: "", label: "" };
78
+ }
79
+
80
+ async function readOptionNode(client, nodeId, index, { selector, source }) {
81
+ const [attributes, textResult] = await Promise.all([
82
+ getAttributesMap(client, nodeId),
83
+ readNodeText(client, nodeId)
84
+ ]);
85
+ const label = normalizeJobText(attributes.title || textResult.text);
86
+ if (!label) return null;
87
+ const rawValue = normalizeText(attributes.value || attributes["data-value"] || attributes["data-id"] || "");
88
+ return {
89
+ node_id: nodeId,
90
+ index,
91
+ label,
92
+ title: label,
93
+ value: rawValue || label,
94
+ active: isActiveClass(attributes.class),
95
+ is_all: rawValue === "-1" || /^(全部职位|全部岗位|全部)$/u.test(label),
96
+ source,
97
+ selector
98
+ };
99
+ }
100
+
101
+ async function readClickableOptionNode(client, nodeId, index, { selector, source }) {
102
+ const option = await readOptionNode(client, nodeId, index, { selector, source });
103
+ if (!option) return null;
104
+ try {
105
+ const box = await getNodeBox(client, nodeId);
106
+ option.center = box.center;
107
+ option.rect = box.rect;
108
+ option.visible = box.rect.width > 2 && box.rect.height > 2;
109
+ } catch {
110
+ option.center = null;
111
+ option.rect = null;
112
+ option.visible = false;
113
+ }
114
+ return option;
115
+ }
116
+
117
+ async function readOptionsForSelector(client, rootNodeId, selector, { source }) {
118
+ const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
119
+ const options = [];
120
+ for (const nodeId of nodeIds) {
121
+ let option = null;
122
+ try {
123
+ option = await readClickableOptionNode(client, nodeId, options.length + 1, {
124
+ selector,
125
+ source
126
+ });
127
+ } catch {
128
+ option = null;
129
+ }
130
+ if (option) options.push(option);
131
+ }
132
+ return options;
133
+ }
134
+
135
+ function dedupeJobOptions(options = []) {
136
+ const seen = new Set();
137
+ const deduped = [];
138
+ for (const option of options) {
139
+ const key = `${normalizeText(option.value).toLowerCase()}|${normalizeText(option.label).toLowerCase()}`;
140
+ if (seen.has(key)) continue;
141
+ seen.add(key);
142
+ deduped.push({
143
+ ...option,
144
+ index: deduped.length + 1
145
+ });
146
+ }
147
+ return deduped;
148
+ }
149
+
150
+ export async function readChatJobOptions(client, rootNodeId, {
151
+ timeoutMs = 12000,
152
+ intervalMs = 300
153
+ } = {}) {
154
+ const started = Date.now();
155
+ let selected = { selector: "", label: "" };
156
+ let lastPrimary = {
157
+ selector: "",
158
+ source: "chat-job-list",
159
+ options: []
160
+ };
161
+
162
+ while (Date.now() - started <= timeoutMs) {
163
+ selected = await readSelectedJobLabel(client, rootNodeId);
164
+ for (const selector of CHAT_JOB_OPTION_SELECTORS) {
165
+ const options = await readOptionsForSelector(client, rootNodeId, selector, {
166
+ source: "chat-job-list"
167
+ });
168
+ if (options.length) {
169
+ lastPrimary = {
170
+ selector,
171
+ source: "chat-job-list",
172
+ options: dedupeJobOptions(options)
173
+ };
174
+ return {
175
+ selector,
176
+ source: "chat-job-list",
177
+ selected_label: selected.label || "",
178
+ selected_selector: selected.selector || "",
179
+ job_options: lastPrimary.options
180
+ };
181
+ }
182
+ }
183
+ await sleep(intervalMs);
184
+ }
185
+
186
+ const fallbackOptions = [];
187
+ for (const selector of CHAT_JOB_FALLBACK_SELECTORS) {
188
+ const options = await readOptionsForSelector(client, rootNodeId, selector, {
189
+ source: "conversation-source-job"
190
+ });
191
+ fallbackOptions.push(...options);
192
+ }
193
+
194
+ const dedupedFallback = dedupeJobOptions(fallbackOptions);
195
+ if (dedupedFallback.length) {
196
+ return {
197
+ selector: CHAT_JOB_FALLBACK_SELECTORS.join(", "),
198
+ source: "conversation-source-job",
199
+ selected_label: selected.label || "",
200
+ selected_selector: selected.selector || "",
201
+ job_options: dedupedFallback
202
+ };
203
+ }
204
+
205
+ return {
206
+ selector: lastPrimary.selector,
207
+ source: lastPrimary.source,
208
+ selected_label: selected.label || "",
209
+ selected_selector: selected.selector || "",
210
+ job_options: []
211
+ };
212
+ }
213
+
214
+ function matchJobOption(option, jobLabel = "") {
215
+ const requested = normalizeJobText(jobLabel).toLowerCase();
216
+ if (!requested) return false;
217
+ return [
218
+ option.value,
219
+ option.label,
220
+ option.title
221
+ ].map((value) => normalizeJobText(value).toLowerCase()).some((value) => (
222
+ value === requested
223
+ || value.includes(requested)
224
+ || requested.includes(value)
225
+ ));
226
+ }
227
+
228
+ function activeMatchingJobOption(options = [], jobLabel = "") {
229
+ return (options || []).find((option) => option.active && matchJobOption(option, jobLabel)) || null;
230
+ }
231
+
232
+ function selectedLabelMatches(label = "", jobLabel = "") {
233
+ const normalized = normalizeJobText(label);
234
+ return Boolean(normalized && matchJobOption({ label: normalized, value: normalized, title: normalized }, jobLabel));
235
+ }
236
+
237
+ async function clickFirstVisible(client, rootNodeId, selectors = []) {
238
+ for (const selector of selectors) {
239
+ const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
240
+ for (const nodeId of nodeIds) {
241
+ try {
242
+ const box = await getNodeBox(client, nodeId);
243
+ if (box.rect.width <= 2 || box.rect.height <= 2) continue;
244
+ await clickPoint(client, box.center.x, box.center.y, DETERMINISTIC_CLICK_OPTIONS);
245
+ return {
246
+ clicked: true,
247
+ selector,
248
+ node_id: nodeId,
249
+ center: box.center
250
+ };
251
+ } catch {}
252
+ }
253
+ }
254
+ return {
255
+ clicked: false,
256
+ selector: "",
257
+ node_id: 0
258
+ };
259
+ }
260
+
261
+ async function openChatJobDropdown(client, rootNodeId, {
262
+ timeoutMs = 12000,
263
+ intervalMs = 300,
264
+ settleMs = 800
265
+ } = {}) {
266
+ const started = Date.now();
267
+ const triedPoints = new Set();
268
+ const attempts = [];
269
+ const initialClose = await closeChatJobDropdownQuietly(client, rootNodeId, Math.min(settleMs, 300));
270
+ for (const selector of CHAT_JOB_TRIGGER_SELECTORS) {
271
+ const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
272
+ const nodeIds = await safeQuerySelectorAll(client, currentRootNodeId, selector);
273
+ for (const nodeId of nodeIds) {
274
+ try {
275
+ const box = await getNodeBox(client, nodeId);
276
+ if (box.rect.width <= 2 || box.rect.height <= 2) continue;
277
+ const y = box.center.y;
278
+ const xCandidates = [
279
+ ["center", box.center.x],
280
+ ["right_12", box.rect.x + box.rect.width - 12],
281
+ ["right_44", box.rect.x + box.rect.width - 44],
282
+ ["right_64", box.rect.x + box.rect.width - 64]
283
+ ].filter(([, x]) => x > box.rect.x + 4 && x < box.rect.x + box.rect.width - 4);
284
+ for (const [pointName, x] of xCandidates) {
285
+ const pointKey = `${nodeId}:${Math.round(x)}:${Math.round(y)}`;
286
+ if (triedPoints.has(pointKey)) continue;
287
+ triedPoints.add(pointKey);
288
+ await clickPoint(client, x, y, DETERMINISTIC_CLICK_OPTIONS);
289
+ if (settleMs > 0) await sleep(Math.min(settleMs, 800));
290
+ const remaining = Math.max(300, timeoutMs - (Date.now() - started));
291
+ const optionsResult = await waitForChatJobOptions(client, currentRootNodeId, {
292
+ timeoutMs: Math.min(remaining, 1800),
293
+ intervalMs,
294
+ requireVisible: true
295
+ });
296
+ const visibleCount = (optionsResult.job_options || []).filter((option) => option.visible).length;
297
+ const attempt = {
298
+ clicked: true,
299
+ selector,
300
+ node_id: nodeId,
301
+ point: pointName,
302
+ center: { x, y },
303
+ visible_option_count: visibleCount,
304
+ initial_close: initialClose
305
+ };
306
+ attempts.push(attempt);
307
+ if (visibleCount > 0) {
308
+ return {
309
+ ...attempt,
310
+ attempts,
311
+ options_result: optionsResult
312
+ };
313
+ }
314
+ if (Date.now() - started > timeoutMs) break;
315
+ }
316
+ } catch (error) {
317
+ attempts.push({
318
+ clicked: false,
319
+ selector,
320
+ node_id: nodeId,
321
+ error: error?.message || String(error)
322
+ });
323
+ }
324
+ if (Date.now() - started > timeoutMs) break;
325
+ }
326
+ if (Date.now() - started > timeoutMs) break;
327
+ }
328
+ return {
329
+ clicked: attempts.some((attempt) => attempt.clicked),
330
+ selector: attempts.find((attempt) => attempt.clicked)?.selector || "",
331
+ node_id: attempts.find((attempt) => attempt.clicked)?.node_id || 0,
332
+ attempts,
333
+ options_result: null
334
+ };
335
+ }
336
+
337
+ async function waitForChatJobOptions(client, rootNodeId, {
338
+ timeoutMs = 12000,
339
+ intervalMs = 300,
340
+ requireVisible = false
341
+ } = {}) {
342
+ const started = Date.now();
343
+ let latest = null;
344
+ while (Date.now() - started <= timeoutMs) {
345
+ const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
346
+ latest = await readChatJobOptions(client, currentRootNodeId, {
347
+ timeoutMs: Math.min(intervalMs, 300),
348
+ intervalMs
349
+ });
350
+ const options = latest.job_options || [];
351
+ if (options.length && (!requireVisible || options.some((option) => option.visible))) {
352
+ return latest;
353
+ }
354
+ await sleep(intervalMs);
355
+ }
356
+ return latest || {
357
+ selector: "",
358
+ source: "chat-job-list",
359
+ selected_label: "",
360
+ job_options: []
361
+ };
362
+ }
363
+
364
+ async function waitForSelectedChatJob(client, rootNodeId, jobLabel = "", {
365
+ timeoutMs = 5000,
366
+ intervalMs = 300
367
+ } = {}) {
368
+ const started = Date.now();
369
+ let latest = null;
370
+ while (Date.now() - started <= timeoutMs) {
371
+ const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
372
+ latest = await readChatJobOptions(client, currentRootNodeId, {
373
+ timeoutMs: Math.min(intervalMs, 300),
374
+ intervalMs
375
+ });
376
+ if (
377
+ selectedLabelMatches(latest.selected_label, jobLabel)
378
+ || activeMatchingJobOption(latest.job_options || [], jobLabel)
379
+ ) {
380
+ return {
381
+ verified: true,
382
+ result: latest
383
+ };
384
+ }
385
+ await sleep(intervalMs);
386
+ }
387
+ return {
388
+ verified: false,
389
+ result: latest
390
+ };
391
+ }
392
+
393
+ async function visibleChatJobOptions(client, rootNodeId) {
394
+ const currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
395
+ const visible = [];
396
+ for (const selector of CHAT_JOB_OPTION_SELECTORS) {
397
+ const nodeIds = await safeQuerySelectorAll(client, currentRootNodeId, selector);
398
+ for (const nodeId of nodeIds) {
399
+ try {
400
+ const box = await getNodeBox(client, nodeId);
401
+ if (box.rect.width > 2 && box.rect.height > 2) {
402
+ visible.push({
403
+ selector,
404
+ node_id: nodeId,
405
+ center: box.center,
406
+ rect: box.rect
407
+ });
408
+ }
409
+ } catch {
410
+ // Hidden job options are normal when the dropdown is closed.
411
+ }
412
+ }
413
+ }
414
+ return visible;
415
+ }
416
+
417
+ export async function closeChatJobDropdown(client, rootNodeId, {
418
+ settleMs = 180
419
+ } = {}) {
420
+ const before = await visibleChatJobOptions(client, rootNodeId);
421
+ if (!before.length) {
422
+ return {
423
+ ok: true,
424
+ closed: false,
425
+ reason: "already_closed",
426
+ visible_before_count: 0,
427
+ visible_after_count: 0
428
+ };
429
+ }
430
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
431
+ return {
432
+ ok: false,
433
+ closed: false,
434
+ reason: "dispatch_key_unavailable",
435
+ visible_before_count: before.length,
436
+ visible_after_count: before.length
437
+ };
438
+ }
439
+ await pressKey(client, "Escape", {
440
+ code: "Escape",
441
+ windowsVirtualKeyCode: 27,
442
+ nativeVirtualKeyCode: 27
443
+ });
444
+ if (settleMs > 0) await sleep(settleMs);
445
+ const after = await visibleChatJobOptions(client, rootNodeId);
446
+ return {
447
+ ok: after.length === 0,
448
+ closed: after.length === 0,
449
+ reason: after.length ? "still_visible_after_escape" : "escape",
450
+ visible_before_count: before.length,
451
+ visible_after_count: after.length,
452
+ first_visible_before: before[0] || null,
453
+ first_visible_after: after[0] || null
454
+ };
455
+ }
456
+
457
+ async function closeChatJobDropdownQuietly(client, rootNodeId, settleMs = 180) {
458
+ try {
459
+ return await closeChatJobDropdown(client, rootNodeId, { settleMs });
460
+ } catch (error) {
461
+ return {
462
+ ok: false,
463
+ closed: false,
464
+ reason: "close_failed",
465
+ error: error?.message || String(error)
466
+ };
467
+ }
468
+ }
469
+
470
+ export async function selectChatJob(client, rootNodeId, {
471
+ jobLabel = "",
472
+ timeoutMs = 12000,
473
+ intervalMs = 300,
474
+ settleMs = 800
475
+ } = {}) {
476
+ const requested = normalizeJobText(jobLabel);
477
+ if (!requested) {
478
+ return {
479
+ selected: false,
480
+ reason: "missing_job_label"
481
+ };
482
+ }
483
+
484
+ let currentRootNodeId = await freshTopRootNodeId(client, rootNodeId);
485
+ let optionsResult = await readChatJobOptions(client, currentRootNodeId, {
486
+ timeoutMs: Math.min(timeoutMs, 1500),
487
+ intervalMs
488
+ });
489
+ let matched = (optionsResult.job_options || []).find((option) => matchJobOption(option, requested)) || null;
490
+ if (
491
+ matched
492
+ && (
493
+ matched.active
494
+ || selectedLabelMatches(optionsResult.selected_label, matched.label)
495
+ || selectedLabelMatches(optionsResult.selected_label, requested)
496
+ )
497
+ ) {
498
+ const menuClose = await closeChatJobDropdownQuietly(client, currentRootNodeId, Math.min(settleMs, 300));
499
+ return {
500
+ selected: true,
501
+ verified: true,
502
+ already_current: true,
503
+ requested,
504
+ selected_option: matched,
505
+ options: optionsResult.job_options || [],
506
+ selected_label: optionsResult.selected_label || matched.label,
507
+ menu_close: menuClose
508
+ };
509
+ }
510
+
511
+ if (!matched || !matched.visible) {
512
+ const triggerRootNodeId = await freshTopRootNodeId(client, currentRootNodeId);
513
+ const trigger = await openChatJobDropdown(client, triggerRootNodeId, {
514
+ timeoutMs,
515
+ intervalMs,
516
+ settleMs
517
+ });
518
+ currentRootNodeId = await freshTopRootNodeId(client, triggerRootNodeId);
519
+ optionsResult = trigger.options_result || await waitForChatJobOptions(client, currentRootNodeId, {
520
+ timeoutMs,
521
+ intervalMs,
522
+ requireVisible: true
523
+ });
524
+ matched = (optionsResult.job_options || []).find((option) => matchJobOption(option, requested)) || null;
525
+ if (!matched || !matched.visible) {
526
+ const menuClose = await closeChatJobDropdownQuietly(client, currentRootNodeId, Math.min(settleMs, 300));
527
+ return {
528
+ selected: false,
529
+ reason: matched ? "job_option_not_visible" : "job_option_not_found",
530
+ requested,
531
+ trigger,
532
+ options: optionsResult.job_options || [],
533
+ selected_label_before: optionsResult.selected_label || "",
534
+ menu_close: menuClose
535
+ };
536
+ }
537
+ }
538
+
539
+ if (matched.active || normalizeJobText(optionsResult.selected_label).toLowerCase() === normalizeJobText(matched.label).toLowerCase()) {
540
+ const menuClose = await closeChatJobDropdownQuietly(client, currentRootNodeId, Math.min(settleMs, 300));
541
+ return {
542
+ selected: true,
543
+ verified: true,
544
+ already_current: true,
545
+ requested,
546
+ selected_option: matched,
547
+ options: optionsResult.job_options || [],
548
+ selected_label: optionsResult.selected_label || matched.label,
549
+ menu_close: menuClose
550
+ };
551
+ }
552
+
553
+ if (matched.center) {
554
+ await clickPoint(client, matched.center.x, matched.center.y, DETERMINISTIC_CLICK_OPTIONS);
555
+ } else {
556
+ await clickNodeCenter(client, matched.node_id, {
557
+ ...DETERMINISTIC_CLICK_OPTIONS,
558
+ scrollIntoView: true
559
+ });
560
+ }
561
+ if (settleMs > 0) await sleep(settleMs);
562
+
563
+ const afterRootNodeId = await freshTopRootNodeId(client, currentRootNodeId);
564
+ const verification = await waitForSelectedChatJob(client, afterRootNodeId, matched.label, {
565
+ timeoutMs: Math.min(timeoutMs, 5000),
566
+ intervalMs
567
+ });
568
+ const after = verification.result || {
569
+ selected_label: "",
570
+ job_options: []
571
+ };
572
+ const afterMatch = (after.job_options || []).find((option) => matchJobOption(option, matched.label)) || matched;
573
+ const selectedLabel = normalizeJobText(after.selected_label || "");
574
+ const activeMatch = activeMatchingJobOption(after.job_options || [], matched.label);
575
+ const verified = Boolean(verification.verified || selectedLabelMatches(selectedLabel, matched.label) || activeMatch);
576
+ const menuClose = await closeChatJobDropdownQuietly(client, afterRootNodeId, Math.min(settleMs, 300));
577
+
578
+ return {
579
+ selected: verified,
580
+ verified,
581
+ already_current: false,
582
+ reason: verified ? "verified" : "job_selection_not_verified",
583
+ requested,
584
+ selected_option: afterMatch,
585
+ active_option: activeMatch,
586
+ options: after.job_options || optionsResult.job_options || [],
587
+ selected_label: selectedLabel,
588
+ before: optionsResult,
589
+ after,
590
+ menu_close: menuClose
591
+ };
592
+ }