The job search process is competitive and repetitive. With hundreds of applications needed to land meaningful roles, it makes sense to automate the parts that don't require human judgment. This post walks through building a LinkedIn job application bot with Puppeteer and TypeScript: what the architecture looks like, how the core services fit together, and where the real complexity hides.
Understanding the Technical Architecture
LinkedIn runs as a single-page application built on React. That rules out simple HTTP scraping. You need a real browser, which is where Puppeteer comes in. The system is built around four ideas:
Puppeteer provides programmatic control over a headless Chrome or Chromium browser, so you interact with LinkedIn exactly as a real user would. TypeScript's strong typing keeps the DOM manipulation and async operations from becoming a maintenance nightmare. Separating the browser layer (PuppeteerService) from the LinkedIn-specific logic (LinkedInService) makes each piece testable on its own. And centralizing configuration through constants keeps credentials and search parameters out of the core logic.
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')
}
This lets you change search parameters, location filters, and your personal bio without touching the application logic.
Building the Core Services
PuppeteerService: Browser Management
PuppeteerService owns everything browser-related: startup, page creation, and cleanup. It keeps Puppeteer's details contained so the rest of the code doesn't need to think about them.
export class PuppeteerService {
private browser: Browser;
private page: Page;
public static async init(): Promise<PuppeteerService> {
const service = new PuppeteerService();
await service.initializeBrowser();
return service;
}
private async initializeBrowser(): Promise<void> {
this.browser = await puppeteer.launch({
headless: false, // For debugging; set to true for production
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1920,1080'
]
});
this.page = await this.browser.newPage();
await this.page.setViewport({ width: 1920, height: 1080 });
}
public getPage(): Page {
return this.page;
}
public async close(): Promise<void> {
await this.browser.close();
}
}
The browser flags disable GPU and shared memory features that cause crashes in headless environments. The fixed viewport ensures consistent rendering whether you're running locally or on a remote server.
LinkedInService: Business Logic
LinkedInService handles authentication, job search, and application submission. It tracks authentication state and implements the actual automation flow.
export class LinkedInService {
private page: Page;
private isAuthenticated: boolean = false;
constructor(private puppeteerService: PuppeteerService) {
this.page = puppeteerService.getPage();
}
public async login(): Promise<void> {
await this.page.goto('https://www.linkedin.com/login');
// Wait for login form to be available
await this.page.waitForSelector('#username');
await this.page.waitForSelector('#password');
// Input credentials (should be from environment variables)
await this.page.type('#username', process.env.LINKEDIN_EMAIL || '');
await this.page.type('#password', process.env.LINKEDIN_PASSWORD || '');
// Submit login form
await this.page.click('button[type="submit"]');
// Wait for navigation to complete
await this.page.waitForNavigation();
// Verify successful login
await this.page.waitForSelector('.global-nav__primary-link');
this.isAuthenticated = true;
}
public async searchJobs(): Promise<void> {
if (!this.isAuthenticated) {
throw new Error('Must be authenticated before searching jobs');
}
await this.page.goto(DEFINES.JOB_LINK);
await this.page.waitForSelector('.jobs-search-results-list');
// Process job listings
await this.processJobListings();
}
private async processJobListings(): Promise<void> {
const jobCards = await this.page.$$('.job-card-container');
for (const jobCard of jobCards) {
await this.processIndividualJob(jobCard);
}
}
private async processIndividualJob(jobCard: ElementHandle): Promise<void> {
// Extract job information
const jobTitle = await jobCard.$eval('.job-card-list__title', el => el.textContent?.trim());
const company = await jobCard.$eval('.job-card-container__company-name', el => el.textContent?.trim());
// Click on the job to open details
await jobCard.click();
await this.page.waitForSelector('.job-details-module');
// Check if we should apply to this job
if (await this.shouldApplyToJob(jobTitle, company)) {
await this.applyToJob();
}
}
private async shouldApplyToJob(title: string, company: string): Promise<boolean> {
// Implement job filtering logic
const titleKeywords = ['senior', 'software', 'engineer', 'developer', 'typescript', 'node.js'];
const titleMatch = titleKeywords.some(keyword =>
title?.toLowerCase().includes(keyword.toLowerCase())
);
return titleMatch;
}
private async applyToJob(): Promise<void> {
const easyApplyButton = await this.page.$('button[aria-label*="Easy Apply"]');
if (easyApplyButton) {
await easyApplyButton.click();
await this.handleApplicationFlow();
}
}
private async handleApplicationFlow(): Promise<void> {
// Handle multi-step application process
let currentStep = 1;
const maxSteps = 5;
while (currentStep <= maxSteps) {
await this.page.waitForTimeout(2000);
// Check if we're on a form page
const nextButton = await this.page.$('button[aria-label="Continue to next step"]');
const submitButton = await this.page.$('button[aria-label="Submit application"]');
if (submitButton) {
await submitButton.click();
break;
} else if (nextButton) {
await this.fillCurrentFormStep();
await nextButton.click();
currentStep++;
} else {
break;
}
}
}
private async fillCurrentFormStep(): Promise<void> {
// Fill out common form fields
const textareas = await this.page.$$('textarea');
for (const textarea of textareas) {
const placeholder = await textarea.getAttribute('placeholder');
if (placeholder?.toLowerCase().includes('cover letter') ||
placeholder?.toLowerCase().includes('why')) {
await textarea.type(DEFINES.ABOUT_ME);
}
}
}
}
Implementing Intelligent Job Filtering
Submitting applications blindly wastes time and credibility. A filtering layer lets you define what you actually want before the bot clicks anything.
interface JobCriteria {
requiredKeywords: string[];
preferredKeywords: string[];
excludeKeywords: string[];
minSalary?: number;
maxCommute?: number;
experienceLevel: string[];
}
class JobFilter {
private criteria: JobCriteria;
constructor(criteria: JobCriteria) {
this.criteria = criteria;
}
public shouldApply(jobData: JobData): boolean {
return this.passesKeywordFilter(jobData) &&
this.passesExperienceFilter(jobData) &&
this.passesLocationFilter(jobData) &&
!this.containsExcludedKeywords(jobData);
}
private passesKeywordFilter(jobData: JobData): boolean {
const allText = `${jobData.title} ${jobData.description}`.toLowerCase();
// Must contain at least one required keyword
const hasRequiredKeyword = this.criteria.requiredKeywords.some(keyword =>
allText.includes(keyword.toLowerCase())
);
if (!hasRequiredKeyword) return false;
// Bonus points for preferred keywords
const preferredMatches = this.criteria.preferredKeywords.filter(keyword =>
allText.includes(keyword.toLowerCase())
).length;
return preferredMatches >= 2; // Require at least 2 preferred matches
}
private containsExcludedKeywords(jobData: JobData): boolean {
const allText = `${jobData.title} ${jobData.description}`.toLowerCase();
return this.criteria.excludeKeywords.some(keyword =>
allText.includes(keyword.toLowerCase())
);
}
}
Handling LinkedIn's Dynamic Content
LinkedIn loads content asynchronously and frequently throws up modal dialogs. The navigator class handles both.
class LinkedInNavigator {
private page: Page;
constructor(page: Page) {
this.page = page;
}
public async waitForJobListings(): Promise<void> {
await this.page.waitForFunction(() => {
const jobCards = document.querySelectorAll('.job-card-container');
return jobCards.length > 0;
}, { timeout: 10000 });
}
public async scrollToLoadMore(): Promise<void> {
await this.page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
await this.page.waitForTimeout(3000);
// Check if more jobs loaded
const newJobCount = await this.page.$$eval('.job-card-container', els => els.length);
if (newJobCount > 0) {
await this.waitForJobListings();
}
}
public async handlePopups(): Promise<void> {
// Handle common LinkedIn popups
const popupSelectors = [
'.artdeco-modal__dismiss',
'.msg-overlay-conversation-bubble__dismiss-btn',
'.notification-banner__dismiss'
];
for (const selector of popupSelectors) {
const dismissButton = await this.page.$(selector);
if (dismissButton) {
await dismissButton.click();
await this.page.waitForTimeout(1000);
}
}
}
}
Error Handling and Resilience
Network timeouts, missing elements, and anti-bot challenges are not edge cases here. They're routine. The error handler wraps operations in a retry loop and detects security challenges before they stall the process silently.
class ErrorHandler {
private retryAttempts: number = 3;
private retryDelay: number = 2000;
public async withRetry<T>(operation: () => Promise<T>, context: string): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
console.log(`Attempt ${attempt} failed for ${context}: ${error.message}`);
if (attempt < this.retryAttempts) {
await this.delay(this.retryDelay * attempt);
}
}
}
throw new Error(`Operation failed after ${this.retryAttempts} attempts: ${lastError.message}`);
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
public async handleLinkedInBlocking(): Promise<void> {
// Check for common blocking indicators
const blockingIndicators = [
'.challenge-page',
'.security-challenge',
'.captcha-container'
];
for (const indicator of blockingIndicators) {
const element = await this.page.$(indicator);
if (element) {
console.log('LinkedIn security challenge detected');
await this.handleSecurityChallenge();
break;
}
}
}
private async handleSecurityChallenge(): Promise<void> {
// Implement appropriate response to security challenges
console.log('Pausing automation due to security challenge');
await this.delay(300000); // Wait 5 minutes
// In production, might want to send notification to user
}
}
Data Persistence and Tracking
Without a record of past applications, the bot will apply to the same job twice. The tracker also generates a daily report so you can see what's actually being submitted.
interface ApplicationRecord {
jobId: string;
jobTitle: string;
company: string;
appliedAt: Date;
applicationStatus: 'pending' | 'submitted' | 'failed';
coverLetter?: string;
}
class ApplicationTracker {
private applications: ApplicationRecord[] = [];
private dataFile: string = 'applications.json';
public async loadApplicationHistory(): Promise<void> {
try {
const data = await fs.readFile(this.dataFile, 'utf8');
this.applications = JSON.parse(data);
} catch (error) {
this.applications = [];
}
}
public async recordApplication(record: ApplicationRecord): Promise<void> {
this.applications.push(record);
await this.saveApplicationHistory();
}
public hasAppliedToJob(jobId: string): boolean {
return this.applications.some(app => app.jobId === jobId);
}
private async saveApplicationHistory(): Promise<void> {
const data = JSON.stringify(this.applications, null, 2);
await fs.writeFile(this.dataFile, data, 'utf8');
}
public generateDailyReport(): string {
const today = new Date();
const todayApplications = this.applications.filter(app =>
app.appliedAt.toDateString() === today.toDateString()
);
return `Daily Report - ${today.toDateString()}
Applications submitted: ${todayApplications.length}
Success rate: ${this.calculateSuccessRate()}%
Total applications: ${this.applications.length}`;
}
private calculateSuccessRate(): number {
const submitted = this.applications.filter(app => app.applicationStatus === 'submitted').length;
return this.applications.length > 0 ? Math.round((submitted / this.applications.length) * 100) : 0;
}
}
Ethical Considerations and Best Practices
LinkedIn's terms of service prohibit automated scraping and mass application. That's a real constraint, not just legal boilerplate. A few rules worth following:
Add delays between actions to avoid triggering rate limits and anti-bot detection. Only collect information you actually need, and don't store other users' personal data. Periodically review LinkedIn's terms, because they change and enforcement varies. If the bot gets challenged or blocked, stop and investigate rather than hammering through.
The bot works best as a tool to handle the mechanical parts of a search you're already running, not as a replacement for actually reading job descriptions.
Deployment and Scaling
For personal use, running this locally on a schedule is straightforward. For anything beyond that:
Docker containers keep the environment consistent between machines. AWS Lambda or Google Cloud Functions work for scheduled runs, though cold starts add latency. Credentials belong in environment variables, never in source control. Logging every application attempt, retry, and failure makes debugging significantly easier.
Conclusion
This LinkedIn bot is a practical automation project, not a theoretical one. TypeScript keeps the codebase navigable as it grows, Puppeteer handles the dynamic content, and the service separation makes individual pieces testable. The filtering and tracking layers are where most of the real value lives: applying broadly but thoughtlessly is worse than not automating at all.