Skip to content

Commit b2a5513

Browse files
RajjjAryanCopilot
andcommitted
feat: semi-auto Greenhouse apply with reCAPTCHA user step
- Discovered Greenhouse uses reCAPTCHA Enterprise (blocks full automation) - Rebuilt browser adapter: auto-fills form, opens visible browser for user - User just solves CAPTCHA + clicks Apply (saves 5-10 min per application) - Added batch CLI: apply-jobs.mjs with --board/--jobs or --csv support - React-Select dropdown handling via .select__option selectors - waitForSubmission polls for URL change or success element Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 811daec commit b2a5513

2 files changed

Lines changed: 592 additions & 0 deletions

File tree

apply-jobs.mjs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env node
2+
// apply-jobs.mjs — Semi-automated Greenhouse job application CLI
3+
//
4+
// Usage:
5+
// node apply-jobs.mjs --board=gitlab --jobs=8481922002,8488966002
6+
// node apply-jobs.mjs --board=gitlab --jobs=8481922002 --resume=output/cv-raj-aryan.pdf
7+
// node apply-jobs.mjs --csv=~/Desktop/raj-aryan-job-matches.csv --top=5
8+
//
9+
// The script opens a visible browser, pre-fills the application form,
10+
// then waits for you to solve the reCAPTCHA and click Apply.
11+
12+
import { readFileSync, existsSync } from 'fs';
13+
import { resolve, join } from 'path';
14+
import { submitViaBrowser, batchApply } from './lib/adapters/greenhouse-browser.mjs';
15+
16+
// ── Parse profile.yml (same as apply-engine.mjs) ──────────────────────
17+
function loadProfile() {
18+
const raw = readFileSync('config/profile.yml', 'utf8');
19+
const profile = { candidate: {}, auto_apply: {}, location: {} };
20+
let section = null;
21+
for (const line of raw.split('\n')) {
22+
const trimmed = line.trim();
23+
if (!trimmed || trimmed.startsWith('#')) continue;
24+
if (/^candidate\s*:/.test(trimmed)) { section = 'candidate'; continue; }
25+
if (/^auto_apply\s*:/.test(trimmed)) { section = 'auto_apply'; continue; }
26+
if (/^location\s*:/.test(trimmed)) { section = 'location'; continue; }
27+
if (/^\S/.test(line)) { section = null; continue; }
28+
if (!section) continue;
29+
const m = trimmed.match(/^(\w[\w_]*):\s*(.+)/);
30+
if (m) profile[section][m[1]] = m[2].replace(/^["']|["']$/g, '');
31+
}
32+
return profile;
33+
}
34+
35+
// ── Parse CLI args ──────────────────────────────────────────────────────
36+
const args = Object.fromEntries(
37+
process.argv.slice(2)
38+
.filter(a => a.startsWith('--'))
39+
.map(a => {
40+
const [k, ...v] = a.slice(2).split('=');
41+
return [k, v.join('=') || 'true'];
42+
})
43+
);
44+
45+
if (args.help || (!args.board && !args.csv)) {
46+
console.log(`
47+
Semi-Automated Greenhouse Job Applicator
48+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49+
50+
Usage:
51+
node apply-jobs.mjs --board=<token> --jobs=<id1,id2,...> [--resume=<path>]
52+
node apply-jobs.mjs --csv=<path> [--top=N] [--resume=<path>]
53+
54+
Options:
55+
--board Greenhouse board token (e.g. gitlab, databricks)
56+
--jobs Comma-separated job IDs
57+
--csv Path to job matches CSV (reads board + job ID from apply_url column)
58+
--top When using --csv, apply to top N jobs (default: 5)
59+
--resume Path to resume PDF (default: first match in output/)
60+
--timeout Seconds to wait for user submission per job (default: 180)
61+
--help Show this help
62+
63+
The browser opens in visible mode. After auto-fill:
64+
1. Review the pre-filled answers
65+
2. Solve the reCAPTCHA
66+
3. Click "Apply"
67+
`);
68+
process.exit(0);
69+
}
70+
71+
const profile = loadProfile();
72+
73+
// Find resume PDF
74+
let resumePath = args.resume;
75+
if (!resumePath) {
76+
const outputDir = resolve('output');
77+
if (existsSync(outputDir)) {
78+
const files = readFileSync !== undefined
79+
? (await import('fs/promises')).then(m => m.readdir(outputDir))
80+
: [];
81+
const pdfFiles = (await files).filter(f => f.endsWith('.pdf')).sort().reverse();
82+
if (pdfFiles.length > 0) {
83+
resumePath = join(outputDir, pdfFiles[0]);
84+
console.log(`📄 Using resume: ${resumePath}`);
85+
}
86+
}
87+
}
88+
if (resumePath && !existsSync(resumePath)) {
89+
console.error(`❌ Resume not found: ${resumePath}`);
90+
process.exit(1);
91+
}
92+
93+
const timeout = (parseInt(args.timeout) || 180) * 1000;
94+
95+
// ── Collect jobs to apply ──────────────────────────────────────────────
96+
let jobs = []; // [{ boardToken, jobId }]
97+
98+
if (args.csv) {
99+
const csvPath = resolve(args.csv.replace('~', process.env.HOME || ''));
100+
if (!existsSync(csvPath)) {
101+
console.error(`❌ CSV not found: ${csvPath}`);
102+
process.exit(1);
103+
}
104+
const lines = readFileSync(csvPath, 'utf8').split('\n');
105+
const header = lines[0].split(',');
106+
const urlIdx = header.indexOf('apply_url');
107+
if (urlIdx < 0) {
108+
console.error('❌ CSV must have an apply_url column');
109+
process.exit(1);
110+
}
111+
const top = parseInt(args.top) || 5;
112+
for (const line of lines.slice(1, top + 1)) {
113+
if (!line.trim()) continue;
114+
const cols = line.split(',');
115+
const applyUrl = cols[urlIdx]?.trim();
116+
const m = applyUrl?.match(/greenhouse\.io\/(\w+)\/jobs\/(\d+)/);
117+
if (m) jobs.push({ boardToken: m[1], jobId: m[2] });
118+
}
119+
} else if (args.board && args.jobs) {
120+
jobs = args.jobs.split(',').map(id => ({
121+
boardToken: args.board,
122+
jobId: id.trim(),
123+
}));
124+
}
125+
126+
if (jobs.length === 0) {
127+
console.error('❌ No jobs to apply to. Use --board + --jobs, or --csv');
128+
process.exit(1);
129+
}
130+
131+
// ── Apply ──────────────────────────────────────────────────────────────
132+
console.log(`\n🚀 Applying to ${jobs.length} job(s)...\n`);
133+
134+
const results = [];
135+
for (let i = 0; i < jobs.length; i++) {
136+
const { boardToken, jobId } = jobs[i];
137+
console.log(`\n━━━ [${i + 1}/${jobs.length}] ${boardToken}/jobs/${jobId} ━━━`);
138+
139+
const r = await submitViaBrowser({
140+
boardToken,
141+
jobId,
142+
profile,
143+
pdfPath: resumePath,
144+
submitTimeout: timeout,
145+
});
146+
147+
results.push({ boardToken, jobId, ...r });
148+
149+
if (i < jobs.length - 1 && r.success) {
150+
console.log(' ⏭️ Next job in 3 seconds...\n');
151+
await new Promise(res => setTimeout(res, 3000));
152+
}
153+
}
154+
155+
// ── Summary ────────────────────────────────────────────────────────────
156+
console.log('\n━━━ Summary ━━━');
157+
const succeeded = results.filter(r => r.success);
158+
const failed = results.filter(r => !r.success);
159+
console.log(` ✅ Submitted: ${succeeded.length}`);
160+
console.log(` ❌ Failed: ${failed.length}`);
161+
for (const r of failed) {
162+
console.log(` - ${r.boardToken}/jobs/${r.jobId}: ${r.message}`);
163+
}
164+
console.log('');

0 commit comments

Comments
 (0)