Introduction
One of the things that made me fall in love with software engineering is the ability to build robots that take care of tedious tasks for me.
Job hunting is definitely one of those tasks. It's repetitive, time-consuming, and often frustrating. So I automated it.
Motivation
Watching dozens of Easy Apply pop-ups each day is mind-numbing. I wanted a script that could find roles matching my filters, use AI to decide if the job description actually fits my background, and auto-fill each form with human-sounding, personalized responses.
The result: linkedin-jobs-applier.
Tech Stack
| Layer | Choice | Why |
|---|---|---|
| Runtime | Bun | Fast start-up, native TypeScript support, zero-config bun install |
| Browser automation | Puppeteer | Mature Chrome API, excellent debugging with headless: false |
| Language | TypeScript | Strong typing for DOM handles & strict prompt validation |
| AI layer | OpenAI ChatGPT | Reliable completion API with multiple model options |
| Prompt storage | about-me.txt | Keeps personal data & CV separate from source code |
Project Structure
src/
├── index.ts # Bootstrap & global configuration
├── functions.ts # Utility helpers (sleep, getText, getTextFromElement)
├── helpers/
│ └── ChatGptHelper.ts # OpenAI API wrapper
└── services/
├── PuppeteerService.ts # Browser/page lifecycle management
├── LinkedInService.ts # Search, pagination & login logic
├── JobCardService.ts # Individual job processing & AI fit check
└── ApplyService.ts # Form navigation & automated filling
about-me.txt # Your profile, resume & preferences
End-to-End Flow
1. Bootstrap Phase
index.ts loads your job search URL, reads your profile from about-me.txt, and launches the browser.
2. Authentication
LinkedInService.login() opens LinkedIn's sign-in page and waits for manual login completion, detected by avatar appearance.
3. Job Discovery
searchJobs() navigates to your search URL, scrolls through job cards, and implements recursive pagination with &start= offsets (25 jobs per page).
4. AI-Powered Filtering
For each job card, JobCardService extracts the job title and full description, then asks ChatGPT if the role fits your background (YES/NO). Only YES responses proceed.
5. Smart Form Filling
When a job passes the AI filter, ApplyService opens the Easy Apply modal, iterates through each form step, scrapes each question's label text, sends a contextual prompt to ChatGPT, and maps the response to the appropriate input type: text, select, radio, file upload, etc.
6. Graceful Completion
After filling all fields, the bot either submits the application or discards it if errors occur. The process continues until LinkedIn's daily limit is reached, then sleeps for one hour.
Deep Dive by Component
1. index.ts: Configuration Hub
const puppeteer = require("puppeteer");
import { PuppeteerService } from "./services/PuppeteerService";
import { LinkedInService } from "./services/LinkedInService";
import fs from 'fs';
export const DEFINES = {
JOB_LINK: `https://www.linkedin.com/jobs/search/?currentJobId=4234489284&distance=25&f_AL=true&f_WT=2&geoId=103644278&keywords=senior%20software%20engineer&origin=JOBS_HOME_SEARCH_CARDS`,
ABOUT_ME: fs.readFileSync(__dirname + '/../about-me.txt', 'utf8')
}
const main = async () => {
const linkedInService = new LinkedInService(await PuppeteerService.init());
await linkedInService.login();
await linkedInService.searchJobs();
}
main();
Bun executes TypeScript directly with bun run, no ts-node, no Babel, no configuration headaches.
2. PuppeteerService.ts: Browser Management
import puppeteer, { Browser, Page } from "puppeteer";
export class PuppeteerService {
static async init(){
const browser = await puppeteer.launch({
headless: false,
defaultViewport: null,
args: ["--start-maximized"],
userDataDir: "./.puppeteer-data",
});
const page = await browser.newPage();
return new PuppeteerService(browser, page);
}
constructor(public browser: Browser, public page: Page) {}
async goto(url: string){
await this.page.goto(url);
}
}
Running with headless: false makes debugging CSS selectors trivial: you can watch exactly what the bot is doing.
3. LinkedInService.ts: Intelligent Navigation
async login(){
await this.puppeteerService.goto("https://www.linkedin.com/login");
let loggedIn = false;
while (!loggedIn) {
try{
await this.puppeteerService.page.waitForSelector(".global-nav__me-photo", { timeout: 1000 });
loggedIn = true;
}catch(e){
console.log("Not logged in, waiting 5 seconds");
await sleep(5000);
}
}
console.log("Logged in");
}
scrollDown() uses page.mouse.wheel() to lazily load more job cards. Recursive pagination keeps memory usage low, and login completion is detected via avatar presence.
4. JobCardService.ts: AI-Powered Job Filtering
const answer = await ChatGptHelper.sendText(
'gpt-4.1-nano',
`PROFILE: ${DEFINES.ABOUT_ME}\n\nBased on my profile, answer if this job is a good fit for me. Return only the "YES" or "NO", without any other text.`
);
if(answer?.toLocaleLowerCase().includes('yes')){
console.log('🟢 This job is a good fit');
await easyApplyButton.click();
// ... proceed with application
}else{
console.log('🔴 This job is not a good fit');
}
Two-stage ChatGPT prompts handle quick YES/NO filtering followed by detailed question answering. The service also monitors for "Easy Apply limit reached" messages and sleeps for 1 hour when limits are hit.
5. ApplyService.ts: Intelligent Form Automation
// Extract question text
const labelHandle = await q.$('label');
const question = (await (await labelHandle.getProperty('innerText')).jsonValue()).trim();
// Get AI-powered answer
let answer = await ChatGptHelper.sendText(
'gpt-4.1-nano',
`PROFILE: ${DEFINES.ABOUT_ME}\n\n` +
`ROLE DESCRIPTION: ${this.jobCardService.about}\n\n` +
`Based on my profile, answer the following question: ${question}\n\n` +
`Return only the answer, without any other text.`
);
// Handle different input types
const textInput = await q.$('input[type="text"], input[type="email"], input[type="tel"]');
if (textInput) {
await textInput.click({ clickCount: 3 });
await textInput.type(answer, { delay: 50 });
}
The service handles text, email, phone, date, file uploads, selects, radios, and checkboxes. Numeric fields strip non-digits automatically. Validation errors trigger a graceful discard, and multi-step navigation handles Next/Review buttons.
6. ChatGptHelper.ts: Clean API Wrapper
import OpenAI from 'openai';
const openai = new OpenAI();
export default class ChatGptHelper {
static async sendText(model: OpenAI.Chat.ChatModel = 'gpt-4o', content: string) {
const completion = await openai.chat.completions.create({
model,
messages: [{ role: 'user', content }],
});
console.log(`🤖 Response from ChatGPT: ${completion.choices[0].message.content}`);
return completion.choices[0].message.content;
}
}
Swap between models (gpt-4o, gpt-4-turbo, gpt-3.5-turbo) by changing the parameter.
Prompt Engineering Strategy
Quick Fit Assessment
PROFILE:
Based on my profile, answer if this job is a good fit for me.
Return only the "YES" or "NO", without any other text.
Detailed Question Answering
PROFILE:
ROLE DESCRIPTION:
Based on my profile, answer the following question:
If you don't know the exact answer, return what you think is the best answer for this role.
Return only the answer, without any other text.
Deterministic prompts keep token costs low. Single-purpose queries improve accuracy. Passing both profile and job description gives the model enough context to answer questions that aren't directly answerable from the resume.
Getting Started
Installation & Setup
# Clone the repository
git clone https://github.com/samuelfaj/linkedin-jobs-applier
cd linkedin-jobs-applier
# Install dependencies
bun install # or npm install
# Set up environment
export OPENAI_API_KEY=sk-your-openai-key-here
export RESUME_PATH=/path/to/your/resume.pdf # Optional
# Create your profile
echo "Your professional background, skills, and preferences..." > about-me.txt
# Run the bot
bun run src/index.ts
First Run
- Chrome launches. Complete LinkedIn login manually.
- Watch the console for real-time updates.
- The bot takes over from there.
Customization Options
| Task | How to Customize |
|---|---|
| Change job search criteria | Edit DEFINES.JOB_LINK with your LinkedIn search URL |
| Adjust response tone | Modify prompts in ApplyService.ts |
| Add cover letter uploads | Extend the file upload handling block |
| Switch to headless mode | Change headless: true in PuppeteerService.ts |
| Modify AI model | Update the model parameter in ChatGptHelper.ts calls |
Important Considerations
Ethics & Compliance
- LinkedIn actively detects automation. Use VPNs, realistic delays, and moderate volume.
- Always review final submissions. AI can occasionally hallucinate salary expectations.
- Keep
about-me.txtcurrent with your latest skills and projects.
Technical Limitations
- Rate limits apply. LinkedIn restricts Easy Apply submissions per day.
- Site changes mean CSS selectors may need updates as LinkedIn evolves.
- Review AI-generated responses for appropriateness before the first run.
Future Enhancements
- Local AI integration (e.g., Ollama + Llama 3) to reduce API costs
- Gmail API integration for tracking recruiter responses
- Analytics dashboard showing application success rates
- A/B testing for prompt optimization
Conclusion
This project shows what happens when you combine Bun, Puppeteer, and ChatGPT: a background script that handles 4+ hours of manual application work. The core idea is simple: AI handles judgment calls (is this job a good fit? how do I answer this question?), while Puppeteer handles the mechanical browser interactions. Neither alone is enough.
Keep your services small and focused, separate cross-cutting concerns, and use TypeScript so the compiler catches mistakes before runtime. That's it.
Use this responsibly, respect platform terms of service, and review your applications before they go out.
Resources: