Introducción

Una de las cosas que me hizo enamorar de la ingeniería de software es la capacidad de construir robots que se encarguen de las tareas tediosas por mí.

Buscar empleo es definitivamente una de esas tareas. Es repetitivo, consume tiempo y suele ser frustrante. Así que lo automaticé.

Motivación

Ver decenas de pop-ups de Easy Apply cada día resulta agotador. Quería un script capaz de encontrar roles que coincidieran con mis filtros, usar IA para decidir si la descripción del puesto realmente encaja con mi perfil, y rellenar automáticamente cada formulario con respuestas humanizadas y personalizadas.

El resultado: linkedin-jobs-applier.


Stack Tecnológico

Capa Elección Por qué
Runtime Bun Arranque rápido, soporte nativo de TypeScript, bun install sin configuración
Automatización de navegador Puppeteer API de Chrome madura, depuración excelente con headless: false
Lenguaje TypeScript Tipado fuerte para handles de DOM y validación estricta de prompts
Capa de IA OpenAI ChatGPT API de completion confiable con múltiples opciones de modelo
Almacenamiento de prompts about-me.txt Mantiene datos personales y CV separados del código fuente

Estructura del Proyecto

src/
├── index.ts               # Bootstrap & configuración global
├── functions.ts           # Utilidades auxiliares (sleep, getText, getTextFromElement)
├── helpers/
│   └── ChatGptHelper.ts   # Wrapper de la API de OpenAI
└── services/
    ├── PuppeteerService.ts # Gestión del ciclo de vida del navegador/página
    ├── LinkedInService.ts  # Búsqueda, paginación & lógica de login
    ├── JobCardService.ts   # Procesamiento individual de ofertas & verificación de fit con IA
    └── ApplyService.ts     # Navegación de formularios & relleno automatizado
about-me.txt               # Tu perfil, currículum & preferencias

Flujo de Extremo a Extremo

1. Fase de Bootstrap

index.ts carga la URL de búsqueda de empleos, lee tu perfil desde about-me.txt y lanza el navegador.

2. Autenticación

LinkedInService.login() abre la página de inicio de sesión de LinkedIn y espera la conclusión manual del login, detectada por la aparición del avatar.

3. Descubrimiento de Ofertas

searchJobs() navega a la URL de búsqueda, desplaza los cards de ofertas y implementa paginación recursiva con offsets &start= (25 empleos por página).

4. Filtrado con IA

Por cada card de oferta, JobCardService extrae el título y la descripción completa del puesto, y luego pregunta a ChatGPT si el rol encaja con tu perfil (SÍ/NO). Solo las respuestas SÍ avanzan.

5. Relleno Inteligente de Formularios

Cuando una oferta supera el filtro de IA, ApplyService abre el modal Easy Apply, itera por cada paso del formulario, extrae el texto del label de cada pregunta, envía un prompt contextual a ChatGPT y mapea la respuesta al tipo de input correspondiente: texto, select, radio, carga de archivo, etc.

6. Finalización Controlada

Tras rellenar todos los campos, el bot envía la postulación o la descarta si ocurren errores. El proceso continúa hasta alcanzar el límite diario de LinkedIn, luego espera una hora.


Análisis Detallado por Componente

1. index.ts: Hub de Configuración

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 ejecuta TypeScript directamente con bun run, sin ts-node, sin Babel, sin dolores de cabeza de configuración.

2. PuppeteerService.ts: Gestión del Navegador

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

Ejecutar con headless: false hace que depurar selectores CSS sea trivial: puedes observar exactamente lo que está haciendo el bot.

3. LinkedInService.ts: Navegación Inteligente

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() usa page.mouse.wheel() para cargar más cards de ofertas de forma lazy. La paginación recursiva mantiene bajo el uso de memoria, y la finalización del login se detecta por la presencia del avatar.

4. JobCardService.ts: Filtrado de Ofertas con IA

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

Prompts en dos etapas para ChatGPT gestionan el filtrado rápido SÍ/NO seguido de respuestas detalladas a las preguntas. El servicio también monitoriza los mensajes de "límite de Easy Apply alcanzado" y espera 1 hora cuando se alcanzan los límites.

5. ApplyService.ts: Automatización Inteligente de Formularios

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

El servicio gestiona texto, email, teléfono, fecha, cargas de archivo, selects, radios y checkboxes. Los campos numéricos eliminan automáticamente los no-dígitos. Los errores de validación activan un descarte controlado, y la navegación en múltiples pasos gestiona los botones Siguiente/Revisar.

6. ChatGptHelper.ts: Wrapper Limpio de la API

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

Cambia entre modelos (gpt-4o, gpt-4-turbo, gpt-3.5-turbo) modificando el parámetro.


Estrategia de Ingeniería de Prompts

Evaluación Rápida de Fit

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.

Respuesta Detallada a Preguntas

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.

Los prompts determinísticos mantienen bajos los costos de tokens. Las consultas de propósito único mejoran la precisión. Pasar tanto el perfil como la descripción del puesto le da al modelo suficiente contexto para responder preguntas que no se pueden responder directamente desde el currículum.


Primeros Pasos

Instalación y Configuración

# 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

Primera Ejecución

  1. Chrome se abre. Completa el inicio de sesión en LinkedIn manualmente.
  2. Observa la consola para actualizaciones en tiempo real.
  3. El bot toma el control a partir de ahí.

Opciones de Personalización

Tarea Cómo Personalizar
Cambiar criterios de búsqueda Edita DEFINES.JOB_LINK con tu URL de búsqueda de LinkedIn
Ajustar tono de las respuestas Modifica los prompts en ApplyService.ts
Agregar cargas de carta de presentación Extiende el bloque de manejo de carga de archivo
Cambiar a modo headless Cambia headless: true en PuppeteerService.ts
Modificar modelo de IA Actualiza el parámetro de modelo en las llamadas de ChatGptHelper.ts

Consideraciones Importantes

Ética y Cumplimiento

  • LinkedIn detecta activamente las automatizaciones. Usa VPNs, delays realistas y volumen moderado.
  • Revisa siempre las postulaciones finales. La IA puede ocasionalmente alucinar expectativas salariales.
  • Mantén about-me.txt actualizado con tus habilidades y proyectos más recientes.

Limitaciones Técnicas

  • Se aplican límites de tasa. LinkedIn restringe los envíos de Easy Apply por día.
  • Los cambios en el sitio significan que los selectores CSS pueden necesitar actualizaciones a medida que LinkedIn evoluciona.
  • Revisa las respuestas generadas por IA para verificar su idoneidad antes de la primera ejecución.

Mejoras Futuras

  • Integración con IA local (ej.: Ollama + Llama 3) para reducir costos de API
  • Integración con la API de Gmail para rastrear respuestas de reclutadores
  • Dashboard de analytics que muestre tasas de éxito de postulaciones
  • Pruebas A/B para optimización de prompts

Conclusión

Este proyecto muestra lo que ocurre cuando combinas Bun, Puppeteer y ChatGPT: un script en segundo plano que gestiona más de 4 horas de trabajo manual de postulación. La idea central es simple: la IA se encarga de los juicios de valor (¿este puesto es una buena opción? ¿cómo respondo esta pregunta?), mientras Puppeteer gestiona las interacciones mecánicas con el navegador. Ninguno de los dos es suficiente por sí solo.

Mantén tus servicios pequeños y enfocados, separa las preocupaciones transversales y usa TypeScript para que el compilador capture errores antes del runtime. Eso es todo.

Úsalo de forma responsable, respeta los términos de servicio de la plataforma y revisa tus postulaciones antes de enviarlas.

Recursos: