diff --git a/job_applier.mjs b/job_applier.mjs index 72445d2..fee1704 100644 --- a/job_applier.mjs +++ b/job_applier.mjs @@ -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}`); diff --git a/lib/apply/easy_apply.mjs b/lib/apply/easy_apply.mjs index 14da4fe..b294a43 100644 --- a/lib/apply/easy_apply.mjs +++ b/lib/apply/easy_apply.mjs @@ -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(() => {}); diff --git a/lib/constants.mjs b/lib/constants.mjs index 0a5513b..16d8220 100644 --- a/lib/constants.mjs +++ b/lib/constants.mjs @@ -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 diff --git a/lib/form_filler.mjs b/lib/form_filler.mjs index 39c1f75..8d3cc60 100644 --- a/lib/form_filler.mjs +++ b/lib/form_filler.mjs @@ -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