This workflow follows the Gmail → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "JobN8N \u00b7 Buscador de vacantes",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 12
}
]
}
},
"id": "783284ae-5636-4a77-9793-ff7fbaaebe75",
"name": "Cada 12 horas",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-3168,
288
]
},
{
"parameters": {
"jsCode": "// \u2500\u2500 Generar busquedas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Arma una peticion HTTP por cada combinacion de (fuente x consulta x zona).\n// Editar las listas de abajo es la forma normal de ajustar que se busca:\n// no hace falta tocar ningun otro nodo.\n\n// \u2500\u2500 Que se busca (vale para las tres bolsas) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst CONSULTAS = [\n 'desarrollador junior',\n 'python developer',\n 'backend junior',\n 'rpa developer',\n 'power automate',\n 'becario sistemas',\n 'practicante sistemas',\n 'automation engineer',\n 'full stack junior',\n 'analista de datos junior',\n 'soporte tecnico junior',\n 'trainee software',\n];\n\n// \u2500\u2500 LinkedIn \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Es la fuente que mas peticiones gasta (zonas x consultas x paginas) y la\n// unica que castiga con 429 si se le pide rapido: de ahi el batchInterval de\n// 2.5 s del nodo \"Descargar OCC\". Si alguna vez llegan pocas vacantes de\n// LinkedIn, lo primero que hay que mirar es eso.\n// f_TPR: r86400 = 24 h, r604800 = 7 dias.\nconst LI = {\n activo: true,\n zonas: ['Monterrey, Nuevo Leon, Mexico', 'Mexico'],\n ventana: 'r604800',\n paginas: 2, // 10 vacantes por pagina\n};\n\n// \u2500\u2500 OCC Mundial \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// OCC arma la busqueda en la ruta, no en la query: los espacios van con guion.\n//\n// OJO con la zona. OCC solo entiende los slugs de ubicacion que el mismo\n// genera en sus propios filtros, y cuando recibe uno que no reconoce NO\n// falla: devuelve la busqueda nacional, como si no hubiera filtro. Eso es lo\n// que pasaba con 'en-monterrey-nuevo-leon', que no es un slug valido. Medido\n// el 2026-08-21: de 197 vacantes que traia OCC, 168 venian de CDMX, Jalisco,\n// Puebla o Estado de Mexico y \"Filtrar y puntuar\" las tiraba por zona.\n// Los dos slugs que OCC si reconoce para esta zona son:\n// 'en-nuevo-leon' -> todo el estado\n// 'en-nuevo-leon/en-la-ciudad-de-monterrey' -> solo el municipio\n// Se usa el del estado porque incluye San Pedro, San Nicolas, Apodaca,\n// Guadalupe, Santa Catarina y Escobedo, que para este perfil valen igual.\n//\n// tm es el filtro de fecha, en dias (1, 2, 3, 7, 14, 30, 60). Sin el llegan\n// vacantes de hace meses que \"Filtrar y puntuar\" acaba tirando igual.\n//\n// Con la zona bien puesta cada consulta devuelve menos de las 20 vacantes\n// que caben en una pagina, y la 2 sale vacia: por eso no se pagina.\nconst OCC = {\n activo: true,\n zona: 'en-nuevo-leon',\n dias: 7, // tm, en dias\n orden: 2, // 1 = relevancia, 2 = fecha\n // Cloudflare desafia a la IP de salida de n8n Cloud cuando pide\n // occ.com.mx. Ver el bloque \"Lector externo\" de mas abajo. Ponlo en\n // false si algun dia ejecutas n8n desde una IP que no este castigada:\n // la ruta directa funciona y es mas rapida.\n viaProxy: true,\n};\n\n// \u2500\u2500 Computrabajo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Igual que OCC, la busqueda va en la ruta y los espacios con guion. A\n// diferencia de OCC, si pagina de verdad (?p=2 trae otras 20 vacantes) y si\n// filtra por fecha con ?pubdate=N (en dias).\n// paginas: 1 a proposito. Medido el 2026-08-21: con pubdate=7 la pagina 2\n// devuelve 0 vacantes en todas las consultas, porque el filtro de fecha deja\n// menos de las 20 que caben en una pagina. Subirlo solo gasta peticiones.\nconst CT = {\n activo: true,\n zona: 'monterrey',\n dias: 7, // pubdate, en dias\n paginas: 1, // 20 vacantes por pagina\n};\n\n// \u2500\u2500 Indeed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Dos limitaciones propias de Indeed, ninguna se puede rodear desde aqui:\n//\n// 1. Solo entrega la primera pagina sin sesion iniciada: a partir de start=10\n// redirige al login. Por eso no hay paginacion y la cobertura se compensa\n// con mas consultas.\n// 2. Rechaza con 403 a los clientes HTTP de Node. Por eso esta version no\n// descarga Indeed por HTTP: usa sus correos de alerta mediante Gmail.\nconst IND = {\n // En n8n Cloud, Indeed NO se descarga: llega por sus correos de\n // alerta (nodo \"Leer alertas Indeed\"). Por eso aqui va apagado.\n activo: false,\n zonas: ['Monterrey, N.L.', 'Nuevo Leon'],\n dias: 7, // fromage\n};\n\n// LinkedIn, OCC y Computrabajo se controlan con activo. Indeed debe permanecer\n// apagado aqui porque entra por \"Leer alertas Indeed\" y no por una URL directa.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n// \u2500\u2500 Lector externo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// OCC no se puede pedir directamente desde n8n Cloud. Medido el\n// 2026-08-21 en una ejecucion real: las 12 peticiones volvieron con 403 y\n// el cuerpo del desafio de Cloudflare (\"Just a moment...\"). No es la\n// huella TLS de Node (fetch de Node 22 con User-Agent de Chrome saca 200\n// desde una IP normal) ni son las cabeceras (las 72 busquedas dan 72x200\n// desde una IP residencial): es que Cloudflare castiga la IP de salida de\n// n8n Cloud, y el nodo HTTP no puede resolver el desafio porque no\n// ejecuta JavaScript.\n//\n// La solucion es que la peticion la haga alguien con otra IP. Jina Reader\n// devuelve el HTML tal cual cuando se le manda la cabecera\n// \"X-Return-Format: html\", que ya va en el nodo \"Descargar OCC\". Con\n// eso el parser de OCC no cambia ni una linea y los ids salen iguales,\n// asi que la deduplicacion y la memoria de \"ya enviadas\" siguen valiendo.\n//\n// Cuesta ~0.9 s por pagina y sin API key admite 20 peticiones por minuto:\n// de ahi el batchInterval de 3.5 s del nodo \"Descargar fuentes\".\n// Suplente probado el mismo dia por si Jina se cae:\n// https://api.allorigins.win/raw?url=<url codificada>\n// Devuelve lo mismo pero tarda ~14 s por pagina, asi que es cambio manual.\nconst LECTOR = 'https://r.jina.ai/';\nconst conLector = (url) => LECTOR + url;\n\nconst peticiones = [];\nconst agregar = (fuente, url, etiqueta) =>\n peticiones.push({ json: { fuente: fuente, url: url, etiqueta: etiqueta } });\n\nif (LI.activo) {\n for (const zona of LI.zonas) {\n for (const q of CONSULTAS) {\n for (let p = 0; p < LI.paginas; p++) {\n agregar('LinkedIn',\n 'https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search'\n + '?keywords=' + encodeURIComponent(q)\n + '&location=' + encodeURIComponent(zona)\n + '&f_TPR=' + LI.ventana\n + '&start=' + (p * 10),\n q + ' @ ' + zona + ' p' + (p + 1));\n }\n }\n }\n}\n\n// \"becario sistemas\" -> \"becario-sistemas\", que es como OCC y Computrabajo\n// arman la ruta de busqueda.\nconst aRuta = (q) => q.normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nif (OCC.activo) {\n for (const q of CONSULTAS) {\n const url = 'https://www.occ.com.mx/empleos/de-' + aRuta(q) + '/' + OCC.zona\n + '?sort=' + OCC.orden + '&tm=' + OCC.dias;\n agregar('OCC', OCC.viaProxy ? conLector(url) : url, q + ' @ OCC');\n }\n}\n\nif (CT.activo) {\n for (const q of CONSULTAS) {\n for (let p = 1; p <= CT.paginas; p++) {\n agregar('Computrabajo',\n 'https://mx.computrabajo.com/trabajo-de-' + aRuta(q) + '-en-' + CT.zona\n + '?pubdate=' + CT.dias + (p > 1 ? '&p=' + p : ''),\n q + ' @ Computrabajo p' + p);\n }\n }\n}\n\nif (IND.activo) {\n for (const zona of IND.zonas) {\n for (const q of CONSULTAS) {\n agregar('Indeed',\n 'https://mx.indeed.com/jobs'\n + '?q=' + encodeURIComponent(q)\n + '&l=' + encodeURIComponent(zona)\n + '&fromage=' + IND.dias\n + '&sort=date',\n q + ' @ ' + zona);\n }\n }\n}\n\nconst porFuente = {};\nfor (const p of peticiones) porFuente[p.json.fuente] = (porFuente[p.json.fuente] || 0) + 1;\nconsole.log('Busquedas generadas: ' + peticiones.length + ' -> ' + JSON.stringify(porFuente));\n\nif (peticiones.length === 0) {\n throw new Error('No se genero ninguna busqueda: revisa que al menos una fuente '\n + 'tenga activo: true y que CONSULTAS no este vacio.');\n}\n\nreturn peticiones;\n"
},
"id": "feab7bb3-562a-4e3d-9aa5-6affe30cdc64",
"name": "Generar busquedas",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2944,
400
]
},
{
"parameters": {
"resource": "message",
"operation": "getAll",
"returnAll": false,
"limit": 40,
"simple": false,
"filters": {
"q": "from:(indeed.com OR indeedemail.com) newer_than:3d",
"readStatus": "both"
},
"options": {}
},
"id": "f4d0b6e5-7c4b-4d90-ae85-4b1a6f0c5d77",
"name": "Leer alertas Indeed",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
-2944,
160
],
"alwaysOutputData": true,
"onError": "continueRegularOutput"
},
{
"parameters": {
"mode": "append",
"numberInputs": 3,
"options": {}
},
"id": "e3c9a5d4-6b3a-4c8f-9d74-3a0f5e9b4c66",
"name": "Juntar fuentes",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
-2496,
288
]
},
{
"parameters": {
"url": "={{ $json.url }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
},
{
"name": "Accept",
"value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
},
{
"name": "Accept-Language",
"value": "es-MX,es;q=0.9,en;q=0.8"
},
{
"name": "sec-ch-ua",
"value": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\""
},
{
"name": "sec-ch-ua-mobile",
"value": "?0"
},
{
"name": "sec-ch-ua-platform",
"value": "\"Windows\""
},
{
"name": "Upgrade-Insecure-Requests",
"value": "1"
},
{
"name": "Sec-Fetch-Dest",
"value": "document"
},
{
"name": "Sec-Fetch-Mode",
"value": "navigate"
},
{
"name": "Sec-Fetch-Site",
"value": "none"
},
{
"name": "Sec-Fetch-User",
"value": "?1"
}
]
},
"options": {
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 2500
}
},
"response": {
"response": {
"responseFormat": "text",
"neverError": true,
"fullResponse": true
}
},
"redirect": {
"redirect": {
"followRedirects": true
}
},
"timeout": 30000
}
},
"id": "894dee71-4944-426a-9109-d3fbfaa59585",
"name": "Descargar fuentes",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-2650,
480
],
"alwaysOutputData": true,
"onError": "continueRegularOutput",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2500
},
{
"parameters": {
"jsCode": "// \u2500\u2500 Parsear vacantes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Convierte la respuesta cruda de LinkedIn, OCC e Indeed a un esquema comun.\n//\n// OJO: el nodo HTTP Request NO conserva los campos del item de entrada, su\n// salida es solo la respuesta. Por eso la fuente NO se lee de $json.fuente:\n// se reconoce por la huella del propio contenido. Cada parser revisa su marca\n// y devuelve vacio si la pagina no le corresponde, asi que el orden da igual.\n//\n// Esquema de salida (identico para las tres fuentes):\n// id, fuente, titulo, empresa, ubicacion, salario, url,\n// descripcion, skills[], publicado (ISO o ''), publicadoTexto,\n// diasAntiguedad (numero o null), remoto (bool o null), destacado (bool)\n\nconst ENTIDADES = {\n amp: '&', lt: '<', gt: '>', quot: '\"', apos: \"'\", nbsp: ' ',\n aacute: 'a', eacute: 'e', iacute: 'i', oacute: 'o', uacute: 'u',\n ntilde: 'n', Ntilde: 'N', uuml: 'u', mdash: '-', ndash: '-', hellip: '...',\n};\n\nfunction limpiar(s) {\n if (!s) return '';\n return String(s)\n .replace(/<br\\s*\\/?>/gi, ' ')\n .replace(/<\\/(li|p|div|h\\d)>/gi, ' . ')\n .replace(/<[^>]+>/g, ' ')\n .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))\n .replace(/&#(\\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))\n .replace(/&(\\w+);/g, (m, n) => (ENTIDADES[n] !== undefined ? ENTIDADES[n] : m))\n .replace(/\\s*\\.\\s*(\\.\\s*)+/g, '. ')\n .replace(/\\s+/g, ' ')\n // pega la puntuacion suelta a la palabra anterior, pero solo si despues\n // viene un espacio o el final: asi \".NET\" y \"N. L.\" quedan intactos\n .replace(/\\s+([.,;:])(?=\\s|$)/g, '$1')\n .replace(/^[\\s.,;:]+/, '')\n .trim();\n}\n\nfunction sacar(re, texto) {\n const m = texto.match(re);\n return m ? limpiar(m[1]) : '';\n}\n\n// El cuerpo llega como texto desde HTTP Request o desde Gmail. Si un nodo\n// falla con continueRegularOutput llega un .error, que se contabiliza antes.\nfunction cuerpo(json) {\n if (typeof json === 'string') return json;\n if (!json || typeof json !== 'object') return '';\n\n const leer = (valor) => {\n if (typeof valor === 'string' && valor.trim()) return valor;\n if (!valor || typeof valor !== 'object') return '';\n for (const k of ['data', 'body', 'html', 'text', 'result', 'response']) {\n if (typeof valor[k] === 'string' && valor[k].trim()) return valor[k];\n }\n return '';\n };\n\n for (const k of ['data', 'body', 'html', 'text', 'result', 'stdout', 'response', 'error']) {\n const hallado = leer(json[k]);\n if (hallado) return hallado;\n }\n return '';\n}\n\nconst HOY = new Date();\n\nfunction diasDesdeEpoch(ms) {\n if (!isFinite(ms)) return null;\n return Math.max(0, Math.floor((HOY.getTime() - ms) / 86400000));\n}\n\nfunction diasDesdeISO(iso) {\n if (!iso) return null;\n return diasDesdeEpoch(Date.parse(iso + (iso.length === 10 ? 'T12:00:00Z' : '')));\n}\n\n// Se prefiere siempre el primer valor que no sea null.\nfunction primero() {\n for (const v of arguments) if (v !== null && v !== undefined) return v;\n return null;\n}\n\n// \"Hoy\" / \"Ayer\" / \"Hace 3 dias\" / \"Publicado hace 2 semanas\" -> numero de dias\nfunction diasDesdeTexto(txt) {\n const t = (txt || '').toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n if (!t) return null;\n if (/\\b(hoy|recien|reciente|justo ahora|hace unos? (minuto|hora)|hace \\d+ (minuto|hora))/.test(t)) return 0;\n if (/\\bayer\\b/.test(t)) return 1;\n const m = t.match(/(\\d+)\\s*(minuto|hora|dia|semana|mes)/);\n if (!m) return null;\n const n = parseInt(m[1], 10);\n if (m[2] === 'minuto' || m[2] === 'hora') return 0;\n if (m[2] === 'dia') return n;\n if (m[2] === 'semana') return n * 7;\n return n * 30;\n}\n\nfunction vacante(v) {\n return {\n id: v.id,\n fuente: v.fuente,\n titulo: v.titulo || '',\n empresa: v.empresa || 'No especificada',\n ubicacion: v.ubicacion || '',\n salario: v.salario || '',\n url: v.url || '',\n descripcion: (v.descripcion || '').slice(0, 600),\n skills: Array.isArray(v.skills) ? v.skills.slice(0, 12) : [],\n publicado: v.publicado || '',\n publicadoTexto: v.publicadoTexto || '',\n diasAntiguedad: v.diasAntiguedad === undefined ? null : v.diasAntiguedad,\n // Prestaciones: lo unico que OCC publica en el listado. NO son aptitudes,\n // por eso van en su propio campo: metidas en skills, la IA las evaluaba\n // como stack tecnico (\"Vales de despensa\") y \"Filtrar y puntuar\" creia\n // que la vacante venia con descripcion cuando no la tiene.\n prestaciones: Array.isArray(v.prestaciones) ? v.prestaciones.slice(0, 8) : [],\n remoto: v.remoto === undefined ? null : v.remoto,\n destacado: !!v.destacado,\n };\n}\n\n// \u2500\u2500 LinkedIn (jobs-guest): fragmento de <li> con tarjetas base-search-card \u2500\u2500\nfunction parsearLinkedIn(html) {\n const out = [];\n if (!html.includes('base-search-card')) return out;\n\n for (const card of html.split(/(?=<div class=\"base-card)/)) {\n if (!card.includes('base-search-card__title')) continue;\n const id = sacar(/data-entity-urn=\"urn:li:jobPosting:(\\d+)\"/, card);\n const link = sacar(/<a class=\"base-card__full-link[^\"]*\"[^>]*href=\"([^\"]+)\"/, card);\n if (!id || !link) continue;\n\n const iso = (card.match(/<time[^>]*datetime=\"(\\d{4}-\\d{2}-\\d{2})\"/) || [])[1] || '';\n const rel = sacar(/<time[^>]*>([\\s\\S]*?)<\\/time>/, card);\n\n out.push(vacante({\n id: 'li-' + id,\n fuente: 'LinkedIn',\n titulo: sacar(/<h3 class=\"base-search-card__title\">([\\s\\S]*?)<\\/h3>/, card),\n empresa: sacar(/<h4 class=\"base-search-card__subtitle\">[\\s\\S]*?<a[^>]*>([\\s\\S]*?)<\\/a>/, card)\n || sacar(/<h4 class=\"base-search-card__subtitle\">([\\s\\S]*?)<\\/h4>/, card),\n ubicacion: sacar(/<span class=\"job-search-card__location\">([\\s\\S]*?)<\\/span>/, card),\n salario: sacar(/<span class=\"job-search-card__salary-info\">([\\s\\S]*?)<\\/span>/, card),\n url: link.split('?')[0],\n publicado: iso,\n publicadoTexto: rel,\n // El texto (\"Hace 8 horas\") es mas fino que el datetime, que solo trae dia.\n diasAntiguedad: primero(diasDesdeTexto(rel), diasDesdeISO(iso)),\n destacado: /job-search-card__listdate--new/.test(card),\n }));\n }\n return out;\n}\n\n// \u2500\u2500 OCC Mundial: HTML renderado en servidor, tarjetas .card-job-offer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// El HTML puede llegar por dos caminos y el formato es el mismo en los dos:\n// directo desde occ.com.mx, o a traves del lector externo que configura\n// \"Generar busquedas\" (hoy es asi, porque Cloudflare desafia a la IP de\n// n8n Cloud). Este parser no distingue ni le hace falta.\nfunction parsearOCC(html) {\n const out = [];\n const texto = String(html || '');\n // La pagina de OCC puede llegar con clases utilitarias distintas, pero\n // siempre conserva la marca de la tarjeta o el enlace de la oferta.\n if (!/card-job-offer|jobcard-\\d+|\\/empleo\\/oferta\\/\\d+/i.test(texto)) return out;\n\n const vistos = new Set();\n // El corte a 6000 caracteres no es cosmetico: como se parte por el INICIO\n // de cada tarjeta, el ultimo trozo se queda con todo lo que viene detras de\n // la ultima vacante (paginador, pie, busquedas relacionadas), unos 127 KB.\n // Sin el tope, esa ultima tarjeta se llevaba los <li> del paginador como si\n // fueran prestaciones (\"1\", \"3\", \"6\"). Medido el 2026-08-21: la tarjeta mas\n // larga de OCC ocupa 4464 caracteres, asi que 6000 deja margen de sobra.\n const tarjetas = texto.split(/(?=<(?:div|article|li)\\b[^>]*\\bcard-job-offer\\b)/i)\n .filter((trozo) => /<(?:div|article|li)\\b[^>]*\\bcard-job-offer\\b/i.test(trozo))\n .map((trozo) => trozo.slice(0, 6000));\n const spanTextos = (card) => (card.match(/<span\\b[^>]*>([\\s\\S]*?)<\\/span>/gi) || [])\n .map((s) => limpiar(s)).filter(Boolean);\n const idDe = (card) => sacar(/data-id\\s*=\\s*[\"'](\\d+)[\"']/i, card)\n || sacar(/id\\s*=\\s*[\"']jobcard-(\\d+)[\"']/i, card)\n || sacar(/(?:\\/empleo\\/oferta\\/|[?&]jobid=)(\\d+)/i, card);\n\n const agregar = (v) => {\n if (!v.id || !v.titulo || vistos.has(v.id)) return;\n vistos.add(v.id);\n out.push(vacante(v));\n };\n\n for (const card of tarjetas) {\n const id = idDe(card);\n if (!id) continue;\n\n // h2 es el formato actual; h1/h3/h4 cubren variantes de OCC y paginas\n // simplificadas que entrega el CDN cuando cambia la maquetacion.\n const titulo = sacar(/<h[1-4]\\b[^>]*>([\\s\\S]*?)<\\/h[1-4]>/i, card)\n || sacar(/(?:data-title|aria-label)\\s*=\\s*[\"']([^\"']{6,})[\"']/i, card);\n if (!titulo) continue;\n\n const spans = spanTextos(card);\n const rel = sacar(/<span\\b[^>]*class=[\"'][^\"']*\\bfont-light\\b[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/span>/i, card)\n || spans.find((s) => /^(hoy|ayer|anteayer|hace\\s+\\d+|publicado)/i.test(s)) || '';\n const salario = spans.find((s) => /\\$\\s?[\\d.,]+|sueldo|salario|no mostrado|no especificado/i.test(s)) || '';\n\n // Las prestaciones son el unico texto libre de una tarjeta OCC. limpiar()\n // convierte el </li> en \" . \", asi que cada una sale con un punto pegado\n // al final; se le quita para no acabar con \"de ley.. Fondo de ahorro\".\n const bloqueUl = (card.match(/<ul\\b[^>]*>([\\s\\S]*?)<\\/ul>/i) || ['', ''])[0];\n const prestaciones = (bloqueUl.match(/<li\\b[^>]*>([\\s\\S]*?)<\\/li>/gi) || [])\n .map((li) => limpiar(li).replace(/[.,;:]+$/, ''))\n // Cuando la tarjeta no trae prestaciones, el primer <ul> del trozo es el\n // paginador de OCC, que va justo despues de la ultima tarjeta. Sus <li>\n // son numeros de pagina y flechas: se descartan exigiendo texto de verdad.\n .filter((t) => t.length >= 4 && t.length <= 90 && /[a-zA-Z\u00c0-\u024f]/.test(t));\n\n agregar({\n id: 'occ-' + id,\n fuente: 'OCC',\n titulo: titulo,\n empresa: sacar(/<span\\b[^>]*class=[\"'][^\"']*\\bline-clamp-title\\b[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/span>/i, card)\n || sacar(/<div\\b[^>]*class=[\"'][^\"']*h-\\[21px\\][^\"']*[\"'][^>]*>\\s*<span\\b[^>]*>([\\s\\S]*?)<\\/span>/i, card)\n || sacar(/(?:empresa|company)[^>]*>([\\s\\S]*?)<\\//i, card)\n || 'Empresa confidencial',\n ubicacion: sacar(/<div\\b[^>]*class=[\"'][^\"']*\\bno-alter-loc-text\\b[^\"']*[\"'][^>]*>[\\s\\S]*?<p\\b[^>]*>([\\s\\S]*?)<\\/p>/i, card)\n || sacar(/<div\\b[^>]*class=[\"'][^\"']*\\bno-alter-loc-text\\b[^\"']*[\"'][^>]*>([\\s\\S]*?)<\\/div>/i, card)\n || sacar(/(?:ubicacion|location)[^>]*>([\\s\\S]*?)<\\//i, card),\n salario: salario,\n url: 'https://www.occ.com.mx/empleo/oferta/' + id,\n // El listado de OCC no trae descripcion ni aptitudes: solo prestaciones.\n descripcion: '',\n skills: [],\n prestaciones: prestaciones,\n publicadoTexto: rel,\n diasAntiguedad: diasDesdeTexto(rel),\n destacado: /no-alter-featured-tag|is-highlighted|featured/i.test(card.slice(0, 700)),\n });\n }\n\n // Fallback para una respuesta OCC sin tarjetas (por ejemplo, una variante\n // SSR o un cambio de clases): toma los enlaces de oferta y su texto visible.\n if (out.length === 0) {\n const reLink = /<a\\b[^>]*href=[\"']([^\"']*\\/empleo\\/oferta\\/(\\d+)[^\"']*)[\"'][^>]*>([\\s\\S]*?)<\\/a>/gi;\n let m;\n while ((m = reLink.exec(texto)) !== null) {\n const titulo = limpiar(m[3]);\n if (titulo.length < 6 || titulo.length > 140) continue;\n agregar({\n id: 'occ-' + m[2], fuente: 'OCC', titulo: titulo,\n empresa: 'Empresa confidencial', ubicacion: '', salario: '',\n url: 'https://www.occ.com.mx/empleo/oferta/' + m[2],\n descripcion: '', skills: [], publicadoTexto: '', diasAntiguedad: null,\n destacado: false,\n });\n }\n }\n\n // Ultimo fallback: ItemList JSON-LD. No depende de clases CSS y permite\n // rescatar ofertas cuando OCC entrega solo datos estructurados.\n if (out.length === 0) {\n const bloques = texto.match(/<script\\b[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi) || [];\n for (const bloque of bloques) {\n const crudo = (bloque.match(/>([\\s\\S]*)<\\/script>/i) || ['', ''])[1].trim();\n let dato;\n try { dato = JSON.parse(crudo); } catch (e) { continue; }\n const entradas = Array.isArray(dato) ? dato\n : (Array.isArray(dato.itemListElement) ? dato.itemListElement : []);\n for (const entrada of entradas) {\n const item = entrada && (entrada.item || entrada);\n const url = item && typeof item.url === 'string' ? item.url : '';\n const id = (url.match(/\\/empleo\\/oferta\\/(\\d+)/i) || [])[1];\n const titulo = limpiar(item && (item.name || item.title) || '');\n if (!id || titulo.length < 6) continue;\n agregar({\n id: 'occ-' + id, fuente: 'OCC', titulo: titulo,\n empresa: limpiar(item.hiringOrganization && (item.hiringOrganization.name || item.hiringOrganization)) || 'Empresa confidencial',\n ubicacion: limpiar(item.jobLocation && (item.jobLocation.address && (item.jobLocation.address.addressLocality || item.jobLocation.address.addressRegion) || item.jobLocation)) || '',\n salario: limpiar(item.baseSalary && (item.baseSalary.value || item.baseSalary)) || '',\n url: 'https://www.occ.com.mx/empleo/oferta/' + id,\n descripcion: limpiar(item.description || ''), skills: [],\n publicado: typeof item.datePosted === 'string' ? item.datePosted : '',\n publicadoTexto: '',\n diasAntiguedad: typeof item.datePosted === 'string' ? diasDesdeISO(item.datePosted) : null,\n destacado: false,\n });\n }\n }\n }\n return out;\n}\n\n// \u2500\u2500 Computrabajo: HTML renderado en servidor, tarjetas <article class=box_offer>\nfunction parsearComputrabajo(html) {\n const out = [];\n if (!html.includes('box_offer')) return out;\n\n for (const card of html.split(/(?=<article class=\"box_offer)/)) {\n const id = sacar(/data-id='([0-9A-Fa-f]+)'/, card);\n if (!id) continue;\n\n // El titulo y el enlace salen del mismo <a>, dentro del <h2>.\n const mt = card.match(/<h2 class=\"fs18[^\"]*\">\\s*<a class=\"js-o-link[^\"]*\"[^>]*href=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/a>/);\n if (!mt) continue;\n const titulo = limpiar(mt[2]);\n if (!titulo) continue;\n const ruta = mt[1].split('#')[0];\n\n // El bloque fs13 mezcla sueldo y modalidad en <span> anidados, asi que se\n // limpia entero y se leen del texto: intentar recortar cada <span> falla\n // porque el primer </span> que aparece es el del icono, no el del dato.\n const bloque = limpiar((card.match(/<div class=\"fs13 mt15\">([\\s\\S]*?)<\\/div>/) || ['', ''])[1]);\n const sueldo = (bloque.match(/\\$\\s?[\\d.,]+(?:\\s*\\([^)]*\\))?/) || [''])[0];\n const rel = sacar(/<p class=\"fs13 fc_aux mt15\">([\\s\\S]*?)<\\/p>/, card);\n\n // Computrabajo distingue \"Remoto\" de \"Presencial y remoto\", que es un\n // hibrido y obliga a presentarse. La alternativa larga va primero: si\n // fuera al reves, /remoto/ casaria antes y se perderia el matiz.\n const modo = (bloque.match(/(presencial y remoto|home ?office|teletrabajo|hibrido|remoto|presencial)/i) || [''])[0];\n\n out.push(vacante({\n id: 'ct-' + id,\n fuente: 'Computrabajo',\n titulo: titulo,\n // El <p> de la empresa empieza por \"dFlex\" y el de la ubicacion por\n // \"fs16\": por eso la clase se exige completa y no se confunden.\n empresa: sacar(/<a class=\"fc_base t_ellipsis\"[^>]*>([\\s\\S]*?)<\\/a>/, card) || 'Empresa confidencial',\n ubicacion: sacar(/<p class=\"fs16 fc_base mt5\">\\s*<span class=\"mr10\">([\\s\\S]*?)<\\/span>/, card),\n salario: sueldo,\n url: 'https://mx.computrabajo.com' + ruta,\n // La modalidad viaja en descripcion para que el nodo de puntuacion la\n // vea: es lo unico parecido a texto libre que da Computrabajo.\n descripcion: modo,\n skills: [],\n publicadoTexto: rel,\n diasAntiguedad: diasDesdeTexto(rel),\n remoto: /remoto|home ?office|teletrabajo/i.test(modo) && !/presencial/i.test(modo),\n destacado: /fc_urgent|fc_dest/.test(card.slice(0, 600)),\n }));\n }\n return out;\n}\n\n// \u2500\u2500 Indeed: los resultados vienen en un JSON embebido, no en el HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// window.mosaic.providerData[\"mosaic-provider-jobcards\"] = { ... }\n// Se recorta con balanceo de llaves porque el objeto trae llaves dentro de\n// cadenas y cualquier regex se equivoca.\nfunction recorteBalanceado(txt, desde) {\n const i = txt.indexOf('{', desde);\n if (i === -1) return null;\n let prof = 0, enCadena = false, escapado = false;\n for (let k = i; k < txt.length; k++) {\n const c = txt[k];\n if (enCadena) {\n if (escapado) escapado = false;\n else if (c === '\\\\') escapado = true;\n else if (c === '\"') enCadena = false;\n continue;\n }\n if (c === '\"') enCadena = true;\n else if (c === '{') prof++;\n else if (c === '}') { prof--; if (prof === 0) return txt.slice(i, k + 1); }\n }\n return null;\n}\n\nfunction parsearIndeed(html) {\n const out = [];\n const marca = html.indexOf('mosaic-provider-jobcards\"]=');\n if (marca === -1) return out;\n\n const crudo = recorteBalanceado(html, marca);\n if (!crudo) return out;\n\n let datos;\n try { datos = JSON.parse(crudo); } catch (e) { return out; }\n\n const modelo = (datos.metaData || {}).mosaicProviderJobCardsModel || {};\n const resultados = Array.isArray(modelo.results) ? modelo.results : [];\n\n for (const r of resultados) {\n if (!r || !r.jobkey) continue;\n const iso = r.createDate ? new Date(r.createDate).toISOString().slice(0, 10) : '';\n // Indeed publica las aptitudes que pide la vacante repartidas en tres\n // listas segun si el usuario las tiene; sin sesion todas caen en la de\n // \"no coincide\", asi que se juntan las tres y se usan como skills.\n const m = r.jobSeekerMatchSummaryModel || {};\n const skills = []\n .concat((r.taxoAttributes || []).map((a) => a && (a.label || a.attribute)))\n .concat(m.sortedEntityDisplayText || [])\n .concat(m.sortedMatchingEntityDisplayText || [])\n .concat(m.sortedMisMatchingEntityDisplayText || [])\n .filter((x) => typeof x === 'string' && x)\n .filter((x, i, a) => a.indexOf(x) === i);\n\n out.push(vacante({\n id: 'ind-' + r.jobkey,\n fuente: 'Indeed',\n titulo: limpiar(r.displayTitle || r.title),\n empresa: limpiar(r.company || r.truncatedCompany) || 'Empresa confidencial',\n ubicacion: limpiar(r.formattedLocation\n || [r.jobLocationCity, r.jobLocationState].filter(Boolean).join(', ')),\n salario: limpiar((r.salarySnippet && (r.salarySnippet.text || r.salarySnippet.salaryText)) || ''),\n url: 'https://mx.indeed.com/viewjob?jk=' + r.jobkey,\n descripcion: limpiar(r.snippet || ''),\n skills: skills,\n publicado: iso,\n publicadoTexto: limpiar(r.formattedRelativeTime || ''),\n diasAntiguedad: primero(\n diasDesdeEpoch(r.createDate || r.pubDate),\n diasDesdeTexto(r.formattedRelativeTime)),\n remoto: !!r.remoteLocation,\n destacado: !!(r.urgentlyHiring || r.newJob),\n }));\n }\n return out;\n}\n\n// \u2500\u2500 Indeed por correo de alerta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Indeed rechaza a los clientes HTTP de Node (que es lo que usa el nodo HTTP\n// Request de n8n) mirando el cliente TLS, no las cabeceras. En n8n Cloud no\n// hay forma de pedirle la pagina. Su propio canal de suscripcion si funciona:\n// las alertas de empleo que Indeed manda por correo, que aqui se leen con la\n// credencial de Gmail que ya usa el flujo para enviar.\n//\n// El maquetado de esos correos cambia sin aviso, asi que este parser va de lo\n// mas fiable a lo menos:\n// 1. anclas cuyo href lleva el jk= de la vacante (identificador real)\n// 2. si el enlace viene envuelto en un redirector opaco, cualquier ancla a\n// indeed.com con texto que parezca un titulo\n// 3. la ubicacion sale del asunto (\"...en Monterrey\") cuando no se puede\n// sacar de cada tarjeta: la alerta ya viene acotada a una zona\n\n// Anclado solo al principio: asi \"Cancelar suscripcion\" tambien cae.\nconst RUIDO = new RegExp('^(indeed|ver (todos|mas|empleo)|mas empleos|cancelar|'\n + 'darse de baja|baja|preferencias|ayuda|privacidad|terminos|configuracion|'\n + 'subir cv|actualizar|iniciar sesion|postularme|unsubscribe|view all|see all|'\n + 'no te pierdas|encuentra)', 'i');\n\n// Minusculas y sin acentos, igual que en el nodo de puntuacion.\nconst norm = (s) => (s || '').toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n\nconst ES_LUGAR = /\\b(remoto|remote|home ?office|hibrido|monterrey|nuevo leon|n\\.? ?l\\.?|san pedro|san nicolas|guadalupe|apodaca|santa catarina|escobedo|garcia|juarez|cdmx|ciudad de mexico|jalisco|guadalajara|queretaro|mexico)\\b/;\n\nfunction asuntoDe(json) {\n if (!json || typeof json !== 'object') return '';\n if (typeof json.subject === 'string') return json.subject;\n const h = json.headers || {};\n if (typeof h.subject === 'string') return h.subject;\n if (Array.isArray(h)) {\n const s = h.find((x) => x && String(x.name).toLowerCase() === 'subject');\n if (s) return String(s.value || '');\n }\n return '';\n}\n\n// \"Nuevos empleos de python en Monterrey, N. L.\" -> \"Monterrey, N. L.\"\nfunction zonaDelAsunto(asunto) {\n const m = limpiar(asunto).match(/\\ben\\s+([^|:\\-\u2013\u2014]{3,60})$/i);\n return m ? m[1].trim() : '';\n}\n\nfunction parsearAlertaIndeed(html, json) {\n const out = [];\n // Una pagina de resultados NO es un correo: la reconoce el parser de arriba.\n if (html.indexOf('mosaic-provider-jobcards\"]=') !== -1) return out;\n if (!/indeed/i.test(html.slice(0, 4000)) && !/indeed/i.test(asuntoDe(json))) return out;\n\n const zonaAlerta = zonaDelAsunto(asuntoDe(json));\n\n // Cada ancla y la porcion de correo que le sigue, que es donde Indeed pone\n // empresa, ubicacion y sueldo.\n const re = /<a\\b[^>]*href=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/a>/gi;\n const vistosAqui = new Set();\n let m;\n\n while ((m = re.exec(html)) !== null) {\n const href = m[1].replace(/&/g, '&');\n const titulo = limpiar(m[2]);\n\n if (!titulo || titulo.length < 6 || titulo.length > 120) continue;\n if (RUIDO.test(titulo)) continue;\n if (!/indeed\\.com|indeedemail\\.com|jk=/i.test(href)) continue;\n\n const jk = (href.match(/[?&]jk=([0-9a-zA-Z]{8,24})/) || [])[1];\n\n // Identificador: el jk real si viene, y si no una clave estable a partir\n // del titulo, para que la memoria de \"ya enviadas\" siga funcionando.\n const clave = jk || limpiar(titulo).toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9]/g, '').slice(0, 40);\n if (!clave || vistosAqui.has(clave)) continue;\n vistosAqui.add(clave);\n\n // Trozo de correo entre este enlace y el siguiente. Sin ese corte, una\n // tarjeta se traga los datos de la de abajo cuando no hay separadores.\n const desde = m.index + m[0].length;\n const hueco = html.slice(desde, desde + 900);\n const sig = hueco.search(/<a\\b[^>]*href=/i);\n const bloque = sig === -1 ? hueco : hueco.slice(0, sig);\n\n // Se trocea por los limites de bloque del HTML, no por la puntuacion\n // del texto: partiendo por puntos, \"N. L.\" se rompe en dos.\n const segmentos = bloque\n .split(/<br\\s*\\/?>|<\\/(?:div|td|p|tr|span|h\\d|li)>/i)\n .map(limpiar)\n .filter((s) => s.length > 1 && s.length < 90 && !RUIDO.test(s));\n\n // Indeed pone siempre titulo, empresa y ubicacion en ese orden, asi que\n // manda la posicion y el patron de lugar solo desempata. Al reves\n // fallaria con empresas como \"Grupo Tec de Monterrey\", que parecen\n // una ubicacion.\n const iSueldo = segmentos.findIndex((s) => /\\$\\s?[\\d.,]{3,}/.test(s));\n const sueldo = iSueldo === -1 ? '' : segmentos[iSueldo];\n let resto = segmentos.filter((s, i) => i !== iSueldo);\n\n // Hay plantillas que juntan empresa y ubicacion en una sola linea\n // (\"Neoris - Monterrey, N. L.\"). Se parte solo cuando la cola parece un\n // lugar: asi un nombre con guion pegado (Coca-Cola) no se rompe.\n const partido = [];\n for (const s of resto) {\n const m = s.match(/^(.{2,60}?)\\s+[-\u2013\u2014\u00b7|]\\s+(.{2,60})$/);\n if (m && ES_LUGAR.test(norm(m[2]))) partido.push(m[1], m[2]);\n else partido.push(s);\n }\n resto = partido;\n\n // Excepcion a la regla de la posicion: si el primer segmento es el UNICO\n // que parece un lugar, entonces es la ubicacion y la alerta venia sin\n // empresa. Sin esto, \"Trainee de Software / Nuevo Leon\" acababa con\n // \"Nuevo Leon\" como nombre del empleador en el correo.\n const lugares = resto.filter((s) => ES_LUGAR.test(norm(s)));\n const primeroEsLugar = resto.length > 0 && lugares.length === 1\n && ES_LUGAR.test(norm(resto[0]));\n\n const empresa = primeroEsLugar ? '' : (resto[0] || '');\n const ubicTrozo = primeroEsLugar ? resto[0]\n : (resto.slice(1).find((s) => ES_LUGAR.test(norm(s))) || resto[1] || '');\n\n out.push(vacante({\n id: 'ind-' + clave,\n fuente: 'Indeed',\n titulo: titulo,\n empresa: empresa || 'Empresa confidencial',\n // Si no se pudo leer la ubicacion de la tarjeta, se usa la de la alerta:\n // el correo ya viene acotado a la zona que configuro el usuario.\n ubicacion: ubicTrozo || zonaAlerta,\n salario: sueldo,\n url: jk ? 'https://mx.indeed.com/viewjob?jk=' + jk : href,\n descripcion: '',\n skills: [],\n // La alerta no dice cuando se publico la vacante, solo que es nueva.\n // Se deja en null en vez de inventar una fecha: asi el correo muestra\n // \"Por alerta\" y el filtro de antiguedad la deja pasar (solo descarta\n // cuando conoce la fecha y es vieja). La frescura ya la garantiza el\n // newer_than de la busqueda de Gmail.\n publicadoTexto: 'Por alerta',\n diasAntiguedad: null,\n destacado: false,\n }));\n }\n\n return out;\n}\n\n// \u2500\u2500 Recorrido de todas las respuestas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst vacantes = [];\nconst vistos = new Set();\n// Fuentes que se esperan en esta configuracion: si alguna se queda en cero,\n// se avisa en el log. Debe cuadrar con los interruptores \"activo\" del nodo\n// \"Generar busquedas\": si apagas una alli, quitala tambien de aqui.\nconst ESPERADAS = ['LinkedIn', 'OCC', 'Computrabajo', 'Indeed'];\n\n// Diagn\u00f3stico por fuente. No depende de los campos de entrada del HTTP Request:\n// reconoce cada respuesta por la huella de su propio contenido, igual que los\n// parsers. As\u00ed el correo puede distinguir una bolsa vac\u00eda de una que fall\u00f3.\nconst ceroPorFuente = () => Object.fromEntries(ESPERADAS.map((f) => [f, 0]));\n\nconst MARCADORES_DE_TARJETA = {\n LinkedIn: /data-entity-urn=\"urn:li:jobPosting:/,\n // Solo el id de la tarjeta. La clase card-job-offer aparece tambien en la\n // hoja de estilos de OCC, asi que una busqueda sin resultados la llevaba\n // igual y el diagnostico avisaba de un \"cambio de formato\" inexistente.\n OCC: /id=\"jobcard-\\d+/i,\n Computrabajo: /<article class=\"box_offer/,\n Indeed: /mosaic-provider-jobcards\"\\]\\s*=/,\n};\n\nfunction fuenteDelContenido(html, esCorreo) {\n if (esCorreo) return /indeed/i.test(html) ? 'Indeed' : null;\n const muestra = html.slice(0, 60000);\n if (/occ\\.com\\.mx|occ-stc\\.com|card-job-offer/i.test(muestra)) return 'OCC';\n if (/computrabajo|box_offer/i.test(muestra)) return 'Computrabajo';\n if (/linkedin\\.com|base-search-card/i.test(muestra)) return 'LinkedIn';\n if (/indeed|mosaic-provider-jobcards/i.test(muestra)) return 'Indeed';\n return null;\n}\n\nfunction fuenteDelError(json) {\n let t = '';\n try { t = JSON.stringify(json); } catch (e) { return null; }\n if (/occ\\.com\\.mx|occ-stc\\.com/i.test(t)) return 'OCC';\n if (/computrabajo/i.test(t)) return 'Computrabajo';\n if (/linkedin\\.com/i.test(t)) return 'LinkedIn';\n if (/indeed/i.test(t)) return 'Indeed';\n return null;\n}\n\n// Una busqueda sin resultados NO es un fallo: con el filtro de fecha puesto,\n// muchas consultas devuelven una pagina valida y vacia. Estas huellas sirven\n// para distinguir \"la bolsa contesto y no habia nada\" de \"no reconoci la\n// pagina\", que si seria un problema de maquetado.\nconst HUELLAS = [/computrabajo/i, /occ\\.com\\.mx|occ-stc\\.com/i, /linkedin\\.com/i, /indeed/i];\n\n// Respaldo para saber de que bolsa vino una respuesta cuando el contenido no\n// lo dice. Una pagina de bloqueo de Cloudflare no nombra el sitio, asi que sin\n// esto un 403 de OCC se contabilizaba como \"error sin fuente\" y el correo no\n// podia decir cual bolsa se cayo.\n//\n// El orden es fiable, pero hay que reconstruirlo igual que lo arma el lienzo.\n// \"Es OCC?\" reparte las busquedas en dos ramas y cada una tiene su nodo HTTP,\n// asi que \"Juntar fuentes\" recibe: entrada 0 las respuestas directas\n// (LinkedIn y Computrabajo, en el orden en que salieron), entrada 1 las de\n// OCC y entrada 2 los correos. Por eso aqui no vale el orden crudo de\n// \"Generar busquedas\": hay que separar OCC y ponerlo detras.\n//\n// Aun asi es solo un respaldo: manda la huella del contenido. Si algun dia\n// cambia el reparto del lienzo, lo unico que se pierde es poder nombrar la\n// bolsa en una pagina de bloqueo, no las vacantes.\nlet peticiones = [];\ntry {\n const emitidas = $('Generar busquedas').all().map((i) => i.json);\n peticiones = emitidas.filter((p) => p.fuente !== 'OCC')\n .concat(emitidas.filter((p) => p.fuente === 'OCC'));\n} catch (e) {\n peticiones = [];\n}\n\nfunction fuentePorOrden(indice) {\n const p = indice >= 0 ? peticiones[indice] : null;\n return p && ESPERADAS.indexOf(p.fuente) !== -1 ? p.fuente : null;\n}\n\n// Codigos HTTP vistos por bolsa, para que el correo pueda decir \"HTTP 403\"\n// en vez de un generico \"fallo la descarga\".\nconst codigosPorFuente = {};\nfunction anotarCodigo(fuente, codigo) {\n if (!fuente || !codigo) return;\n if (!codigosPorFuente[fuente]) codigosPorFuente[fuente] = {};\n codigosPorFuente[fuente][codigo] = (codigosPorFuente[fuente][codigo] || 0) + 1;\n}\n\n// El codigo dice QUE fallo; este trozo del cuerpo dice POR QUE, que es lo que\n// de verdad decide como se arregla. Los tres casos que hay que distinguir:\n// \"403 Forbidden\" a secas (respuesta de nginx, ~85 bytes)\n// -> regla de User-Agent en el servidor de la bolsa. Comprobado el\n// 2026-08-21 con occ.com.mx: rechaza cualquier User-Agent que no\n// parezca navegador, y tambien rechaza si llegan DOS cabeceras\n// User-Agent en la misma peticion.\n// \"Just a moment\" / \"Attention Required\" / \"Verifying you are human\"\n// -> Cloudflare desafiando al cliente. Ahi las cabeceras no salvan\n// nada: hay que bajar el ritmo o entrar por otra via.\n// \"Too Many Requests\"\n// -> subir el batchInterval de \"Descargar fuentes\".\n// Se guarda solo el primero de cada bolsa: con uno basta para diagnosticar.\nconst motivoPorFuente = {};\nfunction anotarMotivo(fuente, html) {\n if (!fuente || motivoPorFuente[fuente]) return;\n const texto = limpiar(String(html || '').slice(0, 4000)).slice(0, 160);\n if (texto) motivoPorFuente[fuente] = texto;\n}\n\nconst diag = {\n items: 0, conHtml: 0, errores: 0, erroresSinFuente: 0,\n bloqueadas: 0, kb: 0, correos: 0,\n LinkedIn: 0, OCC: 0, Computrabajo: 0, Indeed: 0,\n sinVacantes: 0, sinReconocer: 0,\n respuestasPorFuente: ceroPorFuente(),\n erroresPorFuente: ceroPorFuente(),\n bloqueadasPorFuente: ceroPorFuente(),\n parserPorFuente: ceroPorFuente(),\n};\n// Cuando una alerta de Indeed no deja ninguna vacante, se guarda un trozo del\n// correo: con eso se ajusta el parser sin tener que adivinar.\nconst muestrasSinParsear = [];\n\nlet indiceHttp = 0;\n\nfor (const item of $input.all()) {\n diag.items++;\n const j = item.json || {};\n\n // Un correo de Gmail trae remitente y asunto; una respuesta HTTP no. Eso\n // distingue la alerta de Indeed de una pagina descargada, y ademas dice si\n // este item gasta uno de los turnos de \"Generar busquedas\".\n const esCorreo = !!(j.subject || j.from || j.threadId || j.labelIds);\n const indice = esCorreo ? -1 : indiceHttp++;\n\n if (j.error) {\n diag.errores++;\n const fuenteError = fuenteDelError(j) || fuentePorOrden(indice);\n if (fuenteError) diag.erroresPorFuente[fuenteError]++;\n else diag.erroresSinFuente++;\n continue;\n }\n\n // Con \"Full Response\" activo, el nodo HTTP entrega { body, headers,\n // statusCode }. Como ademas lleva \"Never Error\", un 403 o un 429 llegan\n // como respuesta normal: sin esto se colaban como \"la bolsa no devolvio\n // vacantes\" y el correo daba por buena una fuente caida.\n const codigo = typeof j.statusCode === 'number' ? j.statusCode : null;\n if (codigo !== null && (codigo < 200 || codigo >= 300)) {\n diag.errores++;\n const fuenteError = fuenteDelError(j) || fuentePorOrden(indice);\n if (fuenteError) {\n diag.erroresPorFuente[fuenteError]++;\n anotarCodigo(fuenteError, codigo);\n anotarMotivo(fuenteError, cuerpo(j));\n } else {\n diag.erroresSinFuente++;\n }\n continue;\n }\n\n const html = cuerpo(j);\n if (!html.trim()) continue;\n diag.conHtml++;\n diag.kb += Math.round(html.length / 1024);\n\n const fuenteRespuesta = fuenteDelContenido(html, esCorreo) || fuentePorOrden(indice);\n if (fuenteRespuesta) diag.respuestasPorFuente[fuenteRespuesta]++;\n\n const encontradas = esCorreo\n ? parsearAlertaIndeed(html, j)\n : []\n .concat(parsearLinkedIn(html))\n .concat(parsearOCC(html))\n .concat(parsearComputrabajo(html))\n .concat(parsearIndeed(html));\n\n if (esCorreo) diag.correos++;\n\n // La deteccion de bloqueo se hace solo cuando no se reconocio nada: las\n // paginas buenas son enormes y no vale la pena escanearlas. La pagina de\n // \"Security Check\" de Indeed pone el aviso despues de un <style> gigante,\n // por eso se revisan 60 KB y no los primeros 6.\n //\n // OCC va detras de Cloudflare (lo delata la cookie __cf_bm de sus\n // respuestas). Cuando Cloudflare decide desafiar a un cliente devuelve un\n // \"Just a moment...\" que NO nombra el sitio ni trae ninguna de las frases\n // clasicas de captcha, asi que sin estas huellas ese bloqueo se contaba\n // como \"no reconoci la pagina\" y el correo culpaba al parser. Comprobado\n // el 2026-08-21: ninguna de estas huellas aparece en una pagina buena de\n // OCC, LinkedIn ni Computrabajo.\n if (encontradas.length === 0) {\n if (/captcha|unusual traffic|access denied|challenge-form|are you a robot|security check|verificando que no eres|just a moment|cf[-_]chl|challenge-platform|checking your browser|verifying you are human|enable javascript and cookies|attention required/i\n .test(html.slice(0, 60000))) {\n diag.bloqueadas++;\n if (fuenteRespuesta) {\n diag.bloqueadasPorFuente[fuenteRespuesta]++;\n anotarCodigo(fuenteRespuesta, codigo);\n anotarMotivo(fuenteRespuesta, html);\n }\n } else if (fuenteRespuesta && MARCADORES_DE_TARJETA[fuenteRespuesta]\n && MARCADORES_DE_TARJETA[fuenteRespuesta].test(html)) {\n // Hab\u00eda tarjetas, pero el parser no pudo extraerlas: cambi\u00f3 el HTML.\n diag.sinReconocer++;\n diag.parserPorFuente[fuenteRespuesta]++;\n } else if (esCorreo) {\n // Un correo de Indeed que no deja vacantes va SIEMPRE por aqui, aunque\n // lleve la huella de Indeed: se guarda una muestra para poder ajustar\n // el parser, que es lo unico escrito sin un ejemplar real.\n //\n // Pero solo se guarda si el asunto dice que es una alerta de empleos.\n // La busqueda de Gmail trae todo lo que manda Indeed, y sus correos de\n // marketing (\"Tu CV fue visto por un empleador\") no llevan vacantes\n // nunca: avisar por ellos llenaba el log de ruido y escondia el aviso\n // que si importa. Ojo: /empleo\\b/ no casa con \"empleador\".\n diag.sinReconocer++;\n const asunto = asuntoDe(j);\n const pareceAlerta = /\\b(empleos?|vacantes?|jobs?|oportunidades)\\b/i.test(asunto)\n || /[?&]jk=/.test(html);\n if (pareceAlerta && muestrasSinParsear.length < 2) {\n muestrasSinParsear.push('asunto: ' + asunto + '\\n'\n + 'primeros 1200 caracteres del cuerpo:\\n' + html.slice(0, 1200));\n }\n } else if (HUELLAS.some((h) => h.test(html.slice(0, 4000)))) {\n // La bolsa contesto bien, simplemente no habia vacantes para esa\n // busqueda en la ventana de fechas. Es lo normal, no un fallo.\n diag.sinVacantes++;\n } else {\n diag.sinReconocer++;\n }\n continue;\n }\n\n for (const v of encontradas) {\n if (vistos.has(v.id)) continue; // la misma vacante sale en varias busquedas\n vistos.add(v.id);\n diag[v.fuente]++;\n vacantes.push({ json: v });\n }\n}\n\nconsole.log('Parseo -> respuestas: ' + diag.items + ' | con contenido: ' + diag.conHtml\n + ' (' + diag.correos + ' correos) | errores: ' + diag.errores\n + ' | bloqueadas: ' + diag.bloqueadas\n + ' | busquedas vacias: ' + diag.sinVacantes\n + ' | sin reconocer: ' + diag.sinReconocer + ' | KB: ' + diag.kb\n // Se listan todas las esperadas, incluso en cero: un cero es justo lo que\n // hay que ver de un vistazo cuando una fuente deja de funcionar.\n + ' || ' + ESPERADAS.map((f) => f + ': ' + diag[f]).join(' | '));\n\nfor (const m of muestrasSinParsear) {\n console.warn('Alerta de Indeed que no se pudo parsear. Pega esto en el chat '\n + 'para ajustar el parser:\\n' + m);\n}\n\n// Aviso util cuando una fuente entera se queda en cero pero las demas van bien.\nfor (const f of ESPERADAS) {\n if (diag[f] === 0) {\n console.warn('AVISO: ' + f + ' no devolvio ninguna vacante en esta vuelta.'\n + (f === 'Indeed'\n ? ' Indeed entra por sus correos de alerta (nodo \"Leer alertas Indeed\").'\n + ' Revisa que existan alertas recientes y que Gmail encuentre los mensajes.'\n : ' Puede ser un limite temporal de peticiones: revisa el nodo'\n + ' \"Descargar fuentes\".'));\n }\n}\n\n// Este objeto viaja con cada vacante hasta \"Armar correo\". Los nodos\n// intermedios conservan campos adicionales, y la petici\u00f3n a la IA los ignora.\nconst fuentesDiagnostico = {};\nfor (const f of ESPERADAS) {\n const extraidas = diag[f];\n const respuestas = diag.respuestasPorFuente[f];\n const errores = diag.erroresPorFuente[f];\n const bloqueadas = diag.bloqueadasPorFuente[f];\n const erroresParser = diag.parserPorFuente[f];\n\n let estado = 'sin_respuesta';\n if (erroresParser > 0 && extraidas === 0) estado = 'parser_error';\n else if (bloqueadas > 0 && extraidas === 0) estado = 'bloqueada';\n else if (errores > 0 && extraidas === 0) estado = 'fallo';\n else if (extraidas > 0 && (errores > 0 || bloqueadas > 0)) estado = 'ok_parcial';\n else if (extraidas > 0) estado = 'ok';\n else if (respuestas > 0) estado = 'sin_resultados';\n\n fuentesDiagnostico[f] = {\n estado: estado,\n extraidas: extraidas,\n respuestas: respuestas,\n errores: errores,\n bloqueadas: bloqueadas,\n erroresParser: erroresParser,\n // { \"403\": 12 } -> el correo lo muestra como \"HTTP 403 x12\".\n codigos: codigosPorFuente[f] || {},\n // Primeras palabras de la respuesta fallida, para saber quien rechaza.\n motivo: motivoPorFuente[f] || '',\n };\n}\n\nconst diagnosticoFuentes = {\n generado: new Date().toISOString(),\n fuentes: fuentesDiagnostico,\n global: {\n respuestas: diag.items,\n conContenido: diag.conHtml,\n errores: diag.errores,\n erroresSinFuente: diag.erroresSinFuente,\n bloqueadas: diag.bloqueadas,\n sinReconocer: diag.sinReconocer,\n },\n};\n\nfor (const item of vacantes) item.json._diagnosticoFuentes = diagnosticoFuentes;\n\n// Sin vacantes no hay correo que armar, asi que se corta aqui. El mensaje\n// lleva el estado de cada bolsa porque es lo unico que se va a ver: cuando el\n// nodo lanza, el panel de diagnostico del correo nunca llega a enviarse.\nif (vacantes.length === 0) {\n const QUE_HACER = {\n bloqueada: 'la bolsa respondio con un control anti-bot (Cloudflare o captcha): '\n + 'sube el batchInterval de \"Descargar fuentes\" y reintenta mas tarde',\n fallo: 'la descarga fallo: revisa el codigo HTTP de arriba',\n parser_error: 'la pagina traia tarjetas pero el parser no las reconocio: '\n + 'la bolsa cambio su maquetado',\n sin_respuesta: 'no llego ninguna respuesta de esta bolsa',\n sin_resultados: 'contesto bien, pero no habia vacantes para estas busquedas',\n ok: 'contesto bien',\n ok_parcial: 'contesto a medias',\n };\n\n const resumen = ESPERADAS.map((f) => {\n const d = fuentesDiagnostico[f];\n const codigos = Object.keys(d.codigos);\n return f + ': ' + d.estado\n + (codigos.length ? ' (HTTP ' + codigos.join(', ') + ')' : '')\n + ' -> ' + (QUE_HACER[d.estado] || 'sin diagnostico')\n + (d.motivo ? '\\n respondio: \"' + d.motivo + '\"' : '');\n }).join('\\n ');\n\n throw new Error(\n 'No se extrajo ninguna vacante de ninguna bolsa.\\n ' + resumen\n + '\\n Totales -> respuestas: ' + diag.items + ', con contenido: ' + diag.conHtml\n + ', errores: ' + diag.errores + ', bloqueadas: ' + diag.bloqueadas\n + ', busquedas vacias: ' + diag.sinVacantes + ', sin reconocer: ' + diag.sinReconocer\n + ', KB: ' + diag.kb);\n}\n\nreturn vacantes;\n"
},
"id": "dd78f8f9-5a7d-4c48-9073-bf9dd75e4eab",
"name": "Parsear vacantes",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2112,
288
]
},
{
"parameters": {
"jsCode": "// \u2500\u2500 Filtrar y puntuar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Deduplica, descarta y puntua con reglas locales. Sin API y sin costo.\n// La IA que viene despues solo afina: si falla, este puntaje es el que manda.\n//\n// A diferencia de la version anterior, aqui ya hay algo mas que el titulo:\n// Indeed manda un extracto de la descripcion y la lista de aptitudes, y OCC\n// manda las prestaciones. Por eso las senales positivas miran todo el texto\n// y los descartes duros siguen mirando solo el titulo (una descripcion que\n// menciona \"senior\" de pasada no convierte la vacante en senior).\n\n// \u2500\u2500 Ajustes rapidos \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst DIAS_MAX = 7; // antiguedad maxima aceptada\nconst RECORDAR_DIAS = 21; // cuanto se recuerda una vacante ya enviada\n\n// Minusculas y sin acentos, para que \"An\u00e1lisis\" y \"analisis\" sean lo mismo.\n// El rango de abajo son las marcas diacriticas que suelta normalize('NFD').\nconst norm = (s) => (s || '').toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n\n// OJO con los \\b al final: una raiz truncada NUNCA debe cerrar con \\b, porque\n// /desarrollad\\b/ no casa con \"desarrollador\" (la 'o' que sigue no es\n// frontera). Solo llevan \\b final las alternativas que son palabras completas.\n\n// \u2500\u2500 1. Descarte inmediato (solo por titulo) \u2500\u2500\u2500\u2500\u2500\u2500\u25
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
JobN8N · Buscador de vacantes. Uses gmail, httpRequest. Scheduled trigger; 17 nodes.
Source: https://github.com/oft24/JobN8N/blob/main/workflow/JobN8N.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Relatórios aos Laboratórios. Uses httpRequest, gmail, formTrigger. Scheduled trigger; 54 nodes.
YOUR_ID 4. Uses gmail, googleDrive, googleSheets, httpRequest. Scheduled trigger; 53 nodes.
14310 Send Overdue Invoice Payment Reminders With Ifirma Gmail Postgrid And Slack. Uses httpRequest, stopAndError, slack, gmail. Scheduled trigger; 53 nodes.
Addendo — Blog Automatico Don Jacinto Nahual. Uses httpRequest, redis, github, gmail. Scheduled trigger; 51 nodes.
Addendo — Blog Automatico Don Jacinto Nahual. Uses httpRequest, redis, github, gmail. Scheduled trigger; 51 nodes.