Files
claw-apply/lib/apply/easy_apply.mjs
Matthew Jackson df31019fd0 fix: modal frame detection — LinkedIn renders Easy Apply in /preload/ frame
LinkedIn renders the Easy Apply modal inside a /preload/ iframe, not the
main document. page.$() searches cross-frame but evaluate() only runs in
the main frame context, causing blank headings/buttons and broken navigation.

- Added findModalFrame() to locate the frame owning the modal dialog
- All evaluate/evaluateHandle calls now use modalFrame instead of page
- findModalButton() and dismissModal() updated to accept frame parameter
- formFiller.fill() receives modalFrame so container scoping works correctly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:20:58 -08:00

313 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* easy_apply.mjs — LinkedIn Easy Apply handler
* Handles the LinkedIn Easy Apply modal flow
*
* Button detection strategy: LinkedIn frequently changes aria-labels and button
* structure. We use multiple fallback strategies:
* 1. aria-label exact match (fastest, but brittle)
* 2. aria-label substring match (handles minor text changes)
* 3. Exact button text match (most resilient — matches trimmed innerText)
* All searches are scoped to the modal dialog to avoid clicking page buttons.
*
* Modal flow: Easy Apply → [fill → Next] × N → Review → Submit application
* Check order per step: Next → Review → Submit (only submit when no forward nav exists)
*/
import {
NAVIGATION_TIMEOUT, CLICK_WAIT, MODAL_STEP_WAIT,
SUBMIT_WAIT, DISMISS_TIMEOUT, APPLY_CLICK_TIMEOUT,
LINKEDIN_EASY_APPLY_MODAL_SELECTOR, LINKEDIN_APPLY_BUTTON_SELECTOR,
LINKEDIN_MAX_MODAL_STEPS
} from '../constants.mjs';
export const SUPPORTED_TYPES = ['easy_apply'];
/**
* Find the frame that owns the Easy Apply modal.
* LinkedIn renders the modal inside a /preload/ frame, not the main document.
* page.$() searches cross-frame, but evaluate() only runs in one frame's context.
* We need the correct frame for evaluate/evaluateHandle calls.
*/
async function findModalFrame(page, modalSelector) {
for (const frame of page.frames()) {
const el = await frame.$(modalSelector).catch(() => null);
if (el) return frame;
}
return page; // fallback to main frame
}
/**
* Find a non-disabled button inside the modal using multiple strategies.
* @param {Page} page - Playwright page (for cross-frame $() calls)
* @param {Frame} frame - The frame containing the modal (for evaluate calls)
* @param {string} modalSelector - CSS selector for the modal container
* @param {Object} opts
* @param {string[]} opts.ariaLabels - aria-label values to try (exact then substring)
* @param {string[]} opts.exactTexts - exact button text matches (case-insensitive, trimmed)
* @returns {ElementHandle|null}
*/
async function findModalButton(page, frame, modalSelector, { ariaLabels = [], exactTexts = [] }) {
// Strategy 1: aria-label exact match inside modal (non-disabled only)
// Use frame.$() since modal is in a specific frame
for (const label of ariaLabels) {
const btn = await frame.$(`${modalSelector} button[aria-label="${label}"]:not([disabled])`);
if (btn) return btn;
}
// Strategy 2: aria-label substring match inside modal
for (const label of ariaLabels) {
const btn = await frame.$(`${modalSelector} button[aria-label*="${label}"]:not([disabled])`);
if (btn) return btn;
}
// Strategy 3: exact text match on button innerText (case-insensitive, trimmed)
// Uses evaluateHandle on the correct frame to return a live DOM reference
if (exactTexts.length === 0) return null;
const handle = await frame.evaluateHandle((sel, texts) => {
const modal = document.querySelector(sel);
if (!modal) return null;
const targets = texts.map(t => t.toLowerCase());
const buttons = modal.querySelectorAll('button:not([disabled])');
for (const btn of buttons) {
// Use innerText (not textContent) to get rendered text without hidden children
const text = (btn.innerText || btn.textContent || '').trim().toLowerCase();
if (targets.includes(text)) return btn;
}
return null;
}, modalSelector, exactTexts).catch(() => null);
if (handle) {
const el = handle.asElement();
if (el) return el;
await handle.dispose().catch(() => {});
}
return null;
}
export async function apply(page, job, formFiller) {
const meta = { title: job.title, company: job.company };
// Navigate to job page
await page.goto(job.url, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT });
// 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);
if (!eaBtn) {
const applyEls = await page.evaluate(() =>
Array.from(document.querySelectorAll('[aria-label*="Easy Apply"], [aria-label*="Apply"]'))
.map(el => ({ tag: el.tagName, aria: el.getAttribute('aria-label'), visible: el.offsetParent !== null }))
).catch(() => []);
console.log(` No Easy Apply button found. Page URL: ${page.url()}`);
console.log(` Apply-related elements on page: ${JSON.stringify(applyEls)}`);
console.log(` Action: job may have been removed, filled, or changed to external apply`);
return { status: 'skipped_easy_apply_unsupported', meta };
}
// Re-read meta after page settled
const pageMeta = await page.evaluate(() => ({
title: document.querySelector('.job-details-jobs-unified-top-card__job-title, h1[class*="title"]')?.textContent?.trim(),
company: document.querySelector('.job-details-jobs-unified-top-card__company-name a, .jobs-unified-top-card__company-name a')?.textContent?.trim(),
}));
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);
if (!modal) {
console.log(` ❌ Modal did not open after clicking Easy Apply`);
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;
// LinkedIn renders the modal inside a /preload/ frame, not the main document.
// page.$() works cross-frame, but evaluate() only runs in one frame's context.
// Find the frame that owns the modal so evaluate calls work correctly.
const modalFrame = await findModalFrame(page, MODAL);
const isPreloadFrame = modalFrame !== page;
if (isPreloadFrame) {
console.log(` Modal found in frame: ${modalFrame.url().slice(0, 80)}`);
}
// Step through modal
let lastProgress = '-1';
for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) {
const modalStillOpen = await modalFrame.$(MODAL);
if (!modalStillOpen) {
console.log(` ✅ Modal closed — submitted`);
return { status: 'submitted', meta };
}
const progress = await modalFrame.$eval(`${MODAL} [role="progressbar"]`,
el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || el.style?.width || ''
).catch(() => '');
// Debug snapshot: heading, buttons in modal, and any validation errors
const debugInfo = await modalFrame.evaluate((sel) => {
const modal = document.querySelector(sel);
if (!modal) return { heading: '', buttons: [], errors: [] };
const heading = modal.querySelector('h1, h2, h3, [class*="title"], [class*="heading"]')?.textContent?.trim()?.slice(0, 60) || '';
const buttons = Array.from(modal.querySelectorAll('button, [role="button"]')).map(b => ({
text: (b.innerText || b.textContent || '').trim().slice(0, 50),
aria: b.getAttribute('aria-label'),
disabled: b.disabled,
})).filter(b => b.text || b.aria);
const errors = Array.from(modal.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'))
.map(e => e.textContent?.trim().slice(0, 60)).filter(Boolean);
return { heading, buttons, errors };
}, MODAL).catch(() => ({ heading: '', buttons: [], errors: [] }));
console.log(` [step ${step}] progress=${progress} heading="${debugInfo.heading}" buttons=${JSON.stringify(debugInfo.buttons)}${debugInfo.errors.length ? ' errors=' + JSON.stringify(debugInfo.errors) : ''}`);
// Fill form fields — pass modalFrame so form_filler scopes to the correct frame
const unknowns = await formFiller.fill(modalFrame, formFiller.profile.resume_path);
if (unknowns.length > 0) console.log(` [step ${step}] unknown fields: ${JSON.stringify(unknowns.map(u => u.label || u))}`);
if (unknowns[0]?.honeypot) {
await dismissModal(page, modalFrame, MODAL);
return { status: 'skipped_honeypot', meta };
}
if (unknowns.length > 0) {
await dismissModal(page, modalFrame, MODAL);
return { status: 'needs_answer', pending_question: unknowns[0], meta };
}
await page.waitForTimeout(MODAL_STEP_WAIT);
// Check for validation errors after form fill — if LinkedIn shows errors,
// the form won't advance. Re-check errors AFTER fill since fill may have resolved them.
const postFillErrors = await modalFrame.evaluate((sel) => {
const modal = document.querySelector(sel);
if (!modal) return [];
return Array.from(modal.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'))
.map(e => e.textContent?.trim().slice(0, 80)).filter(Boolean);
}, MODAL).catch(() => []);
if (postFillErrors.length > 0) {
console.log(` [step ${step}] ❌ Validation errors after fill: ${JSON.stringify(postFillErrors)}`);
console.log(` Action: check answers.json or profile.json for missing/wrong answers`);
await dismissModal(page, modalFrame, MODAL);
return { status: 'incomplete', meta, validation_errors: postFillErrors };
}
// --- Button check order: Next → Review → Submit ---
// Check Next first — only fall through to Submit when there's no forward navigation.
// This prevents accidentally clicking a Submit-like element on early modal steps.
// Check for Next button
const nextBtn = await findModalButton(page, modalFrame, MODAL, {
ariaLabels: ['Continue to next step'],
exactTexts: ['Next'],
});
if (nextBtn) {
console.log(` [step ${step}] clicking Next`);
await nextBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(CLICK_WAIT);
lastProgress = progress;
continue;
}
// Check for Review button
const reviewBtn = await findModalButton(page, modalFrame, MODAL, {
ariaLabels: ['Review your application'],
exactTexts: ['Review'],
});
if (reviewBtn) {
console.log(` [step ${step}] clicking Review`);
await reviewBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(CLICK_WAIT);
lastProgress = progress;
continue;
}
// Check for Submit button (only when no Next/Review exists)
const submitBtn = await findModalButton(page, modalFrame, MODAL, {
ariaLabels: ['Submit application'],
exactTexts: ['Submit application'],
});
if (submitBtn) {
console.log(` [step ${step}] clicking Submit`);
await submitBtn.click({ timeout: APPLY_CLICK_TIMEOUT }).catch(() => {});
await page.waitForTimeout(SUBMIT_WAIT);
// Verify modal closed or success message appeared
const modalGone = !(await modalFrame.$(MODAL));
if (modalGone) {
console.log(` ✅ Submit confirmed — modal closed`);
return { status: 'submitted', meta };
}
// Modal still open — submit may have failed
console.log(` [step ${step}] ⚠️ Modal still open after Submit click`);
console.log(` Action: submit may have failed due to validation or network error`);
await dismissModal(page, modalFrame, MODAL);
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, modalFrame, MODAL);
return { status: 'stuck', meta };
}
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.`);
break;
}
await dismissModal(page, modalFrame, MODAL);
return { status: 'incomplete', meta };
}
/**
* Dismiss the Easy Apply modal.
* Tries multiple strategies: Dismiss button → Close/X → Escape key.
* Handles the "Discard" confirmation dialog that appears after Escape.
* @param {Page} page - Playwright page (for keyboard and cross-frame fallbacks)
* @param {Frame} frame - The frame containing the modal
* @param {string} modalSelector - CSS selector for the modal container
*/
async function dismissModal(page, frame, modalSelector) {
// Try aria-label Dismiss (search in the modal's frame)
const dismissBtn = await frame.$(`${modalSelector} button[aria-label="Dismiss"]`);
if (dismissBtn) {
await dismissBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
return;
}
// Try close/X button
const closeBtn = await frame.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`);
if (closeBtn) {
await closeBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
return;
}
// Fallback: Escape key (works regardless of frame)
await page.keyboard.press('Escape').catch(() => {});
// Handle "Discard" confirmation dialog — may appear in any frame
const discardBtn = await frame.waitForSelector(
'button[data-test-dialog-primary-btn]',
{ timeout: DISMISS_TIMEOUT, state: 'visible' }
).catch(() => null);
if (discardBtn) {
await discardBtn.click().catch(() => {});
return;
}
// Last resort: find Discard by exact text in the modal's frame
const handle = await frame.evaluateHandle(() => {
for (const b of document.querySelectorAll('button')) {
if ((b.innerText || '').trim().toLowerCase() === 'discard') return b;
}
return null;
}).catch(() => null);
const el = handle?.asElement();
if (el) await el.click().catch(() => {});
else await handle?.dispose().catch(() => {});
}