El proceso de búsqueda de empleo es competitivo y repetitivo. Con cientos de solicitudes necesarias para conseguir posiciones relevantes, tiene sentido automatizar las partes que no requieren juicio humano. Este post recorre la construcción de un bot de solicitud de empleo en LinkedIn con Puppeteer y TypeScript: cómo es la arquitectura, cómo encajan los servicios principales y dónde se esconde la complejidad real.
Comprendiendo la Arquitectura Técnica
LinkedIn funciona como una aplicación de página única construida en React. Eso descarta el scraping HTTP simple. Necesitas un navegador real, y ahí es donde entra Puppeteer. El sistema se construye alrededor de cuatro ideas:
Puppeteer proporciona control programático sobre un navegador Chrome o Chromium sin cabeza, por lo que interactúas con LinkedIn exactamente como lo haría un usuario real. La tipificación fuerte de TypeScript evita que la manipulación del DOM y las operaciones asíncronas se conviertan en una pesadilla de mantenimiento. Separar la capa del navegador (PuppeteerService) de la lógica específica de LinkedIn (LinkedInService) hace que cada pieza sea comprobable por sí sola. Y centralizar la configuración mediante constantes mantiene las credenciales y los parámetros de búsqueda fuera de la 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')
}
Esto te permite cambiar parámetros de búsqueda, filtros de ubicación y tu biografía personal sin tocar la lógica de la aplicación.
Construcción de los Servicios Principales
PuppeteerService: Gestión del Navegador
PuppeteerService se encarga de todo lo relacionado con el navegador: inicio, creación de páginas y limpieza. Mantiene los detalles de Puppeteer encapsulados para que el resto del código no tenga que pensar en ellos.
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();
}
}
Los flags del navegador deshabilitan las funciones de GPU y memoria compartida que causan bloqueos en entornos sin cabeza. El viewport fijo garantiza una representación consistente tanto si se ejecuta localmente como en un servidor remoto.
LinkedInService: Lógica de Negocio
LinkedInService gestiona la autenticación, la búsqueda de empleo y el envío de solicitudes. Hace un seguimiento del estado de autenticación e implementa el flujo de automatización real.
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);
}
}
}
}
Implementación de Filtrado Inteligente de Empleos
Enviar solicitudes sin criterio desperdicia tiempo y credibilidad. Una capa de filtrado te permite definir lo que realmente quieres antes de que el bot haga clic en cualquier cosa.
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())
);
}
}
Manejo del Contenido Dinámico de LinkedIn
LinkedIn carga contenido de forma asíncrona y frecuentemente muestra diálogos modales. La clase navigator maneja ambos casos.
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);
}
}
}
}
Manejo de Errores y Resiliencia
Los tiempos de espera de red, los elementos faltantes y los desafíos anti-bot no son casos extremos aquí. Son rutinarios. El manejador de errores envuelve las operaciones en un bucle de reintentos y detecta los desafíos de seguridad antes de que paralicen el proceso 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
}
}
Persistencia de Datos y Seguimiento
Sin un registro de solicitudes anteriores, el bot aplicará a la misma oferta dos veces. El rastreador también genera un informe diario para que puedas ver qué se está enviando realmente.
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;
}
}
Consideraciones Éticas y Buenas Prácticas
Los términos de servicio de LinkedIn prohíben el scraping automatizado y las solicitudes masivas. Es una restricción real, no solo lenguaje legal. Algunas reglas que vale la pena seguir:
Añade retrasos entre acciones para evitar activar límites de velocidad y detección anti-bot. Solo recopila la información que realmente necesitas y no almacenes datos personales de otros usuarios. Revisa periódicamente los términos de LinkedIn, porque cambian y la aplicación varía. Si el bot es desafiado o bloqueado, detente e investiga en lugar de forzar la continuación.
El bot funciona mejor como una herramienta para manejar las partes mecánicas de una búsqueda que ya estás realizando, no como sustituto de leer realmente las descripciones de los empleos.
Implementación y Escalabilidad
Para uso personal, ejecutar esto localmente con un horario programado es sencillo. Para cualquier cosa más allá de eso:
Los contenedores Docker mantienen el entorno consistente entre máquinas. AWS Lambda o Google Cloud Functions funcionan para ejecuciones programadas, aunque los arranques en frío añaden latencia. Las credenciales pertenecen a las variables de entorno, nunca al control de versiones. Registrar cada intento de solicitud, reintento y fallo facilita significativamente la depuración.
Conclusión
Este bot de LinkedIn es un proyecto de automatización práctico, no teórico. TypeScript mantiene el código navegable a medida que crece, Puppeteer maneja el contenido dinámico y la separación de servicios hace que las piezas individuales sean comprobables. Las capas de filtrado y seguimiento son donde reside la mayor parte del valor real: aplicar ampliamente pero sin reflexión es peor que no automatizar en absoluto.