{
  "name": "Avisos de licitaciones (muestra)",
  "nodes": [
    {
      "name": "Cada mañana",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "45 7 * * 1-5"
            }
          ]
        }
      },
      "id": "def7810c-35b3-5306-aa4b-9ebffa5da369"
    },
    {
      "name": "Configuración",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        300
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "45e092b4-ed2e-5a60-b430-fa6512e1a94e",
              "name": "zonas",
              "type": "string",
              "value": "ES70"
            },
            {
              "id": "9322bac9-6fcb-598d-92d8-1330acbf4206",
              "name": "codigosPostales",
              "type": "string",
              "value": "35,38"
            },
            {
              "id": "d92f0f43-a026-5d48-82c9-abed6e3a74b9",
              "name": "paginasMax",
              "type": "number",
              "value": 8
            },
            {
              "id": "47e7ef12-7297-5e2d-848f-cc57f4a14f94",
              "name": "correoDestino",
              "type": "string",
              "value": "tu-correo@ejemplo.es"
            },
            {
              "id": "a52bde80-2937-5c67-8b21-11a60c5f928e",
              "name": "canal",
              "type": "string",
              "value": "https://contrataciondelestado.es/sindicacion/sindicacion_643/licitacionesPerfilesContratanteCompleto3.atom"
            }
          ]
        },
        "options": {}
      },
      "id": "8389a6cb-2734-5c76-8349-d3373c72a106"
    },
    {
      "name": "Buscar en la Plataforma",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ],
      "parameters": {
        "jsCode": "// Recorre el canal ATOM de la Plataforma de Contratación del Sector Público\n// (lo último modificado va primero) y se queda con las licitaciones en plazo\n// de la zona configurada que no se hayan avisado antes.\nconst cfg = $('Configuración').first().json;\nconst ahora = new Date();\nconst TIPOS = { 1: 'Suministros', 2: 'Servicios', 3: 'Obras', 21: 'Gestión de servicios públicos', 22: 'Concesión de servicios', 31: 'Concesión de obras públicas', 32: 'Concesión de obras', 40: 'Colaboración público-privada', 7: 'Administrativo especial', 8: 'Privado', 50: 'Patrimonial' };\nconst PROCEDIMIENTOS = { 1: 'Abierto', 2: 'Restringido', 3: 'Negociado sin publicidad', 4: 'Negociado con publicidad', 5: 'Diálogo competitivo', 7: 'Derivado de acuerdo marco', 8: 'Concurso de proyectos', 9: 'Abierto simplificado', 10: 'Asociación para la innovación', 12: 'Sistema dinámico de adquisición', 13: 'Licitación con negociación' };\nconst zonas = String(cfg.zonas || '').split(',').map((z) => z.trim().toUpperCase()).filter(Boolean);\nconst postales = String(cfg.codigosPostales || '').split(',').map((z) => z.trim()).filter(Boolean);\n\n// Lectura de CODICE sin parser XML: el entorno de n8n no trae DOMParser.\nconst des = (s) => String(s || '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '\"').replace(/&apos;/g, \"'\").replace(/&#(\\d+);/g, (_, n) => String.fromCharCode(n)).replace(/&amp;/g, '&').replace(/\\s+/g, ' ').trim();\nconst uno = (xml, tag) => { const m = xml.match(new RegExp(`<(?:[\\\\w-]+:)?${tag}\\\\b[^>]*>([^<]*)<`)); return m ? des(m[1]) : ''; };\nconst todos = (xml, tag) => [...xml.matchAll(new RegExp(`<(?:[\\\\w-]+:)?${tag}\\\\b[^>]*>([^<]*)<`, 'g'))].map((m) => des(m[1]));\nconst bloque = (xml, tag) => { const m = xml.match(new RegExp(`<(?:[\\\\w-]+:)?${tag}\\\\b[^>]*>([\\\\s\\\\S]*?)</(?:[\\\\w-]+:)?${tag}>`)); return m ? m[1] : ''; };\n\nconst pedir = async (url) => {\n  for (let i = 1; i <= 4; i++) {\n    try {\n      const r = await this.helpers.httpRequest({ url, encoding: 'arraybuffer', timeout: 120000, headers: { 'User-Agent': 'n8n-licitaciones (+https://www.albydev.com)' } });\n      return Buffer.from(r).toString('utf8');\n    } catch (e) {\n      if (i === 4) throw e;\n      await new Promise((ok) => setTimeout(ok, 2000 * i));\n    }\n  }\n};\n\nconst memoria = $getWorkflowStaticData('global');\nmemoria.avisadas = memoria.avisadas || {};\nconst salida = [];\nlet url = cfg.canal || 'https://contrataciondelestado.es/sindicacion/sindicacion_643/licitacionesPerfilesContratanteCompleto3.atom';\nlet paginas = 0, revisadas = 0;\nwhile (url && paginas < Number(cfg.paginasMax || 8)) {\n  const xml = await pedir(url);\n  paginas++;\n  const entradas = xml.split(/<entry>/).slice(1);\n  for (const e of entradas) {\n    revisadas++;\n    if (uno(e, 'ContractFolderStatusCode') !== 'PUB') continue; // solo en plazo de presentación\n    const id = uno(e, 'id');\n    if (!id || memoria.avisadas[id]) continue;\n    const proyecto = bloque(e, 'ProcurementProject');\n    const nuts = todos(proyecto || e, 'CountrySubentityCode').map((n) => n.toUpperCase());\n    const cp = uno(bloque(e, 'LocatedContractingParty'), 'PostalZone');\n    const enZona = (!zonas.length && !postales.length) || nuts.some((n) => zonas.some((z) => n.startsWith(z))) || (!nuts.length && postales.some((p) => cp.startsWith(p)));\n    if (!enZona) continue;\n    const plazo = bloque(e, 'TenderSubmissionDeadlinePeriod');\n    const fin = uno(plazo, 'EndDate');\n    const hora = uno(plazo, 'EndTime').slice(0, 5);\n    if (fin && new Date(`${fin}T${hora || '23:59'}:00`) < ahora) continue;\n    const presupuesto = bloque(proyecto, 'BudgetAmount');\n    const importe = Number(uno(presupuesto, 'TaxExclusiveAmount') || uno(presupuesto, 'EstimatedOverallContractAmount') || uno(e, 'TaxExclusiveAmount')) || null;\n    const tipo = uno(proyecto, 'TypeCode');\n    const proc = uno(e, 'ProcedureCode');\n    if (proc === '3') continue; // negociado sin publicidad: solo pueden ofertar las empresas invitadas\n    const enlace = (e.match(/<link[^>]*href=\"([^\"]+)\"/) || [])[1];\n    // Muchos ayuntamientos y cabildos marcan solo «Canarias» (ES70). En ese\n    // caso la isla se saca del código postal del órgano; las del Gobierno de\n    // Canarias siguen valiendo para todas las islas.\n    const organo = uno(bloque(e, 'LocatedContractingParty'), 'Name');\n    let isla = nuts.find((n) => /^ES70\\d$/.test(n)) || null;\n    if (!isla && /ayuntamiento|alcald|cabildo|consejo insular|mancomunidad/i.test(organo)) {\n      const n = Number(cp);\n      isla = n >= 35500 && n <= 35589 ? 'ES708' : n >= 35600 && n <= 35699 ? 'ES704' : n >= 35000 && n <= 35499 ? 'ES705'\n        : n >= 38700 && n <= 38799 ? 'ES707' : n >= 38800 && n <= 38899 ? 'ES706' : n >= 38900 && n <= 38999 ? 'ES703' : n >= 38000 && n <= 38699 ? 'ES709' : null;\n    }\n    salida.push({ json: {\n      id,\n      expediente: uno(e, 'ContractFolderID'),\n      titulo: uno(e, 'title'),\n      organo,\n      isla,\n      tipo: TIPOS[tipo] || '',\n      procedimiento: PROCEDIMIENTOS[proc] || '',\n      importe,\n      cpv: [...new Set(todos(proyecto, 'ItemClassificationCode'))],\n      nuts: [...new Set(nuts)],\n      lugar: uno(proyecto, 'CountrySubentity') || uno(bloque(e, 'LocatedContractingParty'), 'CityName'),\n      plazoFin: fin || null,\n      plazoHora: hora || null,\n      enlace: enlace ? des(enlace) : null,\n    } });\n  }\n  url = (xml.match(/<link href=\"([^\"]+)\" rel=\"next\"/) || [])[1] || null;\n}\n\n// Se apuntan como avisadas para no repetirlas. Se olvidan a los 120 días.\nconst limite = Date.now() - 120 * 864e5;\nfor (const [k, t] of Object.entries(memoria.avisadas)) if (t < limite) delete memoria.avisadas[k];\nfor (const s of salida) memoria.avisadas[s.json.id] = Date.now();\n\nconsole.log(`Páginas: ${paginas} · entradas revisadas: ${revisadas} · en plazo y en la zona, nuevas: ${salida.length}`);\nreturn salida;\n"
      },
      "id": "51ccdcf5-12a4-5ad8-851d-4eed250f13f4"
    },
    {
      "name": "Preparar resumen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        300
      ],
      "parameters": {
        "jsCode": "// Un correo con las licitaciones en plazo de la zona. Sin perfiles ni cruce:\n// eso está en la versión completa.\nconst cfg = $('Configuración').first().json;\nconst lics = $input.all().map((i) => i.json).sort((a, b) => (a.plazoFin || '9999').localeCompare(b.plazoFin || '9999'));\nif (!lics.length) return [];\nconst esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst eur = (n) => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(n);\nconst MAX = 20;\nconst filas = lics.slice(0, MAX).map((l) => `<li style=\"margin-bottom:12px\"><a href=\"${esc(l.enlace)}\"><b>${esc(l.titulo)}</b></a><br><span style=\"color:#475569;font-size:13px\">${esc(l.organo)} · ${esc(l.tipo)}${l.importe ? ' · ' + eur(l.importe) : ''}${l.plazoFin ? ' · ofertas hasta el ' + l.plazoFin : ''}</span></li>`).join('');\nconst html = `<div style=\"font-family:Arial,Helvetica,sans-serif;max-width:640px;color:#0f172a\"><h1 style=\"font-size:19px\">${lics.length} licitaciones nuevas en plazo en tu zona</h1><ul style=\"padding-left:18px\">${filas}</ul>`\n  + (lics.length > MAX ? `<p>Y ${lics.length - MAX} más.</p>` : '')\n  + `<p style=\"font-size:12px;color:#64748b\">Versión de muestra: te llega todo lo de tu zona. La completa lo filtra por la isla, el tipo, el importe, los CPV y las palabras de cada perfil: ${'https://www.albydev.com/n8n-licitaciones-publicas.html'}</p></div>`;\nreturn [{ json: { para: cfg.correoDestino, asunto: `Licitaciones: ${lics.length} nuevas en plazo en tu zona`, html } }];\n"
      },
      "id": "7cb2f26b-e75e-5c90-9aac-d5138fa6b863"
    },
    {
      "name": "Enviar resumen",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        880,
        300
      ],
      "parameters": {
        "fromEmail": "avisos@tu-dominio.es",
        "toEmail": "={{ $json.para }}",
        "subject": "={{ $json.asunto }}",
        "emailFormat": "html",
        "html": "={{ $json.html }}",
        "options": {}
      },
      "id": "2743814a-01e6-55a2-bdf2-f596303935af"
    },
    {
      "name": "Versión de muestra",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -200
      ],
      "parameters": {
        "width": 640,
        "height": 320,
        "color": 5,
        "content": "## Avisos de licitaciones públicas · versión de muestra\nCada mañana revisa la Plataforma de Contratación del Sector Público y te manda las licitaciones en plazo de Canarias que no te haya avisado antes (sin negociados sin publicidad).\n\n**No incluye**: los perfiles de empresa, el filtro por isla, tipo, importe, CPV y palabras clave, ni el registro en hoja de cálculo.\n\n**Versión completa, instalada y mantenida:** https://www.albydev.com/n8n-licitaciones-publicas.html"
      },
      "id": "01da5afd-9b19-5a27-9ba6-a34d558647ca"
    }
  ],
  "connections": {
    "Cada mañana": {
      "main": [
        [
          {
            "node": "Configuración",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuración": {
      "main": [
        [
          {
            "node": "Buscar en la Plataforma",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Buscar en la Plataforma": {
      "main": [
        [
          {
            "node": "Preparar resumen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Preparar resumen": {
      "main": [
        [
          {
            "node": "Enviar resumen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Atlantic/Canary"
  },
  "pinData": {},
  "active": false,
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}