@reconcrap/boss-recommend-mcp 1.3.38 → 2.0.0

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 (85) hide show
  1. package/README.md +53 -33
  2. package/package.json +61 -9
  3. package/skills/boss-recommend-pipeline/SKILL.md +4 -0
  4. package/src/chat-mcp.js +1333 -0
  5. package/src/chat-runtime-config.js +559 -0
  6. package/src/cli.js +1095 -196
  7. package/src/core/browser/index.js +378 -0
  8. package/src/core/capture/index.js +298 -0
  9. package/src/core/cv-acquisition/index.js +219 -0
  10. package/src/core/greet-quota/index.js +54 -0
  11. package/src/core/infinite-list/index.js +459 -0
  12. package/src/core/reporting/legacy-csv.js +332 -0
  13. package/src/core/run/index.js +286 -0
  14. package/src/core/screening/index.js +1166 -0
  15. package/src/core/self-heal/index.js +848 -0
  16. package/src/domains/chat/cards.js +129 -0
  17. package/src/domains/chat/constants.js +183 -0
  18. package/src/domains/chat/detail.js +1369 -0
  19. package/src/domains/chat/index.js +7 -0
  20. package/src/domains/chat/jobs.js +334 -0
  21. package/src/domains/chat/page-guard.js +88 -0
  22. package/src/domains/chat/roots.js +56 -0
  23. package/src/domains/chat/run-service.js +1101 -0
  24. package/src/domains/recommend/actions.js +457 -0
  25. package/src/domains/recommend/cards.js +228 -0
  26. package/src/domains/recommend/constants.js +141 -0
  27. package/src/domains/recommend/detail.js +341 -0
  28. package/src/domains/recommend/filters.js +581 -0
  29. package/src/domains/recommend/index.js +10 -0
  30. package/src/domains/recommend/jobs.js +232 -0
  31. package/src/domains/recommend/refresh.js +204 -0
  32. package/src/domains/recommend/roots.js +78 -0
  33. package/src/domains/recommend/run-service.js +903 -0
  34. package/src/domains/recommend/scopes.js +245 -0
  35. package/src/domains/recruit/actions.js +277 -0
  36. package/src/domains/recruit/cards.js +67 -0
  37. package/src/domains/recruit/constants.js +130 -0
  38. package/src/domains/recruit/detail.js +414 -0
  39. package/src/domains/recruit/index.js +9 -0
  40. package/src/domains/recruit/instruction-parser.js +451 -0
  41. package/src/domains/recruit/refresh.js +40 -0
  42. package/src/domains/recruit/roots.js +68 -0
  43. package/src/domains/recruit/run-service.js +580 -0
  44. package/src/domains/recruit/search.js +1149 -0
  45. package/src/index.js +578 -419
  46. package/src/recommend-mcp.js +1257 -0
  47. package/src/recruit-mcp.js +1035 -0
  48. package/src/adapters.js +0 -3079
  49. package/src/boss-chat.js +0 -1037
  50. package/src/pipeline.js +0 -2249
  51. package/src/recommend-healing-config.js +0 -131
  52. package/src/recommend-healing-rules.json +0 -261
  53. package/src/self-heal.js +0 -2237
  54. package/src/test-adapters-runtime.js +0 -628
  55. package/src/test-boss-chat.js +0 -3196
  56. package/src/test-index-async.js +0 -498
  57. package/src/test-parser.js +0 -742
  58. package/src/test-pipeline.js +0 -2703
  59. package/src/test-run-state.js +0 -152
  60. package/src/test-self-heal.js +0 -224
  61. package/vendor/boss-chat-cli/README.md +0 -134
  62. package/vendor/boss-chat-cli/package.json +0 -53
  63. package/vendor/boss-chat-cli/src/app.js +0 -1501
  64. package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
  65. package/vendor/boss-chat-cli/src/cli.js +0 -1713
  66. package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
  67. package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
  68. package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
  69. package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
  70. package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
  71. package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
  72. package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
  73. package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
  74. package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
  75. package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
  76. package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
  77. package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
  78. package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
  79. package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
  80. package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -6927
  81. package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
  82. package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
  83. package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2294
  84. package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
  85. package/vendor/boss-recommend-search-cli/src/test-job-selection.js +0 -211
@@ -0,0 +1,232 @@
1
+ import {
2
+ clickNodeCenter,
3
+ getAttributesMap,
4
+ getNodeBox,
5
+ getOuterHTML,
6
+ pressKey,
7
+ querySelectorAll,
8
+ sleep,
9
+ waitForSelector
10
+ } from "../../core/browser/index.js";
11
+ import {
12
+ htmlToText,
13
+ normalizeText
14
+ } from "../../core/screening/index.js";
15
+
16
+ export const RECOMMEND_JOB_SELECTORS = Object.freeze({
17
+ trigger: ".job-selecter-wrap, [class*=\"job-selecter-wrap\"], .ui-dropmenu",
18
+ option: ".job-selecter-options .job-item, .job-list .job-item, .job-item",
19
+ current: ".job-selecter-options .job-item.curr, .job-list .job-item.curr, .job-item.curr"
20
+ });
21
+
22
+ function normalizeJobText(value) {
23
+ return normalizeText(value).replace(/\s+/g, "");
24
+ }
25
+
26
+ function trimSalarySuffix(label) {
27
+ return normalizeText(label)
28
+ .replace(/\s+(?:\d+(?:-\d+)?K|面议|\d+-\d+元\/天).*$/i, "")
29
+ .trim();
30
+ }
31
+
32
+ export function jobLabelMatches(optionLabel, targetLabel) {
33
+ const option = normalizeJobText(optionLabel);
34
+ const target = normalizeJobText(targetLabel);
35
+ if (!option || !target) return false;
36
+ return option === target
37
+ || option.startsWith(target)
38
+ || normalizeJobText(trimSalarySuffix(optionLabel)) === target;
39
+ }
40
+
41
+ function isVisibleBox(box) {
42
+ return Boolean(box && box.rect.width > 4 && box.rect.height > 4);
43
+ }
44
+
45
+ async function readJobOption(client, nodeId, index) {
46
+ const [attributes, outerHTML] = await Promise.all([
47
+ getAttributesMap(client, nodeId),
48
+ getOuterHTML(client, nodeId)
49
+ ]);
50
+ const label = normalizeText(htmlToText(outerHTML));
51
+ let box = null;
52
+ try {
53
+ box = await getNodeBox(client, nodeId);
54
+ } catch {}
55
+ const className = attributes.class || "";
56
+ return {
57
+ node_id: nodeId,
58
+ index,
59
+ label,
60
+ label_without_salary: trimSalarySuffix(label),
61
+ class_name: className,
62
+ current: /\bcurr\b|\bactive\b|\bselected\b/.test(className),
63
+ visible: isVisibleBox(box),
64
+ center: box?.center || null,
65
+ rect: box?.rect || null
66
+ };
67
+ }
68
+
69
+ export async function findRecommendJobTrigger(client, frameNodeId) {
70
+ const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_JOB_SELECTORS.trigger);
71
+ for (const nodeId of nodeIds) {
72
+ try {
73
+ const box = await getNodeBox(client, nodeId);
74
+ if (isVisibleBox(box)) {
75
+ return {
76
+ node_id: nodeId,
77
+ center: box.center,
78
+ rect: box.rect
79
+ };
80
+ }
81
+ } catch {}
82
+ }
83
+ return null;
84
+ }
85
+
86
+ export async function openRecommendJobDropdown(client, frameNodeId, {
87
+ timeoutMs = 4000
88
+ } = {}) {
89
+ const trigger = await findRecommendJobTrigger(client, frameNodeId);
90
+ if (!trigger) {
91
+ throw new Error("Recommend job trigger was not found");
92
+ }
93
+
94
+ let optionNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_JOB_SELECTORS.option, {
95
+ timeoutMs: 300,
96
+ intervalMs: 100
97
+ });
98
+ if (optionNodeId) {
99
+ const options = await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
100
+ if (options.some((option) => option.visible)) {
101
+ return {
102
+ opened: true,
103
+ already_open: true,
104
+ trigger,
105
+ options
106
+ };
107
+ }
108
+ }
109
+
110
+ await clickNodeCenter(client, trigger.node_id);
111
+ optionNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_JOB_SELECTORS.option, {
112
+ timeoutMs,
113
+ intervalMs: 200
114
+ });
115
+ if (!optionNodeId) {
116
+ throw new Error("Recommend job dropdown did not mount options after trigger click");
117
+ }
118
+ const options = await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
119
+ return {
120
+ opened: true,
121
+ already_open: false,
122
+ trigger,
123
+ options
124
+ };
125
+ }
126
+
127
+ export async function listRecommendJobOptions(client, frameNodeId, {
128
+ openDropdown = true
129
+ } = {}) {
130
+ if (openDropdown) {
131
+ await openRecommendJobDropdown(client, frameNodeId);
132
+ }
133
+
134
+ const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_JOB_SELECTORS.option);
135
+ const options = [];
136
+ const seen = new Set();
137
+ for (let index = 0; index < nodeIds.length; index += 1) {
138
+ const nodeId = nodeIds[index];
139
+ if (seen.has(nodeId)) continue;
140
+ seen.add(nodeId);
141
+ const option = await readJobOption(client, nodeId, index);
142
+ if (!option.label) continue;
143
+ if (option.label.length > 120) continue;
144
+ options.push(option);
145
+ }
146
+ return options;
147
+ }
148
+
149
+ export async function closeRecommendJobDropdown(client) {
150
+ await pressKey(client, "Escape", {
151
+ code: "Escape",
152
+ windowsVirtualKeyCode: 27,
153
+ nativeVirtualKeyCode: 27
154
+ });
155
+ await sleep(300);
156
+ }
157
+
158
+ export async function selectRecommendJob(client, frameNodeId, {
159
+ jobLabel = "",
160
+ settleMs = 6000
161
+ } = {}) {
162
+ const target = normalizeText(jobLabel);
163
+ if (!target) {
164
+ return {
165
+ requested: "",
166
+ selected: false,
167
+ reason: "no_job_requested",
168
+ options: []
169
+ };
170
+ }
171
+
172
+ const opened = await openRecommendJobDropdown(client, frameNodeId);
173
+ const options = opened.options.length
174
+ ? opened.options
175
+ : await listRecommendJobOptions(client, frameNodeId, { openDropdown: false });
176
+ const visibleOptions = options.filter((option) => option.visible);
177
+ const match = visibleOptions.find((option) => jobLabelMatches(option.label, target))
178
+ || options.find((option) => jobLabelMatches(option.label, target));
179
+
180
+ if (!match) {
181
+ await closeRecommendJobDropdown(client);
182
+ return {
183
+ requested: target,
184
+ selected: false,
185
+ reason: "job_not_found",
186
+ options: options.map(compactJobOption)
187
+ };
188
+ }
189
+
190
+ if (match.current) {
191
+ await closeRecommendJobDropdown(client);
192
+ return {
193
+ requested: target,
194
+ selected: true,
195
+ already_current: true,
196
+ selected_option: compactJobOption(match),
197
+ options: options.map(compactJobOption)
198
+ };
199
+ }
200
+
201
+ if (!match.center) {
202
+ await closeRecommendJobDropdown(client);
203
+ throw new Error(`Matched recommend job has no clickable center: ${match.label}`);
204
+ }
205
+
206
+ const clickedBox = await clickNodeCenter(client, match.node_id);
207
+ if (settleMs > 0) await sleep(settleMs);
208
+ return {
209
+ requested: target,
210
+ selected: true,
211
+ already_current: false,
212
+ selected_option: compactJobOption(match),
213
+ click_box: {
214
+ center: clickedBox.center,
215
+ rect: clickedBox.rect
216
+ },
217
+ options: options.map(compactJobOption)
218
+ };
219
+ }
220
+
221
+ function compactJobOption(option) {
222
+ return {
223
+ label: option.label,
224
+ label_without_salary: option.label_without_salary,
225
+ current: Boolean(option.current),
226
+ visible: Boolean(option.visible),
227
+ class_name: option.class_name,
228
+ node_id: option.node_id,
229
+ center: option.center,
230
+ rect: option.rect
231
+ };
232
+ }
@@ -0,0 +1,204 @@
1
+ import { sleep } from "../../core/browser/index.js";
2
+ import {
3
+ clickRecommendEndRefreshButton,
4
+ waitForRecommendCardNodeIds
5
+ } from "./cards.js";
6
+ import {
7
+ RECOMMEND_RECENT_NOT_VIEW_LABEL
8
+ } from "./constants.js";
9
+ import { selectAndConfirmFirstSafeFilter } from "./filters.js";
10
+ import { selectRecommendJob } from "./jobs.js";
11
+ import { selectRecommendPageScope } from "./scopes.js";
12
+ import {
13
+ getRecommendRoots,
14
+ waitForRecommendRoots
15
+ } from "./roots.js";
16
+
17
+ function normalizeLabels(labels = []) {
18
+ return labels.map((label) => String(label || "").trim()).filter(Boolean);
19
+ }
20
+
21
+ function normalizeFilterGroup(spec = {}) {
22
+ return {
23
+ group: String(spec.group || "").trim(),
24
+ labels: normalizeLabels(spec.labels || spec.filterLabels || []),
25
+ selectAllLabels: spec.selectAllLabels !== false
26
+ };
27
+ }
28
+
29
+ export function buildRecommendFilterGroups(filter = {}, {
30
+ forceRecentNotView = false
31
+ } = {}) {
32
+ const groups = [];
33
+ const sourceGroups = Array.isArray(filter.filterGroups)
34
+ ? filter.filterGroups
35
+ : Array.isArray(filter.groups)
36
+ ? filter.groups
37
+ : [];
38
+
39
+ for (const spec of sourceGroups) {
40
+ const group = normalizeFilterGroup(spec);
41
+ if (group.group || group.labels.length) groups.push(group);
42
+ }
43
+
44
+ const rootGroup = normalizeFilterGroup(filter);
45
+ if ((rootGroup.group || rootGroup.labels.length) && !groups.length) {
46
+ groups.push(rootGroup);
47
+ }
48
+
49
+ if (forceRecentNotView) {
50
+ const recentGroup = groups.find((item) => item.group === "recentNotView");
51
+ if (recentGroup) {
52
+ if (!recentGroup.labels.some((label) => label.replace(/\s+/g, "") === RECOMMEND_RECENT_NOT_VIEW_LABEL)) {
53
+ recentGroup.labels.push(RECOMMEND_RECENT_NOT_VIEW_LABEL);
54
+ }
55
+ recentGroup.selectAllLabels = true;
56
+ } else {
57
+ groups.unshift({
58
+ group: "recentNotView",
59
+ labels: [RECOMMEND_RECENT_NOT_VIEW_LABEL],
60
+ selectAllLabels: true
61
+ });
62
+ }
63
+ }
64
+
65
+ return groups;
66
+ }
67
+
68
+ export function buildRecommendFilterSelectionOptions(filter = {}, {
69
+ forceRecentNotView = false
70
+ } = {}) {
71
+ const filterGroups = buildRecommendFilterGroups(filter, { forceRecentNotView });
72
+ if (filterGroups.length > 1 || forceRecentNotView || Array.isArray(filter.filterGroups) || Array.isArray(filter.groups)) {
73
+ return { filterGroups };
74
+ }
75
+ const [singleGroup] = filterGroups;
76
+ if (singleGroup) {
77
+ return {
78
+ group: singleGroup.group,
79
+ labels: singleGroup.labels,
80
+ selectAllLabels: singleGroup.selectAllLabels
81
+ };
82
+ }
83
+ return {
84
+ group: filter.group || "",
85
+ labels: normalizeLabels(filter.labels || filter.filterLabels || []),
86
+ selectAllLabels: filter.selectAllLabels !== false
87
+ };
88
+ }
89
+
90
+ export async function refreshRecommendListAtEnd(client, {
91
+ rootState = null,
92
+ jobLabel = "",
93
+ pageScope = "recommend",
94
+ fallbackPageScope = "recommend",
95
+ filter = {},
96
+ preferEndRefreshButton = true,
97
+ forceRecentNotView = true,
98
+ cardTimeoutMs = 30000,
99
+ buttonSettleMs = 8000,
100
+ reloadSettleMs = 8000
101
+ } = {}) {
102
+ const attempts = [];
103
+ let currentRootState = rootState || await getRecommendRoots(client);
104
+
105
+ if (preferEndRefreshButton) {
106
+ const buttonResult = await clickRecommendEndRefreshButton(
107
+ client,
108
+ currentRootState.iframe.documentNodeId,
109
+ { settleMs: buttonSettleMs }
110
+ );
111
+ attempts.push(buttonResult);
112
+ if (buttonResult.ok) {
113
+ currentRootState = await getRecommendRoots(client);
114
+ const pageScopeResult = await selectRecommendPageScope(
115
+ client,
116
+ currentRootState.iframe.documentNodeId,
117
+ {
118
+ pageScope,
119
+ fallbackScope: fallbackPageScope,
120
+ settleMs: buttonSettleMs > 10000 ? 3000 : 1200,
121
+ timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
122
+ }
123
+ );
124
+ if (!pageScopeResult.selected) {
125
+ throw new Error(`Recommend page scope was not selected after end refresh: ${pageScopeResult.reason || pageScope}`);
126
+ }
127
+ currentRootState = await getRecommendRoots(client);
128
+ const filterResult = await selectAndConfirmFirstSafeFilter(
129
+ client,
130
+ currentRootState.iframe.documentNodeId,
131
+ buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
132
+ );
133
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
134
+ timeoutMs: cardTimeoutMs,
135
+ intervalMs: 500
136
+ });
137
+ return {
138
+ ok: cardNodeIds.length > 0,
139
+ method: "end_refresh_button",
140
+ attempts,
141
+ page_scope: pageScopeResult,
142
+ filter: filterResult,
143
+ card_count: cardNodeIds.length,
144
+ root_state: currentRootState,
145
+ forced_recent_not_view: forceRecentNotView
146
+ };
147
+ }
148
+ }
149
+
150
+ await client.Page.reload({ ignoreCache: true });
151
+ if (reloadSettleMs > 0) await sleep(reloadSettleMs);
152
+ currentRootState = await waitForRecommendRoots(client, {
153
+ timeoutMs: Math.max(30000, reloadSettleMs * 4),
154
+ intervalMs: 500
155
+ });
156
+ if (!currentRootState?.iframe?.documentNodeId) {
157
+ throw new Error("Recommend iframe was not ready after refresh reload");
158
+ }
159
+ let jobSelection = null;
160
+ if (jobLabel) {
161
+ jobSelection = await selectRecommendJob(client, currentRootState.iframe.documentNodeId, {
162
+ jobLabel,
163
+ settleMs: reloadSettleMs > 10000 ? 12000 : 6000
164
+ });
165
+ if (!jobSelection.selected) {
166
+ throw new Error(`Requested recommend job was not selected after refresh reload: ${jobSelection.reason}`);
167
+ }
168
+ currentRootState = await getRecommendRoots(client);
169
+ }
170
+ const pageScopeResult = await selectRecommendPageScope(
171
+ client,
172
+ currentRootState.iframe.documentNodeId,
173
+ {
174
+ pageScope,
175
+ fallbackScope: fallbackPageScope,
176
+ settleMs: reloadSettleMs > 10000 ? 3000 : 1200,
177
+ timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
178
+ }
179
+ );
180
+ if (!pageScopeResult.selected) {
181
+ throw new Error(`Recommend page scope was not selected after refresh reload: ${pageScopeResult.reason || pageScope}`);
182
+ }
183
+ currentRootState = await getRecommendRoots(client);
184
+ const filterResult = await selectAndConfirmFirstSafeFilter(
185
+ client,
186
+ currentRootState.iframe.documentNodeId,
187
+ buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
188
+ );
189
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
190
+ timeoutMs: cardTimeoutMs,
191
+ intervalMs: 500
192
+ });
193
+ return {
194
+ ok: cardNodeIds.length > 0,
195
+ method: "page_reload",
196
+ attempts,
197
+ job_selection: jobSelection,
198
+ page_scope: pageScopeResult,
199
+ filter: filterResult,
200
+ card_count: cardNodeIds.length,
201
+ root_state: currentRootState,
202
+ forced_recent_not_view: forceRecentNotView
203
+ };
204
+ }
@@ -0,0 +1,78 @@
1
+ import {
2
+ findIframeDocument,
3
+ getDocumentRoot,
4
+ querySelector,
5
+ sleep
6
+ } from "../../core/browser/index.js";
7
+ import { RECOMMEND_IFRAME_SELECTORS } from "./constants.js";
8
+
9
+ export async function getRecommendRoots(client, {
10
+ iframeSelectors = RECOMMEND_IFRAME_SELECTORS,
11
+ requireFrame = true
12
+ } = {}) {
13
+ const topRoot = await getDocumentRoot(client);
14
+ const iframe = await findIframeDocument(client, topRoot.nodeId, iframeSelectors);
15
+ if (!iframe && requireFrame) {
16
+ throw new Error("recommendFrame iframe was not found");
17
+ }
18
+
19
+ return {
20
+ topRoot,
21
+ iframe,
22
+ roots: [
23
+ { name: "top", nodeId: topRoot.nodeId },
24
+ iframe ? { name: "recommend-frame", nodeId: iframe.documentNodeId } : null
25
+ ].filter(Boolean),
26
+ rootNodes: {
27
+ top: topRoot.nodeId,
28
+ frame: iframe?.documentNodeId || 0
29
+ }
30
+ };
31
+ }
32
+
33
+ export async function waitForRecommendRoots(client, {
34
+ timeoutMs = 10000,
35
+ intervalMs = 250,
36
+ iframeSelectors = RECOMMEND_IFRAME_SELECTORS
37
+ } = {}) {
38
+ const started = Date.now();
39
+ let lastState = null;
40
+ while (Date.now() - started <= timeoutMs) {
41
+ try {
42
+ lastState = await getRecommendRoots(client, {
43
+ iframeSelectors,
44
+ requireFrame: false
45
+ });
46
+ } catch (error) {
47
+ lastState = {
48
+ error: error?.message || String(error),
49
+ roots: [],
50
+ rootNodes: {
51
+ top: 0,
52
+ frame: 0
53
+ }
54
+ };
55
+ }
56
+ if (lastState.iframe?.documentNodeId) return lastState;
57
+ await sleep(intervalMs);
58
+ }
59
+ return lastState;
60
+ }
61
+
62
+ export async function queryFirstAcrossRoots(client, roots, selectors) {
63
+ for (const root of roots) {
64
+ if (!root?.nodeId) continue;
65
+ for (const selector of selectors) {
66
+ const nodeId = await querySelector(client, root.nodeId, selector);
67
+ if (nodeId) {
68
+ return {
69
+ root: root.name,
70
+ root_node_id: root.nodeId,
71
+ selector,
72
+ node_id: nodeId
73
+ };
74
+ }
75
+ }
76
+ }
77
+ return null;
78
+ }