diff --git a/lib/apply/easy_apply.mjs b/lib/apply/easy_apply.mjs index e340143..1ee42f4 100644 --- a/lib/apply/easy_apply.mjs +++ b/lib/apply/easy_apply.mjs @@ -112,7 +112,21 @@ export async function apply(page, job, formFiller) { // Scroll slightly to trigger lazy-loaded content, then wait for Easy Apply button await page.evaluate(() => window.scrollTo(0, 300)).catch(() => {}); - const eaBtn = await page.waitForSelector(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: 12000, state: 'attached' }).catch(() => null); + let eaBtn = await page.waitForSelector(LINKEDIN_APPLY_BUTTON_SELECTOR, { timeout: 12000, state: 'attached' }).catch(() => null); + + // Fallback: LinkedIn shows plain "Continue" when a draft exists (not a button — a span) + // Look for it via the apply URL pattern in the page + if (!eaBtn) { + const continueEl = await page.$(`a[href*="/apply/"] span, [class*="apply"] span`); + if (continueEl) { + const text = await continueEl.evaluate(el => (el.innerText || '').trim()).catch(() => ''); + if (text === 'Continue') { + eaBtn = continueEl; + console.log(` ℹ️ Found "Continue" element (draft application)`); + } + } + } + if (!eaBtn) { console.log(` ℹ️ No Easy Apply button found. Page URL: ${page.url()}`); console.log(` Action: job may have been removed, filled, or changed to external apply`); @@ -139,6 +153,8 @@ export async function apply(page, job, formFiller) { // Step through modal let lastProgress = '-1'; + let lastHeading = ''; + let samePageCount = 0; for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) { const modalStillOpen = await page.$(MODAL); if (!modalStillOpen) { @@ -203,7 +219,22 @@ export async function apply(page, job, formFiller) { console.log(` [step ${step}] clicking Next`); await nextBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); await page.waitForTimeout(CLICK_WAIT); + + // Detect if we're stuck — same heading+progress means page didn't advance + const curHeading = debugInfo.heading; + if (curHeading === lastHeading && progress === lastProgress) { + samePageCount++; + if (samePageCount >= 2) { + console.log(` [step ${step}] stuck — clicked Next but page didn't advance (${samePageCount} times)`); + console.log(` Action: a required field may be unfilled. Check select dropdowns still at "Select an option"`); + await dismissModal(page, MODAL); + return { status: 'stuck', meta }; + } + } else { + samePageCount = 0; + } lastProgress = progress; + lastHeading = curHeading; continue; } @@ -217,6 +248,8 @@ export async function apply(page, job, formFiller) { await reviewBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {}); await page.waitForTimeout(CLICK_WAIT); lastProgress = progress; + lastHeading = debugInfo.heading; + samePageCount = 0; continue; } @@ -244,12 +277,8 @@ export async function apply(page, job, formFiller) { return { status: 'incomplete', meta }; } - // Stuck detection — progress hasn't changed and we've been through a few steps - if (progress && progress === lastProgress && step > 2) { - console.log(` [step ${step}] stuck — progress unchanged at ${progress}`); - await dismissModal(page, MODAL); - return { status: 'stuck', meta }; - } + // Stuck detection — no Next/Review/Submit found + // (stuck-after-click detection is handled above in the Next button section) console.log(` [step ${step}] ❌ No Next/Review/Submit button found in modal`); console.log(` Action: LinkedIn may have changed button text/structure. Check button snapshot above.`); diff --git a/lib/form_filler.mjs b/lib/form_filler.mjs index 364618a..02f831d 100644 --- a/lib/form_filler.mjs +++ b/lib/form_filler.mjs @@ -285,7 +285,8 @@ export class FormFiller { if (!await sel.isVisible().catch(() => false)) continue; const lbl = await this.getLabel(sel); const existing = await sel.inputValue().catch(() => ''); - if (existing) continue; + // "Select an option" is LinkedIn's placeholder — treat as unfilled + if (existing && !/^select an? /i.test(existing)) continue; const answer = this.answerFor(lbl); if (answer) { await sel.selectOption({ label: answer }).catch(async () => {