fix: stuck loop on unfilled selects, Continue button detection

- Stuck detection after clicking Next: if heading+progress unchanged 2x,
  exit with 'stuck' status instead of looping forever
- Select dropdowns: treat "Select an option" as unfilled (LinkedIn's
  placeholder has a truthy value that was skipping the fill logic)
- Continue button fallback: detect draft "Continue" span via apply URL
  pattern when Easy Apply button not found

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 10:51:26 -08:00
parent 5e4a0c6599
commit 4c85a88902
2 changed files with 38 additions and 8 deletions

View File

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

View File

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