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:
2026-03-06 11:30:09 -08:00
parent 7f8cc3658e
commit 14cf9a12c1
4 changed files with 145 additions and 17 deletions

View File

@@ -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}`);

View File

@@ -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(() => {});

View File

@@ -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

View File

@@ -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();