refactor: handler registry pattern — lib/apply/<ats>.mjs, applyToJob() routes by apply_type

This commit is contained in:
2026-03-06 01:03:11 +00:00
parent 2574276a85
commit 35fdbc487a
9 changed files with 326 additions and 138 deletions

10
lib/apply/ashby.mjs Normal file
View File

@@ -0,0 +1,10 @@
/**
* ashby.mjs — Ashby ATS handler
* TODO: implement
*/
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' };
}

98
lib/apply/easy_apply.mjs Normal file
View File

@@ -0,0 +1,98 @@
/**
* easy_apply.mjs — LinkedIn Easy Apply handler
* Handles the LinkedIn Easy Apply modal flow
*/
import {
NAVIGATION_TIMEOUT, PAGE_LOAD_WAIT, CLICK_WAIT, MODAL_STEP_WAIT,
SUBMIT_WAIT, DISMISS_TIMEOUT, APPLY_CLICK_TIMEOUT,
LINKEDIN_EASY_APPLY_MODAL_SELECTOR, LINKEDIN_APPLY_BUTTON_SELECTOR,
LINKEDIN_SUBMIT_SELECTOR, LINKEDIN_NEXT_SELECTOR,
LINKEDIN_REVIEW_SELECTOR, LINKEDIN_DISMISS_SELECTOR,
LINKEDIN_MAX_MODAL_STEPS
} from '../constants.mjs';
export const SUPPORTED_TYPES = ['easy_apply'];
export async function apply(page, job, formFiller) {
const meta = { title: job.title, company: job.company };
// Navigate to job page
await page.goto(job.url, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT });
await page.waitForTimeout(PAGE_LOAD_WAIT);
// Re-read meta
const pageMeta = await page.evaluate(() => ({
title: document.querySelector('.job-details-jobs-unified-top-card__job-title, h1[class*="title"]')?.textContent?.trim(),
company: document.querySelector('.job-details-jobs-unified-top-card__company-name a, .jobs-unified-top-card__company-name a')?.textContent?.trim(),
}));
Object.assign(meta, pageMeta);
// Verify Easy Apply button
const eaBtn = await page.$(`${LINKEDIN_APPLY_BUTTON_SELECTOR}[aria-label*="Easy Apply"]`);
if (!eaBtn) return { status: 'skipped_easy_apply_unsupported', meta };
// Click Easy Apply
await page.click(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(CLICK_WAIT);
const modal = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR);
if (!modal) return { status: 'no_modal', meta };
// Step through modal
let lastProgress = '-1';
for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) {
const modalStillOpen = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR);
if (!modalStillOpen) return { status: 'submitted', meta };
const progress = await page.$eval('[role="progressbar"]',
el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || String(el.style?.width || step)
).catch(() => String(step));
const unknowns = await formFiller.fill(page, formFiller.profile.resume_path);
if (unknowns[0]?.honeypot) {
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
return { status: 'skipped_honeypot', meta };
}
if (unknowns.length > 0) {
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
return { status: 'needs_answer', pending_question: unknowns[0], meta };
}
await page.waitForTimeout(MODAL_STEP_WAIT);
const hasSubmit = await page.$(LINKEDIN_SUBMIT_SELECTOR);
if (hasSubmit) {
await page.click(LINKEDIN_SUBMIT_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT });
await page.waitForTimeout(SUBMIT_WAIT);
return { status: 'submitted', meta };
}
if (progress === lastProgress && step > 2) {
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
return { status: 'stuck', meta };
}
const hasNext = await page.$(LINKEDIN_NEXT_SELECTOR);
if (hasNext) {
await page.click(LINKEDIN_NEXT_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(CLICK_WAIT);
lastProgress = progress;
continue;
}
const hasReview = await page.$(LINKEDIN_REVIEW_SELECTOR);
if (hasReview) {
await page.click(LINKEDIN_REVIEW_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(CLICK_WAIT);
lastProgress = progress;
continue;
}
break;
}
await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {});
return { status: 'incomplete', meta };
}

11
lib/apply/greenhouse.mjs Normal file
View File

@@ -0,0 +1,11 @@
/**
* greenhouse.mjs — Greenhouse ATS handler
* Applies directly to greenhouse.io application forms
* TODO: implement
*/
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' };
}

62
lib/apply/index.mjs Normal file
View File

@@ -0,0 +1,62 @@
/**
* index.mjs — Apply handler registry
* Maps apply_type → handler module
* To add a new ATS: create lib/apply/<name>.mjs and add one line here
*/
import * as easyApply from './easy_apply.mjs';
import * as greenhouse from './greenhouse.mjs';
import * as lever from './lever.mjs';
import * as workday from './workday.mjs';
import * as ashby from './ashby.mjs';
import * as jobvite from './jobvite.mjs';
import * as wellfound from './wellfound.mjs';
const ALL_HANDLERS = [
easyApply,
greenhouse,
lever,
workday,
ashby,
jobvite,
wellfound,
];
// Build registry: apply_type → handler
const REGISTRY = {};
for (const handler of ALL_HANDLERS) {
for (const type of handler.SUPPORTED_TYPES) {
REGISTRY[type] = handler;
}
}
/**
* Get handler for a given apply_type
* Returns null if not supported
*/
export function getHandler(applyType) {
return REGISTRY[applyType] || null;
}
/**
* List all supported apply types
*/
export function supportedTypes() {
return Object.keys(REGISTRY);
}
/**
* Apply to a job using the appropriate handler
* Returns result object with status
*/
export async function applyToJob(page, job, formFiller) {
const handler = getHandler(job.apply_type);
if (!handler) {
return {
status: 'skipped_external_unsupported',
meta: { title: job.title, company: job.company },
externalUrl: job.apply_url || '',
ats_platform: job.apply_type || 'unknown',
};
}
return handler.apply(page, job, formFiller);
}

10
lib/apply/jobvite.mjs Normal file
View File

@@ -0,0 +1,10 @@
/**
* jobvite.mjs — Jobvite ATS handler
* TODO: implement
*/
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' };
}

10
lib/apply/lever.mjs Normal file
View File

@@ -0,0 +1,10 @@
/**
* lever.mjs — Lever ATS handler
* TODO: implement
*/
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' };
}

10
lib/apply/wellfound.mjs Normal file
View File

@@ -0,0 +1,10 @@
/**
* wellfound.mjs — Wellfound ATS handler
* TODO: implement
*/
export const SUPPORTED_TYPES = ['wellfound'];
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: 'wellfound' };
}

10
lib/apply/workday.mjs Normal file
View File

@@ -0,0 +1,10 @@
/**
* workday.mjs — Workday ATS handler
* TODO: implement
*/
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' };
}