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

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