Skip to content

Instantly share code, notes, and snippets.

@ashrocket
Created April 7, 2026 16:27
Show Gist options
  • Select an option

  • Save ashrocket/c99ef7a36cc4ebf2fa5ab0a91f1bff5d to your computer and use it in GitHub Desktop.

Select an option

Save ashrocket/c99ef7a36cc4ebf2fa5ab0a91f1bff5d to your computer and use it in GitHub Desktop.
JobScout: Collaborative AI-assisted job application pipeline built with Claude Code — Playwright automation for Greenhouse forms with tailored resumes and cover letters
"""Auto-apply to Greenhouse job postings using Playwright.
Usage:
python -m jobscout.apply_greenhouse --job-id greenhouse-anthropic-4902636008
python -m jobscout.apply_greenhouse --all-tuned
python -m jobscout.apply_greenhouse --dry-run --job-id greenhouse-gusto-7577659
"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright, Page, Frame
DATA_DIR = Path(__file__).parent.parent / "data"
TAILORED_DIR = DATA_DIR / "tailored"
# Candidate info
CANDIDATE = {
"first_name": "Ashley",
"last_name": "Raiteri",
"email": "ashleyraiteri@gmail.com",
"phone": "206-981-8060",
"linkedin": "https://www.linkedin.com/in/ashleyraiteri",
"location": "Charlotte, NC",
"website": "https://www.linkedin.com/in/ashleyraiteri",
}
def find_resume_pdf(job_id: str) -> Path | None:
pdf = TAILORED_DIR / f"{job_id}.pdf"
if pdf.exists():
return pdf
html = TAILORED_DIR / f"{job_id}.html"
if html.exists():
return html
return None
def get_cover_letter(job_id: str) -> str | None:
"""Load cover letter from data/cover_letters/{job_id}.txt if it exists."""
cl_path = DATA_DIR / "cover_letters" / f"{job_id}.txt"
if cl_path.exists():
return cl_path.read_text().strip()
return None
def find_greenhouse_frame(page: Page):
"""Find the Greenhouse application form, handling iframes and direct forms."""
# Strategy 1: Click Apply button to scroll/reveal form
apply_btn = page.query_selector('button:has-text("Apply")')
if apply_btn:
apply_btn.click()
time.sleep(2)
# Strategy 2: Use frame_locator for Greenhouse iframe (recommended approach)
for selector in ['#grnhse_iframe', 'iframe[src*="greenhouse"]', 'iframe[src*="grnh"]']:
try:
fl = page.frame_locator(selector)
# Test if frame has content
fl.locator('input').first.wait_for(timeout=5000)
print(f" Found form via frame_locator({selector})")
return fl, "frame_locator"
except Exception:
continue
# Strategy 3: Access iframe via content_frame()
for iframe in page.query_selector_all('iframe'):
frame = iframe.content_frame()
if frame:
try:
frame.wait_for_selector('input', timeout=3000)
print(" Found form via content_frame()")
return frame, "frame"
except Exception:
continue
# Strategy 4: Form directly on page (no iframe)
if page.query_selector('form input[type="text"]'):
print(" Found form directly on page")
return page, "page"
return None, None
def fill_greenhouse_form(page: Page, job_url: str, job_id: str, dry_run: bool = False):
"""Navigate to a Greenhouse job posting and fill out the application form."""
print(f"\n{'[DRY RUN] ' if dry_run else ''}Applying to: {job_url}")
page.goto(job_url, wait_until="networkidle", timeout=30000)
time.sleep(3)
form_ctx, ctx_type = find_greenhouse_frame(page)
if not form_ctx:
print(" ERROR: Could not find application form")
return False
print(" Found application form")
# Build locator helper that works with both frame_locator and frame/page
def loc(selector):
if ctx_type == "frame_locator":
return form_ctx.locator(selector)
else:
return form_ctx.locator(selector) if hasattr(form_ctx, 'locator') else None
def fill_field(selector, value, label=""):
try:
el = loc(selector)
if el and el.count() > 0:
el.first.fill(value)
print(f" Filled: {label or selector} = {value}")
return True
except Exception:
pass
return False
# Fill basic fields — try Greenhouse standard names, then fallback to labels
fill_field('input[name="job_application[first_name]"]', CANDIDATE["first_name"], "First Name") or \
fill_field('#first_name', CANDIDATE["first_name"], "First Name")
fill_field('input[name="job_application[last_name]"]', CANDIDATE["last_name"], "Last Name") or \
fill_field('#last_name', CANDIDATE["last_name"], "Last Name")
fill_field('input[name="job_application[email]"]', CANDIDATE["email"], "Email") or \
fill_field('#email', CANDIDATE["email"], "Email")
fill_field('input[name="job_application[phone]"]', CANDIDATE["phone"], "Phone") or \
fill_field('#phone', CANDIDATE["phone"], "Phone")
fill_field('input[name="job_application[location]"]', CANDIDATE["location"], "Location")
# LinkedIn URL
fill_field('input[name*="linkedin"]', CANDIDATE["linkedin"], "LinkedIn") or \
fill_field('input[placeholder*="LinkedIn"]', CANDIDATE["linkedin"], "LinkedIn")
# Website
fill_field('input[name*="website"]', CANDIDATE["website"], "Website") or \
fill_field('input[placeholder*="Website"]', CANDIDATE["website"], "Website")
# Upload resume
resume_path = find_resume_pdf(job_id)
if resume_path:
try:
file_input = loc('input[type="file"]')
if file_input and file_input.count() > 0:
file_input.first.set_input_files(str(resume_path))
print(f" Uploaded resume: {resume_path.name}")
time.sleep(2)
else:
print(f" WARNING: Resume ready ({resume_path.name}) but no file upload field found")
except Exception as e:
print(f" WARNING: Resume upload failed: {e}")
else:
print(f" WARNING: No resume found for {job_id}")
# Fill cover letter
cover_letter = get_cover_letter(job_id)
if cover_letter:
filled = fill_field('textarea[name*="cover_letter"]', cover_letter, "Cover Letter") or \
fill_field('textarea[name*="cover"]', cover_letter, "Cover Letter") or \
fill_field('textarea', cover_letter, "Cover Letter (generic textarea)")
if not filled:
print(f" WARNING: Cover letter ready but no textarea found")
# Handle country field (often an autocomplete text input, not a select)
try:
country_input = loc('#country, input[id="country"], input[name*="country"]')
if country_input and country_input.count() > 0:
country_input.first.fill("United States")
country_input.first.press("ArrowDown")
time.sleep(0.5)
country_input.first.press("Enter")
print(f" Filled: Country = United States")
except Exception as e:
print(f" WARNING: Country field issue: {e}")
# Handle yes/no dropdowns (work authorization, etc.)
try:
selects = loc('select')
if selects:
for i in range(selects.count()):
sel = selects.nth(i)
current = sel.evaluate('el => el.value')
if current:
continue # Already filled (e.g. country)
options = sel.locator('option').all_inner_texts()
if 'Yes' in options:
sel.select_option(label="Yes")
print(f" Selected 'Yes' for dropdown #{i+1}")
except Exception:
pass
# Handle custom text questions common on Greenhouse forms
custom_answers = {
"earliest": "Immediately / 2 weeks notice",
"start": "Immediately / 2 weeks notice",
"soonest": "Immediately / 2 weeks notice",
"relocat": "Yes, open to relocation",
"deadline": "No specific deadlines",
"timeline": "Available immediately, flexible on start date",
"salary": "Open to discussion",
"compensation": "Open to discussion",
"hear about": "Company careers page",
"how did you": "Company careers page",
"know anyone": "",
"address from which": "Charlotte, NC",
"address": "Charlotte, NC",
"working from": "Charlotte, NC",
"plan on working": "Charlotte, NC",
"in person": "Yes",
"on-site": "Yes",
"onsite": "Yes",
"office": "Yes",
"in the office": "Yes",
"hybrid": "Yes",
"sponsor": "No, I am authorized to work in the US",
"authorized": "Yes",
"legally": "Yes",
}
# Find all labeled inputs/textareas/selects that are empty and try to answer them
try:
labels = loc('label')
if labels:
for i in range(min(labels.count(), 40)):
try:
label = labels.nth(i)
label_text = label.inner_text().strip().lower()
if not label_text or len(label_text) < 3:
continue
label_for = label.get_attribute('for') or ''
if not label_for:
continue
input_el = loc(f'#{label_for}')
if not input_el or input_el.count() == 0:
continue
el = input_el.first
tag = el.evaluate('el => el.tagName').lower()
# Handle select dropdowns
if tag == 'select':
current_select = el.evaluate('el => el.value')
if current_select:
continue
options = el.locator('option').all_inner_texts()
# Check custom answers first
matched = False
for keyword, answer in custom_answers.items():
if keyword in label_text and answer:
# Try to find matching option
for opt in options:
if answer.lower() in opt.lower() or opt.lower() in answer.lower():
el.select_option(label=opt)
print(f" Custom select: '{label_text[:50]}' → '{opt}'")
matched = True
break
if matched:
break
if not matched and 'Yes' in options:
# Default yes/no to Yes for authorization-type questions
auth_keywords = ['authorized', 'legal', 'open to', 'willing', 'in person', 'office', 'onsite', 'hybrid', 'relocat']
if any(kw in label_text for kw in auth_keywords):
el.select_option(label='Yes')
print(f" Auto-select Yes: '{label_text[:50]}'")
continue
# Handle text inputs and textareas
if tag in ('input', 'textarea'):
current_val = el.input_value()
if current_val:
continue
# Check if it's a React Select combobox
role = el.get_attribute('role') or ''
is_combobox = role == 'combobox'
# Use most specific keyword match (longest keyword that matches)
best_match = None
best_len = 0
for keyword, answer in custom_answers.items():
if keyword in label_text and answer and len(keyword) > best_len:
best_match = answer
best_len = len(keyword)
if best_match:
if is_combobox:
# React Select: type answer, wait for dropdown, press Enter
el.click()
time.sleep(0.3)
el.fill(best_match)
time.sleep(0.5)
el.press("ArrowDown")
time.sleep(0.3)
el.press("Enter")
print(f" Custom select: '{label_text[:50]}' → '{best_match}'")
else:
el.fill(best_match)
print(f" Custom Q: '{label_text[:50]}' → '{best_match}'")
except Exception:
continue
except Exception:
pass
# Fill LinkedIn — search by label text since Greenhouse uses dynamic IDs
try:
linkedin_filled = False
linkedin_inputs = loc('input[name*="linkedin"], input[placeholder*="linkedin" i], input[id*="linkedin" i]')
if linkedin_inputs and linkedin_inputs.count() > 0:
for i in range(linkedin_inputs.count()):
inp = linkedin_inputs.nth(i)
if not inp.input_value():
inp.fill(CANDIDATE["linkedin"])
print(f" Filled LinkedIn field (by attr)")
linkedin_filled = True
if not linkedin_filled:
# Search by label text
labels = loc('label')
if labels:
for i in range(labels.count()):
lt = labels.nth(i).inner_text().strip().lower()
if 'linkedin' in lt:
label_for = labels.nth(i).get_attribute('for')
if label_for:
inp = loc(f'#{label_for}')
if inp and inp.count() > 0 and not inp.first.input_value():
inp.first.fill(CANDIDATE["linkedin"])
print(f" Filled LinkedIn field (by label)")
break
except Exception:
pass
# Fill "Why [Company]?" and other common textareas with cover letter
if cover_letter:
try:
textareas = loc('textarea')
if textareas:
for i in range(textareas.count()):
ta = textareas.nth(i)
current = ta.input_value()
if current:
continue
ta_id = ta.get_attribute('id') or ''
if ta_id:
assoc_label = loc(f'label[for="{ta_id}"]')
if assoc_label and assoc_label.count() > 0:
lt = assoc_label.first.inner_text().lower()
if any(kw in lt for kw in ['why', 'interest', 'motivation', 'what excites', 'what attracts']):
ta.fill(cover_letter)
print(f" Filled 'Why' textarea with cover letter")
break
except Exception:
pass
# Fill remaining required comboboxes and fields that weren't caught above
additional_answers = {
"interviewed": "No",
"coding language": "Python",
"language preference": "Python",
"years of professional": "Yes",
"at least": "Yes",
"ai policy": "Yes",
"acknowledge": "Yes",
"experience": "Yes",
"currently employed": "Yes",
}
# Merge with existing custom_answers
custom_answers.update(additional_answers)
# Second pass: fill any remaining empty required fields
try:
labels = loc('label')
if labels:
for i in range(min(labels.count(), 50)):
try:
label = labels.nth(i)
label_text = label.inner_text().strip().lower()
if not label_text or len(label_text) < 3:
continue
label_for = label.get_attribute('for') or ''
if not label_for:
continue
input_el = loc(f'#{label_for}')
if not input_el or input_el.count() == 0:
continue
el = input_el.first
required = el.get_attribute('aria-required') or ''
if required != 'true':
continue
tag = el.evaluate('el => el.tagName').lower()
role = el.get_attribute('role') or ''
current_val = ''
try:
current_val = el.input_value()
except Exception:
pass
if current_val:
continue
# Try to find an answer
best_match = None
best_len = 0
for keyword, answer in custom_answers.items():
if keyword in label_text and answer and len(keyword) > best_len:
best_match = answer
best_len = len(keyword)
if best_match:
if role == 'combobox':
el.click()
time.sleep(0.3)
el.fill(best_match)
time.sleep(0.5)
el.press("ArrowDown")
time.sleep(0.3)
el.press("Enter")
print(f" 2nd pass select: '{label_text[:50]}' → '{best_match}'")
elif tag in ('input', 'textarea'):
el.fill(best_match)
print(f" 2nd pass fill: '{label_text[:50]}' → '{best_match}'")
except Exception:
continue
except Exception:
pass
if dry_run:
print(" [DRY RUN] Form filled — NOT submitting")
print(" Taking screenshot...")
page.screenshot(path=str(DATA_DIR / f"apply-screenshot-{job_id}.png"), full_page=True)
return True
# Submit
try:
submit = loc('button[type="submit"], input[type="submit"]')
if submit and submit.count() > 0:
submit.first.click()
print(" SUBMITTED!")
time.sleep(5)
page.screenshot(path=str(DATA_DIR / f"apply-confirmed-{job_id}.png"), full_page=True)
return True
else:
print(" ERROR: Could not find submit button")
return False
except Exception as e:
print(f" ERROR submitting: {e}")
return False
def main():
parser = argparse.ArgumentParser(description="Auto-apply to Greenhouse jobs")
parser.add_argument("--job-id", help="Specific job ID (e.g. greenhouse-anthropic-4902636008)")
parser.add_argument("--all-tuned", action="store_true", help="Apply to all tuned jobs")
parser.add_argument("--dry-run", action="store_true", help="Fill forms but don't submit")
parser.add_argument("--headed", action="store_true", help="Show browser window")
args = parser.parse_args()
# Build job list
from jobscout.db import init_db, get_job, get_jobs_by_status
conn = init_db(DATA_DIR / "jobscout.db")
if args.job_id:
job = get_job(conn, args.job_id)
if not job:
print(f"Job {args.job_id} not found in database")
sys.exit(1)
jobs = [job]
elif args.all_tuned:
jobs = get_jobs_by_status(conn, "tuned")
else:
parser.print_help()
sys.exit(1)
print(f"Found {len(jobs)} job(s) to apply to")
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=not args.headed)
context = browser.new_context()
page = context.new_page()
results = {"success": 0, "failed": 0, "skipped": 0}
for job in jobs:
job_id = job["id"]
url = job["url"]
title = job["title"]
if not url:
print(f" Skipping {job_id}: no URL")
results["skipped"] += 1
continue
print(f"\n--- {title} ({job_id}) ---")
try:
ok = fill_greenhouse_form(page, url, job_id, dry_run=args.dry_run)
if ok:
results["success"] += 1
if not args.dry_run:
from jobscout.db import update_job_status
update_job_status(conn, job_id, "applied")
else:
results["failed"] += 1
except Exception as e:
print(f" ERROR: {e}")
results["failed"] += 1
browser.close()
print(f"\nResults: {results['success']} applied, {results['failed']} failed, {results['skipped']} skipped")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment