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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
130
lib/linkedin.mjs
130
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 {
|
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
19
lib/lock.mjs
19
lib/lock.mjs
@@ -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...`);
|
||||||
for (const fn of shutdownHandlers) {
|
// Run handlers sequentially, then exit
|
||||||
try { await fn(); } catch {}
|
(async () => {
|
||||||
}
|
for (const fn of shutdownHandlers) {
|
||||||
release();
|
try { await fn(); } catch {}
|
||||||
process.exit(code);
|
}
|
||||||
|
release();
|
||||||
|
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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
console.log('Creating Kernel browser with LinkedIn profile...');
|
let browser;
|
||||||
const b = await createBrowser(settings, 'linkedin');
|
try {
|
||||||
console.log('Browser created, checking login...');
|
console.log('Creating Kernel browser with LinkedIn profile...');
|
||||||
const loggedIn = await verifyLogin(b.page);
|
browser = await createBrowser(settings, 'linkedin');
|
||||||
console.log('Logged in:', loggedIn);
|
console.log('Browser created, checking login...');
|
||||||
console.log('URL:', b.page.url());
|
const loggedIn = await verifyLogin(browser.page);
|
||||||
await b.browser.close();
|
console.log('Logged in:', loggedIn);
|
||||||
console.log('Done.');
|
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.');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user