refactor: extract magic numbers to constants, fix audit issues

- Centralize all magic numbers/strings in lib/constants.mjs
- Fix double-replaced import names in filter.mjs
- Consolidate duplicate fs imports in job_applier/job_searcher
- Remove empty JSDoc block in job_searcher
- Update keywords.mjs model from claude-3-haiku to claude-haiku-4-5
- Extract Anthropic API URLs to constants
- Convert :has-text() selectors to page.locator() API
- Fix SIGTERM handler conflict — move partial-run notification into lock.onShutdown
- Remove unused exports (LOCAL_USER_AGENT, DEFAULT_REVIEW_WINDOW_MINUTES)
- Fix variable shadowing (b -> v) in job_filter reduce callback
- Replace SKILL.md PM2 references with system cron

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 08:45:09 -08:00
parent 3a52bdc72e
commit b1528ac0ad
12 changed files with 67 additions and 80 deletions

View File

@@ -92,40 +92,16 @@ Setup will:
- Send a Telegram test message
- Test LinkedIn + Wellfound logins
### 6. Schedule with PM2
PM2 is a Node.js process manager that runs the searcher and applier as proper system daemons — no SIGTERM issues, survives reboots.
```bash
# Install PM2
npm install -g pm2
# Start both jobs (searcher runs immediately + hourly; applier stopped by default)
pm2 start ecosystem.config.cjs
pm2 stop claw-applier # keep applier off until you're ready
# Survive reboots
pm2 save
pm2 startup # follow the printed command (requires sudo)
```
PM2 manages the processes but **does not schedule them** — scheduling is handled by system cron. This ensures a running searcher is never killed mid-run. If it's already running, the cron invocation hits the lockfile and exits immediately.
### 6. Schedule with cron
Add to crontab (`crontab -e`):
```
0 */12 * * * cd /path/to/claw-apply && node job_searcher.mjs >> /tmp/claw-searcher.log 2>&1
0 */6 * * * cd /path/to/claw-apply && node job_applier.mjs >> /tmp/claw-applier.log 2>&1
```
**PM2 cheatsheet:**
```bash
pm2 list # show all processes + status
pm2 logs claw-searcher # tail searcher logs
pm2 logs claw-applier # tail applier logs
pm2 start claw-searcher # run searcher now
pm2 start claw-applier # run applier now
pm2 stop claw-applier # stop applier mid-run
```
The lockfile mechanism ensures only one instance runs at a time — if a searcher is already running, the cron invocation exits immediately.
### 7. Run manually

View File

@@ -6,8 +6,7 @@ loadEnv(); // load .env before anything else
* Reads jobs queue and applies using the appropriate handler per apply_type
* Run via cron or manually: node job_applier.mjs [--preview]
*/
import { existsSync } from 'fs';
import { writeFileSync } from 'fs';
import { existsSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';

View File

@@ -26,11 +26,11 @@ const __dir = dirname(fileURLToPath(import.meta.url));
import { getJobsByStatus, updateJobStatus, loadConfig, loadQueue, saveQueue, dedupeAfterFilter } from './lib/queue.mjs';
import { loadProfile, submitBatches, checkBatch, downloadResults } from './lib/filter.mjs';
import { sendTelegram } from './lib/notify.mjs';
import { DEFAULT_FILTER_MODEL, DEFAULT_FILTER_MIN_SCORE } from './lib/constants.mjs';
const isStats = process.argv.includes('--stats');
const STATE_PATH = resolve(__dir, 'data/filter_state.json');
const DEFAULT_MODEL = 'claude-sonnet-4-6-20251101';
// ---------------------------------------------------------------------------
// State helpers
@@ -69,9 +69,10 @@ function showStats() {
const state = readState();
if (state) {
console.log(` Pending batch: ${state.batch_id}`);
console.log(` Submitted: ${state.submitted_at}`);
console.log(` Job count: ${state.job_count}\n`);
const batchIds = state.batches?.map(b => b.batchId).join(', ') || 'none';
console.log(` Pending batches: ${batchIds}`);
console.log(` Submitted: ${state.submitted_at}`);
console.log(` Job count: ${state.job_count}\n`);
}
if (filtered.length > 0) {
@@ -97,7 +98,7 @@ async function collect(state, settings) {
b._status = status;
b._counts = counts;
if (status === 'in_progress') {
const total = Object.values(counts).reduce((a, b) => a + b, 0);
const total = Object.values(counts).reduce((a, v) => a + v, 0);
const done = (counts.succeeded || 0) + (counts.errored || 0) + (counts.canceled || 0) + (counts.expired || 0);
console.log(` [${b.track}] Still processing — ${done}/${total} complete`);
allDone = false;
@@ -124,7 +125,7 @@ async function collect(state, settings) {
}
const searchConfig = loadConfig(resolve(__dir, 'config/search_config.json'));
const globalMin = searchConfig.filter_min_score ?? 5;
const globalMin = searchConfig.filter_min_score ?? DEFAULT_FILTER_MIN_SCORE;
let passed = 0, filtered = 0, errors = 0;
const queue = loadQueue();
@@ -222,7 +223,7 @@ async function submit(settings, searchConfig, candidateProfile) {
return;
}
const model = settings.filter?.model || DEFAULT_MODEL;
const model = settings.filter?.model || DEFAULT_FILTER_MODEL;
const submittedAt = new Date().toISOString();
console.log(`🚀 Submitting batches — ${filterable.length} jobs across ${Object.keys(jobProfilesByTrack).length} tracks, model: ${model}`);

View File

@@ -6,8 +6,7 @@
*/
import { loadEnv } from './lib/env.mjs';
loadEnv(); // load .env before anything else
/**
*/
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
@@ -35,15 +34,6 @@ async function main() {
const settings = loadConfig(resolve(__dir, 'config/settings.json'));
// Send notification even if SIGTERM kills the process mid-run
process.once('SIGTERM', async () => {
if (totalAdded > 0) {
const summary = formatSearchSummary(totalAdded, totalSeen - totalAdded, platformsRun.length ? platformsRun : ['LinkedIn']);
await sendTelegram(settings, summary + '\n_(partial run — timed out)_').catch(() => {});
}
process.exit(0);
});
const writeLastRun = (finished = false) => {
const entry = {
started_at: startedAt,
@@ -68,9 +58,13 @@ async function main() {
writeFileSync(runsPath, JSON.stringify(runs, null, 2));
};
lock.onShutdown(() => {
lock.onShutdown(async () => {
console.log(' Writing partial results to last-run file...');
writeLastRun(false);
if (totalAdded > 0) {
const summary = formatSearchSummary(totalAdded, totalSeen - totalAdded, platformsRun.length ? platformsRun : ['LinkedIn']);
await sendTelegram(settings, summary + '\n_(partial run — interrupted)_').catch(() => {});
}
});
// Load config

View File

@@ -16,8 +16,8 @@ export async function apply(page, job, formFiller) {
company: document.querySelector('[class*="company"] h2, [class*="startup"] h2, h2')?.textContent?.trim(),
}));
const applyBtn = await page.$('a:has-text("Apply"), button:has-text("Apply Now"), a:has-text("Apply Now")');
if (!applyBtn) return { status: 'no_button', meta };
const applyBtn = page.locator('a:has-text("Apply"), button:has-text("Apply Now"), a:has-text("Apply Now")').first();
if (await applyBtn.count() === 0) return { status: 'no_button', meta };
await applyBtn.click();
await page.waitForTimeout(FORM_FILL_WAIT);

View File

@@ -35,18 +35,36 @@ export const LINKEDIN_MAX_MODAL_STEPS = 12;
export const WELLFOUND_BASE = 'https://wellfound.com';
// --- Browser ---
export const LOCAL_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
export const KERNEL_SDK_PATH = '/home/ubuntu/.openclaw/workspace/node_modules/@onkernel/sdk/index.js';
export const DEFAULT_PLAYWRIGHT_PATH = '/home/ubuntu/.npm-global/lib/node_modules/playwright/index.mjs';
// --- Search ---
export const LINKEDIN_MAX_SEARCH_PAGES = 40;
export const WELLFOUND_MAX_INFINITE_SCROLL = 10;
export const LINKEDIN_SECONDS_PER_DAY = 86400;
// --- Session ---
export const SESSION_REFRESH_POLL_TIMEOUT = 30000;
export const SESSION_REFRESH_POLL_WAIT = 2000;
export const SESSION_LOGIN_VERIFY_WAIT = 3000;
// --- Form Filler Defaults ---
export const DEFAULT_YEARS_EXPERIENCE = 7;
export const DEFAULT_DESIRED_SALARY = 150000;
export const MINIMUM_SALARY_FACTOR = 0.85;
export const DEFAULT_SKILL_RATING = '8';
export const FORM_PATTERN_MAX_LENGTH = 200;
export const DEFAULT_FIRST_RUN_DAYS = 90;
export const SEARCH_RESULTS_MAX = 30;
// --- Anthropic API ---
export const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
export const ANTHROPIC_BATCH_API_URL = 'https://api.anthropic.com/v1/messages/batches';
export const FILTER_DESC_MAX_CHARS = 800;
export const FILTER_BATCH_MAX_TOKENS = 1024;
export const DEFAULT_FILTER_MODEL = 'claude-sonnet-4-6-20251101';
export const DEFAULT_FILTER_MIN_SCORE = 5;
// --- Notification ---
export const TELEGRAM_API_BASE = 'https://api.telegram.org/bot';
export const NOTIFY_RATE_LIMIT_MS = 1500;
@@ -68,5 +86,4 @@ export const EXTERNAL_ATS_PATTERNS = [
];
// --- Queue ---
export const DEFAULT_REVIEW_WINDOW_MINUTES = 30;
export const DEFAULT_MAX_RETRIES = 2;

View File

@@ -5,9 +5,9 @@
*/
import { readFileSync, existsSync } from 'fs';
const DESC_MAX_CHARS = 800;
const BATCH_API = 'https://api.anthropic.com/v1/messages/batches';
import {
ANTHROPIC_BATCH_API_URL, FILTER_DESC_MAX_CHARS, FILTER_BATCH_MAX_TOKENS
} from './constants.mjs';
// ---------------------------------------------------------------------------
// Helpers
@@ -43,7 +43,7 @@ function sanitize(str) {
}
function buildJobMessage(job) {
const desc = sanitize(job.description).substring(0, DESC_MAX_CHARS).replace(/\s+/g, ' ').trim();
const desc = sanitize(job.description).substring(0, FILTER_DESC_MAX_CHARS).replace(/\s+/g, ' ').trim();
return `Title: ${sanitize(job.title)}
Company: ${sanitize(job.company) || 'Unknown'}
Location: ${sanitize(job.location) || 'Unknown'}
@@ -99,14 +99,14 @@ export async function submitBatches(jobs, jobProfilesByTrack, candidateProfile,
custom_id: customId,
params: {
model,
max_tokens: 1024,
max_tokens: FILTER_BATCH_MAX_TOKENS,
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
messages: [{ role: 'user', content: buildJobMessage(job) }],
}
});
}
const res = await fetch(BATCH_API, {
const res = await fetch(ANTHROPIC_BATCH_API_URL, {
method: 'POST',
headers: apiHeaders(apiKey),
body: JSON.stringify({ requests }),
@@ -129,7 +129,7 @@ export async function submitBatches(jobs, jobProfilesByTrack, candidateProfile,
* Check batch status. Returns { status: 'in_progress'|'ended', counts }
*/
export async function checkBatch(batchId, apiKey) {
const res = await fetch(`${BATCH_API}/${batchId}`, {
const res = await fetch(`${ANTHROPIC_BATCH_API_URL}/${batchId}`, {
headers: apiHeaders(apiKey),
});
@@ -155,7 +155,7 @@ const PRICING = {
};
export async function downloadResults(batchId, apiKey, idMap = {}) {
const res = await fetch(`${BATCH_API}/${batchId}/results`, {
const res = await fetch(`${ANTHROPIC_BATCH_API_URL}/${batchId}/results`, {
headers: apiHeaders(apiKey),
});

View File

@@ -6,7 +6,7 @@
import {
DEFAULT_YEARS_EXPERIENCE, DEFAULT_DESIRED_SALARY,
MINIMUM_SALARY_FACTOR, DEFAULT_SKILL_RATING,
LINKEDIN_EASY_APPLY_MODAL_SELECTOR
LINKEDIN_EASY_APPLY_MODAL_SELECTOR, FORM_PATTERN_MAX_LENGTH
} from './constants.mjs';
export class FormFiller {
@@ -23,7 +23,7 @@ export class FormFiller {
// Check custom answers first (user-defined, pattern is substring or regex)
for (const entry of this.answers) {
try {
if (entry.pattern.length > 200) throw new Error('pattern too long');
if (entry.pattern.length > FORM_PATTERN_MAX_LENGTH) throw new Error('pattern too long');
const re = new RegExp(entry.pattern, 'i');
if (re.test(l)) return String(entry.answer);
} catch {
@@ -180,8 +180,8 @@ export class FormFiller {
if (anyChecked) continue;
const answer = this.answerFor(leg);
if (answer) {
const lbl = await fs.$(`label:has-text("${answer}")`);
if (lbl) await lbl.click().catch(() => {});
const lbl = fs.locator(`label:has-text("${answer}")`).first();
if (await lbl.count() > 0) await lbl.click().catch(() => {});
} else {
unknown.push(leg);
}

View File

@@ -3,6 +3,8 @@
* One Claude call per search track using full profile + search config context
*/
import { ANTHROPIC_API_URL } from './constants.mjs';
export async function generateKeywords(search, profile, apiKey) {
if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
@@ -38,7 +40,7 @@ Think about:
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', {
const res = await fetch(ANTHROPIC_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -46,7 +48,7 @@ Example format: ["query one", "query two", "query three"]`;
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }]
})

View File

@@ -5,11 +5,9 @@
import {
LINKEDIN_BASE, NAVIGATION_TIMEOUT, FEED_NAVIGATION_TIMEOUT,
PAGE_LOAD_WAIT, SCROLL_WAIT, CLICK_WAIT,
EXTERNAL_ATS_PATTERNS
EXTERNAL_ATS_PATTERNS, LINKEDIN_LINKEDIN_MAX_SEARCH_PAGES, LINKEDIN_SECONDS_PER_DAY
} from './constants.mjs';
const MAX_SEARCH_PAGES = 40;
export async function verifyLogin(page) {
await page.goto(`${LINKEDIN_BASE}/feed/`, { waitUntil: 'domcontentloaded', timeout: FEED_NAVIGATION_TIMEOUT });
await page.waitForTimeout(CLICK_WAIT);
@@ -30,7 +28,7 @@ export async function searchLinkedIn(page, search, { onPage, onKeyword } = {}) {
if (search.filters?.remote) params.set('f_WT', '2');
if (search.filters?.easy_apply_only) params.set('f_LF', 'f_AL');
if (search.filters?.posted_within_days) {
params.set('f_TPR', `r${search.filters.posted_within_days * 86400}`);
params.set('f_TPR', `r${search.filters.posted_within_days * LINKEDIN_SECONDS_PER_DAY}`);
}
const url = `${LINKEDIN_BASE}/jobs/search/?${params.toString()}`;
@@ -43,7 +41,7 @@ export async function searchLinkedIn(page, search, { onPage, onKeyword } = {}) {
await page.waitForTimeout(PAGE_LOAD_WAIT);
let pageNum = 0;
while (pageNum < MAX_SEARCH_PAGES) {
while (pageNum < LINKEDIN_MAX_SEARCH_PAGES) {
// Scroll to load all cards
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(SCROLL_WAIT);

View File

@@ -3,10 +3,12 @@
* Call refreshSession() before creating a browser to ensure the profile is fresh
*/
import { createRequire } from 'module';
import {
KERNEL_SDK_PATH, SESSION_REFRESH_POLL_TIMEOUT, SESSION_REFRESH_POLL_WAIT,
SESSION_LOGIN_VERIFY_WAIT
} from './constants.mjs';
const require = createRequire(import.meta.url);
const KERNEL_SDK_PATH = '/home/ubuntu/.openclaw/workspace/node_modules/@onkernel/sdk/index.js';
export async function refreshSession(platform, apiKey, connectionIds = {}) {
const connectionId = connectionIds[platform];
if (!connectionId) throw new Error(`No Kernel connection ID configured for platform: ${platform} — add it to settings.json under kernel.connection_ids`);
@@ -28,8 +30,8 @@ export async function refreshSession(platform, apiKey, connectionIds = {}) {
console.log(`${platform} session pending (status: ${loginResp.status}), polling...`);
const start = Date.now();
let pollCount = 0;
while (Date.now() - start < 30000) {
await new Promise(r => setTimeout(r, 2000));
while (Date.now() - start < SESSION_REFRESH_POLL_TIMEOUT) {
await new Promise(r => setTimeout(r, SESSION_REFRESH_POLL_WAIT));
pollCount++;
const conn = await kernel.auth.connections.retrieve(connectionId);
if (conn.status === 'SUCCESS') {
@@ -58,7 +60,7 @@ export async function ensureLoggedIn(page, verifyFn, platform, apiKey, connectio
console.warn(` ⚠️ ${platform} not logged in (attempt ${attempt}), refreshing session...`);
await refreshSession(platform, apiKey, connectionIds);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
await page.waitForTimeout(SESSION_LOGIN_VERIFY_WAIT);
}
}
return false;

View File

@@ -5,11 +5,9 @@
import {
WELLFOUND_BASE, NAVIGATION_TIMEOUT, SEARCH_NAVIGATION_TIMEOUT,
SEARCH_LOAD_WAIT, SEARCH_SCROLL_WAIT, LOGIN_WAIT,
SEARCH_RESULTS_MAX
SEARCH_RESULTS_MAX, WELLFOUND_WELLFOUND_MAX_INFINITE_SCROLL
} from './constants.mjs';
const MAX_INFINITE_SCROLL = 10;
export async function verifyLogin(page) {
await page.goto(`${WELLFOUND_BASE}/`, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT });
await page.waitForTimeout(LOGIN_WAIT);
@@ -34,7 +32,7 @@ export async function searchWellfound(page, search, { onPage } = {}) {
// Scroll to bottom repeatedly to trigger infinite scroll
let lastHeight = 0;
for (let i = 0; i < MAX_INFINITE_SCROLL; i++) {
for (let i = 0; i < WELLFOUND_MAX_INFINITE_SCROLL; i++) {
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(SEARCH_SCROLL_WAIT);
const newHeight = await page.evaluate(() => document.body.scrollHeight);