Files
claw-apply/lib/apply/ashby.mjs
Matthew Jackson c38f80086f Handle Ashby button-style questions and Greenhouse React Selects
Ashby: detect Yes/No button questions for work auth, sponsorship,
and consent. Click appropriate button in beforeSubmit hook.

Greenhouse: use pressSequentially instead of fill() for React
Select comboboxes (Country, Location). Click the dropdown option
after typing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:32:49 -08:00

81 lines
3.6 KiB
JavaScript

/**
* ashby.mjs — Ashby ATS handler (extends generic)
*/
import { apply as genericApply } from './generic.mjs';
export const SUPPORTED_TYPES = ['ashby'];
export async function apply(page, job, formFiller) {
return genericApply(page, job, formFiller, {
transformUrl: (url) => url.includes('/application') ? url : url.replace(/\/?(\?|$)/, '/application$1'),
closedTexts: ['job not found', 'the job you requested was not found'],
formDetector: '#_systemfield_name',
applyButtonSelector: 'button:has-text("Apply for this Job"), a:has-text("Apply for this Job")',
submitSelector: 'button:has-text("Submit Application")',
verifySelector: '#_systemfield_name',
beforeSubmit: async (page, formFiller) => {
// Upload resume — #_systemfield_resume IS the file input (not a container)
if (formFiller.profile.resume_path) {
const fileInput = await page.$('input[type="file"]#_systemfield_resume') ||
await page.$('input[type="file"]');
if (fileInput) {
const hasFile = await fileInput.evaluate(el => !!el.value);
if (!hasFile) {
await fileInput.setInputFiles(formFiller.profile.resume_path).catch(() => {});
await page.waitForTimeout(2000);
}
}
}
// Ashby uses button-style Yes/No for questions (not radios/fieldsets).
// Find question labels and click the appropriate button.
const buttonQuestions = await page.evaluate(() => {
const questions = [];
// Find containers with question text + Yes/No buttons
const allBtns = Array.from(document.querySelectorAll('button[type="submit"]'));
const yesNoBtns = allBtns.filter(b => b.innerText.trim() === 'Yes' || b.innerText.trim() === 'No');
const seen = new Set();
for (const btn of yesNoBtns) {
const container = btn.parentElement?.parentElement;
if (!container || seen.has(container)) continue;
seen.add(container);
const label = container.innerText.replace(/Yes\s*No/g, '').trim();
if (label) questions.push({ label, containerIdx: questions.length });
}
return questions;
});
const p = formFiller.profile;
for (const q of buttonQuestions) {
const ll = q.label.toLowerCase();
let answer = null;
if (ll.includes('authorized') || ll.includes('legally') || ll.includes('eligible') || ll.includes('right to work')) {
answer = p.work_authorization?.authorized ? 'Yes' : 'No';
} else if (ll.includes('sponsor')) {
answer = p.work_authorization?.requires_sponsorship ? 'Yes' : 'No';
} else if (ll.includes('consent') || ll.includes('text message') || ll.includes('sms')) {
answer = 'Yes';
}
if (answer) {
// Click the matching button - find by text within the question's container
const clicked = await page.evaluate((label, answer) => {
const allBtns = Array.from(document.querySelectorAll('button[type="submit"]'));
const containers = new Set();
for (const btn of allBtns) {
const c = btn.parentElement?.parentElement;
if (!c) continue;
const cText = c.innerText.replace(/Yes\s*No/g, '').trim();
if (cText === label) {
const target = Array.from(c.querySelectorAll('button')).find(b => b.innerText.trim() === answer);
if (target) { target.click(); return true; }
}
}
return false;
}, q.label, answer);
if (clicked) await page.waitForTimeout(300);
}
}
},
});
}