fix: robustness improvements — atomic writes, timeouts, shell injection, validation errors
- Atomic JSON writes (write-to-tmp + rename) prevent queue/log corruption
- Per-job (3min) and overall run (45min) timeouts prevent hangs
- execFileSync in ai_answer.mjs prevents shell injection with resume paths
- Validation error detection after form fill in Easy Apply modal
- Config-driven enabled_apply_types (from settings.json)
- isRequired() detects required/aria-required/label * patterns
- getLabel() strips trailing * from required field labels
- Actionable logging on failures ("Action: ..." messages)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,20 +16,25 @@ import { ANTHROPIC_API_URL } from './constants.mjs';
|
||||
export async function generateAnswer(question, profile, apiKey, job = {}) {
|
||||
if (!apiKey) return null;
|
||||
|
||||
// Read resume text if available
|
||||
// Read resume text if available — try pdftotext for PDFs, fall back to raw read
|
||||
let resumeText = '';
|
||||
if (profile.resume_path && existsSync(profile.resume_path)) {
|
||||
try {
|
||||
// Try to read as text — PDF will be garbled but still useful for key facts
|
||||
// If pdftotext is available, use it; otherwise skip
|
||||
const { execSync } = await import('child_process');
|
||||
// Only attempt pdftotext for .pdf files
|
||||
if (profile.resume_path.toLowerCase().endsWith('.pdf')) {
|
||||
try {
|
||||
resumeText = execSync(`pdftotext "${profile.resume_path}" -`, { timeout: 5000 }).toString().slice(0, 4000);
|
||||
const { execFileSync } = await import('child_process');
|
||||
// execFileSync avoids shell injection — args passed as array, not interpolated
|
||||
resumeText = execFileSync('pdftotext', [profile.resume_path, '-'], { timeout: 3000 }).toString().slice(0, 4000);
|
||||
} catch {
|
||||
// pdftotext not available — skip resume text
|
||||
// pdftotext not available or failed — skip
|
||||
}
|
||||
} else {
|
||||
// Plain text resume
|
||||
try {
|
||||
resumeText = readFileSync(profile.resume_path, 'utf8').slice(0, 4000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,9 @@ export async function apply(page, job, formFiller) {
|
||||
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 element found. Apply-related elements: ${JSON.stringify(applyEls)}`);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -97,7 +99,11 @@ export async function apply(page, job, formFiller) {
|
||||
// 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) return { status: 'no_modal', meta };
|
||||
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;
|
||||
|
||||
@@ -146,6 +152,22 @@ export async function apply(page, job, formFiller) {
|
||||
|
||||
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 page.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, 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.
|
||||
@@ -195,7 +217,8 @@ export async function apply(page, job, formFiller) {
|
||||
return { status: 'stuck', meta };
|
||||
}
|
||||
|
||||
console.log(` [step ${step}] no Next/Review/Submit found — breaking`);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,3 +87,7 @@ export const EXTERNAL_ATS_PATTERNS = [
|
||||
|
||||
// --- Queue ---
|
||||
export const DEFAULT_MAX_RETRIES = 2;
|
||||
|
||||
// --- Run limits ---
|
||||
export const APPLY_RUN_TIMEOUT_MS = 45 * 60 * 1000; // 45 minutes
|
||||
export const PER_JOB_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes per job
|
||||
|
||||
@@ -119,10 +119,30 @@ export class FormFiller {
|
||||
const ariaLabel = node.getAttribute('aria-label') || '';
|
||||
const ariaLabelledBy = node.getAttribute('aria-labelledby');
|
||||
const linked = ariaLabelledBy ? document.getElementById(ariaLabelledBy)?.textContent?.trim() : '';
|
||||
return forLabel || ariaLabel || linked || node.placeholder || node.name || '';
|
||||
// Clean up — remove trailing * from required field labels
|
||||
const raw = forLabel || ariaLabel || linked || node.placeholder || node.name || '';
|
||||
return raw.replace(/\s*\*\s*$/, '').trim();
|
||||
}).catch(() => '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form element is required.
|
||||
* LinkedIn uses multiple patterns: required attribute, aria-required, or * in label.
|
||||
*/
|
||||
async isRequired(el) {
|
||||
return await el.evaluate(node => {
|
||||
if (node.required || node.getAttribute('required') !== null) return true;
|
||||
if (node.getAttribute('aria-required') === 'true') return true;
|
||||
// Check if the associated label contains *
|
||||
const id = node.id;
|
||||
if (id) {
|
||||
const label = document.querySelector(`label[for="${id}"]`);
|
||||
if (label && label.textContent.includes('*')) return true;
|
||||
}
|
||||
return false;
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the first option from an autocomplete dropdown.
|
||||
* Waits for the dropdown to appear, then clicks the first option.
|
||||
@@ -181,8 +201,7 @@ export class FormFiller {
|
||||
await this.selectAutocomplete(page, inp);
|
||||
}
|
||||
} else if (!answer) {
|
||||
const required = await inp.getAttribute('required').catch(() => null);
|
||||
if (required !== null) unknown.push(lbl);
|
||||
if (await this.isRequired(inp)) unknown.push(lbl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +215,7 @@ export class FormFiller {
|
||||
if (answer) {
|
||||
await ta.fill(answer).catch(() => {});
|
||||
} else {
|
||||
const required = await ta.getAttribute('required').catch(() => null);
|
||||
if (required !== null) unknown.push(lbl);
|
||||
if (await this.isRequired(ta)) unknown.push(lbl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Handles jobs_queue.json read/write/update
|
||||
* Uses in-memory cache to avoid redundant disk I/O within a run.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -37,6 +37,17 @@ function ensureDir(path) {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write — writes to a temp file then renames.
|
||||
* Prevents corruption if two processes write simultaneously or the process
|
||||
* crashes mid-write. rename() is atomic on POSIX filesystems.
|
||||
*/
|
||||
function atomicWriteJSON(filePath, data) {
|
||||
const tmp = filePath + '.tmp';
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2));
|
||||
renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
// --- In-memory caches (populated on first read, invalidated on write) ---
|
||||
let _queueCache = null;
|
||||
let _logCache = null;
|
||||
@@ -50,7 +61,7 @@ export function loadQueue() {
|
||||
|
||||
export function saveQueue(jobs) {
|
||||
ensureDir(QUEUE_PATH);
|
||||
writeFileSync(QUEUE_PATH, JSON.stringify(jobs, null, 2));
|
||||
atomicWriteJSON(QUEUE_PATH, jobs);
|
||||
_queueCache = jobs;
|
||||
}
|
||||
|
||||
@@ -62,7 +73,8 @@ function loadLog() {
|
||||
}
|
||||
|
||||
function saveLog(log) {
|
||||
writeFileSync(LOG_PATH, JSON.stringify(log, null, 2));
|
||||
ensureDir(LOG_PATH);
|
||||
atomicWriteJSON(LOG_PATH, log);
|
||||
_logCache = log;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user