{
  "name": "Avisos de subvenciones para gestorías (BDNS)",
  "nodes": [
    {
      "name": "Cada mañana",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "30 7 * * 1-5"
            }
          ]
        }
      },
      "id": "f46b1507-0958-588b-a45f-13144b31a21f"
    },
    {
      "name": "Configuración",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        300
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c60a2e25-e63a-5e79-9d2e-f96564666627",
              "name": "regiones",
              "type": "string",
              "value": "1,78,79,80,81,82,83,84,85,86"
            },
            {
              "id": "f5375e68-c037-5cd2-a5c7-f21d48f39a1a",
              "name": "diasAtras",
              "type": "number",
              "value": 3
            },
            {
              "id": "94d9a6cb-b7a2-5a7d-b118-fa3898a19091",
              "name": "correoGestoria",
              "type": "string",
              "value": "equipo@tu-gestoria.es"
            }
          ]
        },
        "options": {}
      },
      "id": "9175cf37-42e5-5a0d-8321-63f34867c4a9"
    },
    {
      "name": "Buscar en la BDNS",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ],
      "parameters": {
        "jsCode": "// Busca en la BDNS las convocatorias registradas en los últimos días, descarta\n// nominativas, convenios y las que ya se avisaron, y pide el detalle de cada una.\nconst cfg = $('Configuración').first().json;\nconst API = 'https://www.infosubvenciones.es/bdnstrans/api';\nconst hoy = new Date();\nconst iso = (d) => d.toISOString().slice(0, 10);\nconst fechaApi = (d) => `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;\nconst desde = new Date(hoy); desde.setDate(desde.getDate() - Number(cfg.diasAtras || 3));\nconst NOMINATIVA = /\\b(nominativ|nominad|convenio|concesi[oó]n directa|premio)|\\b(subvenci[oó]n|subv\\.?)\\s+directa/i;\n\n// La API responde unas veces en UTF-8 y otras en Latin-1: se decodifica a mano.\nconst pedir = async (ruta) => {\n  for (let i = 1; i <= 3; i++) {\n    try {\n      const r = await this.helpers.httpRequest({\n        url: API + ruta, encoding: 'arraybuffer', timeout: 30000,\n        headers: { Accept: 'application/json', 'User-Agent': 'n8n-subvenciones-gestorias (+https://www.albydev.com)' },\n      });\n      const bytes = Buffer.from(r);\n      let texto = bytes.toString('utf8');\n      if (texto.includes('\\uFFFD')) texto = bytes.toString('latin1');\n      return texto.trim() ? JSON.parse(texto) : null;\n    } catch (e) {\n      if (i === 3) throw e;\n      await new Promise((ok) => setTimeout(ok, 800 * i));\n    }\n  }\n};\n\n// 1. Búsqueda: solo trae número, título, organismo y fecha.\nconst regiones = String(cfg.regiones).replace(/\\s/g, '');\nconst filas = [];\nfor (let pagina = 0; pagina < 20; pagina++) {\n  const q = Object.entries({ vpd: 'GE', page: pagina, pageSize: 100, order: 'fechaRecepcion', direccion: 'desc', fechaDesde: fechaApi(desde), regiones })\n    .map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');\n  const d = await pedir(`/convocatorias/busqueda?${q}`);\n  const c = (d && d.content) || [];\n  filas.push(...c);\n  if (!c.length || d.last) break;\n}\n\n// 2. Fuera nominativas, convenios y las que ya se avisaron otro día.\nconst memoria = $getWorkflowStaticData('global');\nmemoria.vistas = memoria.vistas || {};\nconst nuevas = filas.filter((f) => !NOMINATIVA.test(f.descripcion || '') && !memoria.vistas[f.numeroConvocatoria]);\n\n// 3. Detalle de cada una, de tres en tres para no saturar la API.\nconst limpiar = (s) => String(s || '').replace(/\\s+/g, ' ').trim();\nconst frase = (s) => { s = limpiar(s); const m = s.replace(/[^A-ZÁÉÍÓÚÑ]/g, '').length, l = s.replace(/[^A-Za-zÁÉÍÓÚÑáéíóúñ]/g, '').length; return l && m / l > 0.7 ? s.charAt(0) + s.slice(1).toLowerCase() : s; };\nconst enlace = (v) => { const s = limpiar(v).split(/[\\s;,]+/)[0]; return /^https?:\\/\\/[^/]+\\.[^/]+/i.test(s) ? s : null; };\nconst salida = [];\nfor (let i = 0; i < nuevas.length; i += 3) {\n  const tanda = await Promise.all(nuevas.slice(i, i + 3).map((f) => pedir(`/convocatorias?vpd=GE&numConv=${f.numeroConvocatoria}`).catch(() => null)));\n  for (const d of tanda) {\n    if (!d || !d.descripcion) continue;\n    const fin = d.fechaFinSolicitud ? d.fechaFinSolicitud.slice(0, 10) : null;\n    if (fin && fin < iso(hoy)) continue; // ya cerrada\n    const o = d.organo || {};\n    salida.push({ json: {\n      bdns: String(d.codigoBDNS),\n      titulo: frase(d.descripcion),\n      organismo: frase(o.nivel3 || o.nivel2 || o.nivel1),\n      ambito: { ESTADO: 'Estatal', AUTONOMICA: 'Autonómica', LOCAL: 'Local' }[o.nivel1] || limpiar(o.nivel1),\n      importe: Number(d.presupuestoTotal) > 0 ? Number(d.presupuestoTotal) : null,\n      finalidad: limpiar(d.descripcionFinalidad),\n      tipo: limpiar(d.tipoConvocatoria),\n      beneficiarios: (d.tiposBeneficiarios || []).map((b) => frase(b.descripcion)),\n      sectores: (d.sectores || []).map((s) => limpiar(s.codigo)).filter(Boolean),\n      regiones: (d.regiones || []).map((r) => limpiar(r.descripcion)),\n      plazoFin: fin,\n      plazoTexto: fin ? null : frase(d.textFin || d.textInicio) || 'Consultar en las bases',\n      minimis: /minimis/i.test((d.reglamento && d.reglamento.descripcion) || ''),\n      bases: enlace(d.urlBasesReguladoras),\n      sede: enlace(d.sedeElectronica),\n      ficha: `https://www.infosubvenciones.es/bdnstrans/GE/es/convocatorias/${d.codigoBDNS}`,\n    } });\n  }\n}\n\n// Algunas convocatorias se registran en varias líneas con el mismo título: se agrupan.\nconst unicas = new Map();\nfor (const s of salida) {\n  const k = (s.json.titulo + '|' + s.json.organismo).toLowerCase();\n  if (unicas.has(k)) { const u = unicas.get(k).json; u.lineas = (u.lineas || 1) + 1; u.importe = (u.importe || 0) + (s.json.importe || 0) || null; }\n  else unicas.set(k, s);\n}\nsalida.length = 0; salida.push(...unicas.values());\n\n// Se apuntan como vistas para no repetirlas mañana. Se olvidan a los 60 días.\nconst limite = Date.now() - 60 * 864e5;\nfor (const [k, t] of Object.entries(memoria.vistas)) if (t < limite) delete memoria.vistas[k];\nfor (const f of nuevas) memoria.vistas[f.numeroConvocatoria] = Date.now();\n\nreturn salida;\n"
      },
      "id": "4d7a7bbd-1009-59f6-80e3-9302845407ab"
    },
    {
      "name": "Leer clientes",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        660,
        300
      ],
      "executeOnce": true,
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Clientes"
        },
        "options": {}
      },
      "id": "7ae1eff9-bd34-5a62-b338-b669903c8277"
    },
    {
      "name": "Cruzar con clientes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        300
      ],
      "parameters": {
        "jsCode": "// Cruza cada convocatoria con la hoja de clientes: isla o comunidad, tipo de\n// beneficiario, sector (letra CNAE) y palabras clave. Devuelve las que encajan\n// con al menos un cliente, con la lista de clientes y el motivo.\nconst convocatorias = $('Buscar en la BDNS').all().map((i) => i.json);\nconst clientes = $input.all().map((i) => i.json).filter((c) => String(c.Cliente || '').trim());\n\nconst sinTildes = (s) => String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().trim();\nconst lista = (s) => String(s || '').split(/[,;]/).map((x) => x.trim()).filter(Boolean);\n\n// Códigos NUTS de las islas y de lo que las contiene.\nconst ISLAS = { 'el hierro': 'ES703', fuerteventura: 'ES704', 'gran canaria': 'ES705', 'la gomera': 'ES706', 'la palma': 'ES707', lanzarote: 'ES708', tenerife: 'ES709' };\nconst zonaOk = (conv, cli) => {\n  const codigos = conv.regiones.map((r) => r.split(' ')[0]);\n  if (!codigos.length) return true;\n  const isla = ISLAS[sinTildes(cli.Isla)];\n  // «España» solo vale si la ayuda es estatal: hay ayuntamientos que la marcan así.\n  if (conv.ambito === 'Estatal' && codigos.includes('ES')) return true;\n  return codigos.some((c) => c === 'ES7' || c === 'ES70' || (isla ? c === isla : c.startsWith('ES70')));\n};\n\n// Tipos de beneficiario de la BDNS frente al tipo de cliente de la gestoría.\nconst tipoOk = (conv, cli) => {\n  const b = conv.beneficiarios.map(sinTildes).filter((x) => !x.includes('sin informacion'));\n  if (!b.length) return true;\n  const t = sinTildes(cli.Tipo);\n  if (/autonom|pyme|microempresa|persona fisica con actividad/.test(t)) return b.some((x) => x.includes('pyme') || (x.includes('desarrollan actividad economica') && !x.includes('no desarrollan')));\n  if (/gran/.test(t)) return b.some((x) => x.includes('gran'));\n  if (/asociacion|fundacion|sin animo|entidad/.test(t)) return b.some((x) => x.includes('juridicas que no desarrollan'));\n  if (/particular|persona fisica/.test(t)) return b.some((x) => x.includes('fisicas que no desarrollan'));\n  return true;\n};\n\n// La BDNS da el sector unas veces con la letra CNAE («G») y otras con la\n// división («47», «01.4»). Se pasa todo a letra de sección.\nconst SECCIONES = [[3, 'A'], [9, 'B'], [33, 'C'], [35, 'D'], [39, 'E'], [43, 'F'], [47, 'G'], [53, 'H'], [56, 'I'], [63, 'J'], [66, 'K'], [68, 'L'], [75, 'M'], [82, 'N'], [84, 'O'], [85, 'P'], [88, 'Q'], [93, 'R'], [96, 'S'], [98, 'T'], [99, 'U']];\nconst seccion = (codigo) => {\n  const s = String(codigo).trim().toUpperCase();\n  if (/^[A-U]$/.test(s)) return s;\n  const n = parseInt(s, 10);\n  const f = Number.isFinite(n) && SECCIONES.find(([hasta]) => n <= hasta);\n  return f ? f[1] : null;\n};\nfor (const c of convocatorias) c.secciones = [...new Set(c.sectores.map(seccion).filter(Boolean))];\n\n// Sector: si la convocatoria no dice sector, vale para todos.\nconst sectorOk = (conv, cli) => {\n  const suyos = lista(cli.Sectores).map(seccion).filter(Boolean);\n  return !conv.secciones.length || !suyos.length || conv.secciones.some((s) => suyos.includes(s));\n};\n\nconst palabras = (conv, cli) => lista(cli['Palabras clave']).filter((p) => sinTildes(conv.titulo).includes(sinTildes(p)));\n\nconst salida = [];\nfor (const conv of convocatorias) {\n  const encajan = [];\n  for (const cli of clientes) {\n    if (!zonaOk(conv, cli) || !tipoOk(conv, cli) || !sectorOk(conv, cli)) continue;\n    const p = palabras(conv, cli);\n    // Si el cliente tiene palabras clave, tiene que aparecer alguna, salvo que su\n    // sector coincida de forma explícita con el de la convocatoria.\n    const sectorExplicito = conv.secciones.length > 0 && lista(cli.Sectores).length > 0;\n    if (lista(cli['Palabras clave']).length && !p.length && !sectorExplicito) continue;\n    encajan.push({ cliente: String(cli.Cliente).trim(), responsable: String(cli.Responsable || '').trim(), motivo: p.length ? `menciona «${p.join('», «')}»` : sectorExplicito ? 'mismo sector' : 'perfil compatible' });\n  }\n  if (encajan.length) salida.push({ json: { ...conv, clientes: encajan } });\n}\n// Primero las que cierran antes.\nsalida.sort((a, b) => (a.json.plazoFin || '9999').localeCompare(b.json.plazoFin || '9999'));\nreturn salida;\n"
      },
      "id": "428d539c-36e2-5213-bf82-b3597fd921d0"
    },
    {
      "name": "Preparar filas",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        200
      ],
      "parameters": {
        "jsCode": "// Una fila por convocatoria para la pestaña «Convocatorias» de la hoja.\nconst eur = (n) => (n ? new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(n) : '');\nreturn $input.all().map(({ json: c }) => ({ json: {\n  Fecha: new Date().toISOString().slice(0, 10),\n  BDNS: c.bdns,\n  Convocatoria: c.titulo,\n  Organismo: c.organismo,\n  Importe: eur(c.importe),\n  Plazo: c.plazoFin || c.plazoTexto,\n  Clientes: c.clientes.map((x) => x.cliente).join(', '),\n  Enlace: c.ficha,\n  Estado: 'Pendiente de revisar',\n} }));\n"
      },
      "id": "6a2059f4-10ec-5e10-ac28-1a903df96149"
    },
    {
      "name": "Apuntar en la hoja",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1320,
        200
      ],
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Convocatorias"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "cba94ef8-f6aa-5402-bd22-d9fab16dc784"
    },
    {
      "name": "Preparar resumen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        420
      ],
      "parameters": {
        "jsCode": "// Un solo correo con todas las convocatorias del día, ordenadas por cierre.\nconst cfg = $('Configuración').first().json;\nconst convs = $('Cruzar con clientes').all().map((i) => i.json);\nif (!convs.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 hoy = new Date(); hoy.setHours(0, 0, 0, 0);\nconst plazo = (c) => {\n  if (!c.plazoFin) return 'Plazo: ' + esc(c.plazoTexto.charAt(0).toLowerCase() + c.plazoTexto.slice(1));\n  const fin = new Date(c.plazoFin + 'T00:00:00');\n  const dias = Math.round((fin - hoy) / 864e5);\n  const fecha = fin.toLocaleDateString('es-ES', { day: 'numeric', month: 'long' });\n  return `Hasta el ${fecha}${dias <= 7 ? ` · <b style=\"color:#b91c1c\">quedan ${dias} ${dias === 1 ? 'día' : 'días'}</b>` : ''}`;\n};\nconst bloque = (c) => `\n  <tr><td style=\"padding:18px 0;border-bottom:1px solid #e5e7eb\">\n    <div style=\"font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.4px\">${esc(c.ambito)} · ${esc(c.organismo)}</div>\n    <a href=\"${esc(c.ficha)}\" style=\"display:block;margin:4px 0 8px;font-size:16px;font-weight:700;color:#0f172a;text-decoration:none\">${esc(c.titulo)}</a>\n    <div style=\"font-size:14px;color:#334155;line-height:1.6\">\n      ${c.importe ? `<b>${eur(c.importe)}</b> convocados${c.lineas ? ` en ${c.lineas} líneas` : ''} · ` : ''}${plazo(c)}${c.minimis ? ' · <i>de minimis</i>' : ''}<br>\n      Para: ${esc(c.beneficiarios.join('; ') || 'sin especificar')}\n    </div>\n    <div style=\"margin-top:10px\">${c.clientes.map((x) => `<span style=\"display:inline-block;margin:0 6px 6px 0;padding:4px 10px;border-radius:999px;background:#ecfdf5;color:#065f46;font-size:13px\"><b>${esc(x.cliente)}</b>${x.responsable ? ` · ${esc(x.responsable)}` : ''} · ${esc(x.motivo)}</span>`).join('')}</div>\n    <div style=\"margin-top:6px;font-size:13px\">${[c.bases && `<a href=\"${esc(c.bases)}\">Bases</a>`, c.sede && `<a href=\"${esc(c.sede)}\">Sede electrónica</a>`, `<a href=\"${esc(c.ficha)}\">Ficha BDNS ${esc(c.bdns)}</a>`].filter(Boolean).join(' · ')}</div>\n  </td></tr>`;\nconst nClientes = new Set(convs.flatMap((c) => c.clientes.map((x) => x.cliente))).size;\nconst html = `<div style=\"font-family:Arial,Helvetica,sans-serif;max-width:640px;margin:0 auto;color:#0f172a\">\n  <h1 style=\"font-size:20px;margin:0 0 6px\">${convs.length} ${convs.length === 1 ? 'convocatoria nueva encaja' : 'convocatorias nuevas encajan'} con ${nClientes} ${nClientes === 1 ? 'cliente' : 'clientes'}</h1>\n  <p style=\"margin:0 0 8px;color:#475569;font-size:14px\">${hoy.toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' })} · Ordenadas por fecha de cierre. Quedan apuntadas en la hoja «Convocatorias».</p>\n  <table style=\"width:100%;border-collapse:collapse\">${convs.map(bloque).join('')}</table>\n  <p style=\"margin-top:18px;font-size:12px;color:#64748b\">Datos de la Base de Datos Nacional de Subvenciones. El cruce con clientes es orientativo: revisa siempre las bases antes de avisar a un cliente.</p>\n</div>`;\nreturn [{ json: {\n  para: cfg.correoGestoria,\n  asunto: `Subvenciones: ${convs.length} ${convs.length === 1 ? 'novedad' : 'novedades'} para ${nClientes} ${nClientes === 1 ? 'cliente' : 'clientes'}`,\n  html,\n} }];\n"
      },
      "id": "fc299adf-29f8-5c7b-9e45-f2f55d951196"
    },
    {
      "name": "Enviar resumen",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        1320,
        420
      ],
      "parameters": {
        "fromEmail": "avisos@tu-gestoria.es",
        "toEmail": "={{ $json.para }}",
        "subject": "={{ $json.asunto }}",
        "emailFormat": "html",
        "html": "={{ $json.html }}",
        "options": {}
      },
      "id": "ff7831b5-09f4-58ef-85e4-bf4b657226e0"
    },
    {
      "name": "Léeme",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -120
      ],
      "parameters": {
        "width": 700,
        "height": 360,
        "color": 5,
        "content": "## Avisos de subvenciones para gestorías\nCada mañana de lunes a viernes busca en la BDNS las convocatorias nuevas, descarta nominativas, convenios y cerradas, las cruza con tu hoja de clientes y te manda un resumen.\n\n**Para ponerlo en marcha**\n1. En **Configuración**, pon tu correo. Las regiones van ya para Canarias, sus siete islas y las estatales.\n2. Crea una hoja de Google con dos pestañas: **Clientes** (Cliente, Responsable, Tipo, Isla, Sectores, Palabras clave) y **Convocatorias** (vacía).\n3. Pega su URL en **Leer clientes** y **Apuntar en la hoja**, y conecta tu cuenta de Google.\n4. Conecta tu correo (SMTP) en **Enviar resumen** y activa el flujo.\n\nHecho por albydev · www.albydev.com/n8n-subvenciones-gestorias.html"
      },
      "id": "4e73ba78-dfdc-5595-adf8-30ccde88530f"
    },
    {
      "name": "Hoja de clientes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        620,
        500
      ],
      "parameters": {
        "width": 420,
        "height": 260,
        "color": 7,
        "content": "### Columnas de «Clientes»\n- **Tipo**: autónomo, pyme, gran empresa, asociación o particular.\n- **Isla**: Fuerteventura, Lanzarote, Gran Canaria…\n- **Sectores**: letras CNAE separadas por comas (I hostelería, G comercio, A agricultura…).\n- **Palabras clave** (opcional): digitalización, contratación, eficiencia energética…"
      },
      "id": "594c12eb-7f18-5b06-914a-6288b62aa8bb"
    }
  ],
  "connections": {
    "Cada mañana": {
      "main": [
        [
          {
            "node": "Configuración",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuración": {
      "main": [
        [
          {
            "node": "Buscar en la BDNS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Buscar en la BDNS": {
      "main": [
        [
          {
            "node": "Leer clientes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Leer clientes": {
      "main": [
        [
          {
            "node": "Cruzar con clientes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cruzar con clientes": {
      "main": [
        [
          {
            "node": "Preparar filas",
            "type": "main",
            "index": 0
          },
          {
            "node": "Preparar resumen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Preparar filas": {
      "main": [
        [
          {
            "node": "Apuntar en la hoja",
            "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": []
}