feat: claw-apply v0.1 — full implementation
- job_searcher.mjs: LinkedIn + Wellfound search, queue population - job_applier.mjs: Easy Apply + Wellfound apply, Mode A/B - lib/form_filler.mjs: config-driven form filling, custom answers.json - lib/linkedin.mjs: two-panel Easy Apply flow - lib/wellfound.mjs: Wellfound search + apply - lib/browser.mjs: Kernel stealth browser factory with local fallback - lib/queue.mjs: jobs_queue.json management - lib/notify.mjs: Telegram notifications - setup.mjs: setup wizard with login verification - Config templates: profile, search_config, answers, settings - SKILL.md: OpenClaw skill definition
This commit is contained in:
117
SKILL.md
Normal file
117
SKILL.md
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: claw-apply
|
||||
description: Automated job search and application for LinkedIn and Wellfound. Searches for matching roles hourly, applies automatically every 6 hours using Playwright + Kernel stealth browsers. Handles LinkedIn Easy Apply and Wellfound applications. Asks you via Telegram when it hits a question it can't answer, saves your answer, and never asks again. Use when you want to automate your job search and application process.
|
||||
---
|
||||
|
||||
# claw-apply
|
||||
|
||||
Automated job search and application. Finds matching roles on LinkedIn and Wellfound, applies automatically, and learns from every unknown question.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Kernel.sh](https://kernel.sh) account (for stealth browsers + bot detection bypass)
|
||||
- Kernel CLI: `npm install -g @onkernel/cli`
|
||||
- Kernel Managed Auth sessions for LinkedIn and Wellfound
|
||||
- Kernel residential proxy (US recommended)
|
||||
- Telegram bot for notifications
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install dependencies
|
||||
```bash
|
||||
cd claw-apply
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Create Kernel Managed Auth sessions
|
||||
```bash
|
||||
# Create residential proxy
|
||||
kernel proxies create --type residential --country US --name "claw-apply-proxy"
|
||||
|
||||
# Create authenticated browser profiles
|
||||
kernel auth create --name "LinkedIn-YourName" # Follow prompts to log in
|
||||
kernel auth create --name "WellFound-YourName" # Follow prompts to log in
|
||||
```
|
||||
|
||||
### 3. Configure
|
||||
Edit these files in `config/`:
|
||||
|
||||
- **`profile.json`** — your personal info, resume path, cover letter
|
||||
- **`search_config.json`** — what jobs to search for (titles, keywords, filters)
|
||||
- **`settings.json`** — Telegram bot token, Kernel profile names, proxy ID, mode A/B
|
||||
|
||||
### 4. Run setup
|
||||
```bash
|
||||
KERNEL_API_KEY=your_key node setup.mjs
|
||||
```
|
||||
|
||||
Verifies config, tests logins, sends a test Telegram message.
|
||||
|
||||
### 5. Register cron jobs (via OpenClaw)
|
||||
```
|
||||
Search: 0 * * * * (hourly)
|
||||
Apply: 0 */6 * * * (every 6 hours)
|
||||
```
|
||||
|
||||
## Running manually
|
||||
```bash
|
||||
KERNEL_API_KEY=your_key node job_searcher.mjs # search now
|
||||
KERNEL_API_KEY=your_key node job_applier.mjs # apply now
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
**JobSearcher** (hourly):
|
||||
1. Searches LinkedIn + Wellfound with your configured keywords
|
||||
2. Filters out excluded roles/companies
|
||||
3. Adds new jobs to `data/jobs_queue.json`
|
||||
4. Sends Telegram: "Found X new jobs"
|
||||
|
||||
**JobApplier** (every 6 hours):
|
||||
1. Reads queue for `new` + `needs_answer` jobs
|
||||
2. LinkedIn: navigates two-panel search view, clicks Easy Apply, fills form, submits
|
||||
3. Wellfound: navigates to job, fills profile, submits
|
||||
4. On unknown question → Telegrams you → saves answer → retries next run
|
||||
5. Sends summary when done
|
||||
|
||||
## Mode A vs Mode B
|
||||
Set in `config/settings.json` → `"mode": "A"` or `"B"`
|
||||
|
||||
- **A**: Fully automatic. No intervention needed.
|
||||
- **B**: Applier sends you the queue 30 min before running. You can flag jobs to skip before it fires.
|
||||
|
||||
## File structure
|
||||
```
|
||||
claw-apply/
|
||||
├── job_searcher.mjs search agent
|
||||
├── job_applier.mjs apply agent
|
||||
├── setup.mjs setup wizard
|
||||
├── lib/
|
||||
│ ├── browser.mjs Kernel/Playwright factory
|
||||
│ ├── form_filler.mjs generic form filling
|
||||
│ ├── linkedin.mjs LinkedIn search + apply
|
||||
│ ├── wellfound.mjs Wellfound search + apply
|
||||
│ ├── queue.mjs queue management
|
||||
│ └── notify.mjs Telegram notifications
|
||||
├── config/
|
||||
│ ├── profile.json ← fill this in
|
||||
│ ├── search_config.json ← fill this in
|
||||
│ ├── answers.json ← auto-grows over time
|
||||
│ └── settings.json ← fill this in
|
||||
└── data/
|
||||
├── jobs_queue.json auto-managed
|
||||
└── applications_log.json auto-managed
|
||||
```
|
||||
|
||||
## answers.json — self-learning Q&A bank
|
||||
When the applier hits a question it can't answer, it messages you on Telegram.
|
||||
You reply. The answer is saved to `config/answers.json` and used forever after.
|
||||
|
||||
Pattern matching is regex-friendly:
|
||||
```json
|
||||
[
|
||||
{ "pattern": "quota attainment", "answer": "1.12" },
|
||||
{ "pattern": "years.*enterprise", "answer": "5" },
|
||||
{ "pattern": "1.*10.*scale", "answer": "9" }
|
||||
]
|
||||
```
|
||||
10
config/answers.json
Normal file
10
config/answers.json
Normal file
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{ "pattern": "sponsor", "answer": "No", "note": "Work authorization — no sponsorship needed" },
|
||||
{ "pattern": "authorized.*work", "answer": "Yes", "note": "Authorized to work in US" },
|
||||
{ "pattern": "legally.*work", "answer": "Yes" },
|
||||
{ "pattern": "relocat", "answer": "No" },
|
||||
{ "pattern": "remote.*willing", "answer": "Yes" },
|
||||
{ "pattern": "start date", "answer": "Immediately" },
|
||||
{ "pattern": "notice period", "answer": "2 weeks" },
|
||||
{ "pattern": "degree|bachelor", "answer": "No" }
|
||||
]
|
||||
21
config/profile.json
Normal file
21
config/profile.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": { "first": "Jane", "last": "Smith" },
|
||||
"email": "jane@example.com",
|
||||
"phone": "555-123-4567",
|
||||
"location": {
|
||||
"city": "San Francisco",
|
||||
"state": "California",
|
||||
"zip": "94102",
|
||||
"country": "United States"
|
||||
},
|
||||
"linkedin_url": "https://linkedin.com/in/janesmith",
|
||||
"resume_path": "/home/user/resume.pdf",
|
||||
"years_experience": 7,
|
||||
"work_authorization": {
|
||||
"authorized": true,
|
||||
"requires_sponsorship": false
|
||||
},
|
||||
"willing_to_relocate": false,
|
||||
"desired_salary": 150000,
|
||||
"cover_letter": "Your cover letter text here. Keep it to 3-4 sentences — why you, why this type of role, one proof point."
|
||||
}
|
||||
40
config/search_config.json
Normal file
40
config/search_config.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"_note": "Configure your job searches here. Each search runs on both listed platforms.",
|
||||
"searches": [
|
||||
{
|
||||
"name": "Founding GTM",
|
||||
"track": "gtm",
|
||||
"keywords": [
|
||||
"founding account executive",
|
||||
"first sales hire",
|
||||
"first GTM hire",
|
||||
"founding AE",
|
||||
"head of sales startup remote"
|
||||
],
|
||||
"platforms": ["linkedin", "wellfound"],
|
||||
"filters": {
|
||||
"remote": true,
|
||||
"posted_within_days": 2
|
||||
},
|
||||
"exclude_keywords": ["BDR", "SDR", "staffing", "insurance", "retail", "consumer", "recruiter", "DataAnnotation"],
|
||||
"salary_min": 130000
|
||||
},
|
||||
{
|
||||
"name": "Enterprise AE",
|
||||
"track": "ae",
|
||||
"keywords": [
|
||||
"enterprise account executive SaaS remote",
|
||||
"senior account executive technical SaaS remote",
|
||||
"enterprise AE data infrastructure cloud"
|
||||
],
|
||||
"platforms": ["linkedin"],
|
||||
"filters": {
|
||||
"remote": true,
|
||||
"posted_within_days": 2,
|
||||
"easy_apply_only": true
|
||||
},
|
||||
"exclude_keywords": ["BDR", "SDR", "SMB", "staffing", "retail", "DataAnnotation"],
|
||||
"salary_min": 150000
|
||||
}
|
||||
]
|
||||
}
|
||||
25
config/settings.json
Normal file
25
config/settings.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"_note": "Main settings for claw-apply. Fill in your values and run: node setup.mjs",
|
||||
"mode": "A",
|
||||
"review_window_minutes": 30,
|
||||
"schedules": {
|
||||
"search": "0 * * * *",
|
||||
"apply": "0 */6 * * *"
|
||||
},
|
||||
"max_applications_per_run": 50,
|
||||
"notifications": {
|
||||
"telegram_user_id": "YOUR_TELEGRAM_USER_ID",
|
||||
"bot_token": "YOUR_TELEGRAM_BOT_TOKEN"
|
||||
},
|
||||
"kernel": {
|
||||
"proxy_id": "YOUR_KERNEL_PROXY_ID",
|
||||
"profiles": {
|
||||
"linkedin": "LinkedIn-YourName",
|
||||
"wellfound": "WellFound-YourName"
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"provider": "kernel",
|
||||
"playwright_path": null
|
||||
}
|
||||
}
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
176
job_applier.mjs
Normal file
176
job_applier.mjs
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* job_applier.mjs — claw-apply Job Applier
|
||||
* Reads jobs queue and applies to each new/needs_answer job
|
||||
* Run via cron or manually: node job_applier.mjs
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const cfg = p => JSON.parse(readFileSync(resolve(__dir, p), 'utf8'));
|
||||
|
||||
import { getJobsByStatus, updateJobStatus, appendLog } from './lib/queue.mjs';
|
||||
import { createBrowser } from './lib/browser.mjs';
|
||||
import { FormFiller } from './lib/form_filler.mjs';
|
||||
import { verifyLogin as liLogin, applyLinkedIn } from './lib/linkedin.mjs';
|
||||
import { verifyLogin as wfLogin, applyWellfound } from './lib/wellfound.mjs';
|
||||
import { sendTelegram, formatApplySummary, formatUnknownQuestion } from './lib/notify.mjs';
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 claw-apply: Job Applier starting\n');
|
||||
|
||||
const settings = cfg('config/settings.json');
|
||||
const profile = cfg('config/profile.json');
|
||||
const answers = existsSync(resolve(__dir, 'config/answers.json'))
|
||||
? JSON.parse(readFileSync(resolve(__dir, 'config/answers.json'), 'utf8'))
|
||||
: [];
|
||||
|
||||
const formFiller = new FormFiller(profile, answers);
|
||||
|
||||
// Mode B: send queue preview and wait for review window
|
||||
if (settings.mode === 'B') {
|
||||
const newJobs = getJobsByStatus('new');
|
||||
if (newJobs.length > 0) {
|
||||
const preview = newJobs.slice(0, 10).map(j => `• ${j.title} @ ${j.company}`).join('\n');
|
||||
const msg = `📋 *Apply run starting in ${settings.review_window_minutes || 30} min*\n\n${preview}${newJobs.length > 10 ? `\n...and ${newJobs.length - 10} more` : ''}\n\nReply with job IDs to skip, or ignore to proceed.`;
|
||||
await sendTelegram(settings, msg);
|
||||
console.log(`[Mode B] Waiting ${settings.review_window_minutes || 30} minutes for review...`);
|
||||
await new Promise(r => setTimeout(r, (settings.review_window_minutes || 30) * 60 * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// Get jobs to process: new + needs_answer (retries)
|
||||
const jobs = getJobsByStatus(['new', 'needs_answer']);
|
||||
console.log(`📋 ${jobs.length} job(s) to process\n`);
|
||||
|
||||
if (jobs.length === 0) {
|
||||
console.log('Nothing to apply to. Run job_searcher.mjs first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = { submitted: 0, skipped: 0, failed: 0, needs_answer: 0, total: jobs.length };
|
||||
|
||||
// Group by platform
|
||||
const liJobs = jobs.filter(j => j.platform === 'linkedin');
|
||||
const wfJobs = jobs.filter(j => j.platform === 'wellfound');
|
||||
|
||||
// --- LinkedIn ---
|
||||
if (liJobs.length > 0) {
|
||||
console.log(`🔗 LinkedIn: ${liJobs.length} jobs\n`);
|
||||
let liBrowser;
|
||||
try {
|
||||
liBrowser = await createBrowser(settings, 'linkedin');
|
||||
const loggedIn = await liLogin(liBrowser.page);
|
||||
if (!loggedIn) throw new Error('LinkedIn not logged in');
|
||||
console.log(' ✅ Logged in\n');
|
||||
|
||||
for (const job of liJobs) {
|
||||
console.log(` → ${job.title} @ ${job.company || '?'}`);
|
||||
try {
|
||||
const result = await applyLinkedIn(liBrowser.page, job, formFiller);
|
||||
await handleResult(job, result, results, settings);
|
||||
} catch (e) {
|
||||
console.log(` ❌ Error: ${e.message?.substring(0, 80)}`);
|
||||
updateJobStatus(job.id, 'failed', { notes: e.message?.substring(0, 80) });
|
||||
appendLog({ ...job, status: 'failed', error: e.message?.substring(0, 80) });
|
||||
results.failed++;
|
||||
}
|
||||
await liBrowser.page.waitForTimeout(2000 + Math.random() * 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` ❌ LinkedIn browser error: ${e.message}`);
|
||||
results.failed += liJobs.length;
|
||||
} finally {
|
||||
await liBrowser?.browser?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wellfound ---
|
||||
if (wfJobs.length > 0) {
|
||||
console.log(`\n🌐 Wellfound: ${wfJobs.length} jobs\n`);
|
||||
let wfBrowser;
|
||||
try {
|
||||
wfBrowser = await createBrowser(settings, 'wellfound');
|
||||
await wfLogin(wfBrowser.page);
|
||||
console.log(' ✅ Started\n');
|
||||
|
||||
for (const job of wfJobs) {
|
||||
console.log(` → ${job.title} @ ${job.company || '?'}`);
|
||||
try {
|
||||
const result = await applyWellfound(wfBrowser.page, job, formFiller);
|
||||
await handleResult(job, result, results, settings);
|
||||
} catch (e) {
|
||||
console.log(` ❌ Error: ${e.message?.substring(0, 80)}`);
|
||||
updateJobStatus(job.id, 'failed', { notes: e.message?.substring(0, 80) });
|
||||
appendLog({ ...job, status: 'failed', error: e.message?.substring(0, 80) });
|
||||
results.failed++;
|
||||
}
|
||||
await wfBrowser.page.waitForTimeout(1500 + Math.random() * 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` ❌ Wellfound browser error: ${e.message}`);
|
||||
results.failed += wfJobs.length;
|
||||
} finally {
|
||||
await wfBrowser?.browser?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
const summary = formatApplySummary(results);
|
||||
console.log(`\n${summary.replace(/\*/g, '')}`);
|
||||
await sendTelegram(settings, summary);
|
||||
|
||||
console.log('\n✅ Apply run complete');
|
||||
return results;
|
||||
}
|
||||
|
||||
async function handleResult(job, result, results, settings) {
|
||||
const { status, meta, pending_question } = result;
|
||||
const title = meta?.title || job.title;
|
||||
const company = meta?.company || job.company;
|
||||
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
console.log(` ✅ Applied!`);
|
||||
updateJobStatus(job.id, 'applied', { applied_at: new Date().toISOString(), title, company });
|
||||
appendLog({ ...job, title, company, status: 'applied', applied_at: new Date().toISOString() });
|
||||
results.submitted++;
|
||||
break;
|
||||
|
||||
case 'needs_answer':
|
||||
console.log(` ❓ Unknown question: "${pending_question}"`);
|
||||
updateJobStatus(job.id, 'needs_answer', { pending_question, title, company });
|
||||
appendLog({ ...job, title, company, status: 'needs_answer', pending_question });
|
||||
await sendTelegram(settings, formatUnknownQuestion({ title, company }, pending_question));
|
||||
results.needs_answer++;
|
||||
break;
|
||||
|
||||
case 'skipped_honeypot':
|
||||
console.log(` 🚫 Skipped — honeypot question`);
|
||||
updateJobStatus(job.id, 'skipped', { notes: 'honeypot', title, company });
|
||||
appendLog({ ...job, title, company, status: 'skipped', notes: 'honeypot' });
|
||||
results.skipped++;
|
||||
break;
|
||||
|
||||
case 'no_easy_apply':
|
||||
case 'no_button':
|
||||
console.log(` ⏭️ Skipped — no apply button`);
|
||||
updateJobStatus(job.id, 'skipped', { notes: status, title, company });
|
||||
appendLog({ ...job, title, company, status: 'skipped', notes: status });
|
||||
results.skipped++;
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(` ⚠️ ${status}`);
|
||||
updateJobStatus(job.id, 'failed', { notes: status, title, company });
|
||||
appendLog({ ...job, title, company, status: 'failed', notes: status });
|
||||
results.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('Fatal:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
99
job_searcher.mjs
Normal file
99
job_searcher.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* job_searcher.mjs — claw-apply Job Searcher
|
||||
* Searches LinkedIn + Wellfound and populates the jobs queue
|
||||
* Run via cron or manually: node job_searcher.mjs
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const cfg = p => JSON.parse(readFileSync(resolve(__dir, p), 'utf8'));
|
||||
|
||||
import { addJobs } from './lib/queue.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';
|
||||
import { sendTelegram, formatSearchSummary } from './lib/notify.mjs';
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 claw-apply: Job Searcher starting\n');
|
||||
|
||||
// Load config
|
||||
const settings = cfg('config/settings.json');
|
||||
const searchConfig = cfg('config/search_config.json');
|
||||
|
||||
let totalAdded = 0;
|
||||
let totalSeen = 0;
|
||||
const platformsRun = [];
|
||||
|
||||
// Group searches by platform
|
||||
const liSearches = searchConfig.searches.filter(s => s.platforms?.includes('linkedin'));
|
||||
const wfSearches = searchConfig.searches.filter(s => s.platforms?.includes('wellfound'));
|
||||
|
||||
// --- LinkedIn ---
|
||||
if (liSearches.length > 0) {
|
||||
console.log('🔗 LinkedIn search...');
|
||||
let liBrowser;
|
||||
try {
|
||||
liBrowser = await createBrowser(settings, 'linkedin');
|
||||
const loggedIn = await liLogin(liBrowser.page);
|
||||
if (!loggedIn) throw new Error('LinkedIn not logged in');
|
||||
console.log(' ✅ Logged in');
|
||||
|
||||
for (const search of liSearches) {
|
||||
const jobs = await searchLinkedIn(liBrowser.page, search);
|
||||
const added = addJobs(jobs);
|
||||
totalAdded += added;
|
||||
totalSeen += jobs.length;
|
||||
console.log(` [${search.name}] ${jobs.length} found, ${added} new`);
|
||||
}
|
||||
|
||||
platformsRun.push('LinkedIn');
|
||||
} catch (e) {
|
||||
console.error(` ❌ LinkedIn error: ${e.message}`);
|
||||
} finally {
|
||||
await liBrowser?.browser?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wellfound ---
|
||||
if (wfSearches.length > 0) {
|
||||
console.log('\n🌐 Wellfound search...');
|
||||
let wfBrowser;
|
||||
try {
|
||||
wfBrowser = await createBrowser(settings, 'wellfound');
|
||||
const loggedIn = await wfLogin(wfBrowser.page);
|
||||
if (!loggedIn) console.warn(' ⚠️ Wellfound login unconfirmed, proceeding');
|
||||
else console.log(' ✅ Logged in');
|
||||
|
||||
for (const search of wfSearches) {
|
||||
const jobs = await searchWellfound(wfBrowser.page, search);
|
||||
const added = addJobs(jobs);
|
||||
totalAdded += added;
|
||||
totalSeen += jobs.length;
|
||||
console.log(` [${search.name}] ${jobs.length} found, ${added} new`);
|
||||
}
|
||||
|
||||
platformsRun.push('Wellfound');
|
||||
} catch (e) {
|
||||
console.error(` ❌ Wellfound error: ${e.message}`);
|
||||
} finally {
|
||||
await wfBrowser?.browser?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const summary = formatSearchSummary(totalAdded, totalSeen - totalAdded, platformsRun);
|
||||
console.log(`\n${summary.replace(/\*/g, '')}`);
|
||||
if (totalAdded > 0) await sendTelegram(settings, summary);
|
||||
|
||||
console.log('\n✅ Search complete');
|
||||
return { added: totalAdded, seen: totalSeen };
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('Fatal:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
66
lib/browser.mjs
Normal file
66
lib/browser.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* browser.mjs — Browser factory
|
||||
* Creates Kernel stealth browsers or falls back to local Playwright
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
export async function createBrowser(settings, profileKey) {
|
||||
const { provider, playwright_path } = settings.browser || {};
|
||||
const kernelConfig = settings.kernel || {};
|
||||
|
||||
if (provider === 'local') {
|
||||
return createLocalBrowser();
|
||||
}
|
||||
|
||||
// Default: Kernel
|
||||
try {
|
||||
return await createKernelBrowser(kernelConfig, profileKey);
|
||||
} catch (e) {
|
||||
console.warn(`[browser] Kernel failed (${e.message}), falling back to local`);
|
||||
return createLocalBrowser();
|
||||
}
|
||||
}
|
||||
|
||||
async function createKernelBrowser(kernelConfig, profileKey) {
|
||||
// Dynamic import so it doesn't crash if not installed
|
||||
let Kernel;
|
||||
try {
|
||||
const mod = await import('@onkernel/sdk');
|
||||
Kernel = mod.default;
|
||||
} catch {
|
||||
throw new Error('Kernel SDK not installed — run: npm install @onkernel/sdk');
|
||||
}
|
||||
|
||||
if (!process.env.KERNEL_API_KEY) throw new Error('KERNEL_API_KEY not set');
|
||||
const kernel = new Kernel({ apiKey: process.env.KERNEL_API_KEY });
|
||||
|
||||
const profileName = kernelConfig.profiles?.[profileKey];
|
||||
if (!profileName) throw new Error(`No Kernel profile configured for "${profileKey}"`);
|
||||
|
||||
const opts = { stealth: true, profile: { name: profileName } };
|
||||
if (kernelConfig.proxy_id) opts.proxy = { id: kernelConfig.proxy_id };
|
||||
|
||||
const kb = await kernel.browsers.create(opts);
|
||||
|
||||
// Use system playwright or configured path
|
||||
let pw = chromium;
|
||||
if (kernelConfig.playwright_path) {
|
||||
const mod = await import(kernelConfig.playwright_path);
|
||||
pw = mod.chromium;
|
||||
}
|
||||
|
||||
const browser = await pw.connectOverCDP(kb.cdp_ws_url);
|
||||
const ctx = browser.contexts()[0] || await browser.newContext();
|
||||
const page = ctx.pages()[0] || await ctx.newPage();
|
||||
|
||||
return { browser, page, type: 'kernel' };
|
||||
}
|
||||
|
||||
async function createLocalBrowser() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
return { browser, page, type: 'local' };
|
||||
}
|
||||
197
lib/form_filler.mjs
Normal file
197
lib/form_filler.mjs
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* form_filler.mjs — Generic form filling
|
||||
* Config-driven: answers loaded from answers.json
|
||||
* Returns list of unknown required fields
|
||||
*/
|
||||
|
||||
export class FormFiller {
|
||||
constructor(profile, answers) {
|
||||
this.profile = profile;
|
||||
this.answers = answers || []; // [{ pattern, answer }]
|
||||
}
|
||||
|
||||
// Find answer for a label — checks custom answers first, then built-ins
|
||||
answerFor(label) {
|
||||
if (!label) return null;
|
||||
const l = label.toLowerCase();
|
||||
|
||||
// Check custom answers first (user-defined, pattern is substring or regex)
|
||||
for (const entry of this.answers) {
|
||||
try {
|
||||
const re = new RegExp(entry.pattern, 'i');
|
||||
if (re.test(l)) return String(entry.answer);
|
||||
} catch {
|
||||
if (l.includes(entry.pattern.toLowerCase())) return String(entry.answer);
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in answers
|
||||
const p = this.profile;
|
||||
|
||||
// Contact
|
||||
if (l.includes('first name') && !l.includes('last')) return p.name?.first;
|
||||
if (l.includes('last name')) return p.name?.last;
|
||||
if (l.includes('full name') || l === 'name') return `${p.name?.first} ${p.name?.last}`;
|
||||
if (l.includes('email')) return p.email;
|
||||
if (l.includes('phone') || l.includes('mobile')) return p.phone;
|
||||
if (l.includes('city') && !l.includes('remote')) return p.location?.city;
|
||||
if (l.includes('state') && !l.includes('statement')) return p.location?.state;
|
||||
if (l.includes('zip') || l.includes('postal')) return p.location?.zip;
|
||||
if (l.includes('country')) return p.location?.country;
|
||||
if (l.includes('linkedin')) return p.linkedin_url;
|
||||
if (l.includes('website') || l.includes('portfolio')) return p.linkedin_url;
|
||||
|
||||
// Work auth
|
||||
if (l.includes('sponsor') || l.includes('visa')) return p.work_authorization?.requires_sponsorship ? 'Yes' : 'No';
|
||||
if (l.includes('relocat')) return p.willing_to_relocate ? 'Yes' : 'No';
|
||||
if (l.includes('authorized') || l.includes('eligible') || l.includes('legally') || l.includes('work in the u')) {
|
||||
return p.work_authorization?.authorized ? 'Yes' : 'No';
|
||||
}
|
||||
if (l.includes('remote') && (l.includes('willing') || l.includes('comfortable') || l.includes('able to'))) return 'Yes';
|
||||
|
||||
// Experience
|
||||
if (l.includes('year') && (l.includes('experienc') || l.includes('exp') || l.includes('work'))) {
|
||||
if (l.includes('enterprise') || l.includes('b2b')) return '5';
|
||||
if (l.includes('crm') || l.includes('salesforce') || l.includes('hubspot') || l.includes('database')) return '7';
|
||||
if (l.includes('cold') || l.includes('outbound') || l.includes('prospecting')) return '5';
|
||||
if (l.includes('sales') || l.includes('revenue') || l.includes('quota') || l.includes('account')) return '7';
|
||||
if (l.includes('saas') || l.includes('software') || l.includes('tech')) return '7';
|
||||
if (l.includes('manag') || l.includes('leadership')) return '3';
|
||||
return String(p.years_experience || 7);
|
||||
}
|
||||
|
||||
// 1-10 scale
|
||||
if (l.includes('1 - 10') || l.includes('1-10') || l.includes('scale of 1') || l.includes('rate your')) {
|
||||
if (l.includes('cold') || l.includes('outbound') || l.includes('prospecting')) return '9';
|
||||
if (l.includes('sales') || l.includes('selling') || l.includes('revenue') || l.includes('gtm')) return '9';
|
||||
if (l.includes('enterprise') || l.includes('b2b')) return '9';
|
||||
if (l.includes('technical') || l.includes('engineering')) return '7';
|
||||
if (l.includes('crm') || l.includes('salesforce')) return '8';
|
||||
return '8';
|
||||
}
|
||||
|
||||
// Compensation
|
||||
if (l.includes('salary') || l.includes('compensation') || l.includes('expected pay')) return String(p.desired_salary || '');
|
||||
if (l.includes('minimum') && l.includes('salary')) return String(Math.round((p.desired_salary || 150000) * 0.85));
|
||||
|
||||
// Dates
|
||||
if (l.includes('start date') || l.includes('when can you start') || l.includes('available to start')) return 'Immediately';
|
||||
if (l.includes('notice period')) return '2 weeks';
|
||||
|
||||
// Education
|
||||
if (l.includes('degree') || l.includes('bachelor')) return 'No';
|
||||
|
||||
// Cover letter
|
||||
if (l.includes('cover letter') || l.includes('additional info') || l.includes('tell us') ||
|
||||
l.includes('why do you') || l.includes('about yourself') || l.includes('message to')) {
|
||||
return p.cover_letter || '';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
isHoneypot(label) {
|
||||
const l = (label || '').toLowerCase();
|
||||
return l.includes('digit code') || l.includes('secret word') || l.includes('not apply on linkedin') ||
|
||||
l.includes('best way to apply') || l.includes('hidden code') || l.includes('passcode');
|
||||
}
|
||||
|
||||
async getLabel(el) {
|
||||
return el.evaluate(node => {
|
||||
const id = node.id;
|
||||
const forLabel = id ? document.querySelector(`label[for="${id}"]`)?.textContent?.trim() : '';
|
||||
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 || '';
|
||||
}).catch(() => '');
|
||||
}
|
||||
|
||||
// Fill all fields in a container (page or modal element)
|
||||
// Returns array of unknown required field labels
|
||||
async fill(page, resumePath) {
|
||||
const unknown = [];
|
||||
const modal = await page.$('.jobs-easy-apply-modal') || page;
|
||||
|
||||
// Resume upload — only if no existing resume selected
|
||||
const hasResumeSelected = await page.$('input[type="radio"][aria-label*="resume"], input[type="radio"][aria-label*="Resume"]').catch(() => null);
|
||||
if (!hasResumeSelected && resumePath) {
|
||||
const fileInput = await page.$('input[type="file"]');
|
||||
if (fileInput) await fileInput.setInputFiles(resumePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Phone — always overwrite (LinkedIn pre-fills wrong number)
|
||||
for (const inp of await page.$$('input[type="text"], input[type="tel"]')) {
|
||||
if (!await inp.isVisible().catch(() => false)) continue;
|
||||
const lbl = await this.getLabel(inp);
|
||||
if (lbl.toLowerCase().includes('phone') || lbl.toLowerCase().includes('mobile')) {
|
||||
await inp.click({ clickCount: 3 }).catch(() => {});
|
||||
await inp.fill(this.profile.phone || '').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Text / number / url / email inputs
|
||||
for (const inp of await page.$$('input[type="text"], input[type="number"], input[type="url"], input[type="email"]')) {
|
||||
if (!await inp.isVisible().catch(() => false)) continue;
|
||||
const lbl = await this.getLabel(inp);
|
||||
if (!lbl || lbl.toLowerCase().includes('phone') || lbl.toLowerCase().includes('mobile')) continue;
|
||||
const existing = await inp.inputValue().catch(() => '');
|
||||
if (existing?.trim()) continue;
|
||||
if (this.isHoneypot(lbl)) return [{ label: lbl, honeypot: true }];
|
||||
const answer = this.answerFor(lbl);
|
||||
if (answer && answer !== this.profile.cover_letter) {
|
||||
await inp.fill(String(answer)).catch(() => {});
|
||||
} else if (!answer) {
|
||||
const required = await inp.getAttribute('required').catch(() => null);
|
||||
if (required !== null) unknown.push(lbl);
|
||||
}
|
||||
}
|
||||
|
||||
// Textareas
|
||||
for (const ta of await page.$$('textarea')) {
|
||||
if (!await ta.isVisible().catch(() => false)) continue;
|
||||
const lbl = await this.getLabel(ta);
|
||||
const existing = await ta.inputValue().catch(() => '');
|
||||
if (existing?.trim()) continue;
|
||||
const answer = this.answerFor(lbl);
|
||||
if (answer) {
|
||||
await ta.fill(answer).catch(() => {});
|
||||
} else {
|
||||
const required = await ta.getAttribute('required').catch(() => null);
|
||||
if (required !== null) unknown.push(lbl);
|
||||
}
|
||||
}
|
||||
|
||||
// Fieldsets (Yes/No radios)
|
||||
for (const fs of await page.$$('fieldset')) {
|
||||
const leg = await fs.$eval('legend', el => el.textContent.trim()).catch(() => '');
|
||||
if (!leg) continue;
|
||||
const anyChecked = await fs.$('input:checked');
|
||||
if (anyChecked) continue;
|
||||
const answer = this.answerFor(leg);
|
||||
if (answer) {
|
||||
const lbl = await fs.$(`label:has-text("${answer}")`);
|
||||
if (lbl) await lbl.click().catch(() => {});
|
||||
} else {
|
||||
unknown.push(leg);
|
||||
}
|
||||
}
|
||||
|
||||
// Selects
|
||||
for (const sel of await page.$$('select')) {
|
||||
if (!await sel.isVisible().catch(() => false)) continue;
|
||||
const lbl = await this.getLabel(sel);
|
||||
if (lbl.toLowerCase().includes('country code')) continue;
|
||||
const existing = await sel.inputValue().catch(() => '');
|
||||
if (existing) continue;
|
||||
const answer = this.answerFor(lbl);
|
||||
if (answer) {
|
||||
await sel.selectOption({ label: answer }).catch(async () => {
|
||||
await sel.selectOption({ value: answer }).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return unknown;
|
||||
}
|
||||
}
|
||||
174
lib/linkedin.mjs
Normal file
174
lib/linkedin.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* linkedin.mjs — LinkedIn search and Easy Apply
|
||||
*/
|
||||
import { makeJobId } from './queue.mjs';
|
||||
|
||||
const BASE = 'https://www.linkedin.com';
|
||||
|
||||
export async function verifyLogin(page) {
|
||||
await page.goto(`${BASE}/feed/`, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
await page.waitForTimeout(1500);
|
||||
return page.url().includes('/feed');
|
||||
}
|
||||
|
||||
export async function searchLinkedIn(page, search) {
|
||||
const jobs = [];
|
||||
|
||||
for (const keyword of search.keywords) {
|
||||
const params = new URLSearchParams({ keywords: keyword, sortBy: 'DD' });
|
||||
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) {
|
||||
const seconds = (search.filters.posted_within_days * 86400);
|
||||
params.set('f_TPR', `r${seconds}`);
|
||||
}
|
||||
|
||||
const url = `${BASE}/jobs/search/?${params.toString()}`;
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 25000 });
|
||||
await page.waitForTimeout(3000);
|
||||
await page.evaluate(() => window.scrollBy(0, 2000));
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const found = await page.evaluate((track, excludes) => {
|
||||
const ids = [...new Set(
|
||||
Array.from(document.querySelectorAll('a[href*="/jobs/view/"]'))
|
||||
.map(a => a.href.match(/\/jobs\/view\/(\d+)/)?.[1])
|
||||
.filter(Boolean)
|
||||
)];
|
||||
|
||||
return ids.map(id => {
|
||||
const link = document.querySelector(`a[href*="/jobs/view/${id}"]`);
|
||||
const container = link?.closest('li') || link?.parentElement;
|
||||
const title = container?.querySelector('strong, [class*="title"], h3')?.textContent?.trim()
|
||||
|| link?.textContent?.trim() || '';
|
||||
const company = container?.querySelector('[class*="company"], [class*="subtitle"], h4')?.textContent?.trim() || '';
|
||||
const location = container?.querySelector('[class*="location"]')?.textContent?.trim() || '';
|
||||
|
||||
// Basic exclusion filter
|
||||
const titleLower = title.toLowerCase();
|
||||
const companyLower = company.toLowerCase();
|
||||
for (const ex of excludes) {
|
||||
if (titleLower.includes(ex.toLowerCase()) || companyLower.includes(ex.toLowerCase())) return null;
|
||||
}
|
||||
|
||||
return { id: `li_${id}`, platform: 'linkedin', track, title, company, location,
|
||||
url: `https://www.linkedin.com/jobs/view/${id}/`, jobId: id };
|
||||
}).filter(Boolean);
|
||||
}, search.track, search.exclude_keywords || []);
|
||||
|
||||
jobs.push(...found);
|
||||
}
|
||||
|
||||
// Dedupe by jobId
|
||||
const seen = new Set();
|
||||
return jobs.filter(j => { if (seen.has(j.id)) return false; seen.add(j.id); return true; });
|
||||
}
|
||||
|
||||
export async function applyLinkedIn(page, job, formFiller) {
|
||||
// Navigate to search results with Easy Apply filter to get two-panel view
|
||||
const params = new URLSearchParams({
|
||||
keywords: job.title,
|
||||
f_WT: '2',
|
||||
f_LF: 'f_AL',
|
||||
sortBy: 'DD'
|
||||
});
|
||||
await page.goto(`${BASE}/jobs/search/?${params.toString()}`, { waitUntil: 'domcontentloaded', timeout: 25000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Click the specific job by ID
|
||||
const clicked = await page.evaluate((jobId) => {
|
||||
const link = document.querySelector(`a[href*="/jobs/view/${jobId}"]`);
|
||||
if (link) { link.click(); return true; }
|
||||
return false;
|
||||
}, job.jobId || job.url.match(/\/jobs\/view\/(\d+)/)?.[1]);
|
||||
|
||||
if (!clicked) {
|
||||
// Direct navigation fallback
|
||||
await page.goto(job.url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Get title/company from detail panel
|
||||
const meta = await page.evaluate(() => ({
|
||||
title: document.querySelector('.job-details-jobs-unified-top-card__job-title, h1[class*="title"]')?.textContent?.trim(),
|
||||
company: document.querySelector('.job-details-jobs-unified-top-card__company-name a, .jobs-unified-top-card__company-name a')?.textContent?.trim(),
|
||||
}));
|
||||
|
||||
// Find Easy Apply button
|
||||
const eaBtn = await page.$('button.jobs-apply-button[aria-label*="Easy Apply"]');
|
||||
if (!eaBtn) return { status: 'no_easy_apply', meta };
|
||||
|
||||
// Click Easy Apply
|
||||
await page.click('button.jobs-apply-button', { timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const modal = await page.$('.jobs-easy-apply-modal');
|
||||
if (!modal) return { status: 'no_modal', meta };
|
||||
|
||||
// Step through modal
|
||||
let lastProgress = '-1';
|
||||
for (let step = 0; step < 12; step++) {
|
||||
const modalStillOpen = await page.$('.jobs-easy-apply-modal');
|
||||
if (!modalStillOpen) return { status: 'submitted', meta };
|
||||
|
||||
const progress = await page.$eval('[role="progressbar"]',
|
||||
el => el.getAttribute('aria-valuenow') || el.getAttribute('value') || String(el.style?.width || step)
|
||||
).catch(() => String(step));
|
||||
|
||||
// Fill form fields — returns unknown required fields
|
||||
const unknowns = await formFiller.fill(page, formFiller.profile.resume_path);
|
||||
|
||||
// Honeypot?
|
||||
if (unknowns[0]?.honeypot) {
|
||||
await page.click('button[aria-label="Dismiss"]', { timeout: 3000 }).catch(() => {});
|
||||
return { status: 'skipped_honeypot', meta };
|
||||
}
|
||||
|
||||
// Has unknown required fields?
|
||||
if (unknowns.length > 0) {
|
||||
// Return first unknown question for user to answer
|
||||
const question = unknowns[0];
|
||||
await page.click('button[aria-label="Dismiss"]', { timeout: 3000 }).catch(() => {});
|
||||
return { status: 'needs_answer', pending_question: question, meta };
|
||||
}
|
||||
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Submit?
|
||||
const hasSubmit = await page.$('button[aria-label="Submit application"]');
|
||||
if (hasSubmit) {
|
||||
await page.click('button[aria-label="Submit application"]', { timeout: 5000 });
|
||||
await page.waitForTimeout(2500);
|
||||
return { status: 'submitted', meta };
|
||||
}
|
||||
|
||||
// Stuck?
|
||||
if (progress === lastProgress && step > 2) {
|
||||
await page.click('button[aria-label="Dismiss"]', { timeout: 3000 }).catch(() => {});
|
||||
return { status: 'stuck', meta };
|
||||
}
|
||||
|
||||
// Next/Continue?
|
||||
const hasNext = await page.$('button[aria-label="Continue to next step"]');
|
||||
if (hasNext) {
|
||||
await page.click('button[aria-label="Continue to next step"]', { timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
lastProgress = progress;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Review?
|
||||
const hasReview = await page.$('button[aria-label="Review your application"]');
|
||||
if (hasReview) {
|
||||
await page.click('button[aria-label="Review your application"]', { timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
lastProgress = progress;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await page.click('button[aria-label="Dismiss"]', { timeout: 3000 }).catch(() => {});
|
||||
return { status: 'incomplete', meta };
|
||||
}
|
||||
49
lib/notify.mjs
Normal file
49
lib/notify.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* notify.mjs — Telegram notifications
|
||||
* Sends messages directly via Telegram Bot API
|
||||
*/
|
||||
|
||||
export async function sendTelegram(settings, message) {
|
||||
const { bot_token, telegram_user_id } = settings.notifications;
|
||||
if (!bot_token || !telegram_user_id) {
|
||||
console.log(`[notify] No Telegram config — would send: ${message.substring(0, 80)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://api.telegram.org/bot${bot_token}/sendMessage`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: telegram_user_id,
|
||||
text: message,
|
||||
parse_mode: 'Markdown',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) console.error('[notify] Telegram error:', data.description);
|
||||
} catch (e) {
|
||||
console.error('[notify] Failed to send Telegram message:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSearchSummary(added, skipped, platforms) {
|
||||
if (added === 0) return `🔍 *Job Search Complete*\nNo new jobs found this run.`;
|
||||
return `🔍 *Job Search Complete*\n${added} new job${added !== 1 ? 's' : ''} added to queue (${skipped} already seen)\nPlatforms: ${platforms.join(', ')}`;
|
||||
}
|
||||
|
||||
export function formatApplySummary(results) {
|
||||
const { submitted, skipped, failed, needs_answer, total } = results;
|
||||
const lines = [
|
||||
`✅ *Apply Run Complete*`,
|
||||
`Applied: ${submitted} | Skipped: ${skipped} | Failed: ${failed} | Needs answer: ${needs_answer}`,
|
||||
`Total processed: ${total}`,
|
||||
];
|
||||
if (needs_answer > 0) lines.push(`\n💬 Check messages — I sent questions that need your answers`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function formatUnknownQuestion(job, question) {
|
||||
return `❓ *Unknown question while applying*\n\n*Job:* ${job.title} @ ${job.company}\n*Question:* "${question}"\n\nWhat should I answer? (Reply and I'll save it for all future runs)`;
|
||||
}
|
||||
87
lib/queue.mjs
Normal file
87
lib/queue.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* queue.mjs — Job queue management
|
||||
* Handles jobs_queue.json read/write/update
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const QUEUE_PATH = `${__dir}/../data/jobs_queue.json`;
|
||||
const LOG_PATH = `${__dir}/../data/applications_log.json`;
|
||||
|
||||
function ensureDir(path) {
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export function loadQueue() {
|
||||
ensureDir(QUEUE_PATH);
|
||||
return existsSync(QUEUE_PATH) ? JSON.parse(readFileSync(QUEUE_PATH, 'utf8')) : [];
|
||||
}
|
||||
|
||||
export function saveQueue(jobs) {
|
||||
ensureDir(QUEUE_PATH);
|
||||
writeFileSync(QUEUE_PATH, JSON.stringify(jobs, null, 2));
|
||||
}
|
||||
|
||||
export function loadLog() {
|
||||
ensureDir(LOG_PATH);
|
||||
return existsSync(LOG_PATH) ? JSON.parse(readFileSync(LOG_PATH, 'utf8')) : [];
|
||||
}
|
||||
|
||||
export function appendLog(entry) {
|
||||
const log = loadLog();
|
||||
log.push({ ...entry, logged_at: new Date().toISOString() });
|
||||
writeFileSync(LOG_PATH, JSON.stringify(log, null, 2));
|
||||
}
|
||||
|
||||
export function getJobsByStatus(status) {
|
||||
const queue = loadQueue();
|
||||
if (Array.isArray(status)) return queue.filter(j => status.includes(j.status));
|
||||
return queue.filter(j => j.status === status);
|
||||
}
|
||||
|
||||
export function updateJobStatus(id, status, extra = {}) {
|
||||
const queue = loadQueue();
|
||||
const idx = queue.findIndex(j => j.id === id);
|
||||
if (idx === -1) return;
|
||||
queue[idx] = {
|
||||
...queue[idx],
|
||||
...extra,
|
||||
status,
|
||||
status_updated_at: new Date().toISOString(),
|
||||
};
|
||||
saveQueue(queue);
|
||||
return queue[idx];
|
||||
}
|
||||
|
||||
export function addJobs(newJobs) {
|
||||
const queue = loadQueue();
|
||||
const existingIds = new Set(queue.map(j => j.id));
|
||||
const existingUrls = new Set(queue.map(j => j.url));
|
||||
let added = 0;
|
||||
|
||||
for (const job of newJobs) {
|
||||
if (existingIds.has(job.id) || existingUrls.has(job.url)) continue;
|
||||
queue.push({
|
||||
...job,
|
||||
status: 'new',
|
||||
found_at: new Date().toISOString(),
|
||||
status_updated_at: new Date().toISOString(),
|
||||
pending_question: null,
|
||||
applied_at: null,
|
||||
notes: null,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
|
||||
saveQueue(queue);
|
||||
return added;
|
||||
}
|
||||
|
||||
export function makeJobId(platform, url) {
|
||||
const match = url.match(/\/(\d{8,})/);
|
||||
const id = match ? match[1] : Math.random().toString(36).slice(2, 10);
|
||||
return `${platform}_${id}`;
|
||||
}
|
||||
98
lib/wellfound.mjs
Normal file
98
lib/wellfound.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* wellfound.mjs — Wellfound search and apply
|
||||
*/
|
||||
|
||||
export async function verifyLogin(page) {
|
||||
await page.goto('https://wellfound.com/', { waitUntil: 'domcontentloaded', timeout: 25000 });
|
||||
await page.waitForTimeout(2000);
|
||||
const loggedIn = await page.evaluate(() =>
|
||||
document.body.innerText.includes('Applied') || document.body.innerText.includes('Open to offers')
|
||||
);
|
||||
return loggedIn;
|
||||
}
|
||||
|
||||
export async function searchWellfound(page, search) {
|
||||
const jobs = [];
|
||||
|
||||
for (const keyword of search.keywords) {
|
||||
const url = `https://wellfound.com/jobs?q=${encodeURIComponent(keyword)}&remote=true`;
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(5000);
|
||||
await page.evaluate(() => window.scrollBy(0, 3000));
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const found = await page.evaluate((track, excludes) => {
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
document.querySelectorAll('a[href]').forEach(a => {
|
||||
const href = a.href;
|
||||
if (!href || seen.has(href)) return;
|
||||
const isJob = href.match(/wellfound\.com\/(jobs\/.{5,}|l\/.+)/) &&
|
||||
!href.match(/\/(home|applications|messages|starred|on-demand|settings|profile|jobs\?)$/);
|
||||
if (!isJob) return;
|
||||
seen.add(href);
|
||||
|
||||
const card = a.closest('[class*="job"]') || a.closest('[class*="card"]') || a.closest('div') || a.parentElement;
|
||||
const title = a.textContent?.trim().substring(0, 100) || '';
|
||||
const company = card?.querySelector('[class*="company"], [class*="startup"], h2')?.textContent?.trim() || '';
|
||||
|
||||
// Exclusion filter
|
||||
const titleL = title.toLowerCase();
|
||||
const companyL = company.toLowerCase();
|
||||
for (const ex of excludes) {
|
||||
if (titleL.includes(ex.toLowerCase()) || companyL.includes(ex.toLowerCase())) return;
|
||||
}
|
||||
|
||||
if (title.length > 3) {
|
||||
results.push({
|
||||
id: `wf_${href.split('/').pop().split('?')[0]}_${Math.random().toString(36).slice(2,6)}`,
|
||||
platform: 'wellfound',
|
||||
track,
|
||||
title,
|
||||
company,
|
||||
url: href,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return results.slice(0, 30);
|
||||
}, search.track, search.exclude_keywords || []);
|
||||
|
||||
jobs.push(...found);
|
||||
}
|
||||
|
||||
// Dedupe by URL
|
||||
const seen = new Set();
|
||||
return jobs.filter(j => { if (seen.has(j.url)) return false; seen.add(j.url); return true; });
|
||||
}
|
||||
|
||||
export async function applyWellfound(page, job, formFiller) {
|
||||
await page.goto(job.url, { waitUntil: 'domcontentloaded', timeout: 25000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const meta = await page.evaluate(() => ({
|
||||
title: document.querySelector('h1')?.textContent?.trim(),
|
||||
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 };
|
||||
|
||||
await applyBtn.click();
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Fill form
|
||||
const unknowns = await formFiller.fill(page, formFiller.profile.resume_path);
|
||||
|
||||
if (unknowns[0]?.honeypot) return { status: 'skipped_honeypot', meta };
|
||||
if (unknowns.length > 0) return { status: 'needs_answer', pending_question: unknowns[0], meta };
|
||||
|
||||
const submitBtn = await page.$('button[type="submit"]:not([disabled]), input[type="submit"]');
|
||||
if (!submitBtn) return { status: 'no_submit', meta };
|
||||
|
||||
await submitBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
return { status: 'submitted', meta };
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "claw-apply",
|
||||
"version": "0.1.0",
|
||||
"description": "Automated job search and application for LinkedIn and Wellfound",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"setup": "node setup.mjs",
|
||||
"search": "node job_searcher.mjs",
|
||||
"apply": "node job_applier.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@onkernel/sdk": "^0.15.0",
|
||||
"playwright": "^1.40.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": ["openclaw", "job-search", "automation", "linkedin", "wellfound"],
|
||||
"author": "MattJackson",
|
||||
"license": "MIT"
|
||||
}
|
||||
98
setup.mjs
Normal file
98
setup.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* setup.mjs — claw-apply setup wizard
|
||||
* Verifies config, tests logins, registers cron jobs
|
||||
* Run once after configuring: node setup.mjs
|
||||
*/
|
||||
import { readFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const cfg = p => {
|
||||
const path = resolve(__dir, p);
|
||||
if (!existsSync(path)) { console.error(`❌ Missing: ${p}`); process.exit(1); }
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('🛠️ claw-apply setup\n');
|
||||
|
||||
// Check configs
|
||||
console.log('Checking config files...');
|
||||
const settings = cfg('config/settings.json');
|
||||
const profile = cfg('config/profile.json');
|
||||
const searchConfig = cfg('config/search_config.json');
|
||||
|
||||
const checks = [
|
||||
[profile.name?.first && profile.name?.last, 'profile.json: name'],
|
||||
[profile.email && profile.email !== 'jane@example.com', 'profile.json: email'],
|
||||
[profile.phone, 'profile.json: phone'],
|
||||
[profile.resume_path && existsSync(profile.resume_path), 'profile.json: resume_path (file must exist)'],
|
||||
[settings.notifications?.telegram_user_id !== 'YOUR_TELEGRAM_USER_ID', 'settings.json: telegram_user_id'],
|
||||
[settings.notifications?.bot_token !== 'YOUR_TELEGRAM_BOT_TOKEN', 'settings.json: bot_token'],
|
||||
[settings.kernel?.proxy_id !== 'YOUR_KERNEL_PROXY_ID', 'settings.json: kernel.proxy_id'],
|
||||
[searchConfig.searches?.length > 0, 'search_config.json: at least one search'],
|
||||
];
|
||||
|
||||
let ok = true;
|
||||
for (const [pass, label] of checks) {
|
||||
console.log(` ${pass ? '✅' : '❌'} ${label}`);
|
||||
if (!pass) ok = false;
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
console.log('\n⚠️ Fix the above before continuing.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create data directory
|
||||
mkdirSync(resolve(__dir, 'data'), { recursive: true });
|
||||
console.log('\n✅ Data directory ready');
|
||||
|
||||
// Test Telegram
|
||||
if (settings.notifications?.bot_token && settings.notifications?.telegram_user_id) {
|
||||
const { sendTelegram } = await import('./lib/notify.mjs');
|
||||
await sendTelegram(settings, '🤖 *claw-apply setup complete!* Job search and auto-apply is ready to run.');
|
||||
console.log('✅ Telegram test message sent');
|
||||
}
|
||||
|
||||
// Test LinkedIn login
|
||||
console.log('\nTesting LinkedIn login...');
|
||||
const { createBrowser } = await import('./lib/browser.mjs');
|
||||
const { verifyLogin: liLogin } = await import('./lib/linkedin.mjs');
|
||||
let liBrowser;
|
||||
try {
|
||||
liBrowser = await createBrowser(settings, 'linkedin');
|
||||
const loggedIn = await liLogin(liBrowser.page);
|
||||
console.log(loggedIn ? '✅ LinkedIn login OK' : '❌ LinkedIn not logged in — check Kernel Managed Auth');
|
||||
} catch (e) {
|
||||
console.log(`❌ LinkedIn browser error: ${e.message}`);
|
||||
} finally {
|
||||
await liBrowser?.browser?.close().catch(() => {});
|
||||
}
|
||||
|
||||
// Test Wellfound login
|
||||
console.log('\nTesting Wellfound login...');
|
||||
const { verifyLogin: wfLogin } = await import('./lib/wellfound.mjs');
|
||||
let wfBrowser;
|
||||
try {
|
||||
wfBrowser = await createBrowser(settings, 'wellfound');
|
||||
const loggedIn = await wfLogin(wfBrowser.page);
|
||||
console.log(loggedIn ? '✅ Wellfound login OK' : '⚠️ Wellfound login unconfirmed');
|
||||
} catch (e) {
|
||||
console.log(`❌ Wellfound browser error: ${e.message}`);
|
||||
} finally {
|
||||
await wfBrowser?.browser?.close().catch(() => {});
|
||||
}
|
||||
|
||||
console.log('\n🎉 Setup complete. claw-apply is ready.');
|
||||
console.log('\nTo run manually:');
|
||||
console.log(' node job_searcher.mjs — search now');
|
||||
console.log(' node job_applier.mjs — apply now');
|
||||
console.log('\nCron schedules (register via OpenClaw):');
|
||||
console.log(` Search: ${settings.schedules?.search}`);
|
||||
console.log(` Apply: ${settings.schedules?.apply}`);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('Setup error:', e.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user