refactor: normalize apply statuses, remove dead code, fix signal handler

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 17:39:48 -08:00
parent 1bf676bb80
commit 8212f97aba
7 changed files with 70 additions and 182 deletions

View File

@@ -198,10 +198,13 @@ claw-apply/
| `applied` | Successfully submitted | | `applied` | Successfully submitted |
| `needs_answer` | Blocked on unknown question, waiting for your reply | | `needs_answer` | Blocked on unknown question, waiting for your reply |
| `failed` | Failed after max retries | | `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_recruiter_only` | LinkedIn recruiter-only listing |
| `skipped_external_unsupported` | External ATS (Greenhouse, Lever — not yet supported) | | `skipped_external_unsupported` | External ATS (Greenhouse, Lever, etc. — stubs ready) |
| `skipped_easy_apply_unsupported` | No Easy Apply button available | | `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 ## Roadmap

View File

@@ -199,11 +199,11 @@ async function handleResult(job, result, results, settings) {
break; break;
} }
case 'skipped_no_apply':
case 'skipped_easy_apply_unsupported': case 'skipped_easy_apply_unsupported':
case 'skipped_honeypot': case 'skipped_honeypot':
case 'stuck': case 'stuck':
case 'incomplete': case 'incomplete':
case 'no_modal':
console.log(` ⏭️ Skipped — ${status}`); console.log(` ⏭️ Skipped — ${status}`);
updateJobStatus(job.id, status, { title, company }); updateJobStatus(job.id, status, { title, company });
appendLog({ ...job, title, company, status }); appendLog({ ...job, title, company, status });
@@ -211,7 +211,9 @@ async function handleResult(job, result, results, settings) {
break; break;
default: default:
console.log(` ⚠️ Unknown status: ${status}`); console.warn(` ⚠️ Unhandled status: ${status}`);
updateJobStatus(job.id, status, { title, company });
appendLog({ ...job, title, company, status });
} }
} }

View File

@@ -44,9 +44,32 @@ export function supportedTypes() {
return Object.keys(REGISTRY); 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 * 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) { export async function applyToJob(page, job, formFiller) {
const handler = getHandler(job.apply_type); const handler = getHandler(job.apply_type);
@@ -58,5 +81,6 @@ export async function applyToJob(page, job, formFiller) {
ats_platform: job.apply_type || 'unknown', 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 };
} }

View File

@@ -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 { import {
LINKEDIN_BASE, NAVIGATION_TIMEOUT, FEED_NAVIGATION_TIMEOUT, LINKEDIN_BASE, NAVIGATION_TIMEOUT, FEED_NAVIGATION_TIMEOUT,
PAGE_LOAD_WAIT, SCROLL_WAIT, CLICK_WAIT, MODAL_STEP_WAIT, PAGE_LOAD_WAIT, SCROLL_WAIT, CLICK_WAIT,
SUBMIT_WAIT, DISMISS_TIMEOUT, APPLY_CLICK_TIMEOUT, EXTERNAL_ATS_PATTERNS
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
} from './constants.mjs'; } from './constants.mjs';
const MAX_SEARCH_PAGES = 40; 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) { export async function verifyLogin(page) {
await page.goto(`${LINKEDIN_BASE}/feed/`, { waitUntil: 'domcontentloaded', timeout: FEED_NAVIGATION_TIMEOUT }); await page.goto(`${LINKEDIN_BASE}/feed/`, { waitUntil: 'domcontentloaded', timeout: FEED_NAVIGATION_TIMEOUT });
await page.waitForTimeout(CLICK_WAIT); await page.waitForTimeout(CLICK_WAIT);
@@ -145,114 +134,3 @@ export async function searchLinkedIn(page, search, { onPage } = {}) {
return jobs; 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 };
}

View File

@@ -29,18 +29,21 @@ export function acquireLock(name, dataDir) {
// Graceful shutdown — call registered cleanup before exiting // Graceful shutdown — call registered cleanup before exiting
const shutdownHandlers = []; const shutdownHandlers = [];
const shutdown = (code) => async () => { const shutdown = (code) => {
console.log(`\n⚠️ ${name}: signal received, shutting down gracefully...`); console.log(`\n⚠️ ${name}: signal received, shutting down gracefully...`);
// Run handlers sequentially, then exit
(async () => {
for (const fn of shutdownHandlers) { for (const fn of shutdownHandlers) {
try { await fn(); } catch {} try { await fn(); } catch {}
} }
release(); release();
process.exit(code); process.exit(code);
})();
}; };
process.on('exit', release); process.on('exit', release);
process.on('SIGINT', shutdown(130)); process.on('SIGINT', () => shutdown(130));
process.on('SIGTERM', shutdown(143)); process.on('SIGTERM', () => shutdown(143));
return { release, onShutdown: (fn) => shutdownHandlers.push(fn) }; return { release, onShutdown: (fn) => shutdownHandlers.push(fn) };
} }

View File

@@ -1,10 +1,11 @@
/** /**
* wellfound.mjs — Wellfound search and apply * wellfound.mjs — Wellfound search
* Apply logic lives in lib/apply/wellfound.mjs
*/ */
import { import {
WELLFOUND_BASE, NAVIGATION_TIMEOUT, SEARCH_NAVIGATION_TIMEOUT, WELLFOUND_BASE, NAVIGATION_TIMEOUT, SEARCH_NAVIGATION_TIMEOUT,
SEARCH_LOAD_WAIT, SEARCH_SCROLL_WAIT, LOGIN_WAIT, PAGE_LOAD_WAIT, SEARCH_LOAD_WAIT, SEARCH_SCROLL_WAIT, LOGIN_WAIT,
FORM_FILL_WAIT, SUBMIT_WAIT, SEARCH_RESULTS_MAX SEARCH_RESULTS_MAX
} from './constants.mjs'; } from './constants.mjs';
const MAX_INFINITE_SCROLL = 10; const MAX_INFINITE_SCROLL = 10;
@@ -89,33 +90,3 @@ export async function searchWellfound(page, search, { onPage } = {}) {
const seen = new Set(); const seen = new Set();
return jobs.filter(j => { if (seen.has(j.url)) return false; seen.add(j.url); return true; }); 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 };
}

View File

@@ -7,11 +7,18 @@ import { fileURLToPath } from 'url';
const __dir = dirname(fileURLToPath(import.meta.url)); const __dir = dirname(fileURLToPath(import.meta.url));
const settings = loadConfig(resolve(__dir, 'config/settings.json')); const settings = loadConfig(resolve(__dir, 'config/settings.json'));
let browser;
try {
console.log('Creating Kernel browser with LinkedIn profile...'); console.log('Creating Kernel browser with LinkedIn profile...');
const b = await createBrowser(settings, 'linkedin'); browser = await createBrowser(settings, 'linkedin');
console.log('Browser created, checking login...'); console.log('Browser created, checking login...');
const loggedIn = await verifyLogin(b.page); const loggedIn = await verifyLogin(browser.page);
console.log('Logged in:', loggedIn); console.log('Logged in:', loggedIn);
console.log('URL:', b.page.url()); console.log('URL:', browser.page.url());
await b.browser.close(); } catch (e) {
console.error('Error:', e.message);
process.exit(1);
} finally {
await browser?.browser?.close().catch(() => {});
console.log('Done.'); console.log('Done.');
}