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:
2026-03-05 23:24:09 +00:00
parent 2056a05429
commit 52a56f59f6
16 changed files with 1278 additions and 0 deletions

66
lib/browser.mjs Normal file
View 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
View 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
View 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
View 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
View 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
View 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 };
}