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 |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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...`);
|
||||
// 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) };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user