refactor: handler registry pattern — lib/apply/<ats>.mjs, applyToJob() routes by apply_type
This commit is contained in:
10
lib/apply/ashby.mjs
Normal file
10
lib/apply/ashby.mjs
Normal 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
98
lib/apply/easy_apply.mjs
Normal 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
11
lib/apply/greenhouse.mjs
Normal 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
62
lib/apply/index.mjs
Normal 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
10
lib/apply/jobvite.mjs
Normal 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
10
lib/apply/lever.mjs
Normal 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
10
lib/apply/wellfound.mjs
Normal 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
10
lib/apply/workday.mjs
Normal 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' };
|
||||
}
|
||||
Reference in New Issue
Block a user