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
- Chrome se abre. Completa el inicio de sesión en LinkedIn manualmente.
- Observa la consola para actualizaciones en tiempo real.
- 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.txtactualizado 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: