Reliability improvements: click retry, resume selection, answer loop, browser recovery
- Easy Apply click: click found element directly, retry with force if modal doesn't open - Resume: select first radio if none checked, fall back to file upload - AI answers: inject stored answers into formFiller on needs_answer retry - Answers persistence: reload answers.json before each job for Telegram replies - Browser recovery: detect dead page, create fresh browser session - Multiple dialogs: findApplyModal() tags the right dialog among cookie banners etc. - Select matching: case-insensitive fuzzy match with substring fallback - dismissModal: scope Discard scan to dialog elements only - Label dedup: normalize whitespace, fix odd-length edge case - no_modal status: explicit handleResult case - Per-job timeout: 10 minutes (was 3) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -135,7 +135,26 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Reload answers.json before each job — picks up Telegram replies between jobs
|
||||
try {
|
||||
const freshAnswers = existsSync(answersPath) ? loadConfig(answersPath) : [];
|
||||
formFiller.answers = freshAnswers;
|
||||
} catch { /* keep existing answers on read error */ }
|
||||
|
||||
try {
|
||||
// If this job previously returned needs_answer and has an AI or user-provided answer,
|
||||
// inject it into formFiller so the question gets answered on retry
|
||||
if (job.status === 'needs_answer' && job.pending_question && job.ai_suggested_answer) {
|
||||
const questionLabel = job.pending_question.label || job.pending_question;
|
||||
const answer = job.ai_suggested_answer;
|
||||
// Only inject if not already in answers (avoid duplicates across retries)
|
||||
const alreadyHas = formFiller.answers.some(a => a.pattern === questionLabel);
|
||||
if (!alreadyHas) {
|
||||
formFiller.answers.push({ pattern: questionLabel, answer });
|
||||
console.log(` ℹ️ Injecting AI answer for "${questionLabel}": "${String(answer).slice(0, 50)}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Per-job timeout — prevents a single hung browser from blocking the run
|
||||
const result = await Promise.race([
|
||||
applyToJob(browser.page, job, formFiller),
|
||||
@@ -145,6 +164,24 @@ async function main() {
|
||||
} catch (e) {
|
||||
console.error(` ❌ Error: ${e.message}`);
|
||||
if (e.stack) console.error(` Stack: ${e.stack.split('\n').slice(1, 3).join(' | ').trim()}`);
|
||||
|
||||
// Browser crash recovery — check if page is still usable
|
||||
const pageAlive = await browser.page.evaluate(() => true).catch(() => false);
|
||||
if (!pageAlive) {
|
||||
console.log(` ℹ️ Browser session dead — creating new browser`);
|
||||
await browser.browser?.close().catch(() => {});
|
||||
try {
|
||||
const newBrowser = platform === 'external'
|
||||
? await createBrowser(settings, null)
|
||||
: await createBrowser(settings, platform);
|
||||
browser = newBrowser;
|
||||
console.log(` ✅ New browser session created`);
|
||||
} catch (browserErr) {
|
||||
console.error(` ❌ Could not recover browser: ${browserErr.message}`);
|
||||
break; // can't continue without a browser
|
||||
}
|
||||
}
|
||||
|
||||
const retries = (job.retry_count || 0) + 1;
|
||||
if (retries <= maxRetries) {
|
||||
updateJobStatus(job.id, 'new', { retry_count: retries });
|
||||
@@ -241,6 +278,7 @@ async function handleResult(job, result, results, settings, profile, apiKey) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'no_modal':
|
||||
case 'skipped_no_apply':
|
||||
case 'skipped_easy_apply_unsupported':
|
||||
console.log(` ⏭️ Skipped — ${status}`);
|
||||
|
||||
@@ -104,6 +104,30 @@ async function getModalDebugInfo(page, modalSelector) {
|
||||
return { heading, buttons, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Easy Apply modal among potentially multiple [role="dialog"] elements.
|
||||
* Returns the dialog that contains apply-related content (form, progress bar, submit button).
|
||||
* Falls back to the first dialog if none match specifically.
|
||||
*/
|
||||
async function findApplyModal(page) {
|
||||
const dialogs = await page.$$('[role="dialog"]');
|
||||
if (dialogs.length <= 1) return dialogs[0] || null;
|
||||
|
||||
// Multiple dialogs — find the one with apply content
|
||||
for (const d of dialogs) {
|
||||
const isApply = await d.evaluate(el => {
|
||||
const text = (el.innerText || '').toLowerCase();
|
||||
const hasForm = el.querySelector('form, input, select, textarea, fieldset') !== null;
|
||||
const hasProgress = el.querySelector('progress, [role="progressbar"]') !== null;
|
||||
const hasApplyHeading = /apply to\b/i.test(text);
|
||||
return hasForm || hasProgress || hasApplyHeading;
|
||||
}).catch(() => false);
|
||||
if (isApply) return d;
|
||||
}
|
||||
|
||||
return dialogs[0]; // fallback
|
||||
}
|
||||
|
||||
export async function apply(page, job, formFiller) {
|
||||
const meta = { title: job.title, company: job.company };
|
||||
|
||||
@@ -152,15 +176,39 @@ export async function apply(page, job, formFiller) {
|
||||
Object.assign(meta, pageMeta);
|
||||
|
||||
// Click Easy Apply and wait for modal to appear
|
||||
await page.click(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||||
const modal = await page.waitForSelector(LINKEDIN_EASY_APPLY_MODAL_SELECTOR, { timeout: 8000 }).catch(() => null);
|
||||
// Click the actual found element — not a fresh selector query that might miss shadow DOM elements
|
||||
await eaBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||||
let modal = await page.waitForSelector(LINKEDIN_EASY_APPLY_MODAL_SELECTOR, { timeout: 8000 }).catch(() => null);
|
||||
|
||||
// Retry: button may not have been interactable on first click (lazy-loaded, overlapping element, etc.)
|
||||
if (!modal) {
|
||||
console.log(` ❌ Modal did not open after clicking Easy Apply`);
|
||||
console.log(` ℹ️ Modal didn't open — retrying click`);
|
||||
await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
|
||||
await page.waitForTimeout(1000);
|
||||
// Re-find button in case DOM changed
|
||||
const eaBtn2 = await page.$(LINKEDIN_APPLY_BUTTON_SELECTOR) || eaBtn;
|
||||
await eaBtn2.click({ force: true, timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
|
||||
modal = await page.waitForSelector(LINKEDIN_EASY_APPLY_MODAL_SELECTOR, { timeout: 8000 }).catch(() => null);
|
||||
}
|
||||
|
||||
if (!modal) {
|
||||
console.log(` ❌ Modal did not open after clicking Easy Apply (2 attempts)`);
|
||||
console.log(` Action: LinkedIn may have changed the modal structure or login expired`);
|
||||
return { status: 'no_modal', meta };
|
||||
}
|
||||
|
||||
const MODAL = LINKEDIN_EASY_APPLY_MODAL_SELECTOR;
|
||||
// If multiple [role="dialog"] exist (cookie banners, notifications), tag the apply modal
|
||||
// so all subsequent selectors target the right one
|
||||
const applyModal = await findApplyModal(page);
|
||||
let MODAL = LINKEDIN_EASY_APPLY_MODAL_SELECTOR;
|
||||
if (applyModal) {
|
||||
const multipleDialogs = (await page.$$('[role="dialog"]')).length > 1;
|
||||
if (multipleDialogs) {
|
||||
await applyModal.evaluate(el => el.setAttribute('data-claw-apply-modal', 'true'));
|
||||
MODAL = '[data-claw-apply-modal="true"]';
|
||||
console.log(` ℹ️ Multiple dialogs detected — tagged apply modal`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step through modal
|
||||
let lastProgress = '-1';
|
||||
@@ -384,9 +432,9 @@ async function dismissModal(page, modalSelector) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: find Discard by text — scan all buttons via page.$$()
|
||||
const allBtns = await page.$$('button');
|
||||
for (const btn of allBtns) {
|
||||
// Fallback: find Discard by text — scope to dialogs/modals to avoid clicking wrong buttons
|
||||
const dialogBtns = await page.$$('[role="dialog"] button, [role="alertdialog"] button, [data-test-modal] button');
|
||||
for (const btn of dialogBtns) {
|
||||
const text = await btn.evaluate(el => (el.innerText || '').trim().toLowerCase()).catch(() => '');
|
||||
if (text === 'discard') {
|
||||
await btn.click().catch(() => {});
|
||||
|
||||
@@ -90,4 +90,4 @@ export const DEFAULT_MAX_RETRIES = 2;
|
||||
|
||||
// --- Run limits ---
|
||||
export const APPLY_RUN_TIMEOUT_MS = 45 * 60 * 1000; // 45 minutes
|
||||
export const PER_JOB_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes per job
|
||||
export const PER_JOB_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes per job
|
||||
|
||||
@@ -138,11 +138,13 @@ export class FormFiller {
|
||||
// Clean up — remove trailing * from required field labels
|
||||
// Also deduplicate labels like "Phone country codePhone country code"
|
||||
let raw = forLabel || ariaLabel || linked || ancestorLabel || node.placeholder || node.name || '';
|
||||
raw = raw.replace(/\s*\*\s*$/, '').trim();
|
||||
// Normalize whitespace and remove trailing * from required field labels
|
||||
raw = raw.replace(/\s+/g, ' ').replace(/\s*\*\s*$/, '').trim();
|
||||
// Deduplicate repeated label text (LinkedIn renders label text twice sometimes)
|
||||
// e.g. "Phone country codePhone country code" → "Phone country code"
|
||||
if (raw.length > 4) {
|
||||
const half = Math.floor(raw.length / 2);
|
||||
if (raw.slice(0, half) === raw.slice(half)) raw = raw.slice(0, half);
|
||||
const half = Math.ceil(raw.length / 2);
|
||||
if (raw.slice(0, half) === raw.slice(half, half * 2)) raw = raw.slice(0, half).trim();
|
||||
}
|
||||
return raw;
|
||||
}).catch(() => '');
|
||||
@@ -176,6 +178,41 @@ export class FormFiller {
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an option from a <select> with case-insensitive, trimmed matching.
|
||||
* Tries: exact label → case-insensitive label → substring match → value match.
|
||||
*/
|
||||
async selectOptionFuzzy(sel, answer) {
|
||||
// Try exact match first (fastest path)
|
||||
const exactWorked = await sel.selectOption({ label: answer }).then(() => true).catch(() => false);
|
||||
if (exactWorked) return;
|
||||
|
||||
// Scan options for case-insensitive / trimmed match
|
||||
const opts = await sel.$$('option');
|
||||
const target = answer.trim().toLowerCase();
|
||||
let substringMatch = null;
|
||||
|
||||
for (const opt of opts) {
|
||||
const text = (await opt.textContent().catch(() => '') || '').trim();
|
||||
if (text.toLowerCase() === target) {
|
||||
await sel.selectOption({ label: text }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Track first substring match as fallback (e.g. answer "Yes" matches "Yes, I am authorized")
|
||||
if (!substringMatch && text.toLowerCase().includes(target)) {
|
||||
substringMatch = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (substringMatch) {
|
||||
await sel.selectOption({ label: substringMatch }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Last resort: try by value
|
||||
await sel.selectOption({ value: answer }).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the first option from an autocomplete dropdown.
|
||||
* Waits for the dropdown to appear, then clicks the first option.
|
||||
@@ -205,9 +242,16 @@ export class FormFiller {
|
||||
// Scope to modal if present, otherwise use page
|
||||
const container = await page.$(LINKEDIN_EASY_APPLY_MODAL_SELECTOR) || page;
|
||||
|
||||
// Resume upload — only if no existing resume selected
|
||||
const hasResumeSelected = await container.$('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]').catch(() => null);
|
||||
if (!hasResumeSelected && resumePath) {
|
||||
// Resume selection — LinkedIn shows radio buttons for previously uploaded resumes
|
||||
// Select the first resume radio if none is already checked
|
||||
const resumeRadios = await container.$$('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]');
|
||||
if (resumeRadios.length > 0) {
|
||||
const anyChecked = await container.$('input[type="radio"][aria-label*="resume"]:checked, input[type="radio"][aria-label*="Resume"]:checked').catch(() => null);
|
||||
if (!anyChecked) {
|
||||
await resumeRadios[0].click().catch(() => {});
|
||||
}
|
||||
} else if (resumePath) {
|
||||
// No resume radios — try file upload
|
||||
const fileInput = await container.$('input[type="file"]');
|
||||
if (fileInput) await fileInput.setInputFiles(resumePath).catch(() => {});
|
||||
}
|
||||
@@ -289,9 +333,7 @@ export class FormFiller {
|
||||
if (existing && !/^select an? /i.test(existing)) continue;
|
||||
const answer = this.answerFor(lbl);
|
||||
if (answer) {
|
||||
await sel.selectOption({ label: answer }).catch(async () => {
|
||||
await sel.selectOption({ value: answer }).catch(() => {});
|
||||
});
|
||||
await this.selectOptionFuzzy(sel, answer);
|
||||
} else {
|
||||
// EEO/voluntary fields — default to "Prefer not to disclose"
|
||||
const ll = lbl.toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user