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

  1. Chrome launches. Complete LinkedIn login manually.
  2. Watch the console for real-time updates.
  3. 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.txt current 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: