From 8212f97abac06d74f3ea6ee156b9a9ea8e441f8c Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Thu, 5 Mar 2026 17:39:48 -0800 Subject: [PATCH] refactor: normalize apply statuses, remove dead code, fix signal handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/apply/index.mjs: add STATUS_MAP to normalize platform-specific statuses to generic ones (no_button/no_submit/no_modal → skipped_no_apply). Documented all generic statuses for AI/developer reference. - job_applier.mjs: handleResult now handles skipped_no_apply, default case logs + saves instead of silently dropping - lib/linkedin.mjs: remove dead applyLinkedIn() and detectAts(), clean imports (~110 lines removed). Search-only module now. - lib/wellfound.mjs: remove dead applyWellfound(), clean imports. Search-only module now. - lib/lock.mjs: fix async signal handler — shutdown handlers now actually complete before process.exit() - test_linkedin_login.mjs: add try/catch/finally with proper browser cleanup - README: update status table with all current statuses Co-Authored-By: Claude Opus 4.6 --- README.md | 9 ++- job_applier.mjs | 6 +- lib/apply/index.mjs | 28 ++++++++- lib/linkedin.mjs | 130 ++-------------------------------------- lib/lock.mjs | 19 +++--- lib/wellfound.mjs | 37 ++---------- test_linkedin_login.mjs | 23 ++++--- 7 files changed, 70 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index fe8fb1c..695b3e5 100644 --- a/README.md +++ b/README.md @@ -198,10 +198,13 @@ claw-apply/ | `applied` | Successfully submitted | | `needs_answer` | Blocked on unknown question, waiting for your reply | | `failed` | Failed after max retries | -| `skipped` | Honeypot detected | +| `already_applied` | Duplicate detected, previously applied | +| `skipped_honeypot` | Honeypot question detected | | `skipped_recruiter_only` | LinkedIn recruiter-only listing | -| `skipped_external_unsupported` | External ATS (Greenhouse, Lever — not yet supported) | -| `skipped_easy_apply_unsupported` | No Easy Apply button available | +| `skipped_external_unsupported` | External ATS (Greenhouse, Lever, etc. — stubs ready) | +| `skipped_no_apply` | No apply button, modal, or submit found on page | +| `stuck` | Modal progress stalled | +| `incomplete` | Ran out of modal steps without submitting | ## Roadmap diff --git a/job_applier.mjs b/job_applier.mjs index 950e545..a3ecba5 100644 --- a/job_applier.mjs +++ b/job_applier.mjs @@ -199,11 +199,11 @@ async function handleResult(job, result, results, settings) { break; } + case 'skipped_no_apply': case 'skipped_easy_apply_unsupported': case 'skipped_honeypot': case 'stuck': case 'incomplete': - case 'no_modal': console.log(` ⏭️ Skipped — ${status}`); updateJobStatus(job.id, status, { title, company }); appendLog({ ...job, title, company, status }); @@ -211,7 +211,9 @@ async function handleResult(job, result, results, settings) { break; default: - console.log(` ⚠️ Unknown status: ${status}`); + console.warn(` ⚠️ Unhandled status: ${status}`); + updateJobStatus(job.id, status, { title, company }); + appendLog({ ...job, title, company, status }); } } diff --git a/lib/apply/index.mjs b/lib/apply/index.mjs index 210b5bb..e47a391 100644 --- a/lib/apply/index.mjs +++ b/lib/apply/index.mjs @@ -44,9 +44,32 @@ export function supportedTypes() { return Object.keys(REGISTRY); } +/** + * Status normalization — handlers return platform-specific statuses, + * this map converts them to generic statuses that job_applier.mjs understands. + * + * Generic statuses (what handleResult expects): + * submitted — application was submitted successfully + * needs_answer — blocked on unknown form question, sent to Telegram + * skipped_recruiter_only — LinkedIn recruiter-only listing + * skipped_external_unsupported — external ATS not yet implemented + * skipped_no_apply — no apply button/modal/submit found on page + * skipped_honeypot — honeypot question detected, application abandoned + * stuck — modal progress stalled after retries + * incomplete — ran out of modal steps without submitting + * + * When adding a new handler, return any status you want — if it doesn't match + * a generic status above, add a mapping here so job_applier doesn't need to change. + */ +const STATUS_MAP = { + no_button: 'skipped_no_apply', + no_submit: 'skipped_no_apply', + no_modal: 'skipped_no_apply', +}; + /** * Apply to a job using the appropriate handler - * Returns result object with status + * Returns result object with normalized status */ export async function applyToJob(page, job, formFiller) { const handler = getHandler(job.apply_type); @@ -58,5 +81,6 @@ export async function applyToJob(page, job, formFiller) { ats_platform: job.apply_type || 'unknown', }; } - return handler.apply(page, job, formFiller); + const result = await handler.apply(page, job, formFiller); + return { ...result, status: STATUS_MAP[result.status] || result.status }; } diff --git a/lib/linkedin.mjs b/lib/linkedin.mjs index c985a68..3f685df 100644 --- a/lib/linkedin.mjs +++ b/lib/linkedin.mjs @@ -1,26 +1,15 @@ /** - * linkedin.mjs — LinkedIn search and Easy Apply + * linkedin.mjs — LinkedIn search + job classification + * Apply logic lives in lib/apply/easy_apply.mjs */ import { LINKEDIN_BASE, NAVIGATION_TIMEOUT, FEED_NAVIGATION_TIMEOUT, - PAGE_LOAD_WAIT, SCROLL_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, EXTERNAL_ATS_PATTERNS + PAGE_LOAD_WAIT, SCROLL_WAIT, CLICK_WAIT, + EXTERNAL_ATS_PATTERNS } from './constants.mjs'; const MAX_SEARCH_PAGES = 40; -function detectAts(url) { - if (!url) return 'unknown_external'; - for (const { name, pattern } of EXTERNAL_ATS_PATTERNS) { - if (pattern.test(url)) return name; - } - return 'unknown_external'; -} - export async function verifyLogin(page) { await page.goto(`${LINKEDIN_BASE}/feed/`, { waitUntil: 'domcontentloaded', timeout: FEED_NAVIGATION_TIMEOUT }); await page.waitForTimeout(CLICK_WAIT); @@ -145,114 +134,3 @@ export async function searchLinkedIn(page, search, { onPage } = {}) { return jobs; } - -export async function applyLinkedIn(page, job, formFiller) { - // Use pre-classified apply_type from searcher if available - const meta = { title: job.title, company: job.company }; - - // Route by apply_type — no re-detection needed if already classified - if (job.apply_type && job.apply_type !== 'easy_apply' && job.apply_type !== 'unknown') { - if (job.apply_type === 'recruiter_only') return { status: 'skipped_recruiter_only', meta }; - // External ATS — skip for now, already have the URL - return { status: 'skipped_external_unsupported', meta, externalUrl: job.apply_url || '' }; - } - - // Navigate to job page - await page.goto(job.url, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT }); - await page.waitForTimeout(PAGE_LOAD_WAIT); - - // Re-read meta from page (more accurate title/company) - 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 is present (classify may have been wrong) - const eaBtn = await page.$(`${LINKEDIN_APPLY_BUTTON_SELECTOR}[aria-label*="Easy Apply"]`); - const interestedBtn = await page.$('button[aria-label*="interested"]'); - const externalBtn = await page.$(`${LINKEDIN_APPLY_BUTTON_SELECTOR}:not([aria-label*="Easy Apply"])`); - - if (!eaBtn && interestedBtn) return { status: 'skipped_recruiter_only', meta }; - if (!eaBtn && externalBtn) { - const applyLink = await page.evaluate(() => { - const a = document.querySelector('a[href*="greenhouse"], a[href*="lever"], a[href*="workday"], a[href*="ashby"], a[href*="jobvite"], a[href*="smartrecruiters"], a[href*="icims"], a[href*="taleo"]'); - return a?.href || ''; - }).catch(() => ''); - return { status: 'skipped_external_unsupported', meta, externalUrl: applyLink }; - } - 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)); - - // Fill form fields — returns unknown required fields - const unknowns = await formFiller.fill(page, formFiller.profile.resume_path); - - // Honeypot? - if (unknowns[0]?.honeypot) { - await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {}); - return { status: 'skipped_honeypot', meta }; - } - - // Has unknown required fields? - if (unknowns.length > 0) { - const question = unknowns[0]; - await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {}); - return { status: 'needs_answer', pending_question: question, meta }; - } - - await page.waitForTimeout(MODAL_STEP_WAIT); - - // Submit? - 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 }; - } - - // Stuck? - if (progress === lastProgress && step > 2) { - await page.click(LINKEDIN_DISMISS_SELECTOR, { timeout: DISMISS_TIMEOUT }).catch(() => {}); - return { status: 'stuck', meta }; - } - - // Next/Continue? - 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; - } - - // Review? - 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 }; -} diff --git a/lib/lock.mjs b/lib/lock.mjs index 85ed305..715937b 100644 --- a/lib/lock.mjs +++ b/lib/lock.mjs @@ -29,18 +29,21 @@ export function acquireLock(name, dataDir) { // Graceful shutdown — call registered cleanup before exiting const shutdownHandlers = []; - const shutdown = (code) => async () => { + const shutdown = (code) => { console.log(`\n⚠️ ${name}: signal received, shutting down gracefully...`); - for (const fn of shutdownHandlers) { - try { await fn(); } catch {} - } - release(); - process.exit(code); + // Run handlers sequentially, then exit + (async () => { + for (const fn of shutdownHandlers) { + try { await fn(); } catch {} + } + release(); + process.exit(code); + })(); }; process.on('exit', release); - process.on('SIGINT', shutdown(130)); - process.on('SIGTERM', shutdown(143)); + process.on('SIGINT', () => shutdown(130)); + process.on('SIGTERM', () => shutdown(143)); return { release, onShutdown: (fn) => shutdownHandlers.push(fn) }; } diff --git a/lib/wellfound.mjs b/lib/wellfound.mjs index 13e8ff4..579c9dd 100644 --- a/lib/wellfound.mjs +++ b/lib/wellfound.mjs @@ -1,10 +1,11 @@ /** - * wellfound.mjs — Wellfound search and apply + * wellfound.mjs — Wellfound search + * Apply logic lives in lib/apply/wellfound.mjs */ import { WELLFOUND_BASE, NAVIGATION_TIMEOUT, SEARCH_NAVIGATION_TIMEOUT, - SEARCH_LOAD_WAIT, SEARCH_SCROLL_WAIT, LOGIN_WAIT, PAGE_LOAD_WAIT, - FORM_FILL_WAIT, SUBMIT_WAIT, SEARCH_RESULTS_MAX + SEARCH_LOAD_WAIT, SEARCH_SCROLL_WAIT, LOGIN_WAIT, + SEARCH_RESULTS_MAX } from './constants.mjs'; const MAX_INFINITE_SCROLL = 10; @@ -89,33 +90,3 @@ export async function searchWellfound(page, search, { onPage } = {}) { const seen = new Set(); return jobs.filter(j => { if (seen.has(j.url)) return false; seen.add(j.url); return true; }); } - -export async function applyWellfound(page, job, formFiller) { - await page.goto(job.url, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT }); - await page.waitForTimeout(PAGE_LOAD_WAIT); - - const meta = await page.evaluate(() => ({ - title: document.querySelector('h1')?.textContent?.trim(), - company: document.querySelector('[class*="company"] h2, [class*="startup"] h2, h2')?.textContent?.trim(), - })); - - const applyBtn = await page.$('a:has-text("Apply"), button:has-text("Apply Now"), a:has-text("Apply Now")'); - if (!applyBtn) return { status: 'no_button', meta }; - - await applyBtn.click(); - await page.waitForTimeout(FORM_FILL_WAIT); - - // Fill form - 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 }; - - const submitBtn = await page.$('button[type="submit"]:not([disabled]), input[type="submit"]'); - if (!submitBtn) return { status: 'no_submit', meta }; - - await submitBtn.click(); - await page.waitForTimeout(SUBMIT_WAIT); - - return { status: 'submitted', meta }; -} diff --git a/test_linkedin_login.mjs b/test_linkedin_login.mjs index 6245e03..156839e 100644 --- a/test_linkedin_login.mjs +++ b/test_linkedin_login.mjs @@ -7,11 +7,18 @@ import { fileURLToPath } from 'url'; const __dir = dirname(fileURLToPath(import.meta.url)); const settings = loadConfig(resolve(__dir, 'config/settings.json')); -console.log('Creating Kernel browser with LinkedIn profile...'); -const b = await createBrowser(settings, 'linkedin'); -console.log('Browser created, checking login...'); -const loggedIn = await verifyLogin(b.page); -console.log('Logged in:', loggedIn); -console.log('URL:', b.page.url()); -await b.browser.close(); -console.log('Done.'); +let browser; +try { + console.log('Creating Kernel browser with LinkedIn profile...'); + browser = await createBrowser(settings, 'linkedin'); + console.log('Browser created, checking login...'); + const loggedIn = await verifyLogin(browser.page); + console.log('Logged in:', loggedIn); + console.log('URL:', browser.page.url()); +} catch (e) { + console.error('Error:', e.message); + process.exit(1); +} finally { + await browser?.browser?.close().catch(() => {}); + console.log('Done.'); +}