O processo de busca de emprego é competitivo e repetitivo. Com centenas de candidaturas necessárias para conseguir posições relevantes, faz sentido automatizar as partes que não exigem julgamento humano. Este post percorre a construção de um bot de candidatura no LinkedIn com Puppeteer e TypeScript: como é a arquitetura, como os serviços principais se encaixam e onde a complexidade real se esconde.

Entendendo a Arquitetura Técnica

O LinkedIn funciona como uma aplicação de página única construída em React. Isso descarta o scraping HTTP simples. Você precisa de um navegador real, e é aí que o Puppeteer entra. O sistema é construído em torno de quatro ideias:

O Puppeteer fornece controle programático sobre um navegador Chrome ou Chromium headless, então você interage com o LinkedIn exatamente como um usuário real faria. A tipagem forte do TypeScript evita que a manipulação do DOM e as operações assíncronas se tornem um pesadelo de manutenção. Separar a camada do navegador (PuppeteerService) da lógica específica do LinkedIn (LinkedInService) torna cada parte testável de forma independente. E centralizar a configuração por meio de constantes mantém as credenciais e os parâmetros de busca fora da lógica central.

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')
}

Isso permite alterar parâmetros de busca, filtros de localização e sua biografia pessoal sem tocar na lógica da aplicação.

Construindo os Serviços Principais

PuppeteerService: Gerenciamento do Navegador

O PuppeteerService é responsável por tudo relacionado ao navegador: inicialização, criação de páginas e limpeza. Ele mantém os detalhes do Puppeteer encapsulados para que o restante do código não precise se preocupar com eles.

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();
    }
}

Os flags do navegador desabilitam recursos de GPU e memória compartilhada que causam travamentos em ambientes headless. O viewport fixo garante renderização consistente, seja executando localmente ou em um servidor remoto.

LinkedInService: Lógica de Negócio

O LinkedInService gerencia autenticação, busca de vagas e envio de candidaturas. Ele rastreia o estado de autenticação e implementa o fluxo real de automação.

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);
            }
        }
    }
}

Implementando Filtragem Inteligente de Vagas

Enviar candidaturas de forma indiscriminada desperdiça tempo e credibilidade. Uma camada de filtragem permite definir o que você realmente quer antes que o bot clique em qualquer coisa.

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())
        );
    }
}

Lidando com o Conteúdo Dinâmico do LinkedIn

O LinkedIn carrega conteúdo de forma assíncrona e frequentemente exibe diálogos modais. A classe navigator lida com ambos.

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);
            }
        }
    }
}

Tratamento de Erros e Resiliência

Timeouts de rede, elementos ausentes e desafios anti-bot não são casos extremos aqui. São rotineiros. O handler de erros envolve as operações em um loop de tentativas e detecta desafios de segurança antes que paralisem o processo silenciosamente.

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
    }
}

Persistência de Dados e Rastreamento

Sem um registro de candidaturas anteriores, o bot aplicará para a mesma vaga duas vezes. O rastreador também gera um relatório diário para que você possa ver o que está sendo enviado.

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;
    }
}

Considerações Éticas e Boas Práticas

Os termos de serviço do LinkedIn proíbem scraping automatizado e candidatura em massa. Essa é uma restrição real, não apenas linguagem jurídica. Algumas regras que vale a pena seguir:

Adicione delays entre as ações para evitar acionar limites de taxa e detecção anti-bot. Colete apenas as informações de que realmente precisa e não armazene dados pessoais de outros usuários. Revise periodicamente os termos do LinkedIn, pois eles mudam e a aplicação varia. Se o bot for desafiado ou bloqueado, pare e investigue em vez de forçar a continuação.

O bot funciona melhor como uma ferramenta para lidar com as partes mecânicas de uma busca que você já está conduzindo, não como substituto para de fato ler as descrições das vagas.

Implantação e Escalabilidade

Para uso pessoal, executar isso localmente com um agendamento é simples. Para qualquer coisa além disso:

Containers Docker mantêm o ambiente consistente entre máquinas. AWS Lambda ou Google Cloud Functions funcionam para execuções agendadas, embora cold starts adicionem latência. Credenciais pertencem a variáveis de ambiente, nunca ao controle de versão. Registrar cada tentativa de candidatura, nova tentativa e falha torna a depuração significativamente mais fácil.

Conclusão

Este bot do LinkedIn é um projeto de automação prático, não teórico. O TypeScript mantém o código navegável à medida que cresce, o Puppeteer lida com o conteúdo dinâmico e a separação de serviços torna as partes individuais testáveis. As camadas de filtragem e rastreamento são onde reside a maior parte do valor real: candidatar-se amplamente, mas de forma irrefletida, é pior do que não automatizar nada.