halo-agent 1.1.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.
- package/README.md +43 -0
- package/browser.js +157 -0
- package/captcha.js +217 -0
- package/config.js +37 -0
- package/filler.js +987 -0
- package/index.js +360 -0
- package/localServer.js +270 -0
- package/manusAutomate.js +349 -0
- package/orchestrator.js +1122 -0
- package/package.json +49 -0
- package/poller.js +172 -0
- package/scanPage.js +606 -0
- package/vision.js +398 -0
package/filler.js
ADDED
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ATS-specific form fill strategies.
|
|
5
|
+
* All filling goes through the user's real Chrome via CDP — no fake browser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TYPING_SPEEDS = { slow: 80, normal: 40, fast: 15 };
|
|
9
|
+
|
|
10
|
+
// Randomize delay slightly to look human
|
|
11
|
+
function jitter(base) {
|
|
12
|
+
return Math.floor(base * (0.7 + Math.random() * 0.6));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Set a form field value in a React/Vue/Angular aware way.
|
|
17
|
+
* Fires the full event sequence that frameworks listen for.
|
|
18
|
+
*/
|
|
19
|
+
async function setFieldValue(page, selector, value) {
|
|
20
|
+
try {
|
|
21
|
+
await page.locator(selector).first().waitFor({ timeout: 3000 });
|
|
22
|
+
await page.evaluate(({ sel, val }) => {
|
|
23
|
+
const el = document.querySelector(sel);
|
|
24
|
+
if (!el) return;
|
|
25
|
+
el.focus();
|
|
26
|
+
// React's synthetic event system requires using the native setter
|
|
27
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
|
28
|
+
|| Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
29
|
+
if (nativeInputValueSetter) nativeInputValueSetter.call(el, val);
|
|
30
|
+
el.dispatchEvent(new Event('focus', { bubbles: true }));
|
|
31
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
32
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
33
|
+
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
34
|
+
}, { sel: selector, val: value });
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Set a field value by Playwright element handle (used after semantic matching).
|
|
43
|
+
*/
|
|
44
|
+
async function setFieldValueByHandle(page, elementHandle, value) {
|
|
45
|
+
try {
|
|
46
|
+
await page.evaluate(({ val }) => {
|
|
47
|
+
const el = arguments[0]; // handled via elementHandle approach below
|
|
48
|
+
}, { val: value });
|
|
49
|
+
// Use evaluate with the handle directly
|
|
50
|
+
await elementHandle.evaluate((el, val) => {
|
|
51
|
+
el.focus();
|
|
52
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
|
53
|
+
|| Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
54
|
+
if (nativeInputValueSetter) nativeInputValueSetter.call(el, val);
|
|
55
|
+
el.dispatchEvent(new Event('focus', { bubbles: true }));
|
|
56
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
57
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
58
|
+
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
59
|
+
}, value);
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Type text character by character (for textareas like cover letters).
|
|
68
|
+
* Much more human-looking than setting value atomically.
|
|
69
|
+
*/
|
|
70
|
+
async function typeFieldValue(page, selector, value, speed = 'normal') {
|
|
71
|
+
const delay = TYPING_SPEEDS[speed] || 40;
|
|
72
|
+
try {
|
|
73
|
+
const el = page.locator(selector).first();
|
|
74
|
+
await el.waitFor({ timeout: 3000 });
|
|
75
|
+
await el.click();
|
|
76
|
+
await el.fill(''); // clear first
|
|
77
|
+
await page.keyboard.type(value, { delay: jitter(delay) });
|
|
78
|
+
await el.dispatchEvent('change');
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
// Fallback to setFieldValue
|
|
82
|
+
return setFieldValue(page, selector, value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fill a native <select> dropdown.
|
|
88
|
+
*/
|
|
89
|
+
async function setSelectValue(page, selector, value) {
|
|
90
|
+
try {
|
|
91
|
+
await page.locator(selector).first().selectOption({ label: value }).catch(() =>
|
|
92
|
+
page.locator(selector).first().selectOption({ value })
|
|
93
|
+
);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
// Try clicking the option via custom React dropdown
|
|
97
|
+
return clickDropdownOption(page, selector, value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* For custom React dropdowns: click trigger, then click matching option.
|
|
103
|
+
*/
|
|
104
|
+
async function clickDropdownOption(page, triggerSelector, optionText) {
|
|
105
|
+
try {
|
|
106
|
+
await page.locator(triggerSelector).first().click();
|
|
107
|
+
await page.waitForTimeout(400);
|
|
108
|
+
// Look for option by text in any listbox or menu element
|
|
109
|
+
const optionEl = page.getByRole('option', { name: optionText }).or(
|
|
110
|
+
page.locator(`[role="menuitem"]:has-text("${optionText}")`).or(
|
|
111
|
+
page.locator(`li:has-text("${optionText}")`)
|
|
112
|
+
)
|
|
113
|
+
).first();
|
|
114
|
+
await optionEl.waitFor({ timeout: 2000 });
|
|
115
|
+
await optionEl.click();
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Click a radio button by group name and option label text.
|
|
124
|
+
*/
|
|
125
|
+
async function selectRadioOption(page, groupName, value) {
|
|
126
|
+
try {
|
|
127
|
+
// Try to find a radio input with matching name and value
|
|
128
|
+
const byValue = page.locator(`input[type="radio"][name="${groupName}"][value="${value}" i]`).first();
|
|
129
|
+
if (await byValue.isVisible({ timeout: 500 }).catch(() => false)) {
|
|
130
|
+
await byValue.click();
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Fall back: find all radios in the group, click the one whose label matches
|
|
135
|
+
const radios = await page.locator(`input[type="radio"][name="${groupName}"]`).all();
|
|
136
|
+
for (const radio of radios) {
|
|
137
|
+
const label = await radio.evaluate(el => {
|
|
138
|
+
// Check label element
|
|
139
|
+
if (el.id) {
|
|
140
|
+
const lbl = document.querySelector(`label[for="${el.id}"]`);
|
|
141
|
+
if (lbl) return lbl.textContent.trim();
|
|
142
|
+
}
|
|
143
|
+
// Walk up to find label text
|
|
144
|
+
let p = el.parentElement;
|
|
145
|
+
let depth = 0;
|
|
146
|
+
while (p && depth < 4) {
|
|
147
|
+
const lbl = p.querySelector('label, span, div');
|
|
148
|
+
if (lbl && lbl !== el) {
|
|
149
|
+
const t = lbl.textContent.trim();
|
|
150
|
+
if (t.length > 0 && t.length < 80) return t;
|
|
151
|
+
}
|
|
152
|
+
p = p.parentElement;
|
|
153
|
+
depth++;
|
|
154
|
+
}
|
|
155
|
+
return el.value || '';
|
|
156
|
+
});
|
|
157
|
+
if (label.toLowerCase().includes(value.toLowerCase()) || value.toLowerCase().includes(label.toLowerCase())) {
|
|
158
|
+
await radio.click();
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Click a checkbox by its label or value.
|
|
170
|
+
*/
|
|
171
|
+
async function clickCheckbox(page, label, value) {
|
|
172
|
+
try {
|
|
173
|
+
const checkboxes = await page.locator('input[type="checkbox"]').all();
|
|
174
|
+
for (const cb of checkboxes) {
|
|
175
|
+
const cbLabel = await cb.evaluate(el => {
|
|
176
|
+
if (el.id) {
|
|
177
|
+
const lbl = document.querySelector(`label[for="${el.id}"]`);
|
|
178
|
+
if (lbl) return lbl.textContent.trim();
|
|
179
|
+
}
|
|
180
|
+
let p = el.parentElement;
|
|
181
|
+
let depth = 0;
|
|
182
|
+
while (p && depth < 4) {
|
|
183
|
+
const t = p.textContent.trim();
|
|
184
|
+
if (t.length > 0 && t.length < 100) return t;
|
|
185
|
+
p = p.parentElement;
|
|
186
|
+
depth++;
|
|
187
|
+
}
|
|
188
|
+
return el.value || '';
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const target = (label || value || '').toLowerCase();
|
|
192
|
+
if (target && cbLabel.toLowerCase().includes(target)) {
|
|
193
|
+
const isChecked = await cb.isChecked();
|
|
194
|
+
if (!isChecked) await cb.click();
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Upload a resume PDF using Playwright's fileChooser interception.
|
|
206
|
+
* Works even when the file input is hidden behind a custom button.
|
|
207
|
+
*/
|
|
208
|
+
async function uploadFile(page, triggerSelector, filePath) {
|
|
209
|
+
try {
|
|
210
|
+
const [fileChooser] = await Promise.all([
|
|
211
|
+
page.waitForFileChooser({ timeout: 5000 }),
|
|
212
|
+
page.locator(triggerSelector).first().click().catch(() => {}),
|
|
213
|
+
]);
|
|
214
|
+
await fileChooser.setFiles(filePath);
|
|
215
|
+
return true;
|
|
216
|
+
} catch {
|
|
217
|
+
// Fallback: direct setInputFiles on any file input
|
|
218
|
+
try {
|
|
219
|
+
await page.locator('input[type="file"]').first().setInputFiles(filePath);
|
|
220
|
+
return true;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Universal field filler — given a Playwright locator and a string value,
|
|
229
|
+
* detects the field type and applies the right fill strategy.
|
|
230
|
+
*
|
|
231
|
+
* Handles all field types that appear in real job applications:
|
|
232
|
+
* - Native <select> dropdown
|
|
233
|
+
* - Custom dropdown (React Select, Ashby combobox — role="combobox" or role="listbox")
|
|
234
|
+
* - Radio group (native input[type=radio] or custom role="radio")
|
|
235
|
+
* - Checkbox (native or custom role="checkbox")
|
|
236
|
+
* - Yes/No button group (styled buttons acting as a radio group)
|
|
237
|
+
* - Textarea / long text
|
|
238
|
+
* - Short text input (text, email, tel, number, url)
|
|
239
|
+
* - Date input
|
|
240
|
+
*
|
|
241
|
+
* Returns true if fill succeeded.
|
|
242
|
+
*/
|
|
243
|
+
async function fillLocator(page, locator, value, label) {
|
|
244
|
+
if (!value) return false;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
// Inspect the element to determine field type
|
|
248
|
+
const meta = await locator.evaluate(el => ({
|
|
249
|
+
tag: el.tagName.toLowerCase(),
|
|
250
|
+
type: (el.type || '').toLowerCase(),
|
|
251
|
+
role: (el.getAttribute('role') || '').toLowerCase(),
|
|
252
|
+
isContentEditable: el.isContentEditable,
|
|
253
|
+
hasOptions: el.tagName === 'SELECT',
|
|
254
|
+
optionCount: el.tagName === 'SELECT' ? el.options.length : 0,
|
|
255
|
+
})).catch(() => ({ tag: 'input', type: 'text', role: '', isContentEditable: false, hasOptions: false, optionCount: 0 }));
|
|
256
|
+
|
|
257
|
+
// ── Native <select> ──
|
|
258
|
+
if (meta.tag === 'select' || meta.hasOptions) {
|
|
259
|
+
const opts = await locator.evaluate(e => Array.from(e.options).map(o => ({ v: o.value, t: o.text.trim() }))).catch(() => []);
|
|
260
|
+
// 1. Exact label match
|
|
261
|
+
let ok = await locator.selectOption({ label: value }).then(() => true).catch(() => false);
|
|
262
|
+
// 2. Exact value match
|
|
263
|
+
if (!ok) ok = await locator.selectOption({ value }).then(() => true).catch(() => false);
|
|
264
|
+
// 3. Case-insensitive label match
|
|
265
|
+
if (!ok) {
|
|
266
|
+
const match = opts.find(o => o.t.toLowerCase() === value.toLowerCase());
|
|
267
|
+
if (match) ok = await locator.selectOption(match.v).then(() => true).catch(() => false);
|
|
268
|
+
}
|
|
269
|
+
// 4. Fuzzy: value is substring of option text or vice versa
|
|
270
|
+
if (!ok) {
|
|
271
|
+
const fuzzy = opts.find(o =>
|
|
272
|
+
o.t.toLowerCase().includes(value.toLowerCase()) ||
|
|
273
|
+
value.toLowerCase().includes(o.t.toLowerCase())
|
|
274
|
+
);
|
|
275
|
+
if (fuzzy) ok = await locator.selectOption(fuzzy.v).then(() => true).catch(() => false);
|
|
276
|
+
}
|
|
277
|
+
return ok;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Custom dropdown / combobox (React Select, Ashby, Lever custom selects) ──
|
|
281
|
+
if (meta.role === 'combobox' || meta.role === 'listbox' ||
|
|
282
|
+
meta.tag === 'div' || meta.tag === 'button') {
|
|
283
|
+
return await fillCustomDropdown(page, locator, value);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Radio group (native) ──
|
|
287
|
+
if (meta.type === 'radio') {
|
|
288
|
+
// locator points to one radio — find its group and select by value
|
|
289
|
+
const groupName = await locator.evaluate(e => e.name).catch(() => '');
|
|
290
|
+
return await selectRadioOption(page, groupName, value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Checkbox (native) ──
|
|
294
|
+
if (meta.type === 'checkbox') {
|
|
295
|
+
const shouldCheck = /^(yes|true|1|on|agree|accept)$/i.test(value.trim());
|
|
296
|
+
const checked = await locator.isChecked().catch(() => false);
|
|
297
|
+
if (shouldCheck !== checked) await locator.click();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Date input ──
|
|
302
|
+
if (meta.type === 'date') {
|
|
303
|
+
// Try to parse value into YYYY-MM-DD format
|
|
304
|
+
const parsed = parseDate(value);
|
|
305
|
+
return await locator.fill(parsed || value).then(() => true).catch(() => false);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Number input ──
|
|
309
|
+
if (meta.type === 'number') {
|
|
310
|
+
const num = value.replace(/[^0-9.]/g, '');
|
|
311
|
+
return await locator.fill(num).then(() => {
|
|
312
|
+
return locator.dispatchEvent('change').then(() => true);
|
|
313
|
+
}).catch(() => false);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── contenteditable div (some ATS use these for rich text fields) ──
|
|
317
|
+
if (meta.isContentEditable) {
|
|
318
|
+
await locator.click();
|
|
319
|
+
await locator.evaluate(el => el.innerHTML = '');
|
|
320
|
+
await locator.type(value, { delay: 20 });
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Textarea / long text ──
|
|
325
|
+
if (meta.tag === 'textarea') {
|
|
326
|
+
await locator.fill(value);
|
|
327
|
+
await locator.dispatchEvent('input');
|
|
328
|
+
await locator.dispatchEvent('change');
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Default: short text input (text, email, tel, url, search, etc.) ──
|
|
333
|
+
return await setFieldValueByHandle(page, locator, value);
|
|
334
|
+
|
|
335
|
+
} catch (e) {
|
|
336
|
+
console.warn(`[filler] fillLocator error for "${label}": ${e.message}`);
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Fill a custom dropdown that is NOT a native <select>.
|
|
343
|
+
* Strategy: click trigger → wait for option list → find closest matching option → click it.
|
|
344
|
+
* Handles React Select, Ashby custom select, Lever, Greenhouse custom dropdowns.
|
|
345
|
+
*/
|
|
346
|
+
async function fillCustomDropdown(page, triggerLocator, value) {
|
|
347
|
+
try {
|
|
348
|
+
await triggerLocator.click();
|
|
349
|
+
await page.waitForTimeout(350);
|
|
350
|
+
|
|
351
|
+
// Look for an open listbox, menu, or option list
|
|
352
|
+
const optionSelectors = [
|
|
353
|
+
`[role="option"]:has-text("${value}")`,
|
|
354
|
+
`[role="menuitem"]:has-text("${value}")`,
|
|
355
|
+
`li:has-text("${value}")`,
|
|
356
|
+
`.Select-option:has-text("${value}")`,
|
|
357
|
+
`[class*="option"]:has-text("${value}")`,
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
for (const sel of optionSelectors) {
|
|
361
|
+
try {
|
|
362
|
+
const opt = page.locator(sel).first();
|
|
363
|
+
if (await opt.isVisible({ timeout: 800 })) {
|
|
364
|
+
await opt.click();
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
} catch {}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Fuzzy: get all visible options and find closest match
|
|
371
|
+
const allOptions = await page.locator('[role="option"], [role="menuitem"]').all();
|
|
372
|
+
let bestMatch = null;
|
|
373
|
+
let bestScore = 0;
|
|
374
|
+
for (const opt of allOptions) {
|
|
375
|
+
const text = (await opt.textContent().catch(() => '')).trim().toLowerCase();
|
|
376
|
+
const target = value.toLowerCase();
|
|
377
|
+
const score = text === target ? 1 :
|
|
378
|
+
text.includes(target) ? 0.8 :
|
|
379
|
+
target.includes(text) ? 0.7 : 0;
|
|
380
|
+
if (score > bestScore) { bestScore = score; bestMatch = opt; }
|
|
381
|
+
}
|
|
382
|
+
if (bestMatch && bestScore >= 0.7) {
|
|
383
|
+
await bestMatch.click();
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Nothing found — press Escape to close the dropdown
|
|
388
|
+
await page.keyboard.press('Escape');
|
|
389
|
+
return false;
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Parse a human date string into YYYY-MM-DD for date inputs.
|
|
397
|
+
*/
|
|
398
|
+
function parseDate(value) {
|
|
399
|
+
try {
|
|
400
|
+
const d = new Date(value);
|
|
401
|
+
if (isNaN(d.getTime())) return null;
|
|
402
|
+
return d.toISOString().split('T')[0];
|
|
403
|
+
} catch { return null; }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Find the best selector for a field given its label, id, name, or placeholder.
|
|
408
|
+
* Tries multiple strategies in order of reliability.
|
|
409
|
+
*/
|
|
410
|
+
function buildSelector(field) {
|
|
411
|
+
if (field.id) return `#${CSS.escape(field.id)}`;
|
|
412
|
+
if (field.name) return `[name="${field.name}"]`;
|
|
413
|
+
// Label-based
|
|
414
|
+
const label = (field.label || '').toLowerCase();
|
|
415
|
+
if (label) {
|
|
416
|
+
if (field.tag === 'textarea') return `textarea[placeholder*="${field.label}"], textarea[aria-label*="${field.label}"]`;
|
|
417
|
+
return `input[placeholder*="${field.label}"], input[aria-label*="${field.label}"]`;
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Escape CSS special chars for querySelector
|
|
423
|
+
const CSS = { escape: (s) => s.replace(/([!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~])/g, '\\$1') };
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Extract all visible form fields from the current page via DOM walking.
|
|
427
|
+
* Uses the same 6-tier label extraction as the Chrome extension (content.js).
|
|
428
|
+
* Returns [{tag, type, id, name, label, value}] for all visible fields.
|
|
429
|
+
*/
|
|
430
|
+
async function extractPageFields(page) {
|
|
431
|
+
return page.evaluate(() => {
|
|
432
|
+
function getFieldLabel(el) {
|
|
433
|
+
// 1. Native <label for="..."> association
|
|
434
|
+
if (el.id) {
|
|
435
|
+
var lbl = document.querySelector('label[for="' + el.id + '"]');
|
|
436
|
+
if (lbl) return lbl.textContent.trim();
|
|
437
|
+
}
|
|
438
|
+
if (el.labels && el.labels[0]) return el.labels[0].textContent.trim();
|
|
439
|
+
// 2. aria-label / aria-labelledby
|
|
440
|
+
var ariaLabel = el.getAttribute('aria-label');
|
|
441
|
+
if (ariaLabel) return ariaLabel.trim();
|
|
442
|
+
var ariaLabelledBy = el.getAttribute('aria-labelledby');
|
|
443
|
+
if (ariaLabelledBy) {
|
|
444
|
+
var lblEl = document.getElementById(ariaLabelledBy);
|
|
445
|
+
if (lblEl) return lblEl.textContent.trim();
|
|
446
|
+
}
|
|
447
|
+
// 3. data-label / data-title
|
|
448
|
+
if (el.dataset && el.dataset.label) return el.dataset.label.trim();
|
|
449
|
+
if (el.dataset && el.dataset.title) return el.dataset.title.trim();
|
|
450
|
+
// 4. Immediately preceding sibling label/span/div
|
|
451
|
+
var prev = el.previousElementSibling;
|
|
452
|
+
while (prev) {
|
|
453
|
+
if (/LABEL|SPAN|DIV|P|LEGEND|H1|H2|H3|H4|H5/.test(prev.tagName)) {
|
|
454
|
+
var t = prev.textContent.trim();
|
|
455
|
+
if (t.length > 0 && t.length < 120) return t;
|
|
456
|
+
}
|
|
457
|
+
prev = prev.previousElementSibling;
|
|
458
|
+
}
|
|
459
|
+
// 5. Walk up DOM — find nearest container with a label/legend/heading
|
|
460
|
+
var parent = el.parentElement;
|
|
461
|
+
var depth = 0;
|
|
462
|
+
while (parent && depth < 6) {
|
|
463
|
+
var lbl2 = parent.querySelector('label, legend, [class*=label], [class*=title], [class*=question], [class*=heading]');
|
|
464
|
+
if (lbl2 && lbl2 !== el) {
|
|
465
|
+
var t2 = lbl2.textContent.trim();
|
|
466
|
+
if (t2.length > 0 && t2.length < 120) return t2;
|
|
467
|
+
}
|
|
468
|
+
parent = parent.parentElement;
|
|
469
|
+
depth++;
|
|
470
|
+
}
|
|
471
|
+
// 6. Placeholder / name fallback
|
|
472
|
+
return el.placeholder || el.name || el.id || '';
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
var fields = [];
|
|
476
|
+
document.querySelectorAll('input, textarea, select').forEach(function(el) {
|
|
477
|
+
if (el.type === 'hidden' || el.type === 'submit' || el.type === 'file' || el.type === 'button' || el.type === 'image') return;
|
|
478
|
+
if (!el.offsetParent && el.type !== 'radio' && el.type !== 'checkbox') return;
|
|
479
|
+
|
|
480
|
+
var label = getFieldLabel(el);
|
|
481
|
+
label = label.replace(/\s+/g, ' ').replace(/^\*\s*/, '').replace(/\s*\*$/, '').trim();
|
|
482
|
+
|
|
483
|
+
fields.push({
|
|
484
|
+
tag: el.tagName.toLowerCase(),
|
|
485
|
+
type: el.type || '',
|
|
486
|
+
id: el.id || '',
|
|
487
|
+
name: el.name || '',
|
|
488
|
+
label: label.slice(0, 200),
|
|
489
|
+
value: el.value || '',
|
|
490
|
+
// Build a unique data-fingerprint so we can locate this element after evaluate()
|
|
491
|
+
// We use a combo of id, name, and label for matching
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
return fields;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Compute Jaccard word-level similarity between two strings.
|
|
500
|
+
* Used for semantic field matching when exact selectors fail.
|
|
501
|
+
*/
|
|
502
|
+
function jaccardSimilarity(a, b) {
|
|
503
|
+
const normalize = s => s.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(w => w.length > 2);
|
|
504
|
+
const setA = new Set(normalize(a));
|
|
505
|
+
const setB = new Set(normalize(b));
|
|
506
|
+
if (setA.size === 0 || setB.size === 0) return 0;
|
|
507
|
+
const intersection = [...setA].filter(w => setB.has(w)).length;
|
|
508
|
+
const union = new Set([...setA, ...setB]).size;
|
|
509
|
+
return intersection / union;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* 3-tier semantic field finder.
|
|
514
|
+
* Tier 1: exact ID/name match
|
|
515
|
+
* Tier 2: aria-label/placeholder substring match
|
|
516
|
+
* Tier 3: full DOM walking label extraction with Jaccard similarity scoring
|
|
517
|
+
*
|
|
518
|
+
* Returns a Playwright Locator or null.
|
|
519
|
+
*/
|
|
520
|
+
async function semanticFindField(page, fieldAnswer) {
|
|
521
|
+
const { field_id, label } = fieldAnswer;
|
|
522
|
+
|
|
523
|
+
// Tier 1: exact ID or name match
|
|
524
|
+
if (field_id && !field_id.startsWith('sa_')) {
|
|
525
|
+
const escaped = CSS.escape(field_id);
|
|
526
|
+
try {
|
|
527
|
+
const el = page.locator(`#${escaped}, [name="${field_id}"]`).first();
|
|
528
|
+
if (await el.isVisible({ timeout: 500 })) return el;
|
|
529
|
+
} catch {}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Tier 2: aria-label / placeholder substring match
|
|
533
|
+
if (label) {
|
|
534
|
+
const escapedLabel = label.replace(/['"]/g, '').trim();
|
|
535
|
+
if (escapedLabel) {
|
|
536
|
+
try {
|
|
537
|
+
const sel = `textarea[aria-label*="${escapedLabel}" i], input[aria-label*="${escapedLabel}" i], textarea[placeholder*="${escapedLabel}" i], input[placeholder*="${escapedLabel}" i], select[aria-label*="${escapedLabel}" i]`;
|
|
538
|
+
const el = page.locator(sel).first();
|
|
539
|
+
if (await el.isVisible({ timeout: 500 })) return el;
|
|
540
|
+
} catch {}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Tier 3: full DOM walk + Jaccard similarity across all visible fields
|
|
545
|
+
if (label) {
|
|
546
|
+
try {
|
|
547
|
+
const pageFields = await extractPageFields(page);
|
|
548
|
+
let bestScore = 0;
|
|
549
|
+
let bestField = null;
|
|
550
|
+
|
|
551
|
+
for (const pf of pageFields) {
|
|
552
|
+
const score = jaccardSimilarity(label, pf.label);
|
|
553
|
+
if (score > bestScore) {
|
|
554
|
+
bestScore = score;
|
|
555
|
+
bestField = pf;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (bestScore >= 0.4 && bestField) {
|
|
560
|
+
// Try to locate this element by its id/name/label combination
|
|
561
|
+
let locator = null;
|
|
562
|
+
|
|
563
|
+
if (bestField.id) {
|
|
564
|
+
try {
|
|
565
|
+
const escaped = CSS.escape(bestField.id);
|
|
566
|
+
const el = page.locator(`#${escaped}`).first();
|
|
567
|
+
if (await el.isVisible({ timeout: 300 })) locator = el;
|
|
568
|
+
} catch {}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (!locator && bestField.name) {
|
|
572
|
+
try {
|
|
573
|
+
const el = page.locator(`[name="${bestField.name}"]`).first();
|
|
574
|
+
if (await el.isVisible({ timeout: 300 })) locator = el;
|
|
575
|
+
} catch {}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (!locator && bestField.label) {
|
|
579
|
+
const escapedBest = bestField.label.replace(/['"]/g, '').trim();
|
|
580
|
+
if (escapedBest) {
|
|
581
|
+
try {
|
|
582
|
+
// Include select elements here — dropdowns matched via Tier3 Jaccard
|
|
583
|
+
const sel = `textarea[aria-label*="${escapedBest}" i], input[aria-label*="${escapedBest}" i], textarea[placeholder*="${escapedBest}" i], input[placeholder*="${escapedBest}" i], select[aria-label*="${escapedBest}" i]`;
|
|
584
|
+
const el = page.locator(sel).first();
|
|
585
|
+
if (await el.isVisible({ timeout: 300 })) locator = el;
|
|
586
|
+
} catch {}
|
|
587
|
+
// Final fallback: getByLabel for fields like Greenhouse dropdowns
|
|
588
|
+
if (!locator) {
|
|
589
|
+
try {
|
|
590
|
+
const el = page.getByLabel(new RegExp(escapedBest.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).first();
|
|
591
|
+
if (await el.isVisible({ timeout: 300 })) locator = el;
|
|
592
|
+
} catch {}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (locator) {
|
|
598
|
+
const confidence = bestScore >= 0.7 ? 'high' : 'medium';
|
|
599
|
+
console.log(`[filler] Tier3 match (${confidence}, score=${bestScore.toFixed(2)}): "${label}" -> "${bestField.label}"`);
|
|
600
|
+
return locator;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
} catch (e) {
|
|
604
|
+
console.warn(`[filler] Tier3 DOM walk failed: ${e.message}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
console.log(`[filler] unmatched: "${label}"`);
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Fill all fields on the current page using per-ATS scanPage() strategy.
|
|
614
|
+
*
|
|
615
|
+
* Pass 1: scanPage() identifies every visible field and classifies it as profile/custom/eeo/file.
|
|
616
|
+
* Pass 2: resolveFieldValues() maps each field to a value from the AEP (profile or AI answers).
|
|
617
|
+
* Pass 3: Fields with value=null and source='needs_ai' are returned in result.needsAI so the
|
|
618
|
+
* orchestrator can fetch AI answers and call fillFields again.
|
|
619
|
+
*
|
|
620
|
+
* Returns { filled, skipped, failed, needsAI: [{label, selector, tag, inputType}] }.
|
|
621
|
+
*/
|
|
622
|
+
async function fillFields(page, aep, options = {}) {
|
|
623
|
+
const speed = options.speed || 'normal';
|
|
624
|
+
const ctx = options.ctx || null;
|
|
625
|
+
const ats = options.ats || 'generic';
|
|
626
|
+
const delay = () => new Promise(r => setTimeout(r, jitter(100 + Math.random() * 150)));
|
|
627
|
+
|
|
628
|
+
let filled = 0, skipped = 0, failed = 0;
|
|
629
|
+
const needsAI = [];
|
|
630
|
+
|
|
631
|
+
// ── Pass 1: scan all visible fields using per-ATS logic ───────────────────
|
|
632
|
+
const { scanPage, resolveFieldValues } = require('./scanPage');
|
|
633
|
+
const scannedFields = await scanPage(page, ats).catch(() => []);
|
|
634
|
+
|
|
635
|
+
// ── Pass 2: resolve values from AEP ──────────────────────────────────────
|
|
636
|
+
const resolved = resolveFieldValues(scannedFields, aep);
|
|
637
|
+
|
|
638
|
+
// ── Pass 3: fill each field ───────────────────────────────────────────────
|
|
639
|
+
for (const { field, value, source } of resolved) {
|
|
640
|
+
// Skip if already answered on a previous page
|
|
641
|
+
const trackKey = field.label || field.selector;
|
|
642
|
+
if (ctx && trackKey && ctx.answeredFields.has(trackKey)) {
|
|
643
|
+
skipped++;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// File uploads — handled separately by uploadResume() in orchestrator
|
|
648
|
+
if (source === 'file_upload') continue;
|
|
649
|
+
|
|
650
|
+
// EEO / consent — skip silently (vision handles EEO, user handles consent)
|
|
651
|
+
if (source === 'skip') continue;
|
|
652
|
+
|
|
653
|
+
// No value and needs AI — collect for batch AI fetch
|
|
654
|
+
if (!value && source === 'needs_ai') {
|
|
655
|
+
if (field.label && field.label.length >= 8) {
|
|
656
|
+
needsAI.push({ label: field.label, selector: field.selector, tag: field.tag, inputType: field.inputType, iframeSelector: field.iframeSelector });
|
|
657
|
+
} else {
|
|
658
|
+
skipped++;
|
|
659
|
+
}
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// No value for a profile field — missing from profile, skip
|
|
664
|
+
if (!value) { skipped++; continue; }
|
|
665
|
+
|
|
666
|
+
// Locate the element — use the scanner's selector first, then fall back to semantic finder
|
|
667
|
+
let locator = null;
|
|
668
|
+
|
|
669
|
+
// If the field is inside an iframe (iCIMS), use frameLocator
|
|
670
|
+
if (field.iframeSelector) {
|
|
671
|
+
try {
|
|
672
|
+
const frame = page.frameLocator(field.iframeSelector);
|
|
673
|
+
locator = frame.locator(field.selector).first();
|
|
674
|
+
const visible = await locator.isVisible({ timeout: 500 }).catch(() => false);
|
|
675
|
+
if (!visible) locator = null;
|
|
676
|
+
} catch {}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Standard locator via scanner selector
|
|
680
|
+
if (!locator && field.selector) {
|
|
681
|
+
try {
|
|
682
|
+
const el = page.locator(field.selector).first();
|
|
683
|
+
const visible = await el.isVisible({ timeout: 500 }).catch(() => false);
|
|
684
|
+
if (visible) locator = el;
|
|
685
|
+
} catch {}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Fallback: semantic finder via label text
|
|
689
|
+
if (!locator && field.label) {
|
|
690
|
+
locator = await semanticFindField(page, { field_id: field.id || field.name, label: field.label }).catch(() => null);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (!locator) { skipped++; continue; }
|
|
694
|
+
|
|
695
|
+
// Special case: cover letter — type character by character
|
|
696
|
+
if (field.category === 'cover_letter' && field.tag === 'textarea') {
|
|
697
|
+
try {
|
|
698
|
+
await locator.click();
|
|
699
|
+
await locator.fill('');
|
|
700
|
+
await page.keyboard.type(value, { delay: jitter(TYPING_SPEEDS[speed] || 40) });
|
|
701
|
+
await locator.dispatchEvent('change');
|
|
702
|
+
filled++;
|
|
703
|
+
if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source });
|
|
704
|
+
await delay();
|
|
705
|
+
} catch { failed++; }
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Radio group — use name-based selector
|
|
710
|
+
if (field.inputType === 'radio') {
|
|
711
|
+
const ok = await selectRadioOption(page, field.name, value);
|
|
712
|
+
if (ok) {
|
|
713
|
+
filled++;
|
|
714
|
+
if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source });
|
|
715
|
+
await delay();
|
|
716
|
+
} else {
|
|
717
|
+
// Try clicking the locator directly (custom styled radio)
|
|
718
|
+
const ok2 = await fillLocator(page, locator, value, field.label);
|
|
719
|
+
if (ok2) { filled++; if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source }); await delay(); }
|
|
720
|
+
else { skipped++; }
|
|
721
|
+
}
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Checkbox
|
|
726
|
+
if (field.inputType === 'checkbox') {
|
|
727
|
+
const shouldCheck = /^(yes|true|1|on|agree|accept)$/i.test(value.trim());
|
|
728
|
+
try {
|
|
729
|
+
const checked = await locator.isChecked().catch(() => false);
|
|
730
|
+
if (shouldCheck !== checked) { await locator.click(); }
|
|
731
|
+
filled++;
|
|
732
|
+
if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source });
|
|
733
|
+
await delay();
|
|
734
|
+
} catch { skipped++; }
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// All other types — use universal fillLocator
|
|
739
|
+
try {
|
|
740
|
+
const ok = await fillLocator(page, locator, value, field.label);
|
|
741
|
+
if (ok) {
|
|
742
|
+
filled++;
|
|
743
|
+
if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source });
|
|
744
|
+
await delay();
|
|
745
|
+
} else { failed++; }
|
|
746
|
+
} catch { failed++; }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── Legacy fallback: also run old profile map + semantic matcher ──────────
|
|
750
|
+
// This catches any fields that scanPage missed (e.g. unusual ATS not yet modeled).
|
|
751
|
+
const profileMap = buildProfileMap(aep.profile_fill);
|
|
752
|
+
for (const [labelPattern, value] of Object.entries(profileMap)) {
|
|
753
|
+
if (!value) continue;
|
|
754
|
+
if (ctx && ctx.answeredFields.has(labelPattern)) continue;
|
|
755
|
+
let didFill = false;
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
const sel = buildProfileSelector(labelPattern);
|
|
759
|
+
if (sel) {
|
|
760
|
+
const el = page.locator(sel).first();
|
|
761
|
+
if (await el.isVisible().catch(() => false)) {
|
|
762
|
+
const ok = await fillLocator(page, el, value, labelPattern);
|
|
763
|
+
if (ok) { didFill = true; filled++; }
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
} catch {}
|
|
767
|
+
|
|
768
|
+
if (!didFill) {
|
|
769
|
+
for (const part of labelPattern.split('|').map(p => p.trim())) {
|
|
770
|
+
if (didFill) break;
|
|
771
|
+
try {
|
|
772
|
+
const ok = await fillProfileFieldByLabel(page, part, value, speed);
|
|
773
|
+
if (ok) { didFill = true; filled++; }
|
|
774
|
+
} catch {}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (didFill && ctx) {
|
|
779
|
+
ctx.answeredFields.set(labelPattern, { value, pageIndex: ctx.currentPageIndex, source: 'profile' });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return { filled, skipped, failed, needsAI };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Build a CSS selector from a label string for textareas and inputs.
|
|
788
|
+
*/
|
|
789
|
+
function buildLabelSelector(label) {
|
|
790
|
+
if (!label) return null;
|
|
791
|
+
const escaped = label.replace(/['"]/g, '').trim();
|
|
792
|
+
return `textarea[aria-label*="${escaped}" i], input[aria-label*="${escaped}" i], textarea[placeholder*="${escaped}" i], input[placeholder*="${escaped}" i]`;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Map profile_fill keys to CSS selector patterns.
|
|
797
|
+
*/
|
|
798
|
+
function buildProfileMap(profile) {
|
|
799
|
+
if (!profile) return {};
|
|
800
|
+
const phone = profile.phone || '';
|
|
801
|
+
const email = profile.email || '';
|
|
802
|
+
const fullName = [profile.first_name, profile.last_name].filter(Boolean).join(' ');
|
|
803
|
+
return {
|
|
804
|
+
// Keys are pipe-separated substrings matched against label text (case-insensitive)
|
|
805
|
+
// Full name (Lever, Ashby) — must come before first/last to avoid partial matches
|
|
806
|
+
'full name|full_name|your name|^name$': fullName,
|
|
807
|
+
'first_name|firstname|first-name|first name|given name': profile.first_name,
|
|
808
|
+
'last_name|lastname|last-name|last name|surname|family name': profile.last_name,
|
|
809
|
+
'preferred|goes by': profile.preferred_name || profile.first_name || '',
|
|
810
|
+
'email': email,
|
|
811
|
+
'phone|mobile|telephone|cell': phone,
|
|
812
|
+
'linkedin': profile.linkedin,
|
|
813
|
+
'github': profile.github,
|
|
814
|
+
'twitter': profile.twitter || '',
|
|
815
|
+
'portfolio|personal site|personal url': profile.portfolio || '',
|
|
816
|
+
'website': profile.portfolio || profile.website || '',
|
|
817
|
+
'school|university|college|institution': profile.school || '',
|
|
818
|
+
'degree|education|qualification': profile.degree || '',
|
|
819
|
+
'gpa|grade point': profile.gpa || '',
|
|
820
|
+
'address|street': profile.address || '',
|
|
821
|
+
'city': profile.city || '',
|
|
822
|
+
'state|province|region': profile.state || '',
|
|
823
|
+
'zip|postal': profile.zip || '',
|
|
824
|
+
'country': profile.country || 'United States',
|
|
825
|
+
'location|current location': profile.city || profile.location || '',
|
|
826
|
+
'current company|current employer|organization|employer': profile.current_company || '',
|
|
827
|
+
'desired salary|expected salary|salary expectation': profile.desired_salary || '',
|
|
828
|
+
'start date|available|availability': profile.start_date || 'Immediately',
|
|
829
|
+
// Combined phone+email (Ashby contact field pattern)
|
|
830
|
+
'phone.*email|email.*phone|contact.*reach|reach.*contact': phone && email ? `${phone} | ${email}` : (phone || email),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Fill a single profile field using label-text matching via DOM walk.
|
|
836
|
+
* More reliable than attribute-only selectors for Greenhouse/Ashby where
|
|
837
|
+
* inputs have no aria-label but have a visible <label> element.
|
|
838
|
+
*/
|
|
839
|
+
async function fillProfileFieldByLabel(page, labelText, value, speed) {
|
|
840
|
+
if (!value) return false;
|
|
841
|
+
try {
|
|
842
|
+
const el = page.getByLabel(new RegExp(labelText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).first();
|
|
843
|
+
if (!await el.isVisible({ timeout: 600 }).catch(() => false)) return false;
|
|
844
|
+
// Route through universal fillLocator — handles all field types correctly
|
|
845
|
+
return await fillLocator(page, el, value, labelText);
|
|
846
|
+
} catch { return false; }
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function buildProfileSelector(labelPattern) {
|
|
850
|
+
// Build selectors for both input and select elements using id/name/aria-label/placeholder
|
|
851
|
+
const parts = labelPattern.split('|').map(p => p.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
852
|
+
const selectors = [];
|
|
853
|
+
for (const p of parts) {
|
|
854
|
+
// Match on id, name, aria-label, placeholder for both input and select
|
|
855
|
+
selectors.push(
|
|
856
|
+
`input[name*="${p}" i]`, `input[id*="${p}" i]`,
|
|
857
|
+
`input[aria-label*="${p}" i]`, `input[placeholder*="${p}" i]`,
|
|
858
|
+
`select[name*="${p}" i]`, `select[id*="${p}" i]`, `select[aria-label*="${p}" i]`,
|
|
859
|
+
`textarea[name*="${p}" i]`, `textarea[id*="${p}" i]`,
|
|
860
|
+
`textarea[aria-label*="${p}" i]`, `textarea[placeholder*="${p}" i]`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
return selectors.join(', ');
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Find the "Next" or "Continue" button on a multi-page form.
|
|
868
|
+
*/
|
|
869
|
+
async function findNextButton(page) {
|
|
870
|
+
const selectors = [
|
|
871
|
+
// Workday-specific
|
|
872
|
+
'[data-automation-id="bottom-navigation-next-button"]',
|
|
873
|
+
'[data-automation-id="next-button"]',
|
|
874
|
+
// Generic
|
|
875
|
+
'button:has-text("Next")',
|
|
876
|
+
'button:has-text("Continue")',
|
|
877
|
+
'button:has-text("Save and Continue")',
|
|
878
|
+
'button:has-text("Save & Continue")',
|
|
879
|
+
'button[type="submit"]:has-text("Next")',
|
|
880
|
+
'[data-testid="next-button"]',
|
|
881
|
+
'[aria-label="Next"]',
|
|
882
|
+
'[aria-label="Continue"]',
|
|
883
|
+
];
|
|
884
|
+
for (const sel of selectors) {
|
|
885
|
+
try {
|
|
886
|
+
const el = page.locator(sel).first();
|
|
887
|
+
if (await el.isVisible({ timeout: 500 })) {
|
|
888
|
+
const text = (await el.textContent().catch(() => '')).toLowerCase().trim();
|
|
889
|
+
if (text.includes('submit')) continue; // don't treat Submit as Next
|
|
890
|
+
return el;
|
|
891
|
+
}
|
|
892
|
+
} catch {}
|
|
893
|
+
}
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Find the final "Submit" button on a review/submit page.
|
|
899
|
+
* Tries many patterns to handle SmartRecruiters, Greenhouse, Ashby, Lever, Workday, etc.
|
|
900
|
+
*/
|
|
901
|
+
async function findSubmitButton(page) {
|
|
902
|
+
// Ordered from most specific to most generic
|
|
903
|
+
const selectors = [
|
|
904
|
+
// Workday-specific
|
|
905
|
+
'[data-automation-id="bottom-navigation-next-button"]:has-text("Submit")',
|
|
906
|
+
'[data-automation-id="submit-button"]',
|
|
907
|
+
'[data-automation-id="review-submit-button"]',
|
|
908
|
+
// Generic
|
|
909
|
+
'button:has-text("Submit application")',
|
|
910
|
+
'button:has-text("Submit Application")',
|
|
911
|
+
'button:has-text("Submit My Application")',
|
|
912
|
+
'button:has-text("Submit")',
|
|
913
|
+
'button:has-text("Apply Now")',
|
|
914
|
+
'button:has-text("Send Application")',
|
|
915
|
+
'button:has-text("Complete Application")',
|
|
916
|
+
'button:has-text("Finish")',
|
|
917
|
+
'button:has-text("Review and Submit")',
|
|
918
|
+
'[data-testid="submit-button"]',
|
|
919
|
+
'[data-testid="apply-button"]',
|
|
920
|
+
'[aria-label*="submit" i]',
|
|
921
|
+
'input[type="submit"]',
|
|
922
|
+
// Generic: any submit-type button that isn't navigation
|
|
923
|
+
'button[type="submit"]',
|
|
924
|
+
'form button:last-of-type',
|
|
925
|
+
];
|
|
926
|
+
for (const sel of selectors) {
|
|
927
|
+
try {
|
|
928
|
+
const el = page.locator(sel).first();
|
|
929
|
+
if (await el.isVisible({ timeout: 500 })) {
|
|
930
|
+
// Skip if it looks like a Next/Continue button
|
|
931
|
+
const text = (await el.textContent().catch(() => '')).toLowerCase().trim();
|
|
932
|
+
if (text.includes('next') || text.includes('continue') || text.includes('save and') || text.includes('save &')) continue;
|
|
933
|
+
return el;
|
|
934
|
+
}
|
|
935
|
+
} catch {}
|
|
936
|
+
}
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Wait for the DOM's form field count to stabilize (smart replacement for waitForTimeout).
|
|
942
|
+
* Stops polling as soon as the count has been stable for 2 consecutive 300ms intervals.
|
|
943
|
+
*/
|
|
944
|
+
async function waitForStableDOM(page, maxWait = 4000) {
|
|
945
|
+
const start = Date.now();
|
|
946
|
+
let lastCount = -1;
|
|
947
|
+
let stableCount = 0;
|
|
948
|
+
|
|
949
|
+
while (Date.now() - start < maxWait) {
|
|
950
|
+
await page.waitForTimeout(300);
|
|
951
|
+
const count = await page.evaluate(() =>
|
|
952
|
+
document.querySelectorAll('input:not([type=hidden]):not([type=submit]), textarea, select').length
|
|
953
|
+
).catch(() => 0);
|
|
954
|
+
|
|
955
|
+
if (count === lastCount && count > 0) {
|
|
956
|
+
stableCount++;
|
|
957
|
+
if (stableCount >= 2) break; // stable for 600ms — good enough
|
|
958
|
+
} else {
|
|
959
|
+
stableCount = 0;
|
|
960
|
+
}
|
|
961
|
+
lastCount = count;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Snapshot all current visible field labels on the page.
|
|
967
|
+
* Used by orchestrator to detect conditional fields that appear after clicking Next.
|
|
968
|
+
*/
|
|
969
|
+
async function snapshotFieldLabels(page) {
|
|
970
|
+
const fields = await extractPageFields(page).catch(() => []);
|
|
971
|
+
return new Set(fields.map(f => f.label).filter(Boolean));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
module.exports = {
|
|
975
|
+
fillFields,
|
|
976
|
+
uploadFile,
|
|
977
|
+
findNextButton,
|
|
978
|
+
findSubmitButton,
|
|
979
|
+
setFieldValue,
|
|
980
|
+
typeFieldValue,
|
|
981
|
+
extractPageFields,
|
|
982
|
+
semanticFindField,
|
|
983
|
+
waitForStableDOM,
|
|
984
|
+
snapshotFieldLabels,
|
|
985
|
+
selectRadioOption,
|
|
986
|
+
clickCheckbox,
|
|
987
|
+
};
|