diff --git a/job_applier.mjs b/job_applier.mjs index 45fe93c..8d599ba 100644 --- a/job_applier.mjs +++ b/job_applier.mjs @@ -11,6 +11,7 @@ import { fileURLToPath } from 'url'; const __dir = dirname(fileURLToPath(import.meta.url)); import { getJobsByStatus, updateJobStatus, appendLog, loadConfig } from './lib/queue.mjs'; +import { acquireLock } from './lib/lock.mjs'; import { createBrowser } from './lib/browser.mjs'; import { FormFiller } from './lib/form_filler.mjs'; import { verifyLogin as liLogin, applyLinkedIn } from './lib/linkedin.mjs'; @@ -25,6 +26,7 @@ import { const isPreview = process.argv.includes('--preview'); async function main() { + acquireLock('applier', resolve(__dir, 'data')); console.log('šŸš€ claw-apply: Job Applier starting\n'); const settings = loadConfig(resolve(__dir, 'config/settings.json')); diff --git a/job_searcher.mjs b/job_searcher.mjs index e01e317..158dd09 100644 --- a/job_searcher.mjs +++ b/job_searcher.mjs @@ -10,6 +10,7 @@ import { fileURLToPath } from 'url'; const __dir = dirname(fileURLToPath(import.meta.url)); import { addJobs, loadQueue, loadConfig } from './lib/queue.mjs'; +import { acquireLock } from './lib/lock.mjs'; import { createBrowser } from './lib/browser.mjs'; import { verifyLogin as liLogin, searchLinkedIn } from './lib/linkedin.mjs'; import { verifyLogin as wfLogin, searchWellfound } from './lib/wellfound.mjs'; @@ -18,6 +19,7 @@ import { DEFAULT_FIRST_RUN_DAYS } from './lib/constants.mjs'; import { generateKeywords } from './lib/keywords.mjs'; async function main() { + acquireLock('searcher', resolve(__dir, 'data')); console.log('šŸ” claw-apply: Job Searcher starting\n'); // Load config diff --git a/lib/keywords.mjs b/lib/keywords.mjs new file mode 100644 index 0000000..e7741cf --- /dev/null +++ b/lib/keywords.mjs @@ -0,0 +1,61 @@ +/** + * keywords.mjs — AI-generated search keywords + * One Claude call per search track using full profile + search config context + */ + +export async function generateKeywords(search, profile, apiKey) { + if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set'); + + const prompt = `You are an expert job search strategist helping a candidate find the right roles on LinkedIn and Wellfound. + +## Candidate Profile +- Name: ${profile.name.first} ${profile.name.last} +- Location: ${profile.location.city}, ${profile.location.state} (remote only) +- Years experience: ${profile.years_experience} +- Desired salary: $${profile.desired_salary.toLocaleString()} +- Work authorization: Authorized to work in US + UK, no sponsorship needed +- Willing to relocate: ${profile.willing_to_relocate ? 'Yes' : 'No'} +- Background summary: ${profile.cover_letter?.substring(0, 400)} + +## Job Search Track: "${search.name}" +- Salary minimum: $${(search.salary_min || 0).toLocaleString()} +- Platforms: ${(search.platforms || []).join(', ')} +- Remote only: ${search.filters?.remote ? 'Yes' : 'No'} +- Exclude these keywords: ${(search.exclude_keywords || []).join(', ')} +- Current keywords already in use: ${(search.keywords || []).join(', ')} + +## Task +Generate 15 additional LinkedIn/Wellfound job search query strings to find "${search.name}" roles for this candidate. + +Think about: +- How do startups and hiring managers actually title these roles at seed/Series A/B companies? +- What variations exist across industries (fintech, devtools, data infra, security, AI/ML)? +- What seniority + function combinations surface the best matches? +- What terms does this specific candidate's background match well? +- Do NOT repeat keywords already listed above +- Do NOT use excluded keywords + +Return ONLY a JSON array of strings, no explanation, no markdown. +Example format: ["query one", "query two", "query three"]`; + + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: 'claude-3-haiku-20240307', + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }] + }) + }); + + const data = await res.json(); + if (data.error) throw new Error(data.error.message); + + const text = data.content[0].text.trim(); + const clean = text.replace(/```json\n?|\n?```/g, '').trim(); + return JSON.parse(clean); +} diff --git a/lib/lock.mjs b/lib/lock.mjs new file mode 100644 index 0000000..13a50b8 --- /dev/null +++ b/lib/lock.mjs @@ -0,0 +1,34 @@ +/** + * lock.mjs — Simple PID-based lockfile to prevent parallel runs + */ +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { resolve } from 'path'; + +export function acquireLock(name, dataDir) { + const lockFile = resolve(dataDir, `${name}.lock`); + + if (existsSync(lockFile)) { + const pid = parseInt(readFileSync(lockFile, 'utf8').trim(), 10); + // Check if that process is still alive + try { + process.kill(pid, 0); // signal 0 = check existence only + console.error(`āš ļø ${name} already running (PID ${pid}). Exiting.`); + process.exit(0); + } catch { + // Stale lock — process is gone, safe to continue + console.warn(`šŸ”“ Stale lock found (PID ${pid}), clearing.`); + } + } + + writeFileSync(lockFile, String(process.pid)); + + // Release lock on exit (normal or crash) + const release = () => { + try { unlinkSync(lockFile); } catch {} + }; + process.on('exit', release); + process.on('SIGINT', () => { release(); process.exit(130); }); + process.on('SIGTERM', () => { release(); process.exit(143); }); + + return release; +} diff --git a/test_ai_keywords.mjs b/test_ai_keywords.mjs new file mode 100644 index 0000000..140f44f --- /dev/null +++ b/test_ai_keywords.mjs @@ -0,0 +1,31 @@ +/** + * test_ai_keywords.mjs — Test AI-generated search terms with full context + */ +import { readFileSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { generateKeywords } from './lib/keywords.mjs'; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const profile = JSON.parse(readFileSync(resolve(__dir, 'config/profile.json'), 'utf8')); +const searchConfig = JSON.parse(readFileSync(resolve(__dir, 'config/search_config.json'), 'utf8')); +const apiKey = process.env.ANTHROPIC_API_KEY; + +async function main() { + console.log('šŸ¤– AI keyword generation — full context test\n'); + + for (const search of searchConfig.searches) { + console.log(`\n━━ ${search.name} ━━`); + console.log('Static keywords:', search.keywords.join(', ')); + console.log('\nGenerating AI keywords...'); + + const keywords = await generateKeywords(search, profile, apiKey); + console.log(`\nAI generated (${keywords.length}):`); + keywords.forEach((k, i) => console.log(` ${i+1}. "${k}"`)); + + const merged = [...new Set([...search.keywords, ...keywords])]; + console.log(`\nMerged total: ${merged.length} unique queries`); + } +} + +main().catch(e => { console.error('Error:', e.message); process.exit(1); });