diff --git a/job_applier.mjs b/job_applier.mjs index 85310f8..7453dd1 100644 --- a/job_applier.mjs +++ b/job_applier.mjs @@ -33,7 +33,11 @@ import { APPLY_RUN_TIMEOUT_MS, PER_JOB_TIMEOUT_MS, RATE_LIMIT_COOLDOWN_MS } from './lib/constants.mjs'; -const DEFAULT_ENABLED_APPLY_TYPES = ['easy_apply', 'wellfound']; +const DEFAULT_ENABLED_APPLY_TYPES = [ + 'easy_apply', 'wellfound', + 'greenhouse', 'lever', 'ashby', 'workday', 'jobvite', + 'unknown_external', +]; const isPreview = process.argv.includes('--preview'); @@ -387,7 +391,9 @@ async function handleResult(job, result, results, settings, profile, apiKey) { break; case 'skipped_honeypot': - console.log(` ⏭️ Skipped — honeypot`); + case 'skipped_login_required': + case 'skipped_captcha': + console.log(` ⏭️ Skipped — ${status.replace('skipped_', '')}`); updateJobStatus(job.id, status, { title, company }); appendLog({ ...job, title, company, status }); results.skipped_other++; diff --git a/lib/apply/ashby.mjs b/lib/apply/ashby.mjs index b76c3be..13e83e9 100644 --- a/lib/apply/ashby.mjs +++ b/lib/apply/ashby.mjs @@ -1,10 +1,11 @@ /** * ashby.mjs — Ashby ATS handler - * TODO: implement + * Delegates to generic handler — Ashby forms are standard HTML forms */ +import { apply as genericApply } from './generic.mjs'; + export const SUPPORTED_TYPES = ['ashby']; export async function apply(page, job, formFiller) { - return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company }, - externalUrl: job.apply_url, ats_platform: 'ashby' }; + return genericApply(page, job, formFiller); } diff --git a/lib/apply/generic.mjs b/lib/apply/generic.mjs new file mode 100644 index 0000000..e7be81b --- /dev/null +++ b/lib/apply/generic.mjs @@ -0,0 +1,146 @@ +/** + * generic.mjs — Generic external ATS handler + * Best-effort form filler for any career page with a standard HTML form. + * Handles single-page and multi-step flows (up to 5 steps). + * Skips pages that require account creation or have CAPTCHAs. + */ +import { + NAVIGATION_TIMEOUT, PAGE_LOAD_WAIT, FORM_FILL_WAIT, SUBMIT_WAIT +} from '../constants.mjs'; + +export const SUPPORTED_TYPES = ['unknown_external']; + +const MAX_STEPS = 5; + +export async function apply(page, job, formFiller) { + const url = job.apply_url; + if (!url) return { status: 'no_button', meta: { title: job.title, company: job.company } }; + + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT }); + await page.waitForTimeout(PAGE_LOAD_WAIT); + + const meta = await page.evaluate(() => ({ + title: document.querySelector('h1')?.textContent?.trim()?.slice(0, 100), + company: document.querySelector('[class*="company"] h2, h2, [class*="employer"]')?.textContent?.trim()?.slice(0, 80), + })).catch(() => ({})); + meta.title = meta.title || job.title; + meta.company = meta.company || job.company; + + // Detect blockers: login walls, CAPTCHAs, closed listings + const pageCheck = await page.evaluate(() => { + const text = (document.body.innerText || '').toLowerCase(); + const hasLogin = !!(document.querySelector('input[type="password"]') || + (text.includes('sign in') && text.includes('create account')) || + (text.includes('log in') && text.includes('register'))); + const hasCaptcha = !!(document.querySelector('iframe[src*="recaptcha"], iframe[src*="captcha"], [class*="captcha"]')); + const isClosed = text.includes('no longer accepting') || text.includes('position has been filled') || + text.includes('this job is no longer') || text.includes('job not found') || + text.includes('this position is closed') || text.includes('listing has expired'); + return { hasLogin, hasCaptcha, isClosed }; + }).catch(() => ({})); + + if (pageCheck.isClosed) return { status: 'closed', meta }; + if (pageCheck.hasLogin) return { status: 'skipped_login_required', meta }; + if (pageCheck.hasCaptcha) return { status: 'skipped_captcha', meta }; + + // Some pages land directly on the form; others need an Apply button click + const hasFormAlready = await page.$('form input[type="text"], form input[type="email"], form textarea'); + if (!hasFormAlready) { + const applyBtn = page.locator([ + 'a:has-text("Apply Now")', + 'button:has-text("Apply Now")', + 'a:has-text("Apply for this job")', + 'button:has-text("Apply for this job")', + 'a:has-text("Apply")', + 'button:has-text("Apply")', + ].join(', ')).first(); + + if (await applyBtn.count() === 0) return { status: 'no_button', meta }; + + // Check if Apply button opens a new tab + const [newPage] = await Promise.all([ + page.context().waitForEvent('page', { timeout: 3000 }).catch(() => null), + applyBtn.click(), + ]); + + if (newPage) { + // Apply opened a new tab — switch to it + await newPage.waitForLoadState('domcontentloaded').catch(() => {}); + await newPage.waitForTimeout(PAGE_LOAD_WAIT); + // Recursively handle the new page (but return result to caller) + return applyOnPage(newPage, job, formFiller, meta); + } + + await page.waitForTimeout(FORM_FILL_WAIT); + + // Re-check for blockers after click + const postClick = await page.evaluate(() => ({ + hasLogin: !!document.querySelector('input[type="password"]'), + hasCaptcha: !!document.querySelector('iframe[src*="recaptcha"], [class*="captcha"]'), + })).catch(() => ({})); + if (postClick.hasLogin) return { status: 'skipped_login_required', meta }; + if (postClick.hasCaptcha) return { status: 'skipped_captcha', meta }; + } + + return applyOnPage(page, job, formFiller, meta); +} + +async function applyOnPage(page, job, formFiller, meta) { + for (let step = 0; step < MAX_STEPS; step++) { + // Fill the current page/step + 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 }; + + // Look for submit button + const submitBtn = await page.$([ + 'button[type="submit"]:not([disabled])', + 'input[type="submit"]:not([disabled])', + 'button:has-text("Submit Application")', + 'button:has-text("Submit")', + ].join(', ')); + + // Look for Next/Continue button (multi-step forms) + const nextBtn = !submitBtn ? await page.$([ + 'button:has-text("Next")', + 'button:has-text("Continue")', + 'button:has-text("Save and Continue")', + 'a:has-text("Next")', + ].join(', ')) : null; + + if (submitBtn) { + await submitBtn.click(); + await page.waitForTimeout(SUBMIT_WAIT); + + const postSubmit = await page.evaluate(() => { + const text = (document.body.innerText || '').toLowerCase(); + return { + hasSuccess: text.includes('application submitted') || text.includes('successfully applied') || + text.includes('thank you') || text.includes('application received') || + text.includes('application has been') || text.includes('we received your'), + hasForm: !!document.querySelector('form button[type="submit"]:not([disabled])'), + }; + }).catch(() => ({ hasSuccess: false, hasForm: false })); + + if (postSubmit.hasSuccess || !postSubmit.hasForm) { + return { status: 'submitted', meta }; + } + + console.log(` [generic] Submit clicked but form still present — may not have submitted`); + return { status: 'incomplete', meta }; + } + + if (nextBtn) { + await nextBtn.click(); + await page.waitForTimeout(FORM_FILL_WAIT); + continue; // Fill next step + } + + // No submit or next button found + return { status: 'no_submit', meta }; + } + + console.log(` [generic] Exceeded ${MAX_STEPS} form steps`); + return { status: 'incomplete', meta }; +} diff --git a/lib/apply/greenhouse.mjs b/lib/apply/greenhouse.mjs index 02773ec..d4fa1f8 100644 --- a/lib/apply/greenhouse.mjs +++ b/lib/apply/greenhouse.mjs @@ -1,11 +1,11 @@ /** * greenhouse.mjs — Greenhouse ATS handler - * Applies directly to greenhouse.io application forms - * TODO: implement + * Delegates to generic handler — Greenhouse forms are standard HTML forms */ +import { apply as genericApply } from './generic.mjs'; + export const SUPPORTED_TYPES = ['greenhouse']; export async function apply(page, job, formFiller) { - return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company }, - externalUrl: job.apply_url, ats_platform: 'greenhouse' }; + return genericApply(page, job, formFiller); } diff --git a/lib/apply/index.mjs b/lib/apply/index.mjs index e47a391..b8d98ad 100644 --- a/lib/apply/index.mjs +++ b/lib/apply/index.mjs @@ -10,6 +10,7 @@ import * as workday from './workday.mjs'; import * as ashby from './ashby.mjs'; import * as jobvite from './jobvite.mjs'; import * as wellfound from './wellfound.mjs'; +import * as generic from './generic.mjs'; const ALL_HANDLERS = [ easyApply, @@ -19,6 +20,7 @@ const ALL_HANDLERS = [ ashby, jobvite, wellfound, + generic, ]; // Build registry: apply_type → handler diff --git a/lib/apply/jobvite.mjs b/lib/apply/jobvite.mjs index 8e1b8e8..f82916d 100644 --- a/lib/apply/jobvite.mjs +++ b/lib/apply/jobvite.mjs @@ -1,10 +1,11 @@ /** * jobvite.mjs — Jobvite ATS handler - * TODO: implement + * Delegates to generic handler — Jobvite forms are standard HTML forms */ +import { apply as genericApply } from './generic.mjs'; + export const SUPPORTED_TYPES = ['jobvite']; export async function apply(page, job, formFiller) { - return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company }, - externalUrl: job.apply_url, ats_platform: 'jobvite' }; + return genericApply(page, job, formFiller); } diff --git a/lib/apply/lever.mjs b/lib/apply/lever.mjs index d894a4f..8a692cc 100644 --- a/lib/apply/lever.mjs +++ b/lib/apply/lever.mjs @@ -1,10 +1,11 @@ /** * lever.mjs — Lever ATS handler - * TODO: implement + * Delegates to generic handler — Lever forms are standard HTML forms */ +import { apply as genericApply } from './generic.mjs'; + export const SUPPORTED_TYPES = ['lever']; export async function apply(page, job, formFiller) { - return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company }, - externalUrl: job.apply_url, ats_platform: 'lever' }; + return genericApply(page, job, formFiller); } diff --git a/lib/apply/workday.mjs b/lib/apply/workday.mjs index a64c538..507c10c 100644 --- a/lib/apply/workday.mjs +++ b/lib/apply/workday.mjs @@ -1,10 +1,12 @@ /** * workday.mjs — Workday ATS handler - * TODO: implement + * Delegates to generic handler. Workday often requires account creation, + * so many will return skipped_login_required — that's expected. */ +import { apply as genericApply } from './generic.mjs'; + export const SUPPORTED_TYPES = ['workday']; export async function apply(page, job, formFiller) { - return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company }, - externalUrl: job.apply_url, ats_platform: 'workday' }; + return genericApply(page, job, formFiller); }