Add generic external ATS applier
Best-effort form filler for any career page with standard HTML forms. Handles single-page and multi-step flows, resume upload, login wall and CAPTCHA detection. All ATS stub handlers now delegate to generic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,11 @@ import {
|
|||||||
APPLY_RUN_TIMEOUT_MS, PER_JOB_TIMEOUT_MS, RATE_LIMIT_COOLDOWN_MS
|
APPLY_RUN_TIMEOUT_MS, PER_JOB_TIMEOUT_MS, RATE_LIMIT_COOLDOWN_MS
|
||||||
} from './lib/constants.mjs';
|
} 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');
|
const isPreview = process.argv.includes('--preview');
|
||||||
|
|
||||||
@@ -387,7 +391,9 @@ async function handleResult(job, result, results, settings, profile, apiKey) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'skipped_honeypot':
|
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 });
|
updateJobStatus(job.id, status, { title, company });
|
||||||
appendLog({ ...job, title, company, status });
|
appendLog({ ...job, title, company, status });
|
||||||
results.skipped_other++;
|
results.skipped_other++;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* ashby.mjs — Ashby ATS handler
|
* 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 const SUPPORTED_TYPES = ['ashby'];
|
||||||
|
|
||||||
export async function apply(page, job, formFiller) {
|
export async function apply(page, job, formFiller) {
|
||||||
return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company },
|
return genericApply(page, job, formFiller);
|
||||||
externalUrl: job.apply_url, ats_platform: 'ashby' };
|
|
||||||
}
|
}
|
||||||
|
|||||||
146
lib/apply/generic.mjs
Normal file
146
lib/apply/generic.mjs
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* greenhouse.mjs — Greenhouse ATS handler
|
* greenhouse.mjs — Greenhouse ATS handler
|
||||||
* Applies directly to greenhouse.io application forms
|
* Delegates to generic handler — Greenhouse forms are standard HTML forms
|
||||||
* TODO: implement
|
|
||||||
*/
|
*/
|
||||||
|
import { apply as genericApply } from './generic.mjs';
|
||||||
|
|
||||||
export const SUPPORTED_TYPES = ['greenhouse'];
|
export const SUPPORTED_TYPES = ['greenhouse'];
|
||||||
|
|
||||||
export async function apply(page, job, formFiller) {
|
export async function apply(page, job, formFiller) {
|
||||||
return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company },
|
return genericApply(page, job, formFiller);
|
||||||
externalUrl: job.apply_url, ats_platform: 'greenhouse' };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as workday from './workday.mjs';
|
|||||||
import * as ashby from './ashby.mjs';
|
import * as ashby from './ashby.mjs';
|
||||||
import * as jobvite from './jobvite.mjs';
|
import * as jobvite from './jobvite.mjs';
|
||||||
import * as wellfound from './wellfound.mjs';
|
import * as wellfound from './wellfound.mjs';
|
||||||
|
import * as generic from './generic.mjs';
|
||||||
|
|
||||||
const ALL_HANDLERS = [
|
const ALL_HANDLERS = [
|
||||||
easyApply,
|
easyApply,
|
||||||
@@ -19,6 +20,7 @@ const ALL_HANDLERS = [
|
|||||||
ashby,
|
ashby,
|
||||||
jobvite,
|
jobvite,
|
||||||
wellfound,
|
wellfound,
|
||||||
|
generic,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build registry: apply_type → handler
|
// Build registry: apply_type → handler
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* jobvite.mjs — Jobvite ATS handler
|
* 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 const SUPPORTED_TYPES = ['jobvite'];
|
||||||
|
|
||||||
export async function apply(page, job, formFiller) {
|
export async function apply(page, job, formFiller) {
|
||||||
return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company },
|
return genericApply(page, job, formFiller);
|
||||||
externalUrl: job.apply_url, ats_platform: 'jobvite' };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* lever.mjs — Lever ATS handler
|
* 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 const SUPPORTED_TYPES = ['lever'];
|
||||||
|
|
||||||
export async function apply(page, job, formFiller) {
|
export async function apply(page, job, formFiller) {
|
||||||
return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company },
|
return genericApply(page, job, formFiller);
|
||||||
externalUrl: job.apply_url, ats_platform: 'lever' };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* workday.mjs — Workday ATS handler
|
* 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 const SUPPORTED_TYPES = ['workday'];
|
||||||
|
|
||||||
export async function apply(page, job, formFiller) {
|
export async function apply(page, job, formFiller) {
|
||||||
return { status: 'skipped_external_unsupported', meta: { title: job.title, company: job.company },
|
return genericApply(page, job, formFiller);
|
||||||
externalUrl: job.apply_url, ats_platform: 'workday' };
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user