feat: lockfile to prevent parallel runs + AI keywords lib
This commit is contained in:
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'url';
|
|||||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
import { getJobsByStatus, updateJobStatus, appendLog, loadConfig } from './lib/queue.mjs';
|
import { getJobsByStatus, updateJobStatus, appendLog, loadConfig } from './lib/queue.mjs';
|
||||||
|
import { acquireLock } from './lib/lock.mjs';
|
||||||
import { createBrowser } from './lib/browser.mjs';
|
import { createBrowser } from './lib/browser.mjs';
|
||||||
import { FormFiller } from './lib/form_filler.mjs';
|
import { FormFiller } from './lib/form_filler.mjs';
|
||||||
import { verifyLogin as liLogin, applyLinkedIn } from './lib/linkedin.mjs';
|
import { verifyLogin as liLogin, applyLinkedIn } from './lib/linkedin.mjs';
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
const isPreview = process.argv.includes('--preview');
|
const isPreview = process.argv.includes('--preview');
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
acquireLock('applier', resolve(__dir, 'data'));
|
||||||
console.log('🚀 claw-apply: Job Applier starting\n');
|
console.log('🚀 claw-apply: Job Applier starting\n');
|
||||||
|
|
||||||
const settings = loadConfig(resolve(__dir, 'config/settings.json'));
|
const settings = loadConfig(resolve(__dir, 'config/settings.json'));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'url';
|
|||||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
import { addJobs, loadQueue, loadConfig } from './lib/queue.mjs';
|
import { addJobs, loadQueue, loadConfig } from './lib/queue.mjs';
|
||||||
|
import { acquireLock } from './lib/lock.mjs';
|
||||||
import { createBrowser } from './lib/browser.mjs';
|
import { createBrowser } from './lib/browser.mjs';
|
||||||
import { verifyLogin as liLogin, searchLinkedIn } from './lib/linkedin.mjs';
|
import { verifyLogin as liLogin, searchLinkedIn } from './lib/linkedin.mjs';
|
||||||
import { verifyLogin as wfLogin, searchWellfound } from './lib/wellfound.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';
|
import { generateKeywords } from './lib/keywords.mjs';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
acquireLock('searcher', resolve(__dir, 'data'));
|
||||||
console.log('🔍 claw-apply: Job Searcher starting\n');
|
console.log('🔍 claw-apply: Job Searcher starting\n');
|
||||||
|
|
||||||
// Load config
|
// Load config
|
||||||
|
|||||||
61
lib/keywords.mjs
Normal file
61
lib/keywords.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
34
lib/lock.mjs
Normal file
34
lib/lock.mjs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
31
test_ai_keywords.mjs
Normal file
31
test_ai_keywords.mjs
Normal file
@@ -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); });
|
||||||
Reference in New Issue
Block a user