linkedin-pilot
# linkedin-pilot
Servidor **MCP** (y CLI) que controla LinkedIn con tu sesión real, en un navegador
persistente. Cubre las tres cosas de punta a punta:
1. **Actualizar tu perfil completo**: titular, acerca de, experiencia, educación,
aptitudes, certificaciones, proyectos, idiomas, foto, portada, información de
contacto, URL personalizada y "Abierto a trabajar".
2. **Interactuar con otros perfiles**: buscar personas, invitar a conectar con
nota, enviar mensajes, seguir, validar aptitudes, gestionar invitaciones,
reaccionar y comentar publicaciones, publicar en el feed.
3. **Postular a ofertas de empleo**: buscar con todos los filtros de LinkedIn,
leer el detalle y completar la Solicitud sencilla (Easy Apply) paso a paso.
---
## Por qué está hecho así
**La API oficial no sirve para esto.** Desde 2015 LinkedIn exige entrar al
Partner Program para cualquier acceso real, y ni siquiera los partners tienen
escritura sobre el perfil: la API pública devuelve nombre, titular y correo, y
poco más. SNAP dejó de aceptar solicitudes nuevas. No hay ruta oficial para
"actualiza mi experiencia" ni para "postula a esta oferta".
**Entonces la vía es la interfaz real**, conducida por Playwright con tu propia
sesión.
### Leer y escribir van por caminos distintos
LinkedIn migró el perfil, la oferta de empleo y los resultados de búsqueda a
renderizado **SDUI**: ya no hay `<h1>`, desaparecieron las anclas de sección
(`#experience`, `#about`), las clases CSS son hashes que cambian solos y el
contenido se carga de forma diferida. Raspar ese HTML para *leer* es frágil y
lento.
Por eso el proyecto usa dos vías:
- **Para leer**: la API interna (Voyager), que devuelve el perfil completo y
el detalle de una oferta en una sola llamada, estructurados. Se aprovecha la
sesión del propio navegador, así que las peticiones salen con las mismas
cookies, el mismo user-agent y la misma IP que la navegación real.
- **Para escribir**: la interfaz, porque no hay otra: no existe endpoint para
guardar tu experiencia ni para postular.
Cuando una vía falla se cae a la otra, y ambas quedan reportadas.
### Tres decisiones sostienen lo demás
- **Sesión persistente, login manual.** El navegador guarda su perfil en disco,
así que inicias sesión una sola vez (con verificación en dos pasos y captcha
incluidos) y las cookies sobreviven entre ejecuciones. No se automatiza el
login a propósito: automatizarlo es justo lo que dispara los bloqueos, y
además obligaría a guardar tu contraseña en algún lado.
- **Los campos se identifican por su etiqueta visible, no por clases CSS.**
LinkedIn cambia el HTML constantemente y sirve la interfaz en el idioma de la
cuenta. El motor de formularios lee el nombre accesible de cada control
(`aria-label`, `<label for>`, `legend`...) y lo empareja contra un glosario
bilingüe: pedir `Titular` encuentra `Headline`, y al revés. Esto se ve
funcionando en las postulaciones reales, donde una pregunta como
«Confirm the name of the company where you work / Confirme o nome da
empresa...» se resuelve sola con la respuesta guardada como «Current company».
- **El contenedor del formulario se descubre, no se asume.** Los editores de
perfil dejaron de ser ventanas modales: ahora son páginas que ni siquiera
cuelgan de `<main>`, y el asistente de Solicitud sencilla tampoco es un
`role="dialog"`. En vez de perseguir esa estructura, el motor parte del botón
que cierra el paso (Guardar, Siguiente, Enviar) y sube hasta el primer
ancestro que agrupa varios campos. Funcione donde funcione el formulario.
- **Una sola pieza rellena todo.** Editar el perfil y postular a un empleo son
el mismo problema: un modal con campos. El mismo motor sirve para ambos, así
que cualquier sección nueva que LinkedIn agregue ya está soportada sin tocar
código.
Y hay una salida de emergencia: si algo se rompe, las herramientas
`linkedin_browser_*` dan control directo del navegador (radiografía de la
página, clic por referencia, escritura en campos) para terminar el trabajo a
mano sin editar el proyecto.
---
## Instalación
```bash
cd linkedin-pilot
npm install
npm run build
```
Requiere Node 20+. Usa el Chrome instalado en el sistema; si no lo encuentra
prueba con Edge y luego con el Chromium de Playwright.
### Registrarlo en Claude Code
Ya quedó registrado en el ámbito de usuario:
```bash
claude mcp add linkedin --scope user -- node "<ruta>/linkedin-pilot/dist/index.js"
```
También hay un `.mcp.json` en la carpeta padre por si prefieres el ámbito de
proyecto. Comprueba con `claude mcp list`.
---
## Puesta en marcha
```bash
npm run login # abre el navegador; inicia sesión a mano una sola vez
npm run doctor # diagnóstico: navegador, sesión, rutas, banco de respuestas
```
Todo lo persistente vive en `~/.linkedin-pilot/`:
| Ruta | Qué guarda |
|---|---|
| `browser-profile/` | Perfil de Chrome con las cookies de sesión |
| `state.json` | Postulaciones, interacciones y contadores diarios |
| `answers.json` | Banco de respuestas reutilizables y ruta del CV |
| `screenshots/` | Capturas automáticas cuando algo falla |
| `logs/` | Registro diario en JSON |
---
## Uso desde la terminal
```bash
linkedin-pilot status # ¿hay sesión?
linkedin-pilot profile # lee tu perfil completo
linkedin-pilot jobs "ingeniero de datos" # busca ofertas con Solicitud sencilla
linkedin-pilot job 4021234567 # detalle de una oferta
linkedin-pilot apply 4021234567 # SIMULA la postulación
linkedin-pilot apply 4021234567 --send # postula de verdad
linkedin-pilot applications # historial local
```
---
## Herramientas MCP
### Sesión
| Herramienta | Qué hace |
|---|---|
| `linkedin_session_status` | Estado de la sesión y consumo del día. Empieza siempre por aquí. |
| `linkedin_login` | Abre el navegador para que inicies sesión a mano. |
| `linkedin_session_cookies` | Exporta o importa cookies (`li_at`, `JSESSIONID`). |
| `linkedin_close_browser` | Cierra el navegador; la sesión queda guardada. |
### Perfil
| Herramienta | Qué hace |
|---|---|
| `linkedin_profile_read` | Lee un perfil completo (el tuyo o el de otra persona). |
| `linkedin_profile_sections` | Lista las secciones editables y sus campos habituales. |
| `linkedin_profile_inspect_form` | Abre el editor y devuelve los campos **reales** sin guardar nada. |
| `linkedin_profile_edit` | Rellena cualquier sección por etiqueta y guarda si se lo pides. |
| `linkedin_profile_headline` | Atajo para el titular. |
| `linkedin_profile_about` | Atajo para "Acerca de". |
| `linkedin_profile_image` | Sube foto de perfil o portada. |
| `linkedin_profile_open_to_work` | Configura "Abierto a trabajar". |
| `linkedin_profile_custom_url` | Cambia la URL personalizada. |
Secciones disponibles en `linkedin_profile_edit`, con sus rutas verificadas
contra LinkedIn el 2026-09-01: `intro`, `about`, `experience`, `education`,
`skill`, `certification`, `project`, `language`, `course`, `honor`,
`publication`, `organization`, `patent`, `contactInfo`, `openToWork`.
**Flujo recomendado la primera vez que tocas una sección:**
```
1. linkedin_profile_inspect_form { "section": "experience" }
→ te dice exactamente qué campos existen hoy y cuáles son obligatorios
2. linkedin_profile_edit { "section": "experience", "values": {...}, "save": false }
→ los rellena y te muestra qué quedó; revisas en pantalla
3. linkedin_profile_edit { ..., "save": true }
```
### Personas y contenido
| Herramienta | Qué hace |
|---|---|
| `linkedin_people_search` | Busca personas con filtros de grado de contacto. |
| `linkedin_connect` | Invitación a conectar, con nota opcional. |
| `linkedin_message` | Mensaje directo. |
| `linkedin_follow` | Seguir o dejar de seguir. |
| `linkedin_endorse` | Validar aptitudes. |
| `linkedin_invitations` | Listar, aceptar, ignorar o retirar invitaciones. |
| `linkedin_posts_read` | Publicaciones del feed o de un perfil. |
| `linkedin_post_react` | Reaccionar (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`). |
| `linkedin_post_comment` | Comentar. |
| `linkedin_post_create` | Publicar en el feed. |
| `linkedin_notifications` | Leer notificaciones. |
### Empleos
| Herramienta | Qué hace |
|---|---|
| `linkedin_jobs_search` | Búsqueda con filtros de fecha, experiencia, modalidad y tipo. |
| `linkedin_job_detail` | Detalle completo de una oferta. |
| `linkedin_job_apply` | Solicitud sencilla paso a paso. **Simula por defecto.** |
| `linkedin_job_save` | Guardar o quitar de guardados. |
| `linkedin_my_jobs` | Ofertas guardadas o solicitudes según LinkedIn. |
| `linkedin_applications_log` | Historial local con las respuestas que diste. |
### Soporte
| Herramienta | Qué hace |
|---|---|
| `linkedin_answers_bank` | Consulta y edita las respuestas reutilizables y el CV por defecto. |
| `linkedin_usage` | Consumo frente a los topes diarios. |
| `linkedin_browser_snapshot` | Radiografía de la página: enlaces, botones y campos con referencia. |
| `linkedin_browser_navigate` / `_click` / `_type` / `_scroll` / `_screenshot` | Control manual del navegador. |
| `linkedin_voyager_request` | Llamada directa a la API interna de LinkedIn. |
---
## Cómo funciona la postulación
`linkedin_job_apply` **simula por defecto**. Recorre el asistente completo,
rellena lo que sabe y se detiene justo antes de enviar:
```json
{ "job": "4021234567" }
```
Si el formulario pide algo que no sabe, no inventa: devuelve
`status: "needs-answers"` con la lista exacta de preguntas, sus opciones y una
captura. Se las pasas y vuelve a intentar:
```json
{
"job": "4021234567",
"answers": { "Años de experiencia en Python": "5" },
"dryRun": false,
"confirm": true
}
```
Las respuestas quedan guardadas en el banco, así que la siguiente oferta que
pregunte lo mismo (aunque lo redacte distinto o en otro idioma) ya no se
atasca. Cuanto más completo el banco, menos intervención necesitas.
Detalles que importan:
- Las ofertas que no son Solicitud sencilla devuelven `status: "external"` con
el enlace de la empresa. No se puede completar desde LinkedIn.
- "Seguir a la empresa" viene marcado por defecto en LinkedIn; aquí se
**desmarca** salvo que pidas `followCompany: true`.
- Cada postulación queda registrada, así que no repites ofertas.
- El CV (`resumePath`, o el del banco) se **sube en cada postulación** con el
botón "Cargar currículum". LinkedIn solo lo guarda en tu cuenta cuando la
solicitud se envía: si se descarta, desaparece, así que no sirve subirlo una
vez y luego elegirlo de la lista. En la pantalla de revisión se comprueba
que el CV adjunto es el pedido; si no lo es, no se envía.
- Un desplegable que LinkedIn trae ya elegido (el correo de contacto, por
ejemplo) se cambia si el banco tiene un dato explícito distinto. El correo
tiene que estar registrado en tu cuenta de LinkedIn para aparecer.
- Si el asistente no avanza de un paso, se detiene con `status: "failed"`, la
pregunta que LinkedIn marca como obligatoria y la captura, en vez de
repetir "Siguiente" hasta el límite.
---
## Cuidados con la cuenta
Automatizar LinkedIn va contra sus condiciones de uso y las cuentas que se pasan
de volumen terminan restringidas. El proyecto está construido para quedarse muy
por debajo del umbral:
- **Toda acción visible para terceros exige `confirm: true`.** Invitaciones,
mensajes, comentarios, publicaciones y postulaciones no salen por accidente.
- **Topes diarios conservadores**, con contador persistente:
| Acción | Tope | Variable de entorno |
|---|---|---|
| Invitaciones enviadas | 20 | `LINKEDIN_PILOT_MAX_INVITATIONS` |
| Respuestas a invitaciones | 100 | `LINKEDIN_PILOT_MAX_INVITATION_RESPONSES` |
| Validaciones de aptitudes | 20 | `LINKEDIN_PILOT_MAX_ENDORSEMENTS` |
| Mensajes | 25 | `LINKEDIN_PILOT_MAX_MESSAGES` |
| Postulaciones | 20 | `LINKEDIN_PILOT_MAX_APPLICATIONS` |
| Reacciones | 50 | `LINKEDIN_PILOT_MAX_REACTIONS` |
| Comentarios | 15 | `LINKEDIN_PILOT_MAX_COMMENTS` |
| Seguimientos | 30 | `LINKEDIN_PILOT_MAX_FOLLOWS` |
| Publicaciones | 3 | `LINKEDIN_PILOT_MAX_POSTS` |
- **Ritmo humano**: pausas con variación aleatoria, escritura con cadencia
irregular y desplazamiento por pasos.
- **Un navegador normal**: Chrome real, ventana visible, sin el marcador
`navigator.webdriver`.
Subir los topes es tu decisión, pero los valores por defecto son lo que mantiene
la cuenta sana.
---
## Configuración
| Variable | Por defecto | Para qué |
|---|---|---|
| `LINKEDIN_PILOT_HOME` | `~/.linkedin-pilot` | Carpeta de datos |
| `LINKEDIN_PILOT_HEADLESS` | `false` | Ocultar la ventana (no recomendado) |
| `LINKEDIN_PILOT_CHANNEL` | `chrome` | `chrome`, `msedge` o `chromium` |
| `LINKEDIN_PILOT_LOCALE` | `es-CO` | Idioma del navegador |
| `LINKEDIN_PILOT_TZ` | `America/Bogota` | Zona horaria |
| `LINKEDIN_PILOT_REQUIRE_CONFIRM` | `true` | Exigir confirmación explícita |
| `LINKEDIN_PILOT_MIN_DELAY` / `_MAX_DELAY` | `700` / `2200` | Rango de pausa entre acciones (ms) |
| `LINKEDIN_PILOT_VERBOSE` | `false` | Diagnóstico detallado en stderr |
---
## Pruebas
```bash
npm test # emparejado de etiquetas + arranque del servidor MCP
npm run test:forms # motor de formularios contra el HTML real de LinkedIn
npm run test:live # editores de perfil, empleos y personas con tu sesión
```
`test:matching` cubre lo más delicado: que pedir un campo no escriba en otro,
que el glosario funcione en los dos idiomas y que el banco de respuestas no se
invente una respuesta para una pregunta que no conoce.
`test:live` necesita sesión iniciada. Abre los editores de perfil para
comprobar que exponen sus campos, y hace una búsqueda de empleos y otra de
personas. **No guarda ni envía nada.** Es lo que hay que correr cuando algo
deje de funcionar: dice en qué punto se rompió.
---
## Cuando LinkedIn cambie algo
Pasará. El orden para arreglarlo, de menos a más esfuerzo:
1. `linkedin_profile_inspect_form` o `linkedin_browser_snapshot` para ver qué
campos y botones hay **ahora**.
2. Terminar la tarea con `linkedin_browser_click` y `linkedin_browser_type`.
3. Si el cambio es permanente, ajustar la ruta en `SECTIONS`
(`src/tools/profile.ts`) o añadir el término nuevo al glosario
(`src/text.ts`). Casi nunca hace falta más.
TDQS
Scored across 39 tools
Most tools have clearly distinct resource+action purposes, and the high-level vs. browser-primitive vs. Voyager boundaries are usually clear. However, the generic profile_edit overlaps with specific wrappers like profile_headline, profile_about, profile_image, and profile_open_to_work, so an agent could reasonably choose either path for the same outcome.
All tools use snake_case with a consistent linkedin_ prefix, making the set predictable overall. Minor deviations exist, such as linkedin_posts_read (plural) versus linkedin_post_create/react/comment (singular), and linkedin_jobs_search versus linkedin_job_detail/apply/save.
With 39 tools, the set is well beyond the 25+ threshold that typically signals excessive surface area. While the domain is broad, several convenience wrappers and low-level browser primitives could be consolidated or exposed through fewer generic tools without losing core capability.
Core workflows are well covered: session management, profile editing, people search/connect/message, post interaction, job search/apply/save, usage monitoring, and low-level browser fallbacks. The notable gap is reading or managing the message inbox/conversations, though browser_snapshot/navigate and voyager_request provide workarounds.