From 52a56f59f61f9f6d535c66eaf743731ec677b80e Mon Sep 17 00:00:00 2001 From: Claw Date: Thu, 5 Mar 2026 23:24:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20claw-apply=20v0.1=20=E2=80=94=20full=20?= =?UTF-8?q?implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- SKILL.md | 117 ++++++++++++++++++++++ config/answers.json | 10 ++ config/profile.json | 21 ++++ config/search_config.json | 40 ++++++++ config/settings.json | 25 +++++ data/.gitkeep | 0 job_applier.mjs | 176 ++++++++++++++++++++++++++++++++++ job_searcher.mjs | 99 +++++++++++++++++++ lib/browser.mjs | 66 +++++++++++++ lib/form_filler.mjs | 197 ++++++++++++++++++++++++++++++++++++++ lib/linkedin.mjs | 174 +++++++++++++++++++++++++++++++++ lib/notify.mjs | 49 ++++++++++ lib/queue.mjs | 87 +++++++++++++++++ lib/wellfound.mjs | 98 +++++++++++++++++++ package.json | 21 ++++ setup.mjs | 98 +++++++++++++++++++ 16 files changed, 1278 insertions(+) create mode 100644 SKILL.md create mode 100644 config/answers.json create mode 100644 config/profile.json create mode 100644 config/search_config.json create mode 100644 config/settings.json create mode 100644 data/.gitkeep create mode 100644 job_applier.mjs create mode 100644 job_searcher.mjs create mode 100644 lib/browser.mjs create mode 100644 lib/form_filler.mjs create mode 100644 lib/linkedin.mjs create mode 100644 lib/notify.mjs create mode 100644 lib/queue.mjs create mode 100644 lib/wellfound.mjs create mode 100644 package.json create mode 100644 setup.mjs diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..fe73fb9 --- /dev/null +++ b/SKILL.md @@ -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" } +] +``` diff --git a/config/answers.json b/config/answers.json new file mode 100644 index 0000000..fae8ca2 --- /dev/null +++ b/config/answers.json @@ -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" } +] diff --git a/config/profile.json b/config/profile.json new file mode 100644 index 0000000..88f8b4b --- /dev/null +++ b/config/profile.json @@ -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." +} diff --git a/config/search_config.json b/config/search_config.json new file mode 100644 index 0000000..a3ec4cc --- /dev/null +++ b/config/search_config.json @@ -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 + } + ] +} diff --git a/config/settings.json b/config/settings.json new file mode 100644 index 0000000..10b3b87 --- /dev/null +++ b/config/settings.json @@ -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 + } +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/job_applier.mjs b/job_applier.mjs new file mode 100644 index 0000000..7f164bc --- /dev/null +++ b/job_applier.mjs @@ -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); +}); diff --git a/job_searcher.mjs b/job_searcher.mjs new file mode 100644 index 0000000..7d0e3b2 --- /dev/null +++ b/job_searcher.mjs @@ -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); +}); diff --git a/lib/browser.mjs b/lib/browser.mjs new file mode 100644 index 0000000..f87a121 --- /dev/null +++ b/lib/browser.mjs @@ -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' }; +} diff --git a/lib/form_filler.mjs b/lib/form_filler.mjs new file mode 100644 index 0000000..fada8cc --- /dev/null +++ b/lib/form_filler.mjs @@ -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; + } +} diff --git a/lib/linkedin.mjs b/lib/linkedin.mjs new file mode 100644 index 0000000..15efc29 --- /dev/null +++ b/lib/linkedin.mjs @@ -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 }; +} diff --git a/lib/notify.mjs b/lib/notify.mjs new file mode 100644 index 0000000..31b3852 --- /dev/null +++ b/lib/notify.mjs @@ -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)`; +} diff --git a/lib/queue.mjs b/lib/queue.mjs new file mode 100644 index 0000000..eb401e4 --- /dev/null +++ b/lib/queue.mjs @@ -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}`; +} diff --git a/lib/wellfound.mjs b/lib/wellfound.mjs new file mode 100644 index 0000000..e8be1de --- /dev/null +++ b/lib/wellfound.mjs @@ -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 }; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..174691b --- /dev/null +++ b/package.json @@ -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" +} diff --git a/setup.mjs b/setup.mjs new file mode 100644 index 0000000..b0cad66 --- /dev/null +++ b/setup.mjs @@ -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); });