halo-agent 2.0.6 → 2.0.9
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/captcha.js +36 -3
- package/detectFormErrors.js +17 -7
- package/orchestrator.js +17 -2
- package/package.json +1 -1
- package/smartFill.js +116 -5
package/captcha.js
CHANGED
|
@@ -16,10 +16,43 @@ const CAPSOLVER_API = 'https://api.capsolver.com';
|
|
|
16
16
|
* Detect if a CAPTCHA is present anywhere on the page (including nested iframes).
|
|
17
17
|
* Returns { detected, type, sitekey, pageUrl }
|
|
18
18
|
*/
|
|
19
|
+
// ATS domains that EMBED their form (and its reCAPTCHA) in an iframe on a
|
|
20
|
+
// company-branded careers page. When the captcha lives in one of these
|
|
21
|
+
// frames, the sitekey is registered to THIS domain — so CapSolver must be
|
|
22
|
+
// told the frame URL, not the wrapper page URL. (The Orion case:
|
|
23
|
+
// orioninnovation.com/careers embeds job-boards.greenhouse.io; sending the
|
|
24
|
+
// orioninnovation.com URL → "Invalid domain for site key".)
|
|
25
|
+
const EMBEDDED_ATS_HOSTS = /(greenhouse\.io|lever\.co|ashbyhq\.com|myworkdayjobs\.com|icims\.com|smartrecruiters\.com|jobvite\.com|workable\.com)/i;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pick the URL to hand CapSolver for a given reCAPTCHA. The sitekey is
|
|
29
|
+
* domain-locked, so the URL must match the domain the widget actually runs
|
|
30
|
+
* on. If the form is embedded in a known-ATS iframe, that frame's URL wins
|
|
31
|
+
* over the top-level page URL.
|
|
32
|
+
*/
|
|
33
|
+
function resolveCaptchaPageUrl(page) {
|
|
34
|
+
const topUrl = page.url();
|
|
35
|
+
// If the top URL is itself an ATS domain, use it directly.
|
|
36
|
+
if (EMBEDDED_ATS_HOSTS.test(topUrl)) return topUrl;
|
|
37
|
+
// Otherwise, look for an ATS iframe — its URL is where the sitekey lives.
|
|
38
|
+
for (const frame of page.frames()) {
|
|
39
|
+
if (frame === page.mainFrame()) continue;
|
|
40
|
+
const fUrl = frame.url();
|
|
41
|
+
if (fUrl && EMBEDDED_ATS_HOSTS.test(fUrl)) {
|
|
42
|
+
// Strip the recaptcha frame itself — we want the FORM's frame, not
|
|
43
|
+
// google's. recaptcha frames are on google.com so they won't match
|
|
44
|
+
// EMBEDDED_ATS_HOSTS anyway, but be defensive.
|
|
45
|
+
if (!/google\.com|gstatic\.com/i.test(fUrl)) return fUrl;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return topUrl;
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
async function detectCaptcha(page) {
|
|
20
|
-
// The
|
|
21
|
-
//
|
|
22
|
-
|
|
52
|
+
// The URL CapSolver needs is the domain the sitekey is registered to —
|
|
53
|
+
// the embedded ATS frame's URL when the form is iframed into a branded
|
|
54
|
+
// careers page, otherwise the top-level URL.
|
|
55
|
+
const pageUrl = resolveCaptchaPageUrl(page);
|
|
23
56
|
|
|
24
57
|
// First try the top-level frame via evaluate
|
|
25
58
|
const topResult = await page.evaluate(() => {
|
package/detectFormErrors.js
CHANGED
|
@@ -150,18 +150,28 @@ async function detectFormErrors(page) {
|
|
|
150
150
|
// we falsely flag every filled dropdown as empty (the bug that made
|
|
151
151
|
// the retry re-pick Gender + Ethnicity).
|
|
152
152
|
if (el.getAttribute('role') === 'combobox' || tag === 'select') {
|
|
153
|
-
// Walk up to the React-Select
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
// Walk up to the React-Select CONTROL specifically (not the inner
|
|
154
|
+
// value-container — we want the whole control so single-value is
|
|
155
|
+
// in scope). react-select structure:
|
|
156
|
+
// .select__control > .select__value-container > .select__single-value
|
|
157
|
+
// > .select__input-container > input[role=combobox]
|
|
158
|
+
// The aria-invalid lands on the inner input, so we MUST climb to
|
|
159
|
+
// .select__control to see the rendered value chip.
|
|
160
|
+
let wrap = el.closest('[class*="select__control"]');
|
|
161
|
+
if (!wrap) {
|
|
162
|
+
// Climb manually for class variants
|
|
163
|
+
let p = el;
|
|
164
|
+
for (let i = 0; i < 8 && p; i++, p = p.parentElement) {
|
|
165
|
+
if (/select__control|select-control|\bSelect\b.*control/i.test(p.className || '')) { wrap = p; break; }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!wrap) wrap = el.closest('[class*="select"], [class*="combobox"], [class*="Select"]') || el.parentElement;
|
|
159
169
|
if (wrap) {
|
|
160
170
|
// Any rendered value chip → NOT empty.
|
|
161
171
|
const valueEl = wrap.querySelector(
|
|
162
172
|
'[class*="single-value"], [class*="multi-value"], ' +
|
|
163
173
|
'[class*="multiValue"], [class*="singleValue"], ' +
|
|
164
|
-
'[class*="chip"], [class*="tag"]
|
|
174
|
+
'[class*="chip"], [class*="tag"]'
|
|
165
175
|
);
|
|
166
176
|
if (valueEl && (valueEl.innerText || valueEl.textContent || '').trim()) return false;
|
|
167
177
|
// Also: the control's own text, minus the placeholder. If the
|
package/orchestrator.js
CHANGED
|
@@ -87,6 +87,11 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
87
87
|
|
|
88
88
|
let page = null;
|
|
89
89
|
let tempResumeFile = null;
|
|
90
|
+
// When the run ends in a terminal SUCCESS (DONE), we leave the tab open
|
|
91
|
+
// so the user sees the confirmation page — visual loop-closure. Only
|
|
92
|
+
// close the tab on errors/cleanup. Set true right before each successful
|
|
93
|
+
// DONE return/fall-through.
|
|
94
|
+
let leaveTabOpen = false;
|
|
90
95
|
|
|
91
96
|
// Initialize cross-page context tracker
|
|
92
97
|
const ctx = createFormContext();
|
|
@@ -246,6 +251,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
246
251
|
const currentUrl = page.url();
|
|
247
252
|
const looksLikeConfirmation = /thank|confirm|success|applied|submitted/i.test(currentUrl);
|
|
248
253
|
if (looksLikeConfirmation) {
|
|
254
|
+
leaveTabOpen = true;
|
|
249
255
|
await reportStatus('DONE', { fields_filled: cumulativeFilled });
|
|
250
256
|
await clearCheckpoint(config, queueId);
|
|
251
257
|
console.log(`[orchestrator] Detected confirmation page by URL: ${currentUrl}`);
|
|
@@ -388,6 +394,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
388
394
|
if (vRes.ok) vVerdict = await vRes.json();
|
|
389
395
|
} catch {}
|
|
390
396
|
if (vVerdict.submitted === true) {
|
|
397
|
+
leaveTabOpen = true;
|
|
391
398
|
await reportStatus('DONE', { confirmation_screenshot_r2_key: confirmKey || null, fields_filled: cumulativeFilled });
|
|
392
399
|
await clearCheckpoint(config, queueId);
|
|
393
400
|
console.log(`[orchestrator] Done via vision (verified): ${queueItem.company} - ${queueItem.title}`);
|
|
@@ -613,6 +620,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
613
620
|
fields_filled: cumulativeFilled,
|
|
614
621
|
});
|
|
615
622
|
} else {
|
|
623
|
+
leaveTabOpen = true;
|
|
616
624
|
await reportStatus('DONE', {
|
|
617
625
|
confirmation_screenshot_r2_key: confirmKey || null,
|
|
618
626
|
fields_filled: cumulativeFilled,
|
|
@@ -668,9 +676,16 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
668
676
|
if (tempResumeFile) {
|
|
669
677
|
try { fs.unlinkSync(tempResumeFile); } catch {}
|
|
670
678
|
}
|
|
671
|
-
// Close the tab
|
|
672
|
-
|
|
679
|
+
// Close the tab on failure/cleanup — but on a successful submit, LEAVE
|
|
680
|
+
// it open so the user sees the confirmation page (visual loop-closure).
|
|
681
|
+
// The next job opens its own new tab, so leaving this one doesn't block
|
|
682
|
+
// anything; sequential processing is enforced by the poller (one job at
|
|
683
|
+
// a time). Stale success tabs accumulate, but that's the user's call to
|
|
684
|
+
// close — they asked for the satisfaction of seeing it land.
|
|
685
|
+
if (page && !leaveTabOpen) {
|
|
673
686
|
try { await page.close(); } catch {}
|
|
687
|
+
} else if (page && leaveTabOpen) {
|
|
688
|
+
console.log('[orchestrator] Leaving confirmation tab open for your review.');
|
|
674
689
|
}
|
|
675
690
|
}
|
|
676
691
|
}
|
package/package.json
CHANGED
package/smartFill.js
CHANGED
|
@@ -293,14 +293,125 @@ async function tryIntlTelCountry(page, triggerLocator, value) {
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/**
|
|
297
|
+
* React-Select handler. Greenhouse migrated its ENTIRE form to react-select
|
|
298
|
+
* (.select__control / .select__input / .select__menu / .select__option,
|
|
299
|
+
* with remix-css-* emotion classes). The diagnostic dump confirmed every
|
|
300
|
+
* failing dropdown — Country, Location — is this widget.
|
|
301
|
+
*
|
|
302
|
+
* The reason a plain click+look-for-options failed: react-select renders
|
|
303
|
+
* its .select__menu ONLY after the input is focused, and for long lists
|
|
304
|
+
* (countries) it filters by typed text. So the flow is:
|
|
305
|
+
* 1. click the .select__control to focus + open
|
|
306
|
+
* 2. type the value to filter the menu (react-select narrows as you type)
|
|
307
|
+
* 3. wait for .select__option to appear
|
|
308
|
+
* 4. click the best-matching option (or press Enter to take the highlight)
|
|
309
|
+
*
|
|
310
|
+
* For Location it's the same widget but backed by an async (Google Places)
|
|
311
|
+
* option source — typing triggers a network fetch, options arrive ~700ms
|
|
312
|
+
* later as .select__option. Same handler covers it.
|
|
313
|
+
*
|
|
314
|
+
* Returns null if this isn't a react-select field (so other handlers run).
|
|
315
|
+
*/
|
|
316
|
+
async function tryReactSelect(page, triggerLocator, value) {
|
|
317
|
+
try {
|
|
318
|
+
// Detect: the trigger or an ancestor carries a .select__ class.
|
|
319
|
+
const info = await triggerLocator.evaluate((el) => {
|
|
320
|
+
const control = el.closest('[class*="select__control"]')
|
|
321
|
+
|| el.closest('[class*="select__value-container"]')?.closest('[class*="select__control"]')
|
|
322
|
+
|| (el.className && /select__/.test(el.className) ? el.closest('[class*="select__"]')?.closest('[class*="control"]') : null)
|
|
323
|
+
// Last resort: walk up to any node whose class has select__control
|
|
324
|
+
|| (() => { let p = el; for (let i = 0; i < 8 && p; i++, p = p.parentElement) { if (/select__control/.test(p.className || '')) return p; } return null; })();
|
|
325
|
+
if (!control) return null;
|
|
326
|
+
// The real text input inside the control:
|
|
327
|
+
const input = control.querySelector('input.select__input, input[class*="select__input"], input[role="combobox"]') || el;
|
|
328
|
+
// Tag both with a temp id so Playwright can target them precisely.
|
|
329
|
+
const cid = 'halo-rs-control-' + Math.random().toString(36).slice(2, 8);
|
|
330
|
+
control.setAttribute('data-halo-rs', cid);
|
|
331
|
+
if (input) input.setAttribute('data-halo-rs-input', cid);
|
|
332
|
+
return { cid };
|
|
333
|
+
}).catch(() => null);
|
|
334
|
+
if (!info) return null;
|
|
335
|
+
|
|
336
|
+
const control = page.locator(`[data-halo-rs="${info.cid}"]`).first();
|
|
337
|
+
const input = page.locator(`[data-halo-rs-input="${info.cid}"]`).first();
|
|
338
|
+
|
|
339
|
+
// 1. Open: click the control. react-select opens the menu on control click.
|
|
340
|
+
await control.click({ timeout: 2500 }).catch(() => {});
|
|
341
|
+
await page.waitForTimeout(250);
|
|
342
|
+
|
|
343
|
+
// 2. Type the value to filter. Strip "+1" / parens noise from country
|
|
344
|
+
// values ("United States +1" → "United States"). For location, type
|
|
345
|
+
// the city ("Tempe").
|
|
346
|
+
const typed = String(value).replace(/\s*\+\d+\s*$/, '').split(/[,;]/)[0].trim();
|
|
347
|
+
// Focus the input then type char-by-char so react-select's onChange fires.
|
|
348
|
+
await input.click({ timeout: 1500 }).catch(() => {});
|
|
349
|
+
await input.fill('').catch(() => {});
|
|
350
|
+
await page.keyboard.type(typed, { delay: 50 });
|
|
351
|
+
|
|
352
|
+
// 3. Wait for options. react-select menu is .select__menu containing
|
|
353
|
+
// .select__option. Async (Places) options take longer — wait up to 2s.
|
|
354
|
+
const optSel = '.select__option, [class*="select__option"], [id*="react-select"][id*="option"]';
|
|
355
|
+
try {
|
|
356
|
+
await page.locator(optSel).first().waitFor({ state: 'visible', timeout: 2200 });
|
|
357
|
+
} catch {
|
|
358
|
+
// No options appeared. For react-select, pressing Enter on a typed
|
|
359
|
+
// value sometimes commits a free-text/create option. Try it, then
|
|
360
|
+
// verify the control now shows a value.
|
|
361
|
+
await page.keyboard.press('Enter').catch(() => {});
|
|
362
|
+
await page.waitForTimeout(200);
|
|
363
|
+
const filled = await control.evaluate((c) => {
|
|
364
|
+
const v = c.querySelector('[class*="single-value"], [class*="multi-value"]');
|
|
365
|
+
return v ? (v.textContent || '').trim() : '';
|
|
366
|
+
}).catch(() => '');
|
|
367
|
+
if (filled) return { ok: true, reason: `react-select (enter-commit): ${filled}` };
|
|
368
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
369
|
+
return { ok: false, reason: 'react-select menu showed no options after typing' };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 4. Pick best option.
|
|
373
|
+
const opts = page.locator(optSel);
|
|
374
|
+
const texts = await opts.allTextContents().catch(() => []);
|
|
375
|
+
const tl = typed.toLowerCase();
|
|
376
|
+
let idx = texts.findIndex((t) => t.toLowerCase().trim() === tl);
|
|
377
|
+
if (idx === -1) idx = texts.findIndex((t) => t.toLowerCase().trim().startsWith(tl));
|
|
378
|
+
if (idx === -1) idx = texts.findIndex((t) => t.toLowerCase().includes(tl) || tl.includes(t.toLowerCase().trim()));
|
|
379
|
+
if (idx === -1) idx = 0; // react-select already filtered; first is best
|
|
380
|
+
await opts.nth(idx).click({ timeout: 2000 }).catch(async () => {
|
|
381
|
+
// react-select options sometimes need Enter on the highlighted item
|
|
382
|
+
await page.keyboard.press('Enter');
|
|
383
|
+
});
|
|
384
|
+
await page.waitForTimeout(150);
|
|
385
|
+
// Verify a value chip rendered
|
|
386
|
+
const got = await control.evaluate((c) => {
|
|
387
|
+
const v = c.querySelector('[class*="single-value"], [class*="multi-value"]');
|
|
388
|
+
return v ? (v.textContent || '').trim() : '';
|
|
389
|
+
}).catch(() => '');
|
|
390
|
+
if (got) return { ok: true, reason: `react-select picked: ${got}` };
|
|
391
|
+
return { ok: true, reason: `react-select clicked option: ${texts[idx] || typed}` };
|
|
392
|
+
} catch (e) {
|
|
393
|
+
return { ok: false, reason: `react-select handler threw: ${e.message}` };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
296
397
|
async function openAndPickOption(page, triggerLocator, value, llmCtx) {
|
|
398
|
+
// React-Select FIRST — Greenhouse uses it for every dropdown. The
|
|
399
|
+
// diagnostic confirmed Country + Location are both .select__ widgets.
|
|
400
|
+
// Returns null if not react-select, so other handlers still run.
|
|
401
|
+
try {
|
|
402
|
+
const rs = await tryReactSelect(page, triggerLocator, value);
|
|
403
|
+
if (rs !== null) {
|
|
404
|
+
if (rs.ok) return rs;
|
|
405
|
+
// react-select detected but pick failed — log + fall through to the
|
|
406
|
+
// generic path as a last resort (rare).
|
|
407
|
+
console.warn(`[smartFill] react-select pick failed: ${rs.reason}`);
|
|
408
|
+
}
|
|
409
|
+
} catch {}
|
|
410
|
+
|
|
297
411
|
try {
|
|
298
|
-
//
|
|
299
|
-
// Greenhouse is a non-standard widget that ignores clicks to its
|
|
300
|
-
// input — has to click the flag button. tryIntlTelCountry returns
|
|
301
|
-
// null when the field isn't intl-tel, so other dropdowns continue.
|
|
412
|
+
// intl-tel-input special case (kept for forms that genuinely use it).
|
|
302
413
|
const itlResult = await tryIntlTelCountry(page, triggerLocator, value);
|
|
303
|
-
if (itlResult !== null) return itlResult;
|
|
414
|
+
if (itlResult !== null && itlResult.ok) return itlResult;
|
|
304
415
|
} catch {}
|
|
305
416
|
|
|
306
417
|
// Second: Google Places typeahead. The Greenhouse Location field is
|