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>
This commit is contained in:
@@ -21,33 +21,49 @@ import {
|
|||||||
|
|
||||||
export const SUPPORTED_TYPES = ['easy_apply'];
|
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.
|
* Find a non-disabled button inside the modal using multiple strategies.
|
||||||
* @param {Page} page - Playwright page
|
* @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 {string} modalSelector - CSS selector for the modal container
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
* @param {string[]} opts.ariaLabels - aria-label values to try (exact then substring)
|
* @param {string[]} opts.ariaLabels - aria-label values to try (exact then substring)
|
||||||
* @param {string[]} opts.exactTexts - exact button text matches (case-insensitive, trimmed)
|
* @param {string[]} opts.exactTexts - exact button text matches (case-insensitive, trimmed)
|
||||||
* @returns {ElementHandle|null}
|
* @returns {ElementHandle|null}
|
||||||
*/
|
*/
|
||||||
async function findModalButton(page, modalSelector, { ariaLabels = [], exactTexts = [] }) {
|
async function findModalButton(page, frame, modalSelector, { ariaLabels = [], exactTexts = [] }) {
|
||||||
// Strategy 1: aria-label exact match inside modal (non-disabled only)
|
// 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) {
|
for (const label of ariaLabels) {
|
||||||
const btn = await page.$(`${modalSelector} button[aria-label="${label}"]:not([disabled])`);
|
const btn = await frame.$(`${modalSelector} button[aria-label="${label}"]:not([disabled])`);
|
||||||
if (btn) return btn;
|
if (btn) return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: aria-label substring match inside modal
|
// Strategy 2: aria-label substring match inside modal
|
||||||
for (const label of ariaLabels) {
|
for (const label of ariaLabels) {
|
||||||
const btn = await page.$(`${modalSelector} button[aria-label*="${label}"]:not([disabled])`);
|
const btn = await frame.$(`${modalSelector} button[aria-label*="${label}"]:not([disabled])`);
|
||||||
if (btn) return btn;
|
if (btn) return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: exact text match on button innerText (case-insensitive, trimmed)
|
// Strategy 3: exact text match on button innerText (case-insensitive, trimmed)
|
||||||
// Uses evaluateHandle to return a live DOM reference
|
// Uses evaluateHandle on the correct frame to return a live DOM reference
|
||||||
if (exactTexts.length === 0) return null;
|
if (exactTexts.length === 0) return null;
|
||||||
|
|
||||||
const handle = await page.evaluateHandle((sel, texts) => {
|
const handle = await frame.evaluateHandle((sel, texts) => {
|
||||||
const modal = document.querySelector(sel);
|
const modal = document.querySelector(sel);
|
||||||
if (!modal) return null;
|
if (!modal) return null;
|
||||||
const targets = texts.map(t => t.toLowerCase());
|
const targets = texts.map(t => t.toLowerCase());
|
||||||
@@ -107,23 +123,32 @@ export async function apply(page, job, formFiller) {
|
|||||||
|
|
||||||
const MODAL = LINKEDIN_EASY_APPLY_MODAL_SELECTOR;
|
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
|
// Step through modal
|
||||||
let lastProgress = '-1';
|
let lastProgress = '-1';
|
||||||
for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) {
|
for (let step = 0; step < LINKEDIN_MAX_MODAL_STEPS; step++) {
|
||||||
const modalStillOpen = await page.$(MODAL);
|
const modalStillOpen = await modalFrame.$(MODAL);
|
||||||
if (!modalStillOpen) {
|
if (!modalStillOpen) {
|
||||||
console.log(` ✅ Modal closed — submitted`);
|
console.log(` ✅ Modal closed — submitted`);
|
||||||
return { status: 'submitted', meta };
|
return { status: 'submitted', meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = await page.$eval(`${MODAL} [role="progressbar"]`,
|
const progress = await modalFrame.$eval(`${MODAL} [role="progressbar"]`,
|
||||||
el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || el.style?.width || ''
|
el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || el.style?.width || ''
|
||||||
).catch(() => '');
|
).catch(() => '');
|
||||||
|
|
||||||
// Debug snapshot: heading, buttons in modal, iframes, and any validation errors
|
// Debug snapshot: heading, buttons in modal, and any validation errors
|
||||||
const debugInfo = await page.evaluate((sel) => {
|
const debugInfo = await modalFrame.evaluate((sel) => {
|
||||||
const modal = document.querySelector(sel);
|
const modal = document.querySelector(sel);
|
||||||
if (!modal) return { heading: '', buttons: [], errors: [], iframes: 0, childTags: '', htmlSnippet: '' };
|
if (!modal) return { heading: '', buttons: [], errors: [] };
|
||||||
const heading = modal.querySelector('h1, h2, h3, [class*="title"], [class*="heading"]')?.textContent?.trim()?.slice(0, 60) || '';
|
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 => ({
|
const buttons = Array.from(modal.querySelectorAll('button, [role="button"]')).map(b => ({
|
||||||
text: (b.innerText || b.textContent || '').trim().slice(0, 50),
|
text: (b.innerText || b.textContent || '').trim().slice(0, 50),
|
||||||
@@ -132,29 +157,21 @@ export async function apply(page, job, formFiller) {
|
|||||||
})).filter(b => b.text || b.aria);
|
})).filter(b => b.text || b.aria);
|
||||||
const errors = Array.from(modal.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'))
|
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);
|
.map(e => e.textContent?.trim().slice(0, 60)).filter(Boolean);
|
||||||
const iframes = modal.querySelectorAll('iframe').length;
|
return { heading, buttons, errors };
|
||||||
// Top-level child tags for structure debugging
|
}, MODAL).catch(() => ({ heading: '', buttons: [], errors: [] }));
|
||||||
const childTags = Array.from(modal.children).map(c => c.tagName.toLowerCase() + (c.className ? '.' + c.className.split(' ')[0] : '')).join(', ');
|
|
||||||
const htmlSnippet = modal.innerHTML.slice(0, 500);
|
|
||||||
return { heading, buttons, errors, iframes, childTags, htmlSnippet };
|
|
||||||
}, MODAL).catch(() => ({ heading: '', buttons: [], errors: [], iframes: 0, childTags: '', htmlSnippet: '' }));
|
|
||||||
console.log(` [step ${step}] progress=${progress} heading="${debugInfo.heading}" buttons=${JSON.stringify(debugInfo.buttons)}${debugInfo.errors.length ? ' errors=' + JSON.stringify(debugInfo.errors) : ''}`);
|
console.log(` [step ${step}] progress=${progress} heading="${debugInfo.heading}" buttons=${JSON.stringify(debugInfo.buttons)}${debugInfo.errors.length ? ' errors=' + JSON.stringify(debugInfo.errors) : ''}`);
|
||||||
if (step === 0) {
|
|
||||||
console.log(` [diag] iframes=${debugInfo.iframes} children=[${debugInfo.childTags}]`);
|
|
||||||
console.log(` [diag] html=${debugInfo.htmlSnippet}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill form fields
|
// Fill form fields — pass modalFrame so form_filler scopes to the correct frame
|
||||||
const unknowns = await formFiller.fill(page, formFiller.profile.resume_path);
|
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.length > 0) console.log(` [step ${step}] unknown fields: ${JSON.stringify(unknowns.map(u => u.label || u))}`);
|
||||||
|
|
||||||
if (unknowns[0]?.honeypot) {
|
if (unknowns[0]?.honeypot) {
|
||||||
await dismissModal(page, MODAL);
|
await dismissModal(page, modalFrame, MODAL);
|
||||||
return { status: 'skipped_honeypot', meta };
|
return { status: 'skipped_honeypot', meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unknowns.length > 0) {
|
if (unknowns.length > 0) {
|
||||||
await dismissModal(page, MODAL);
|
await dismissModal(page, modalFrame, MODAL);
|
||||||
return { status: 'needs_answer', pending_question: unknowns[0], meta };
|
return { status: 'needs_answer', pending_question: unknowns[0], meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +179,7 @@ export async function apply(page, job, formFiller) {
|
|||||||
|
|
||||||
// Check for validation errors after form fill — if LinkedIn shows errors,
|
// 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.
|
// the form won't advance. Re-check errors AFTER fill since fill may have resolved them.
|
||||||
const postFillErrors = await page.evaluate((sel) => {
|
const postFillErrors = await modalFrame.evaluate((sel) => {
|
||||||
const modal = document.querySelector(sel);
|
const modal = document.querySelector(sel);
|
||||||
if (!modal) return [];
|
if (!modal) return [];
|
||||||
return Array.from(modal.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'))
|
return Array.from(modal.querySelectorAll('[class*="error"], [aria-invalid="true"], .artdeco-inline-feedback--error'))
|
||||||
@@ -172,7 +189,7 @@ export async function apply(page, job, formFiller) {
|
|||||||
if (postFillErrors.length > 0) {
|
if (postFillErrors.length > 0) {
|
||||||
console.log(` [step ${step}] ❌ Validation errors after fill: ${JSON.stringify(postFillErrors)}`);
|
console.log(` [step ${step}] ❌ Validation errors after fill: ${JSON.stringify(postFillErrors)}`);
|
||||||
console.log(` Action: check answers.json or profile.json for missing/wrong answers`);
|
console.log(` Action: check answers.json or profile.json for missing/wrong answers`);
|
||||||
await dismissModal(page, MODAL);
|
await dismissModal(page, modalFrame, MODAL);
|
||||||
return { status: 'incomplete', meta, validation_errors: postFillErrors };
|
return { status: 'incomplete', meta, validation_errors: postFillErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +198,7 @@ export async function apply(page, job, formFiller) {
|
|||||||
// This prevents accidentally clicking a Submit-like element on early modal steps.
|
// This prevents accidentally clicking a Submit-like element on early modal steps.
|
||||||
|
|
||||||
// Check for Next button
|
// Check for Next button
|
||||||
const nextBtn = await findModalButton(page, MODAL, {
|
const nextBtn = await findModalButton(page, modalFrame, MODAL, {
|
||||||
ariaLabels: ['Continue to next step'],
|
ariaLabels: ['Continue to next step'],
|
||||||
exactTexts: ['Next'],
|
exactTexts: ['Next'],
|
||||||
});
|
});
|
||||||
@@ -194,7 +211,7 @@ export async function apply(page, job, formFiller) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for Review button
|
// Check for Review button
|
||||||
const reviewBtn = await findModalButton(page, MODAL, {
|
const reviewBtn = await findModalButton(page, modalFrame, MODAL, {
|
||||||
ariaLabels: ['Review your application'],
|
ariaLabels: ['Review your application'],
|
||||||
exactTexts: ['Review'],
|
exactTexts: ['Review'],
|
||||||
});
|
});
|
||||||
@@ -207,7 +224,7 @@ export async function apply(page, job, formFiller) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for Submit button (only when no Next/Review exists)
|
// Check for Submit button (only when no Next/Review exists)
|
||||||
const submitBtn = await findModalButton(page, MODAL, {
|
const submitBtn = await findModalButton(page, modalFrame, MODAL, {
|
||||||
ariaLabels: ['Submit application'],
|
ariaLabels: ['Submit application'],
|
||||||
exactTexts: ['Submit application'],
|
exactTexts: ['Submit application'],
|
||||||
});
|
});
|
||||||
@@ -217,9 +234,8 @@ export async function apply(page, job, formFiller) {
|
|||||||
await page.waitForTimeout(SUBMIT_WAIT);
|
await page.waitForTimeout(SUBMIT_WAIT);
|
||||||
|
|
||||||
// Verify modal closed or success message appeared
|
// Verify modal closed or success message appeared
|
||||||
const modalGone = !(await page.$(MODAL));
|
const modalGone = !(await modalFrame.$(MODAL));
|
||||||
const successVisible = await page.$('[class*="success"], [class*="confirmation"], [aria-label*="applied"]').catch(() => null);
|
if (modalGone) {
|
||||||
if (modalGone || successVisible) {
|
|
||||||
console.log(` ✅ Submit confirmed — modal closed`);
|
console.log(` ✅ Submit confirmed — modal closed`);
|
||||||
return { status: 'submitted', meta };
|
return { status: 'submitted', meta };
|
||||||
}
|
}
|
||||||
@@ -227,14 +243,14 @@ export async function apply(page, job, formFiller) {
|
|||||||
// Modal still open — submit may have failed
|
// Modal still open — submit may have failed
|
||||||
console.log(` [step ${step}] ⚠️ Modal still open after Submit click`);
|
console.log(` [step ${step}] ⚠️ Modal still open after Submit click`);
|
||||||
console.log(` Action: submit may have failed due to validation or network error`);
|
console.log(` Action: submit may have failed due to validation or network error`);
|
||||||
await dismissModal(page, MODAL);
|
await dismissModal(page, modalFrame, MODAL);
|
||||||
return { status: 'incomplete', meta };
|
return { status: 'incomplete', meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stuck detection — progress hasn't changed and we've been through a few steps
|
// Stuck detection — progress hasn't changed and we've been through a few steps
|
||||||
if (progress && progress === lastProgress && step > 2) {
|
if (progress && progress === lastProgress && step > 2) {
|
||||||
console.log(` [step ${step}] stuck — progress unchanged at ${progress}`);
|
console.log(` [step ${step}] stuck — progress unchanged at ${progress}`);
|
||||||
await dismissModal(page, MODAL);
|
await dismissModal(page, modalFrame, MODAL);
|
||||||
return { status: 'stuck', meta };
|
return { status: 'stuck', meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +259,7 @@ export async function apply(page, job, formFiller) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await dismissModal(page, MODAL);
|
await dismissModal(page, modalFrame, MODAL);
|
||||||
return { status: 'incomplete', meta };
|
return { status: 'incomplete', meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,27 +267,30 @@ export async function apply(page, job, formFiller) {
|
|||||||
* Dismiss the Easy Apply modal.
|
* Dismiss the Easy Apply modal.
|
||||||
* Tries multiple strategies: Dismiss button → Close/X → Escape key.
|
* Tries multiple strategies: Dismiss button → Close/X → Escape key.
|
||||||
* Handles the "Discard" confirmation dialog that appears after Escape.
|
* 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, modalSelector) {
|
async function dismissModal(page, frame, modalSelector) {
|
||||||
// Try aria-label Dismiss
|
// Try aria-label Dismiss (search in the modal's frame)
|
||||||
const dismissBtn = await page.$(`${modalSelector} button[aria-label="Dismiss"]`);
|
const dismissBtn = await frame.$(`${modalSelector} button[aria-label="Dismiss"]`);
|
||||||
if (dismissBtn) {
|
if (dismissBtn) {
|
||||||
await dismissBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
|
await dismissBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try close/X button
|
// Try close/X button
|
||||||
const closeBtn = await page.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`);
|
const closeBtn = await frame.$(`${modalSelector} button[aria-label="Close"], ${modalSelector} button[aria-label*="close"]`);
|
||||||
if (closeBtn) {
|
if (closeBtn) {
|
||||||
await closeBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
|
await closeBtn.click({ timeout: DISMISS_TIMEOUT }).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Escape key
|
// Fallback: Escape key (works regardless of frame)
|
||||||
await page.keyboard.press('Escape').catch(() => {});
|
await page.keyboard.press('Escape').catch(() => {});
|
||||||
|
|
||||||
// Handle "Discard" confirmation dialog that may appear after Escape
|
// Handle "Discard" confirmation dialog — may appear in any frame
|
||||||
const discardBtn = await page.waitForSelector(
|
const discardBtn = await frame.waitForSelector(
|
||||||
'button[data-test-dialog-primary-btn]',
|
'button[data-test-dialog-primary-btn]',
|
||||||
{ timeout: DISMISS_TIMEOUT, state: 'visible' }
|
{ timeout: DISMISS_TIMEOUT, state: 'visible' }
|
||||||
).catch(() => null);
|
).catch(() => null);
|
||||||
@@ -280,8 +299,8 @@ async function dismissModal(page, modalSelector) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: find Discard by exact text
|
// Last resort: find Discard by exact text in the modal's frame
|
||||||
const handle = await page.evaluateHandle(() => {
|
const handle = await frame.evaluateHandle(() => {
|
||||||
for (const b of document.querySelectorAll('button')) {
|
for (const b of document.querySelectorAll('button')) {
|
||||||
if ((b.innerText || '').trim().toLowerCase() === 'discard') return b;
|
if ((b.innerText || '').trim().toLowerCase() === 'discard') return b;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user