Build dedicated Ashby handler instead of generic delegation
Handles Ashby-specific quirks: - Auto-appends /application to job URLs - Targets #_systemfield_resume for resume upload (not autofill input) - Clicks "Submit Application" specifically, avoiding "Upload file" buttons - Checks for Ashby-specific form fields to verify submission Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,82 @@
|
|||||||
/**
|
/**
|
||||||
* ashby.mjs — Ashby ATS handler
|
* ashby.mjs — Ashby ATS handler
|
||||||
* Delegates to generic handler — Ashby forms are standard HTML forms
|
*
|
||||||
|
* Ashby forms have a consistent structure:
|
||||||
|
* - URLs ending in /application land directly on the form
|
||||||
|
* - Other URLs show a job listing with "Apply for this Job" button
|
||||||
|
* - Form fields: Name, Email, Resume (file), optional extras (phone, LinkedIn, etc.)
|
||||||
|
* - Resume input has id="_systemfield_resume"
|
||||||
|
* - There's also an "autofill from resume" file input — don't confuse with actual resume
|
||||||
|
* - "Upload file" buttons are type="submit" — must target "Submit Application" specifically
|
||||||
|
* - Invisible reCAPTCHA on submit
|
||||||
*/
|
*/
|
||||||
import { apply as genericApply } from './generic.mjs';
|
import {
|
||||||
|
NAVIGATION_TIMEOUT, PAGE_LOAD_WAIT, FORM_FILL_WAIT, SUBMIT_WAIT
|
||||||
|
} from '../constants.mjs';
|
||||||
|
|
||||||
export const SUPPORTED_TYPES = ['ashby'];
|
export const SUPPORTED_TYPES = ['ashby'];
|
||||||
|
|
||||||
export async function apply(page, job, formFiller) {
|
export async function apply(page, job, formFiller) {
|
||||||
return genericApply(page, job, formFiller);
|
const url = job.apply_url;
|
||||||
|
if (!url) return { status: 'no_button', meta: { title: job.title, company: job.company } };
|
||||||
|
|
||||||
|
const meta = { title: job.title, company: job.company };
|
||||||
|
|
||||||
|
// Navigate — append /application if not already there
|
||||||
|
const applyUrl = url.includes('/application') ? url : url.replace(/\/?(\?|$)/, '/application$1');
|
||||||
|
await page.goto(applyUrl, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT });
|
||||||
|
await page.waitForTimeout(PAGE_LOAD_WAIT);
|
||||||
|
|
||||||
|
// Check if we landed on the form or a listing page
|
||||||
|
const hasForm = await page.$('#_systemfield_name, input[name="_systemfield_name"]');
|
||||||
|
if (!hasForm) {
|
||||||
|
// Try clicking "Apply for this Job"
|
||||||
|
const applyBtn = page.locator('button:has-text("Apply for this Job"), a:has-text("Apply for this Job")').first();
|
||||||
|
if (await applyBtn.count() === 0) return { status: 'no_button', meta };
|
||||||
|
await applyBtn.click();
|
||||||
|
await page.waitForTimeout(FORM_FILL_WAIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for closed listing
|
||||||
|
const closed = await page.evaluate(() => {
|
||||||
|
const text = (document.body.innerText || '').toLowerCase();
|
||||||
|
return text.includes('no longer accepting') || text.includes('position has been filled') ||
|
||||||
|
text.includes('no longer available') || text.includes('does not exist');
|
||||||
|
}).catch(() => false);
|
||||||
|
if (closed) return { status: 'closed', meta };
|
||||||
|
|
||||||
|
// Fill form fields
|
||||||
|
const unknowns = await formFiller.fill(page, formFiller.profile.resume_path);
|
||||||
|
if (unknowns[0]?.honeypot) return { status: 'skipped_honeypot', meta };
|
||||||
|
if (unknowns.length > 0) return { status: 'needs_answer', pending_question: unknowns[0], meta };
|
||||||
|
|
||||||
|
// Upload resume to the correct file input (not the autofill one)
|
||||||
|
const resumeInput = await page.$('#_systemfield_resume');
|
||||||
|
if (resumeInput && formFiller.profile.resume_path) {
|
||||||
|
await resumeInput.setInputFiles(formFiller.profile.resume_path).catch(() => {});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click "Submit Application" specifically — NOT the "Upload file" buttons
|
||||||
|
const submitBtn = page.locator('button:has-text("Submit Application")').first();
|
||||||
|
if (await submitBtn.count() === 0) return { status: 'no_submit', meta };
|
||||||
|
|
||||||
|
await submitBtn.click();
|
||||||
|
await page.waitForTimeout(SUBMIT_WAIT);
|
||||||
|
|
||||||
|
// Verify submission
|
||||||
|
const postSubmit = await page.evaluate(() => {
|
||||||
|
const text = (document.body.innerText || '').toLowerCase();
|
||||||
|
return {
|
||||||
|
hasSuccess: text.includes('thank you') || text.includes('application submitted') ||
|
||||||
|
text.includes('application received') || text.includes('successfully'),
|
||||||
|
hasForm: !!document.querySelector('#_systemfield_name'),
|
||||||
|
};
|
||||||
|
}).catch(() => ({ hasSuccess: false, hasForm: false }));
|
||||||
|
|
||||||
|
if (postSubmit.hasSuccess || !postSubmit.hasForm) {
|
||||||
|
return { status: 'submitted', meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'incomplete', meta };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user