{
  "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(/&amp;/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\u2500\u2500\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 DESCARTES = [\n  [/\\b(senior|sr\\.?|ssr\\.?|semi ?senior|lead|staff|principal|arquitect|architect)/,\n    'Es un puesto senior o de arquitectura'],\n  [/\\b(manager|gerente|director|jefe|supervisor|coordinador)/,\n    'Es un puesto de jefatura'],\n  [/\\b(chief|cto|cio|ceo|vp|head of)\\b/,\n    'Es un puesto directivo'],\n  [/\\b(ventas|vendedor|comercial|call center|telemarketing|cajer|promotor|mercaderista|asesor)/,\n    'No es un puesto t\u00e9cnico (comercial)'],\n  [/\\b(chofer|conductor|repartidor|almacen|montacarg|operario|ensamble|cocina|limpieza|seguridad privada)/,\n    'No es un puesto t\u00e9cnico (operativo)'],\n  [/\\b(operador|auxiliar de produccion|produccion|mantenimiento industrial|pintor|soldador)/,\n    'No es un puesto t\u00e9cnico (planta)'],\n  [/\\b(enfermer|medic|odontolog|docente|maestr|profesor|educadora|interprete|psicolog)/,\n    'No es un puesto t\u00e9cnico (salud o docencia)'],\n  [/\\b(contador|contable|auditor|nomina|recursos humanos|talento|reclutad|abogad|juridic)/,\n    'No es un puesto t\u00e9cnico (administrativo o de RH)'],\n  [/\\b(arquitecto de proyecto|proyecto ejecutivo|diseno arquitectonico|obra civil)/,\n    'Es arquitectura de construcci\u00f3n, no de software'],\n  [/\\b(sap abap|cobol|mainframe|as ?\\/? ?400|oracle forms)/,\n    'Stack que no maneja y no se aprende r\u00e1pido'],\n];\n\n// Una practica solo sirve si es de un area tecnica. \"Practicante\" es la\n// palabra que mas puntua de toda la tabla, asi que sin este filtro se cuelan\n// \"Practicante de Facilities\" o \"Practicante de seguridad industrial\" con 90\n// y tantos puntos, solo por llamarse practica y estar en Monterrey.\nconst PASANTIA = /\\b(becari|practicante|intern\\b|internship|residencia profesional|trainee|pasante)/;\nconst TECNICO = /\\b(sistemas|software|desarroll|program|ti\\b|it\\b|datos|data|redes|network|python|java|javascript|web|cloud|devops|rpa|automatiz|informatic|computac|tecnolog|ciber|qa\\b|testing|soporte tecnico|help ?desk|inteligencia artificial|ia\\b|ml\\b|digital)/;\n\n// \u2500\u2500 2. Debe encajar con algun rol tecnico. Si no encaja ninguno, fuera. \u2500\u2500\u2500\u2500\u2500\n//     Gana la primera regla que case, asi que van de mas a menos afin.\nconst BASE_TECNICA = 50;\nconst ROLES = [\n  [/\\b(rpa|power ?automate|power ?platform|uipath|automation anywhere|blue prism)/, 15,\n    'RPA / Power Platform coincide con el perfil configurado'],\n  [/\\b(automatiz|automation)/, 15, 'Automatizaci\u00f3n de procesos'],\n  [/\\b(becari|practicante|intern\\b|internship|residencia profesional|trainee)/, 12,\n    'Es una posici\u00f3n de becario, practicante o trainee'],\n  [/\\b(backend|back ?end|full ?stack)/, 10, 'Backend o full stack'],\n  [/\\b(devops|sre\\b|cloud|infraestructura)/, 10, 'Cloud / DevOps coincide con el perfil configurado'],\n  [/\\b(desarrollad|developer|programad|software engineer|ingenier[oa] de software)/, 8,\n    'Es un puesto de desarrollo'],\n  [/\\b(data engineer|ingenier[oa] de datos|analista de datos|data analyst|cientific[oa] de datos|data scien)/, 8,\n    'Datos / anal\u00edtica'],\n  [/\\b(machine learning|inteligencia artificial|\\bia\\b|\\bml\\b|\\bai\\b)/, 8, 'IA / machine learning'],\n  [/\\b(frontend|front ?end|web\\b)/, 6, 'Desarrollo web'],\n  [/\\b(redes|network|cisco|telecomunicaciones)/, 4, 'Redes coincide con el perfil configurado'],\n  [/(\\bqa\\b|\\btest|\\bpruebas\\b|\\bquality assurance\\b)/, 3, 'QA / pruebas'],\n  [/\\b(analista (de )?(sistemas|informacion|software|datos)|analista programador|analista funcional)/, 3,\n    'Analista t\u00e9cnico'],\n  [/\\b(sistemas|\\bti\\b|\\bit\\b|helpdesk)/, 3, '\u00c1rea de sistemas / TI'],\n  [/\\b(soporte tecnico|mesa de ayuda|help ?desk|service desk)/, 0, 'Soporte t\u00e9cnico'],\n];\n\n// \u2500\u2500 3. Senales sobre todo el texto (titulo + descripcion + aptitudes) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst SENALES = [\n  [/\\b(jr\\.?|junior|trainee|entry ?level|sin experiencia|recien egresad|nivel inicial|practicante|becari)/,\n    30, 'Expl\u00edcitamente junior o sin experiencia'],\n  [/\\b(estudiante|estudiantes|pasante|medio tiempo|medio-tiempo|part ?time|tiempo parcial|compatible con estudios|sin titulo|no titulad)/,\n    25, 'Apto para quien a\u00fan estudia (medio tiempo / sin t\u00edtulo)'],\n  [/\\b(python|django|flask|fastapi)/, 20, 'Python coincide con el perfil configurado'],\n  [/\\b(power ?automate|power ?apps|power ?bi|power ?platform)/, 18, 'Power Platform coincide con el perfil configurado'],\n  [/\\b(aws|azure|gcp)\\b/, 15, 'Nube coincide con el perfil configurado'],\n  [/\\b(node|nodejs|javascript|typescript)/, 12, 'JavaScript / Node'],\n  [/\\b(sql|mysql|postgres|mongodb|base de datos|supabase)/, 10, 'Bases de datos'],\n  [/\\b(react|angular|vue)\\b/, 8, 'Framework de frontend'],\n  [/\\b(git|docker|kubernetes|ci ?\\/? ?cd|jenkins)\\b/, 8, 'Herramientas de DevOps'],\n  [/\\b(excel|tableau|looker)/, 6, 'Coincide con herramientas de datos'],\n  [/\\b(scrum|agile|kanban)/, 4, 'Coincide con metodolog\u00eda \u00e1gil'],\n  [/\\b(flutter|kotlin|swift|android|ios)\\b/, -6, 'Tecnolog\u00eda m\u00f3vil con menor afinidad configurada'],\n  [/\\b(titulo (obligatori|requerid|indispensable)|titulad[oa] (obligatori|requerid)|carrera (terminada|concluida|finalizada|titulad))/,\n    -20, 'Exige carrera terminada o t\u00edtulo'],\n  [/\\b(ingles avanzado|english c1|c1 english|fluent english|bilingue|ingles conversacional)/,\n    -12, 'Pide ingl\u00e9s avanzado o conversacional'],\n];\n\n// Un stack ajeno pesa muy distinto segun donde aparezca: en el TITULO es el\n// stack central del puesto (\"Desarrollador .NET\"), mientras que en la\n// descripcion suele ser una mencion de paso entre otras diez tecnologias.\nconst STACK_AJENO = /(\\.net|\\bc#|\\babap\\b|\\bdelphi\\b|\\bphp\\b|\\blaravel\\b|\\bsalesforce\\b)/;\n\nconst REMOTO = /\\b(remoto|remote|home ?office|desde casa|teletrabajo|work from home)\\b/;\n\n// Un hibrido obliga a presentarse: solo sirve si la oficina esta en Monterrey.\n// Por eso NO cuenta como remoto aunque el anuncio diga \"home office\".\nconst HIBRIDO = /(\\bhibrid|\\bhybrid|dias? presencial|presencial y remoto|\\d\\s*x\\s*\\d)/;\n\n// Ojo con los nombres ambiguos: \"Juarez\" y \"Garcia\" son municipios de Nuevo\n// Leon, pero tambien Benito Juarez (CDMX) y Ciudad Juarez (Chihuahua). Por eso\n// no van sueltos aqui: quien decide es el veto FUERA_DE_NL de mas abajo.\nconst MTY = /\\b(monterrey|nuevo leon|n\\.? ?l\\.?|san pedro|san nicolas|guadalupe|apodaca|santa catarina|escobedo|cumbres|obispado)\\b/;\n\n// El perfil es tajante: presencial fuera de Nuevo Leon se descarta sin\n// excepcion. Si la vacante nombra otro estado y no es remota, se va.\nconst FUERA_DE_NL = /\\b(ciudad de mexico|cdmx|distrito federal|estado de mexico|edomex|toluca|naucalpan|tlalnepantla|ecatepec|benito juarez|miguel hidalgo|coyoacan|azcapotzalco|iztapalapa|cuauhtemoc|alvaro obregon|jalisco|guadalajara|zapopan|tlaquepaque|queretaro|puebla|merida|yucatan|tijuana|mexicali|ensenada|baja california|chihuahua|ciudad juarez|coahuila|saltillo|torreon|ramos arizpe|sonora|hermosillo|veracruz|xalapa|guanajuato|irapuato|celaya|silao|sinaloa|culiacan|mazatlan|aguascalientes|san luis potosi|tamaulipas|reynosa|matamoros|tampico|altamira|cancun|quintana roo|playa del carmen|morelos|cuernavaca|chiapas|oaxaca|durango|zacatecas|colima|nayarit|tepic|tabasco|villahermosa|campeche|tlaxcala|hidalgo|pachuca|michoacan|morelia|guerrero|acapulco|bogota|medellin|lima|buenos aires|madrid|barcelona|santiago de chile|sao paulo)\\b/;\n\n// Modalidad de trabajo deducida del texto completo de la vacante.\nfunction modalidad(v) {\n  const ctx = norm(v.ubicacion + ' ' + v.titulo + ' ' + v.descripcion);\n  const hibrida = HIBRIDO.test(ctx);\n  return {\n    hibrida: hibrida,\n    remota: !hibrida && (v.remoto === true || REMOTO.test(ctx)),\n  };\n}\n\n// Anios de experiencia realmente pedidos: se busca un numero cerca de la\n// palabra \"experiencia\", que es mucho mas fiable que cazar \"5 anos\" suelto.\nfunction aniosPedidos(texto) {\n  let max = null;\n  const re = /(\\d{1,2})\\s*(?:\\+|a|-|hasta)?\\s*(\\d{1,2})?\\s*(?:anos|anios|years|yrs)/g;\n  let m;\n  while ((m = re.exec(texto)) !== null) {\n    const ventana = texto.slice(Math.max(0, m.index - 60), m.index + 60);\n    if (!/experien/.test(ventana)) continue;\n    const n = parseInt(m[1], 10);\n    if (!isFinite(n) || n > 30) continue;\n    if (max === null || n > max) max = n;\n  }\n  return max;\n}\n\nfunction evaluar(v) {\n  const t = norm(v.titulo);\n  const loc = norm(v.ubicacion);\n  const ctx = norm(v.titulo + ' . ' + v.descripcion + ' . ' + (v.skills || []).join(' . '));\n  const todo = ctx + ' ' + loc;\n\n  for (const [re, motivo] of DESCARTES) {\n    if (re.test(t)) {\n      return { score: 0, veredicto: 'DESCARTAR', razones: [motivo], brechas: [], gancho: '' };\n    }\n  }\n\n  if (PASANTIA.test(t) && !TECNICO.test(t)) {\n    return {\n      score: 0, veredicto: 'DESCARTAR',\n      razones: ['Es una pr\u00e1ctica, pero no de un \u00e1rea t\u00e9cnica'], brechas: [], gancho: '',\n    };\n  }\n\n  const rol = ROLES.find(([re]) => re.test(t));\n  if (!rol) {\n    return {\n      score: 0, veredicto: 'DESCARTAR',\n      razones: ['El t\u00edtulo no corresponde a un puesto t\u00e9cnico'], brechas: [], gancho: '',\n    };\n  }\n\n  let score = BASE_TECNICA + rol[1];\n  const razones = [rol[2]];\n  const brechas = [];\n\n  for (const [re, pts, etiqueta] of SENALES) {\n    if (!re.test(todo)) continue;\n    score += pts;\n    (pts > 0 ? razones : brechas).push(etiqueta);\n  }\n\n  const anios = aniosPedidos(todo);\n  if (anios !== null) {\n    if (anios >= 6) {\n      return {\n        score: 0, veredicto: 'DESCARTAR',\n        razones: ['Pide ' + anios + ' a\u00f1os de experiencia'], brechas: [], gancho: '',\n      };\n    }\n    if (anios >= 4) { score -= 8; brechas.push('Pide ' + anios + ' a\u00f1os de experiencia'); }\n    else { score += 8; razones.push('Pide solo ' + anios + ' a\u00f1os de experiencia'); }\n  }\n\n  if (STACK_AJENO.test(t)) {\n    score -= 22;\n    brechas.push('El stack central del puesto es ajeno a su experiencia');\n  } else if (STACK_AJENO.test(ctx)) {\n    score -= 10;\n    brechas.push('Menciona tecnolog\u00edas fuera de su stack');\n  }\n\n  const modo = modalidad(v);\n  if (modo.remota) { score += 12; razones.push('Es remoto'); }\n  else if (MTY.test(loc)) {\n    score += 10;\n    razones.push(modo.hibrida ? 'H\u00edbrido en el \u00e1rea de Monterrey' : 'Est\u00e1 en el \u00e1rea de Monterrey');\n  }\n\n  const sueldo = norm(v.salario);\n  const sueldoReal = sueldo && !/no mostrado|no especificado|a convenir/.test(sueldo);\n  if (sueldoReal) { score += 8; razones.push('Publica el sueldo: ' + v.salario); }\n\n  if (v.diasAntiguedad === 0) { score += 5; razones.push('Publicada hoy'); }\n\n  // Sin descripcion solo se juzga el titulo: se topa el puntaje para no\n  // dar un POSTULAR_YA a ciegas.\n  const aCiegas = !v.descripcion && (!v.skills || v.skills.length === 0);\n  if (aCiegas) {\n    score = Math.min(score, 88);\n    brechas.push('La bolsa no publica descripci\u00f3n en el listado');\n  }\n\n  score = Math.max(0, Math.min(100, score));\n  const veredicto = score >= 90 ? 'POSTULAR_YA'\n    : score >= 75 ? 'POSTULAR'\n      : score >= 55 ? 'REVISAR' : 'DESCARTAR';\n\n  return {\n    score: score,\n    veredicto: veredicto,\n    razones: razones.slice(0, 3),\n    brechas: brechas.slice(0, 2),\n    gancho: score >= 55\n      ? 'Mi experiencia coincide con los requisitos principales; me interesa aportar en '\n        + (v.empresa || 'su equipo') + '.'\n      : '',\n  };\n}\n\n// \u2500\u2500 4. Filtro previo: ubicacion imposible, antiguedad, duplicados y repetidas\nconst UBICACIONES_OK = [\n  'monterrey', 'san pedro', 'san nicolas', 'guadalupe', 'apodaca',\n  'santa catarina', 'escobedo', 'garcia', 'juarez', 'nuevo leon', 'n. l.', 'n.l.',\n  'cumbres', 'obispado', 'remoto', 'remote', 'hibrido', 'home office', 'desde casa',\n];\n\n// Memoria entre ejecuciones: evita reenviar la misma vacante cada 12 horas.\n// n8n solo persiste esto en ejecuciones de produccion (con el workflow activo),\n// no en las pruebas manuales desde el editor.\nconst memoria = $getWorkflowStaticData('global');\nif (!memoria.enviadas || typeof memoria.enviadas !== 'object') memoria.enviadas = {};\n\nconst ahora = Date.now();\nconst limite = ahora - RECORDAR_DIAS * 86400000;\nfor (const id of Object.keys(memoria.enviadas)) {\n  if (!(memoria.enviadas[id] > limite)) delete memoria.enviadas[id];\n}\n\nconst vistosId = new Set();\nconst vistosClave = new Set();\nconst salida = [];\nconst diag = { entraron: 0, viejas: 0, fueraDeZona: 0, duplicadas: 0, yaEnviadas: 0, descartadas: 0 };\n\nfor (const item of $input.all()) {\n  const v = item.json;\n  diag.entraron++;\n\n  if (v.diasAntiguedad !== null && v.diasAntiguedad > DIAS_MAX) { diag.viejas++; continue; }\n\n  const ctxUbi = norm(v.ubicacion + ' ' + v.titulo + ' ' + v.descripcion);\n  // Presencial o hibrido fuera de Nuevo Leon: fuera, aunque el municipio se\n  // llame igual que uno del area metropolitana (Benito Juarez, Ciudad Juarez).\n  // Solo el remoto puro salva a una vacante de otro estado.\n  if (!modalidad(v).remota && (FUERA_DE_NL.test(norm(v.ubicacion))\n    || !UBICACIONES_OK.some((u) => ctxUbi.includes(norm(u))))) {\n    diag.fueraDeZona++; continue;\n  }\n\n  // Misma vacante publicada en dos bolsas: mismo titulo + misma empresa.\n  const clave = norm(v.titulo).replace(/[^a-z0-9]/g, '') + '|'\n    + norm(v.empresa).replace(/[^a-z0-9]/g, '');\n  if (vistosId.has(v.id) || vistosClave.has(clave)) { diag.duplicadas++; continue; }\n\n  if (memoria.enviadas[v.id]) { diag.yaEnviadas++; continue; }\n\n  vistosId.add(v.id);\n  vistosClave.add(clave);\n\n  const ev = evaluar(v);\n  if (ev.veredicto === 'DESCARTAR') diag.descartadas++;\n  salida.push({ json: Object.assign({}, v, ev) });\n}\n\nconsole.log('Filtro -> entraron: ' + diag.entraron\n  + ' | viejas (>' + DIAS_MAX + 'd): ' + diag.viejas\n  + ' | fuera de zona: ' + diag.fueraDeZona\n  + ' | duplicadas: ' + diag.duplicadas\n  + ' | ya enviadas antes: ' + diag.yaEnviadas\n  + ' | quedan: ' + salida.length + ' (de ellas ' + diag.descartadas + ' descartadas por reglas)');\n\nreturn salida;\n"
      },
      "id": "1f4d9607-871c-4cbe-bdfc-0b8b3764fb82",
      "name": "Filtrar y puntuar",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1888,
        288
      ]
    },
    {
      "parameters": {
        "jsCode": "// \u2500\u2500 Armar lotes IA \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Prepara las peticiones al modelo. La IA NO es la fuente de verdad: solo\n// afina el puntaje que ya calcularon las reglas. Si falla, no pasa nada.\n//\n// Novedad respecto a la version anterior: ahora Indeed y OCC si entregan\n// descripcion y aptitudes, asi que el modelo ya no juzga solo por el titulo.\n\nconst ENDPOINT =\n  'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions';\nconst MODELO = 'gemini-3.6-flash';\n\nconst TAMANO_LOTE = 10;   // vacantes por peticion\nconst UMBRAL_IA = 45;     // no se gasta IA en lo que las reglas ya tiraron\nconst MAX_A_IA = 60;      // tope duro de vacantes enviadas por ejecucion\n\nconst todas = $input.all().map((i) => i.json);\n\nconst vacantes = todas\n  .filter((v) => v.score >= UMBRAL_IA)\n  .sort((a, b) => b.score - a.score)\n  .slice(0, MAX_A_IA);\n\nconsole.log('A la IA -> ' + vacantes.length + ' de ' + todas.length\n  + ' vacantes (umbral ' + UMBRAL_IA + ')');\n\nif (vacantes.length === 0) return [];\n\n// \u2500\u2500 Perfil del candidato \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// IMPORTANTE: reemplaza los marcadores antes de activar Gemini.\nconst PERFIL = `\nCandidato/a junior residente en [CIUDAD, PAIS].\nFormacion: [CARRERA, INSTITUCION Y ESTADO DE LOS ESTUDIOS].\n\nEXPERIENCIA\n- [EXPERIENCIA RELEVANTE, RESPONSABILIDADES Y RESULTADOS].\n\nSTACK\n- Solido: [TECNOLOGIAS PRINCIPALES].\n- Funcional: [TECNOLOGIAS SECUNDARIAS].\n\nCERTIFICACIONES\n- [CERTIFICACIONES O CURSOS RELEVANTES].\n\nIDIOMAS\n- [IDIOMAS Y NIVEL REAL].\n\nNIVEL Y OBJETIVO\n- [NIVEL ACTUAL, ANOS DE EXPERIENCIA Y ROLES BUSCADOS].\n\nUBICACION\n- Presencial o hibrido: [ZONAS ACEPTADAS].\n- Remoto: [PAISES O RESTRICCIONES].\n`;\n\n// \u2500\u2500 Instrucciones del sistema \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 SYSTEM = `Eres un reclutador tecnico de Monterrey, Nuevo Leon.\nDecides si una vacante vale el tiempo de este candidato junior.\nSe estricto, realista y conservador. No intentes quedar bien con el candidato:\nmejor 5 vacantes buenas que 30 mediocres.\n\n=== PERFIL DEL CANDIDATO ===\n${PERFIL}\n\n=== DESCARTES (score 0-20) ===\n1. Pide 6 o mas anos de experiencia.\n2. El titulo dice Senior, Sr., Lead, Tech/Team Lead, Principal, Manager,\n   Director, Head, Architect, Arquitecto o Staff Engineer.\n3. Es presencial o hibrido fuera del area metropolitana de Monterrey.\n   Un hibrido en otra ciudad obliga a mudarse: descartalo.\n4. No es tecnologica: ventas, call center, almacen, produccion, operador,\n   enfermeria, medicina, docencia, RH, contabilidad no tecnica, chofer,\n   seguridad, manufactura sin software, arquitectura de construccion.\n5. El stack central es SAP ABAP, COBOL, Mainframe u Oracle Forms sin relacion\n   con automatizacion ni con tecnologias que el candidato conozca.\n\n=== SUMA PUNTOS ===\nJunior, Jr, Intern, Internship, Becario, Practicante, Trainee, Entry Level,\nGraduate; 0-3 anos de experiencia; acepta estudiantes; no exige titulo\nterminado; permite seguir estudiando; remoto; Monterrey y su area.\nTecnologias: Python, JavaScript, Node.js, Power Automate, Power Platform, RPA,\nautomatizacion, AWS, Azure, SQL, APIs, Flask, Docker, IA, machine learning,\ningenieria de datos.\n\n=== RESTA PUNTOS ===\nPide 4-5 anos; exige carrera terminada o titulo; exige ingles C1/C2;\nel stack central es .NET, C#, PHP, Flutter, SAP, Salesforce, Oracle o COBOL.\nNo descartes por UNA tecnologia desconocida: evalua que tan central es.\n\n=== ESCALA ===\n90-100 POSTULAR_YA - coincidencia excelente, que se postule ya.\n75-89  POSTULAR    - buena coincidencia, vale la pena aplicar.\n55-74  REVISAR     - puede interesar, pero falta informacion o hay brechas.\n0-54   DESCARTAR   - baja probabilidad de encaje.\n\n=== INFORMACION FALTANTE ===\nRecibes id, titulo, empresa, ubicacion, salario y, cuando la bolsa lo publica,\nuna descripcion corta, una lista de aptitudes y una lista de prestaciones.\nOJO: \"prestaciones\" son beneficios de la empresa (vales, fondo de ahorro,\nseguro), NO son requisitos ni tecnologias: no las cuentes como aptitudes ni\nsubas el score por ellas. NO inventes anos de experiencia, tecnologias,\nrequisitos, modalidad, nivel de ingles ni estudios. Si la vacante llega SIN\ndescripcion NI aptitudes, el score maximo es 60 y debes anotar en \"brechas\"\nque falta informacion (las prestaciones no cuentan como informacion del\npuesto: OCC las publica siempre y aun asi no se sabe que pide la vacante).\n\n=== FORMATO ===\n\"razones\": maximo 3, cortas y concretas (\"Titulo claramente junior\",\n\"Ubicacion compatible con Monterrey\"). Nunca vaguedades tipo \"parece buena\noportunidad\".\n\"brechas\": maximo 3 (tecnologias faltantes, experiencia, ingles, titulo,\ninformacion desconocida). Si no hay, [].\n\"gancho\": una sola frase que el candidato pueda usar para abrir la postulacion,\nmencionando una coincidencia REAL con su perfil. Si score < 55, \"\".\n\nDevuelve UNICAMENTE JSON valido, sin markdown ni texto alrededor:\n{\"resultados\":[{\"id\":\"string\",\"score\":0,\"veredicto\":\"POSTULAR_YA|POSTULAR|REVISAR|DESCARTAR\",\"razones\":[\"string\"],\"brechas\":[\"string\"],\"gancho\":\"string\"}]}\nEl score siempre es un entero de 0 a 100.`;\n\n// \u2500\u2500 Lotes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 lotes = [];\n\nfor (let i = 0; i < vacantes.length; i += TAMANO_LOTE) {\n  const lote = vacantes.slice(i, i + TAMANO_LOTE);\n\n  // Se manda lo minimo indispensable: menos tokens y menos ruido.\n  const compacto = lote.map((v) => {\n    const o = {\n      id: String(v.id || ''),\n      titulo: v.titulo || '',\n      empresa: v.empresa || '',\n      ubicacion: v.ubicacion || '',\n      salario: v.salario || 'no publicado',\n    };\n    if (v.descripcion) o.descripcion = String(v.descripcion).slice(0, 450);\n    if (v.skills && v.skills.length) o.aptitudes = v.skills.slice(0, 10);\n    // OCC solo publica prestaciones en el listado. Van etiquetadas como lo\n    // que son: dicen algo de la empresa, pero no son requisitos ni stack.\n    if (v.prestaciones && v.prestaciones.length) o.prestaciones = v.prestaciones.slice(0, 6);\n    if (v.remoto === true) o.remoto = true;\n    return o;\n  });\n\n  const USER = 'Evalua estas ' + lote.length + ' vacantes. Devuelve EXACTAMENTE '\n    + lote.length + ' objetos en \"resultados\": uno por cada id, sin omitir, sin '\n    + 'agregar y sin cambiar ningun id.\\n\\n' + JSON.stringify(compacto);\n\n  lotes.push({\n    json: {\n      endpoint: ENDPOINT,\n      payload: {\n        model: MODELO,\n        reasoning_effort: 'low',\n        max_tokens: 3000,\n        response_format: { type: 'json_object' },\n        messages: [\n          { role: 'system', content: SYSTEM },\n          { role: 'user', content: USER },\n        ],\n      },\n      // Diagnostico\n      proveedor: 'Google Gemini',\n      modelo: MODELO,\n      numeroLote: Math.floor(i / TAMANO_LOTE) + 1,\n      cantidadVacantes: lote.length,\n      idsEsperados: compacto.map((v) => v.id),\n    },\n  });\n}\n\nconsole.log('Lotes armados: ' + lotes.length + ' (' + TAMANO_LOTE + ' vacantes por lote)');\n\nreturn lotes;\n"
      },
      "id": "7c477282-a0e9-4470-9b95-e809e58dcd40",
      "name": "Armar lotes IA",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1664,
        288
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.payload) }}",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 3000
            }
          },
          "timeout": 120000
        }
      },
      "id": "afd47414-bbe4-4b36-863b-a4fb3f242b8c",
      "name": "GApi",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -1440,
        288
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// \u2500\u2500 Fusionar IA y reglas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// La IA gratuita es inestable por naturaleza: limites de peticiones, JSON mal\n// formado, modelos que desaparecen. Por eso NO es la fuente de verdad, es una\n// capa de refinamiento: cada vacante ya trae su puntaje de reglas y solo se\n// sobrescribe si la IA devolvio algo utilizable para ESA vacante.\n\nfunction extraerJSON(txt) {\n  if (typeof txt !== 'string' || !txt.trim()) return null;\n  // Muchos modelos envuelven la respuesta en ```json ... ```\n  const s = txt.trim()\n    .replace(/^```(?:json)?\\s*/i, '')\n    .replace(/```\\s*$/, '');\n  const a = s.indexOf('{');\n  const b = s.lastIndexOf('}');\n  if (a === -1 || b <= a) return null;\n  try { return JSON.parse(s.slice(a, b + 1)); } catch (e) { return null; }\n}\n\nconst VEREDICTOS = ['POSTULAR_YA', 'POSTULAR', 'REVISAR', 'DESCARTAR'];\n\n// Base: lo que ya calcularon las reglas.\nconst base = {};\nfor (const it of $('Filtrar y puntuar').all()) base[it.json.id] = it.json;\n\n// Encima: lo que devolvio la IA, si es que devolvio algo.\nconst deIA = {};\nlet ok = 0;\nlet fallidos = 0;\nconst motivos = [];\n\nfor (const item of $input.all()) {\n  const cuerpo = item.json || {};\n\n  if (cuerpo.error) {\n    fallidos++;\n    const e = cuerpo.error;\n    motivos.push(typeof e === 'string' ? e.slice(0, 90)\n      : ((e.message || (e.error && e.error.message) || 'error HTTP')).slice(0, 90));\n    continue;\n  }\n\n  const contenido = cuerpo.choices && cuerpo.choices[0]\n    && cuerpo.choices[0].message && cuerpo.choices[0].message.content;\n  const obj = extraerJSON(contenido);\n\n  if (!obj || !Array.isArray(obj.resultados)) {\n    fallidos++;\n    motivos.push(contenido ? 'respuesta sin JSON valido' : 'respuesta vacia del modelo');\n    continue;\n  }\n\n  ok++;\n  for (const r of obj.resultados) {\n    if (r && typeof r.id === 'string') deIA[r.id] = r;\n  }\n}\n\nconst textos = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === 'string' && x.trim()) : []);\n\nconst salida = Object.values(base).map((v) => {\n  const r = deIA[v.id];\n  const usable = r && typeof r.score === 'number' && isFinite(r.score);\n  if (!usable) return { json: Object.assign({}, v, { origen: 'reglas' }) };\n\n  const razones = textos(r.razones);\n  const brechas = textos(r.brechas);\n\n  return {\n    json: Object.assign({}, v, {\n      score: Math.max(0, Math.min(100, Math.round(r.score))),\n      veredicto: VEREDICTOS.indexOf(r.veredicto) !== -1 ? r.veredicto : v.veredicto,\n      razones: razones.length ? razones.slice(0, 3) : v.razones,\n      brechas: brechas.slice(0, 3),\n      gancho: typeof r.gancho === 'string' && r.gancho.trim() ? r.gancho.trim() : v.gancho,\n      origen: 'ia',\n    }),\n  };\n});\n\nconst afinadas = salida.filter((s) => s.json.origen === 'ia').length;\nconsole.log('Lotes de IA -> utiles: ' + ok + ' | fallidos: ' + fallidos\n  + ' | vacantes afinadas: ' + afinadas + '/' + salida.length\n  + (motivos.length ? ' | motivos: ' + motivos.join(' ; ') : ''));\n\nreturn salida;\n"
      },
      "id": "6513298f-c11a-4f62-9907-62e9732fa891",
      "name": "Fusionar IA y reglas",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1216,
        288
      ]
    },
    {
      "parameters": {
        "jsCode": "// \u2500\u2500 Armar correo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Construye el HTML del correo diario.\n//\n// Todo esta maquetado con <table> y estilos en linea a proposito: Gmail y\n// Outlook ignoran flexbox, grid y casi todo lo que vaya en una hoja de\n// estilos. Lo unico que va en <style> son las media queries y el modo\n// oscuro, que son mejoras opcionales: si el cliente las ignora, el correo\n// se sigue viendo bien.\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 UMBRAL = 55;        // baja este numero si llegan pocas vacantes\nconst MAX_EN_CORREO = 25;\n\n// \u2500\u2500 Paleta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 C = {\n  fondo: '#EEF0F4', tarjeta: '#FFFFFF', borde: '#DFE3EA',\n  texto: '#15181D', suave: '#5C6470', tenue: '#8A929E',\n  acento: '#1F5FD0', exito: '#0F7B3C', ambar: '#B26A00',\n};\n\n// Color propio de cada bolsa, sacado de su propia hoja de estilos.\n// El orden manda en el resumen de la cabecera.\nconst FUENTES = {\n  OCC: { color: '#5736AB', nombre: 'OCC Mundial' },\n  Computrabajo: { color: '#0D3878', nombre: 'Computrabajo' },\n  Indeed: { color: '#2557A7', nombre: 'Indeed' },\n  LinkedIn: { color: '#0A66C2', nombre: 'LinkedIn' },\n};\n\nconst VER = {\n  POSTULAR_YA: { color: C.exito, texto: 'Postular ya', orden: 0 },\n  POSTULAR: { color: C.acento, texto: 'Postular', orden: 1 },\n  REVISAR: { color: C.ambar, texto: 'Revisar', orden: 2 },\n  DESCARTAR: { color: C.tenue, texto: 'Descartar', orden: 3 },\n};\n\nconst esc = (s) => String(s === null || s === undefined ? '' : s)\n  .replace(/&/g, '&amp;').replace(/</g, '&lt;')\n  .replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n\nconst corta = (s, n) => {\n  const t = String(s || '');\n  return t.length > n ? t.slice(0, n - 1).trimEnd() + '\u2026' : t;\n};\n\n// \u2500\u2500 Datos \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 todas = $input.all().map((i) => i.json);\n\n// Orden del correo: primero el veredicto, luego el puntaje y, en caso de\n// empate, la posicion que ocupa la vacante DENTRO de su propia bolsa.\n//\n// Ese tercer criterio no es un capricho. Los empates son la norma: una\n// vacante sin descripcion lleva el puntaje topado en 88 y ninguna bolsa\n// publica descripcion en su listado. Sin desempate, Array.sort es estable y\n// gana quien llegue antes en el array, y ese orden lo fija \"Juntar fuentes\":\n// primero las respuestas HTTP y al final los correos. Medido: Indeed extraia\n// 9 vacantes, las 9 pasaban el filtro con 88 puntos y las 9 se quedaban fuera\n// del correo, mientras LinkedIn se llevaba 15 de las 25 plazas con el mismo\n// puntaje. Con el rango, entre vacantes empatadas va primero la mejor de cada\n// bolsa, luego la segunda de cada una, y asi. Un puntaje mas alto sigue\n// ganando siempre: esto solo reparte los empates.\nconst porPuntaje = (a, b) =>\n  (VER[a.veredicto].orden - VER[b.veredicto].orden) || (b.score - a.score);\n\nconst candidatas = todas.filter((v) => v.score >= UMBRAL).sort(porPuntaje);\n\nconst rango = new Map();\nfor (const f of Object.keys(FUENTES)) {\n  candidatas.filter((v) => v.fuente === f).forEach((v, i) => rango.set(v.id, i));\n}\n\nconst ganadoras = candidatas\n  .sort((a, b) => porPuntaje(a, b) || (rango.get(a.id) - rango.get(b.id)))\n  .slice(0, MAX_EN_CORREO);\n\nconsole.log('Correo -> revisadas: ' + todas.length + ' | pasan el umbral ('\n  + UMBRAL + '): ' + candidatas.length + ' | caben en el correo: ' + ganadoras.length);\n\nif (ganadoras.length === 0) {\n  return [{ json: { hay: false, n: 0, ids: [] } }];\n}\n\nconst cuenta = (lista, campo) => {\n  const m = {};\n  for (const v of lista) m[v[campo]] = (m[v[campo]] || 0) + 1;\n  return m;\n};\n\nconst porFuente = cuenta(ganadoras, 'fuente');\nconst revisadasPorFuente = cuenta(todas, 'fuente');\nconst porVeredicto = cuenta(ganadoras, 'veredicto');\nconst conIA = todas.filter((v) => v.origen === 'ia').length;\nconst diagnosticoFuentes = ((todas.find((v) => v._diagnosticoFuentes) || {})\n  ._diagnosticoFuentes || {}).fuentes || {};\n\n// \u2500\u2500 Piezas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Barra de puntaje: dos celdas de una tabla, que es lo unico que dibuja\n// bloques de color de forma fiable en todos los clientes de correo.\nfunction barra(score, color) {\n  const lleno = Math.max(4, Math.min(100, score));\n  return ''\n    + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"'\n    + ' style=\"border-collapse:separate;table-layout:fixed\"><tr>'\n    + '<td width=\"' + lleno + '%\" style=\"height:4px;line-height:4px;font-size:0;'\n    + 'background:' + color + ';border-radius:2px 0 0 2px\">&nbsp;</td>'\n    + '<td width=\"' + (100 - lleno) + '%\" style=\"height:4px;line-height:4px;font-size:0;'\n    + 'background:#E3E7ED;border-radius:0 2px 2px 0\" class=\"pista\">&nbsp;</td>'\n    + '</tr></table>';\n}\n\nfunction etiqueta(texto, color, fondo) {\n  return '<span style=\"display:inline-block;background:' + fondo + ';color:' + color + ';'\n    + 'font-size:11px;font-weight:700;letter-spacing:.3px;padding:3px 8px;'\n    + 'border-radius:4px;white-space:nowrap\">' + esc(texto) + '</span>';\n}\n\nfunction tarjeta(v) {\n  const ver = VER[v.veredicto] || VER.REVISAR;\n  const fte = FUENTES[v.fuente] || { color: C.suave, nombre: v.fuente };\n\n  // Texto plano: lo escapa quien lo inserta, no se escapa dos veces.\n  const antiguedad = v.diasAntiguedad === 0 ? 'Hoy'\n    : v.diasAntiguedad === 1 ? 'Ayer'\n      : v.diasAntiguedad > 1 ? 'Hace ' + v.diasAntiguedad + ' d\u00edas'\n        : (v.publicadoTexto || '');\n\n  const razones = (v.razones || []).map((r) =>\n    '<tr><td valign=\"top\" style=\"width:14px;padding:2px 0;color:' + ver.color\n    + ';font-size:13px;line-height:19px\">&bull;</td>'\n    + '<td style=\"padding:2px 0;font-size:13px;line-height:19px;color:' + C.suave\n    + '\" class=\"suave\">' + esc(r) + '</td></tr>').join('');\n\n  const brechas = (v.brechas || []).length\n    ? '<tr><td style=\"padding:10px 0 0\">'\n      + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\"><tr>'\n      + '<td valign=\"top\" style=\"width:14px;padding:1px 0;font-size:13px;line-height:19px;color:'\n      + C.ambar + '\">&#9888;</td>'\n      + '<td style=\"font-size:12.5px;line-height:19px;color:' + C.ambar + '\">'\n      // se escapa cada brecha por separado: el separador es HTML, no texto\n      + v.brechas.map(esc).join(' &middot; ') + '</td></tr></table></td></tr>'\n    : '';\n\n  const gancho = v.gancho\n    ? '<tr><td style=\"padding:12px 0 0\">'\n      + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"'\n      + ' style=\"border-collapse:collapse\"><tr>'\n      // sin class=\"pista\": esta barra conserva el color de la bolsa tambien\n      // en modo oscuro, es la marca visual de la cita\n      + '<td style=\"width:3px;background:' + fte.color + ';border-radius:2px\">&nbsp;</td>'\n      + '<td style=\"padding:8px 0 8px 11px;font-size:12.5px;line-height:19px;color:' + C.suave\n      + ';font-style:italic\" class=\"suave\">' + esc(corta(v.gancho, 220)) + '</td>'\n      + '</tr></table></td></tr>'\n    : '';\n\n  const salario = v.salario && !/no mostrado|no especificado/i.test(v.salario)\n    ? '<span style=\"color:' + C.exito + ';font-weight:700\">' + esc(corta(v.salario, 40)) + '</span>'\n    : '<span style=\"color:' + C.tenue + '\">Sueldo no publicado</span>';\n\n  // El puntaje de reglas se marca para saber cuando la IA no alcanzo a correr.\n  const origen = v.origen === 'ia' ? '' : ' &middot; <span style=\"color:' + C.tenue\n    + ';font-size:10px\" title=\"la IA no respondi\u00f3: puntaje de las reglas\">reglas</span>';\n\n  return ''\n    + '<tr><td style=\"padding:0 0 12px\">'\n    + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"'\n    + ' class=\"tarjeta\" style=\"border-collapse:separate;background:' + C.tarjeta\n    + ';border:1px solid ' + C.borde + ';border-radius:10px\">'\n\n    // franja superior del color de la bolsa\n    + '<tr><td style=\"height:3px;line-height:3px;font-size:0;background:' + fte.color\n    + ';border-radius:9px 9px 0 0\">&nbsp;</td></tr>'\n\n    + '<tr><td style=\"padding:14px 16px 16px\">'\n    + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">'\n\n    // fila 1: veredicto + fuente\n    + '<tr><td>'\n    + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"><tr>'\n    + '<td align=\"left\">' + etiqueta(ver.texto + ' \u00b7 ' + v.score, '#FFFFFF', ver.color) + '</td>'\n    + '<td align=\"right\" style=\"font-size:11px;color:' + C.tenue + '\">'\n    + '<span style=\"color:' + fte.color + ';font-weight:700\">' + esc(fte.nombre) + '</span>'\n    + (antiguedad ? ' &middot; ' + esc(antiguedad) : '') + origen\n    + '</td></tr></table></td></tr>'\n\n    // fila 2: titulo\n    + '<tr><td style=\"padding:11px 0 0\">'\n    + '<a href=\"' + esc(v.url) + '\" style=\"color:' + C.texto + ';text-decoration:none;'\n    + 'font-size:16.5px;font-weight:700;line-height:1.35\" class=\"titulo\">'\n    + esc(corta(v.titulo, 90)) + '</a></td></tr>'\n\n    // fila 3: empresa y ubicacion\n    + '<tr><td style=\"padding:5px 0 0;font-size:13px;color:' + C.suave + '\" class=\"suave\">'\n    + '<b style=\"color:' + C.texto + '\" class=\"fuerte\">' + esc(corta(v.empresa, 44)) + '</b>'\n    + (v.ubicacion ? ' &nbsp;&middot;&nbsp; ' + esc(corta(v.ubicacion, 40)) : '')\n    + '</td></tr>'\n\n    // fila 4: sueldo\n    + '<tr><td style=\"padding:7px 0 0;font-size:13px\">' + salario + '</td></tr>'\n\n    // fila 5: barra de puntaje\n    + '<tr><td style=\"padding:12px 0 0\">' + barra(v.score, ver.color) + '</td></tr>'\n\n    // fila 6: razones\n    + (razones\n      ? '<tr><td style=\"padding:11px 0 0\">'\n        + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">'\n        + razones + '</table></td></tr>'\n      : '')\n\n    + brechas\n    + gancho\n\n    // fila 7: boton\n    + '<tr><td style=\"padding:15px 0 0\">'\n    + '<a href=\"' + esc(v.url) + '\" style=\"display:inline-block;background:' + fte.color\n    + ';color:#FFFFFF;text-decoration:none;font-size:13px;font-weight:700;'\n    + 'padding:9px 18px;border-radius:6px\">Ver vacante en ' + esc(fte.nombre) + ' &rarr;</a>'\n    + '</td></tr>'\n\n    + '</table></td></tr></table></td></tr>';\n}\n\nfunction seccion(veredicto) {\n  const lista = ganadoras.filter((v) => v.veredicto === veredicto);\n  if (lista.length === 0) return '';\n  const ver = VER[veredicto];\n  return ''\n    + '<tr><td style=\"padding:14px 0 10px\">'\n    + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\"><tr>'\n    + '<td style=\"width:8px;height:8px;font-size:0;line-height:8px;background:' + ver.color\n    + ';border-radius:50%\">&nbsp;</td>'\n    + '<td style=\"padding-left:8px;font-size:12px;font-weight:700;letter-spacing:.8px;'\n    + 'text-transform:uppercase;color:' + ver.color + '\">' + esc(ver.texto)\n    + ' <span style=\"color:' + C.tenue + ';font-weight:600\">(' + lista.length + ')</span></td>'\n    + '</tr></table></td></tr>'\n    + lista.map(tarjeta).join('');\n}\n\n// Resumen: una celda por bolsa, con su color. Solo aparecen las bolsas que\n// aportaron algo, para no mostrar un cero cuando una fuente falla.\nfunction celdaResumen(numero, etiqueta, color) {\n  return '<td align=\"center\" style=\"padding:14px 4px 0\">'\n    + '<div style=\"font-size:21px;font-weight:800;color:' + color + '\" class=\"'\n    + (color === C.texto ? 'fuerte' : '') + '\">' + numero + '</div>'\n    + '<div style=\"font-size:10.5px;color:' + C.tenue + ';padding-top:2px\">'\n    + esc(etiqueta) + '</div></td>';\n}\n\n// Todas las fuentes aparecen siempre, incluso cuando seleccionaron cero.\nconst celdasFuente = Object.keys(FUENTES)\n  .map((f) => celdaResumen(porFuente[f] || 0, FUENTES[f].nombre, FUENTES[f].color))\n  .join('');\n\nconst ESTADOS_FUENTE = {\n  ok: { texto: 'OK', color: C.exito },\n  ok_parcial: { texto: 'Parcial', color: C.ambar },\n  sin_resultados: { texto: 'Sin resultados', color: C.tenue },\n  fallo: { texto: 'Fall\u00f3 descarga', color: '#B42318' },\n  bloqueada: { texto: 'Bloqueada', color: '#B42318' },\n  parser_error: { texto: 'Cambi\u00f3 el formato', color: '#B42318' },\n  sin_respuesta: { texto: 'Sin respuesta', color: '#B42318' },\n  sin_diagnostico: { texto: 'Sin diagn\u00f3stico', color: C.tenue },\n};\n\n// \"403 x12\" a partir de { \"403\": 12 }. Es el dato que evita tener que abrir\n// n8n para saber si una bolsa dejo de contestar o solo no tenia vacantes.\nfunction codigosHttp(d) {\n  const codigos = d && d.codigos ? Object.keys(d.codigos) : [];\n  if (codigos.length === 0) return '';\n  return ' &middot; HTTP ' + codigos\n    .map((c) => c + (d.codigos[c] > 1 ? ' x' + d.codigos[c] : ''))\n    .join(', ');\n}\n\nfunction filaFuente(f) {\n  const d = diagnosticoFuentes[f] || {};\n  const claveEstado = d.estado || ((revisadasPorFuente[f] || 0) > 0 ? 'ok' : 'sin_diagnostico');\n  const estado = ESTADOS_FUENTE[claveEstado] || ESTADOS_FUENTE.sin_diagnostico;\n  const extraidas = Number.isFinite(d.extraidas) ? d.extraidas : (revisadasPorFuente[f] || 0);\n\n  // Cuando una bolsa falla, lo que de verdad hace falta es lo que contesto:\n  // \"403 Forbidden\" a secas es una regla de User-Agent en su servidor, y\n  // \"Just a moment\" es Cloudflare. Son dos problemas distintos y se arreglan\n  // de forma distinta, asi que el correo lo dice y evita abrir n8n.\n  const motivo = d.motivo\n    ? '<tr><td></td><td colspan=\"2\" style=\"padding:0 0 6px;font-size:10px;'\n      + 'line-height:14px;color:' + C.tenue + ';font-style:italic\">'\n      + 'respondi\u00f3: \u201c' + esc(corta(d.motivo, 150)) + '\u201d</td></tr>'\n    : '';\n\n  return '<tr>'\n    + '<td style=\"padding:5px 0;font-size:11.5px;font-weight:700;color:'\n    + FUENTES[f].color + '\">' + esc(FUENTES[f].nombre) + '</td>'\n    + '<td align=\"center\" style=\"padding:5px 8px;font-size:10.5px;color:' + C.tenue + '\">'\n    + extraidas + ' extra\u00eddas &middot; ' + (revisadasPorFuente[f] || 0) + ' evaluadas &middot; '\n    + (porFuente[f] || 0) + ' seleccionadas' + codigosHttp(d) + '</td>'\n    + '<td align=\"right\" style=\"padding:5px 0;font-size:10.5px;font-weight:700;color:'\n    + estado.color + '\">' + esc(estado.texto) + '</td>'\n    + '</tr>'\n    + motivo;\n}\n\nconst panelFuentes = '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">'\n  + Object.keys(FUENTES).map(filaFuente).join('') + '</table>';\n\nconst fecha = new Date().toLocaleDateString('es-MX', {\n  timeZone: 'America/Monterrey', weekday: 'long', day: 'numeric', month: 'long',\n});\nconst hora = new Date().toLocaleTimeString('es-MX', {\n  timeZone: 'America/Monterrey', hour: '2-digit', minute: '2-digit',\n});\n\nconst destacadas = (porVeredicto.POSTULAR_YA || 0) + (porVeredicto.POSTULAR || 0);\nconst preheader = destacadas\n  ? destacadas + ' vacantes para postular hoy, de ' + todas.length + ' revisadas.'\n  : ganadoras.length + ' vacantes por revisar, de ' + todas.length + ' analizadas.';\n\nconst html = '<!doctype html>'\n  + '<html lang=\"es\"><head>'\n  + '<meta charset=\"utf-8\">'\n  + '<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">'\n  + '<meta name=\"color-scheme\" content=\"light dark\">'\n  + '<meta name=\"supported-color-schemes\" content=\"light dark\">'\n  + '<title>Vacantes de hoy</title>'\n  + '<style>'\n  + '@media (max-width:620px){'\n  + '.wrap{width:100%!important}'\n  + '.titulo{font-size:15.5px!important}'\n  + '.pad{padding-left:14px!important;padding-right:14px!important}'\n  + '}'\n  // El modo oscuro es una mejora opcional: si el cliente lo ignora,\n  // el correo se sigue leyendo con la paleta clara.\n  + '@media (prefers-color-scheme:dark){'\n  + 'body,.lienzo{background:#111317!important}'\n  + '.tarjeta{background:#1B1E24!important;border-color:#2C313A!important}'\n  + '.titulo,.fuerte{color:#EDEFF2!important}'\n  + '.suave{color:#A6AEBA!important}'\n  + '.pista{background:#2C313A!important}'\n  + '.cabecera{background:#1B1E24!important;border-color:#2C313A!important}'\n  + '}'\n  + '</style></head>'\n\n  + '<body style=\"margin:0;padding:0;background:' + C.fondo + ';'\n  + '-webkit-font-smoothing:antialiased\" class=\"lienzo\">'\n\n  + '<div style=\"display:none;max-height:0;overflow:hidden;opacity:0;color:transparent\">'\n  + esc(preheader) + '</div>'\n\n  + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"'\n  + ' style=\"background:' + C.fondo + '\" class=\"lienzo\"><tr>'\n  + '<td align=\"center\" style=\"padding:22px 10px 30px\">'\n\n  + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"600\"'\n  + ' class=\"wrap\" style=\"width:600px;max-width:600px;'\n  + 'font-family:-apple-system,BlinkMacSystemFont,\\'Segoe UI\\',Roboto,Helvetica,Arial,sans-serif\">'\n\n  // \u2500\u2500 Cabecera \u2500\u2500\n  + '<tr><td class=\"pad cabecera\" style=\"background:' + C.tarjeta + ';border:1px solid '\n  + C.borde + ';border-radius:10px;padding:20px 18px\">'\n  + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">'\n  + '<tr><td style=\"font-size:20px;font-weight:800;color:' + C.texto + '\" class=\"fuerte\">'\n  + 'Vacantes de hoy</td></tr>'\n  + '<tr><td style=\"padding:3px 0 0;font-size:12.5px;color:' + C.tenue + '\">'\n  + esc(fecha) + ' &middot; ' + esc(hora) + ' &middot; ' + todas.length + ' revisadas &middot; '\n  + conIA + ' afinadas con IA</td></tr>'\n  + '<tr><td style=\"padding:16px 0 0\">'\n  + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\"'\n  + ' style=\"border-top:1px solid ' + C.borde + '\" class=\"cabecera\"><tr>'\n  + celdaResumen(ganadoras.length, 'Seleccionadas', C.texto)\n  + celdasFuente\n  + '</tr></table></td></tr>'\n  + '<tr><td style=\"padding:14px 0 0;border-top:1px solid ' + C.borde + ';margin-top:12px\">'\n  + '<div style=\"font-size:10.5px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;'\n  + 'color:' + C.tenue + ';padding-bottom:4px\">Estado de las fuentes</div>'\n  + panelFuentes + '</td></tr>'\n  + '</table></td></tr>'\n\n  // \u2500\u2500 Secciones \u2500\u2500\n  + '<tr><td class=\"pad\">'\n  + '<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">'\n  + seccion('POSTULAR_YA') + seccion('POSTULAR') + seccion('REVISAR')\n  + '</table></td></tr>'\n\n  // \u2500\u2500 Pie \u2500\u2500\n  + '<tr><td align=\"center\" style=\"padding:8px 12px 0;font-size:11px;line-height:17px;color:'\n  + C.tenue + '\">'\n  // El pie nombra solo las bolsas que aportaron algo: si una falla o esta\n  // apagada, el correo no miente diciendo que la reviso.\n  + 'Agente de vacantes &middot; '\n  + Object.keys(FUENTES).map((f) => esc(FUENTES[f].nombre)).join(' &middot; ')\n  + '<br>'\n  + 'Reglas locales' + (conIA ? ' + Gemini' : '') + ' &middot; '\n  + 'no se repiten vacantes ya enviadas'\n  + '</td></tr>'\n\n  + '</table></td></tr></table></body></html>';\n\nconst asunto = destacadas\n  ? 'Vacantes \u00b7 ' + destacadas + ' para postular'\n    + (ganadoras.length > destacadas ? ' y ' + (ganadoras.length - destacadas) + ' por revisar' : '')\n  : 'Vacantes \u00b7 ' + ganadoras.length + ' por revisar';\n\nreturn [{\n  json: {\n    hay: true,\n    n: ganadoras.length,\n    asunto: asunto,\n    html: html,\n    // El nodo posterior las marca como enviadas para no repetirlas.\n    ids: ganadoras.map((v) => v.id),\n  },\n}];\n"
      },
      "id": "a3d606cf-77f9-4ab1-8dcd-d2d1a3373135",
      "name": "Armar correo",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -992,
        288
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "hay-vacantes",
              "leftValue": "={{ $json.hay }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "04a29b99-f926-4061-ad08-7f226b65b9f9",
      "name": "Hay vacantes?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -768,
        288
      ]
    },
    {
      "parameters": {
        "sendTo": "YOUR_EMAIL@example.com",
        "subject": "={{ $json.asunto }}",
        "emailType": "html",
        "message": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "4ff43c2c-7154-48e1-80b8-83f5991d3d53",
      "name": "Enviar correo",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        -544,
        176
      ]
    },
    {
      "parameters": {
        "jsCode": "// \u2500\u2500 Recordar enviadas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Corre DESPUES de mandar el correo y anota las vacantes que ya se enviaron,\n// para que el nodo \"Filtrar y puntuar\" no las repita en la siguiente vuelta.\n//\n// Va despues del envio a proposito: si Gmail falla, no se marcan y volveran a\n// salir en la ejecucion siguiente.\n//\n// OJO: n8n solo guarda estos datos en ejecuciones de produccion (workflow\n// activo). En las pruebas manuales desde el editor no persiste, asi que en\n// pruebas veras las mismas vacantes una y otra vez. Es lo esperado.\n\nconst memoria = $getWorkflowStaticData('global');\nif (!memoria.enviadas || typeof memoria.enviadas !== 'object') memoria.enviadas = {};\n\nconst ids = ($('Armar correo').first().json.ids) || [];\nconst ahora = Date.now();\n\nfor (const id of ids) memoria.enviadas[id] = ahora;\n\nmemoria.ultimoEnvio = new Date().toISOString();\n\nconsole.log('Recordadas ' + ids.length + ' vacantes. '\n  + 'Total en memoria: ' + Object.keys(memoria.enviadas).length);\n\nreturn [{ json: { marcadas: ids.length, enMemoria: Object.keys(memoria.enviadas).length } }];\n"
      },
      "id": "b81c2f14-3d59-4b7a-9c2e-7f0a1d6e5c33",
      "name": "Recordar enviadas",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -320,
        176
      ]
    },
    {
      "parameters": {},
      "id": "07f17f83-f00b-4551-abe4-9f843037c4fe",
      "name": "Nada hoy (no manda correo)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        -544,
        400
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "es-occ",
              "leftValue": "={{ $json.fuente }}",
              "rightValue": "OCC",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "reparto-occ-0001",
      "name": "Es OCC?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -2830,
        400
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Return-Format",
              "value": "html"
            }
          ]
        },
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 3500
            }
          },
          "response": {
            "response": {
              "responseFormat": "text",
              "neverError": true,
              "fullResponse": true
            }
          },
          "redirect": {
            "redirect": {
              "followRedirects": true
            }
          },
          "timeout": 60000
        }
      },
      "id": "descargar-occ-001",
      "name": "Descargar OCC",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -2650,
        300
      ],
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "alwaysOutputData": true
    }
  ],
  "connections": {
    "Cada 12 horas": {
      "main": [
        [
          {
            "node": "Generar busquedas",
            "type": "main",
            "index": 0
          },
          {
            "node": "Leer alertas Indeed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generar busquedas": {
      "main": [
        [
          {
            "node": "Es OCC?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Descargar fuentes": {
      "main": [
        [
          {
            "node": "Juntar fuentes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Leer alertas Indeed": {
      "main": [
        [
          {
            "node": "Juntar fuentes",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Juntar fuentes": {
      "main": [
        [
          {
            "node": "Parsear vacantes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parsear vacantes": {
      "main": [
        [
          {
            "node": "Filtrar y puntuar",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filtrar y puntuar": {
      "main": [
        [
          {
            "node": "Armar lotes IA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Armar lotes IA": {
      "main": [
        [
          {
            "node": "GApi",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GApi": {
      "main": [
        [
          {
            "node": "Fusionar IA y reglas",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fusionar IA y reglas": {
      "main": [
        [
          {
            "node": "Armar correo",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Armar correo": {
      "main": [
        [
          {
            "node": "Hay vacantes?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Hay vacantes?": {
      "main": [
        [
          {
            "node": "Enviar correo",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nada hoy (no manda correo)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Enviar correo": {
      "main": [
        [
          {
            "node": "Recordar enviadas",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Es OCC?": {
      "main": [
        [
          {
            "node": "Descargar OCC",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Descargar fuentes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Descargar OCC": {
      "main": [
        [
          {
            "node": "Juntar fuentes",
            "type": "main",
            "index": 1
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false
  },
  "versionId": "50661d8f-46fc-4a06-8cca-b83014aee728",
  "nodeGroups": [],
  "id": "c0ff58dd-62a3-4f8f-b8cf-846d95e598a1",
  "tags": []
}