AutomationFlowsAI & RAG › Agente De Monitoramento Educacional - 100% N8n Cloud

Agente De Monitoramento Educacional - 100% N8n Cloud

Agente de Monitoramento Educacional - 100% n8n Cloud. Uses httpRequest. Scheduled trigger; 8 nodes.

Cron / scheduled trigger★★★★☆ complexity8 nodesHTTP Request
AI & RAG Trigger: Cron / scheduled Nodes: 8 Complexity: ★★★★☆ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Agente de Monitoramento Educacional - 100% n8n Cloud",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "weeksInterval": 1,
              "triggerAtHour": 7,
              "triggerAtDay": [
                1
              ]
            }
          ]
        }
      },
      "id": "trigger-schedule",
      "name": "Agendamento Semanal (Segunda 07h)",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -400,
        300
      ]
    },
    {
      "parameters": {},
      "id": "trigger-manual",
      "name": "Executar Manualmente (teste)",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -400,
        460
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =============================================================================\n// N\u00d3: \"Consultar World Bank + Analisar\" (n8n Code node, modo \"Run Once for All Items\")\n// =============================================================================\n// Reimplementa\u00e7\u00e3o em JavaScript do pipeline Python (src/data_loader.py,\n// src/indicators.py, src/analysis.py, src/pipeline.py), para rodar 100%\n// dentro do n8n Cloud, sem precisar de nenhum arquivo local ou Python.\n//\n// Faz, nesta ordem:\n//   1. Consulta a API p\u00fablica do World Bank (mesma fonte do dataset do Kaggle)\n//      para os pa\u00edses e indicadores configurados abaixo.\n//   2. Monta uma grade completa de anos por pa\u00eds/indicador e faz interpola\u00e7\u00e3o\n//      linear dos anos faltantes (equivalente a handle_missing_values do Python).\n//   3. Calcula agrega\u00e7\u00f5es, rankings, crescimento (%) e CAGR, e classifica cada\n//      s\u00e9rie como \"evoluiu\" / \"estagnado\" / \"regrediu\".\n//   4. Gera: (a) CSV final consolidado, (b) CSV de crescimento, e (c) um\n//      resumo estruturado compacto para enviar \u00e0 IA (equivalente ao\n//      summary_for_ai.json do projeto Python).\n//\n// Sa\u00edda: um \u00fanico item com { finalCsv, growthCsv, summary }.\n\nconst INDICATORS = {\n  \"SE.XPD.TOTL.GD.ZS\": \"Gasto p\u00fablico em educa\u00e7\u00e3o (% do PIB)\",\n  \"SE.XPD.TOTL.GB.ZS\": \"Gasto p\u00fablico em educa\u00e7\u00e3o (% do gasto p\u00fablico total)\",\n  \"SE.PRM.ENRR\": \"Matr\u00edcula no ensino prim\u00e1rio (% bruto)\",\n  \"SE.SEC.ENRR\": \"Matr\u00edcula no ensino secund\u00e1rio (% bruto)\",\n  \"SE.TER.ENRR\": \"Matr\u00edcula no ensino superior (% bruto)\",\n  \"SE.ADT.LITR.ZS\": \"Taxa de alfabetiza\u00e7\u00e3o de adultos (%)\",\n};\n\nconst COUNTRIES = [\"BRA\", \"USA\", \"DEU\", \"JPN\", \"IND\", \"CHN\", \"ZAF\", \"ARG\", \"PRT\", \"NGA\"];\nconst MIN_YEAR = 2010;\nconst MAX_YEAR = 2022;\nconst TOP_N = 10;\nconst STAGNATION_THRESHOLD = 3.0;\n\n// -----------------------------------------------------------------------\n// 1) Consulta \u00e0 API do World Bank\n// -----------------------------------------------------------------------\nasync function fetchIndicator(indicatorCode) {\n  const url = `https://api.worldbank.org/v2/country/${COUNTRIES.join(\";\")}/indicator/${indicatorCode}`;\n  const allRecords = [];\n  let page = 1;\n\n  while (true) {\n    const response = await this.helpers.httpRequest({\n      method: \"GET\",\n      url,\n      qs: { format: \"json\", date: `${MIN_YEAR}:${MAX_YEAR}`, per_page: 1000, page },\n      json: true,\n    });\n\n    if (!response || !Array.isArray(response) || response.length < 2 || !response[1]) break;\n    const [meta, records] = response;\n    allRecords.push(...records);\n\n    if (page >= (meta.pages || 1)) break;\n    page += 1;\n  }\n  return allRecords;\n}\n\nfunction recordsToRows(records) {\n  return records.map((r) => ({\n    country_name: r.country.value,\n    country_code: r.countryiso3code || r.country.id,\n    indicator_code: r.indicator.id,\n    year: parseInt(r.date, 10),\n    value: r.value === null || r.value === undefined ? null : parseFloat(r.value),\n  }));\n}\n\n// -----------------------------------------------------------------------\n// 2) Grade completa de anos + interpola\u00e7\u00e3o linear (tratamento de ausentes)\n// -----------------------------------------------------------------------\nfunction interpolateGroup(yearRows) {\n  const values = yearRows.map((r) => r.value);\n  const known = [];\n  for (let i = 0; i < values.length; i++) if (values[i] !== null) known.push(i);\n  if (known.length === 0) return yearRows.map((r) => ({ ...r, value: null }));\n\n  for (let i = 0; i < values.length; i++) {\n    if (values[i] !== null) continue;\n    let prev = null;\n    let next = null;\n    for (const k of known) {\n      if (k < i) prev = k;\n      if (k > i && next === null) next = k;\n    }\n    if (prev !== null && next !== null) {\n      const frac = (i - prev) / (next - prev);\n      values[i] = values[prev] + (values[next] - values[prev]) * frac;\n    } else if (prev !== null) {\n      values[i] = values[prev];\n    } else if (next !== null) {\n      values[i] = values[next];\n    }\n  }\n  return yearRows.map((r, i) => ({ ...r, value: values[i] }));\n}\n\nfunction cleanAndFillMissing(rawRows) {\n  // Agrupa por pa\u00eds+indicador\n  const groups = {};\n  for (const r of rawRows) {\n    const key = `${r.country_code}|${r.indicator_code}`;\n    if (!groups[key]) {\n      groups[key] = { country_name: r.country_name, country_code: r.country_code, indicator_code: r.indicator_code, byYear: {} };\n    }\n    groups[key].byYear[r.year] = r.value;\n  }\n\n  const finalRows = [];\n  for (const key of Object.keys(groups)) {\n    const g = groups[key];\n    const yearRows = [];\n    for (let year = MIN_YEAR; year <= MAX_YEAR; year++) {\n      yearRows.push({\n        country_name: g.country_name,\n        country_code: g.country_code,\n        indicator_code: g.indicator_code,\n        year,\n        value: Object.prototype.hasOwnProperty.call(g.byYear, year) ? g.byYear[year] : null,\n      });\n    }\n    const interpolated = interpolateGroup(yearRows);\n    const stillMissing = interpolated.some((r) => r.value === null);\n    if (stillMissing) continue; // s\u00e9rie sem nenhum dado observ\u00e1vel - descarta\n    finalRows.push(...interpolated);\n  }\n  return finalRows;\n}\n\n// -----------------------------------------------------------------------\n// 3) Agrega\u00e7\u00f5es, rankings, crescimento/CAGR, classifica\u00e7\u00e3o de tend\u00eancia\n// -----------------------------------------------------------------------\nfunction classifyTrend(pctChange) {\n  if (pctChange === null || Number.isNaN(pctChange)) return \"indefinido\";\n  if (pctChange > STAGNATION_THRESHOLD) return \"evoluiu\";\n  if (pctChange < -STAGNATION_THRESHOLD) return \"regrediu\";\n  return \"estagnado\";\n}\n\nfunction calculateGrowth(rows) {\n  const groups = {};\n  for (const r of rows) {\n    const key = `${r.country_code}|${r.indicator_code}`;\n    if (!groups[key]) groups[key] = [];\n    groups[key].push(r);\n  }\n\n  const results = [];\n  for (const key of Object.keys(groups)) {\n    const group = groups[key].sort((a, b) => a.year - b.year);\n    if (group.length < 2) continue;\n    const first = group[0];\n    const last = group[group.length - 1];\n    const nYears = last.year - first.year;\n\n    const absChange = last.value - first.value;\n    const pctChange = first.value !== 0 ? (absChange / first.value) * 100 : null;\n\n    let cagr = null;\n    if (nYears > 0 && first.value > 0 && last.value > 0) {\n      cagr = (Math.pow(last.value / first.value, 1 / nYears) - 1) * 100;\n    }\n\n    results.push({\n      country_name: first.country_name,\n      country_code: first.country_code,\n      indicator_code: first.indicator_code,\n      first_year: first.year,\n      last_year: last.year,\n      first_value: Math.round(first.value * 100) / 100,\n      last_value: Math.round(last.value * 100) / 100,\n      abs_change: Math.round(absChange * 100) / 100,\n      pct_change: pctChange !== null ? Math.round(pctChange * 100) / 100 : null,\n      cagr_pct: cagr !== null ? Math.round(cagr * 100) / 100 : null,\n      trend: classifyTrend(pctChange),\n    });\n  }\n  return results;\n}\n\nfunction aggregateByCountryIndicator(rows) {\n  const groups = {};\n  for (const r of rows) {\n    const key = `${r.country_code}|${r.indicator_code}`;\n    if (!groups[key]) groups[key] = { country_name: r.country_name, country_code: r.country_code, indicator_code: r.indicator_code, values: [] };\n    groups[key].values.push(r.value);\n  }\n  return Object.values(groups).map((g) => {\n    const mean = g.values.reduce((a, b) => a + b, 0) / g.values.length;\n    return {\n      country_name: g.country_name,\n      country_code: g.country_code,\n      indicator_code: g.indicator_code,\n      mean_value: Math.round(mean * 100) / 100,\n      min_value: Math.round(Math.min(...g.values) * 100) / 100,\n      max_value: Math.round(Math.max(...g.values) * 100) / 100,\n    };\n  });\n}\n\nfunction rankCountriesByIndicator(rows, indicatorCode, topN = TOP_N) {\n  const subset = rows.filter((r) => r.indicator_code === indicatorCode);\n  const latestByCountry = {};\n  for (const r of subset) {\n    const key = r.country_code;\n    if (!latestByCountry[key] || r.year > latestByCountry[key].year) latestByCountry[key] = r;\n  }\n  const ranked = Object.values(latestByCountry).sort((a, b) => b.value - a.value);\n  return ranked.slice(0, topN).map((r, i) => ({\n    rank: i + 1,\n    country_name: r.country_name,\n    country_code: r.country_code,\n    year: r.year,\n    value: r.value,\n  }));\n}\n\n// -----------------------------------------------------------------------\n// 4) CSV helper\n// -----------------------------------------------------------------------\nfunction toCsv(rows, columns) {\n  const header = columns.join(\",\");\n  const lines = rows.map((r) =>\n    columns\n      .map((c) => {\n        const v = r[c] === null || r[c] === undefined ? \"\" : r[c];\n        const s = String(v);\n        return s.includes(\",\") ? `\"${s}\"` : s;\n      })\n      .join(\",\")\n  );\n  return [header, ...lines].join(\"\\n\");\n}\n\n// -----------------------------------------------------------------------\n// Execu\u00e7\u00e3o principal\n// -----------------------------------------------------------------------\nlet rawRows = [];\nconst failedIndicators = [];\n\nfor (const indicatorCode of Object.keys(INDICATORS)) {\n  try {\n    const records = await fetchIndicator.call(this, indicatorCode);\n    rawRows.push(...recordsToRows(records));\n  } catch (err) {\n    failedIndicators.push(indicatorCode);\n  }\n}\n\nif (rawRows.length === 0) {\n  throw new Error(\n    \"Nenhum dado retornado pela API do World Bank para nenhum indicador. Verifique conectividade do n8n Cloud.\"\n  );\n}\n\nconst cleanedRows = cleanAndFillMissing(rawRows);\nconst growth = calculateGrowth(cleanedRows);\nconst aggregation = aggregateByCountryIndicator(cleanedRows);\n\nconst rankings = {};\nfor (const indicatorCode of Object.keys(INDICATORS)) {\n  rankings[indicatorCode] = rankCountriesByIndicator(cleanedRows, indicatorCode);\n}\n\nconst topGrowth = [...growth].sort((a, b) => (b.pct_change ?? -Infinity) - (a.pct_change ?? -Infinity)).slice(0, TOP_N);\nconst topDecline = [...growth].sort((a, b) => (a.pct_change ?? Infinity) - (b.pct_change ?? Infinity)).slice(0, TOP_N);\nconst stagnant = growth.filter((g) => g.trend === \"estagnado\").slice(0, TOP_N);\nconst topInvestment = aggregation\n  .filter((a) => a.indicator_code === \"SE.XPD.TOTL.GD.ZS\")\n  .sort((a, b) => b.mean_value - a.mean_value)\n  .slice(0, TOP_N)\n  .map((a) => ({ country_name: a.country_name, mean_value: a.mean_value }));\n\nconst summary = {\n  period_covered: { min_year: MIN_YEAR, max_year: MAX_YEAR },\n  countries_analyzed: [...new Set(cleanedRows.map((r) => r.country_name))].sort(),\n  indicators_analyzed: Object.keys(INDICATORS),\n  indicators_failed: failedIndicators,\n  top_growth: topGrowth,\n  top_decline: topDecline,\n  stagnant_examples: stagnant,\n  top_investment_pct_gdp: topInvestment,\n  rankings_by_indicator: rankings,\n};\n\nconst finalCsv = toCsv(cleanedRows, [\"country_name\", \"country_code\", \"indicator_code\", \"year\", \"value\"]);\nconst growthCsv = toCsv(growth, [\n  \"country_name\",\n  \"country_code\",\n  \"indicator_code\",\n  \"first_year\",\n  \"last_year\",\n  \"first_value\",\n  \"last_value\",\n  \"abs_change\",\n  \"pct_change\",\n  \"cagr_pct\",\n  \"trend\",\n]);\n\nreturn [{ json: { summary, finalCsv, growthCsv } }];\n"
      },
      "id": "fetch-and-analyze",
      "name": "Consultar World Bank + Analisar",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -160,
        380
      ],
      "notes": "Busca os 6 indicadores para os 10 pa\u00edses configurados diretamente na API p\u00fablica do World Bank, faz limpeza + interpola\u00e7\u00e3o de valores ausentes, agrega\u00e7\u00f5es, rankings e c\u00e1lculo de crescimento/CAGR. Tudo em JavaScript puro, sem depender de Python."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =============================================================================\n// N\u00d3: \"Montar Prompt para a IA\" (n8n Code node, modo \"Run Once for All Items\")\n// =============================================================================\n// Recebe o item vindo do n\u00f3 anterior (\"Consultar World Bank + Analisar\"),\n// que cont\u00e9m $json.summary, e monta o corpo da requisi\u00e7\u00e3o para a API do\n// Claude (mesmo prompt usado em src/ai_report.py e prompts/executive_report_prompt.md).\n\nconst summary = $input.first().json.summary;\n\nconst systemPrompt = `Voc\u00ea \u00e9 um analista s\u00eanior de pol\u00edticas educacionais internacionais, atuando para um organismo multilateral. Sua fun\u00e7\u00e3o \u00e9 interpretar indicadores educacionais de diferentes pa\u00edses e produzir intelig\u00eancia acion\u00e1vel para tomadores de decis\u00e3o \u2014 n\u00e3o apenas descrever n\u00fameros.\n\nRegras obrigat\u00f3rias:\n- Nunca apenas repita ou resuma os n\u00fameros recebidos; sempre acrescente interpreta\u00e7\u00e3o, hip\u00f3teses causais plaus\u00edveis e implica\u00e7\u00f5es pr\u00e1ticas.\n- Identifique explicitamente: pa\u00edses que mais evolu\u00edram, pa\u00edses estagnados ou em regress\u00e3o, pa\u00edses com maior investimento relativo, pa\u00edses com melhores indicadores absolutos.\n- Para cada padr\u00e3o identificado, proponha poss\u00edveis explica\u00e7\u00f5es (ex: crises econ\u00f4micas, reformas educacionais, mudan\u00e7as demogr\u00e1ficas) deixando claro quando \u00e9 hip\u00f3tese e n\u00e3o fato comprovado pelos dados.\n- Termine com recomenda\u00e7\u00f5es concretas e priorizadas.\n- Seja direto, use linguagem executiva, evite jarg\u00e3o t\u00e9cnico desnecess\u00e1rio.`;\n\nconst userPrompt = `Abaixo est\u00e1 um resumo estruturado (JSON) com indicadores educacionais de m\u00faltiplos pa\u00edses, j\u00e1 processados (rankings, crescimento, agrega\u00e7\u00f5es).\n\nGere um RELAT\u00d3RIO EXECUTIVO em Markdown com as se\u00e7\u00f5es:\n\n1. Panorama geral (2-3 par\u00e1grafos)\n2. Pa\u00edses em destaque (maior evolu\u00e7\u00e3o, com hip\u00f3teses do porqu\u00ea)\n3. Pa\u00edses estagnados ou em regress\u00e3o (com hip\u00f3teses do porqu\u00ea)\n4. Investimento vs. resultado (quem investe mais e o retorno aparente)\n5. Melhores indicadores absolutos (n\u00e3o confundir com crescimento: quais pa\u00edses t\u00eam hoje os melhores n\u00fameros em termos absolutos em cada indicador, usando os rankings fornecidos)\n6. Compara\u00e7\u00f5es relevantes entre pa\u00edses\n7. Recomenda\u00e7\u00f5es (priorizadas, acion\u00e1veis)\n\nDados:\n\\`\\`\\`json\n${JSON.stringify(summary, null, 2)}\n\\`\\`\\``;\n\nreturn [\n  {\n    json: {\n      model: \"claude-sonnet-4-6\",\n      max_tokens: 4000,\n      system: systemPrompt,\n      messages: [{ role: \"user\", content: userPrompt }],\n    },\n  },\n];\n"
      },
      "id": "build-prompt",
      "name": "Montar Prompt para a IA",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        80,
        380
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {}
      },
      "id": "call-claude",
      "name": "Chamar API do Claude (an\u00e1lise executiva)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        320,
        380
      ],
      "notes": "Credencial 'Header Auth' com header 'x-api-key' = sua ANTHROPIC_API_KEY. Configurar em Credentials > New > Header Auth, e selecionar aqui em Authentication > Generic Credential Type."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =============================================================================\n// N\u00d3: \"Extrair Relat\u00f3rio da Resposta\" (n8n Code node, modo \"Run Once for All Items\")\n// =============================================================================\n// Recebe a resposta bruta da API do Claude (n\u00f3 HTTP Request anterior) e\n// extrai o texto do relat\u00f3rio em Markdown, pronto para ser salvo.\n\nconst response = $input.first().json;\nconst textBlocks = (response.content || []).filter((b) => b.type === \"text\");\nconst reportText = textBlocks.map((b) => b.text).join(\"\\n\");\n\nconst now = new Date();\nconst timestamp = now.toISOString().slice(0, 10);\nconst header = `# Relat\u00f3rio Executivo - Indicadores Educacionais\\n\\n_Gerado automaticamente em ${timestamp} via n8n Cloud_\\n\\n---\\n\\n`;\n\nreturn [\n  {\n    json: {\n      fileName: `executive_report_${timestamp}.md`,\n      content: header + reportText,\n    },\n  },\n];\n"
      },
      "id": "extract-report",
      "name": "Extrair Relat\u00f3rio da Resposta",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        560,
        380
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =============================================================================\n// N\u00d3: \"Montar Relat\u00f3rio HTML com Gr\u00e1ficos\" (n8n Code node, \"Run Once for All Items\")\n// =============================================================================\n// Vers\u00e3o com gr\u00e1ficos em PNG DE VERDADE (via QuickChart.io, servi\u00e7o gratuito,\n// sem necessidade de conta/chave para este uso), em vez de SVG.\n//\n// Motivo da troca: a maioria dos clientes de e-mail (Gmail incluso) N\u00c3O\n// renderiza <svg> dentro do corpo do e-mail, por restri\u00e7\u00e3o de seguran\u00e7a.\n// PNG funciona em qualquer lugar. Os gr\u00e1ficos s\u00e3o gerados chamando a API\n// p\u00fablica do QuickChart (POST /chart/create), que devolve uma URL de\n// imagem est\u00e1vel \u2014 essa URL \u00e9 embutida como <img src=\"...\">.\n//\n// Recebe o texto em Markdown gerado pela IA (n\u00f3 anterior: \"Extrair Relat\u00f3rio\n// da Resposta\") e o resumo estruturado original (n\u00f3 \"Consultar World Bank +\n// Analisar\", acessado por refer\u00eancia cruzada via $()).\n//\n// Sa\u00edda: { fileName, htmlContent } pronto para: (a) enviar por e-mail como\n// corpo HTML, e/ou (b) salvar/anexar como arquivo .html.\n\nconst COLOR_GROWTH = \"#2E7D32\";\nconst COLOR_DECLINE = \"#C62828\";\nconst COLOR_ACCENT = \"#1565C0\";\nconst COLOR_GOLD = \"#B8860B\";\n\n// ---- 1) Cria um gr\u00e1fico no QuickChart e devolve a URL da imagem ----\nasync function createChartUrl(chartConfig, width, height) {\n  try {\n    const response = await this.helpers.httpRequest({\n      method: \"POST\",\n      url: \"https://quickchart.io/chart/create\",\n      body: {\n        chart: chartConfig,\n        width,\n        height,\n        backgroundColor: \"white\",\n        devicePixelRatio: 2.0,\n      },\n      json: true,\n    });\n    if (response && response.success && response.url) {\n      return response.url;\n    }\n  } catch (err) {\n    // Se o QuickChart falhar (indispon\u00edvel, rate limit, etc.), o relat\u00f3rio\n    // ainda deve ser gerado - s\u00f3 sem esse gr\u00e1fico espec\u00edfico.\n  }\n  return null;\n}\n\nasync function barChartImg(title, items, labelKey, valueKey, color, xLabel) {\n  if (!items || items.length === 0) {\n    return `<p><em>Sem dados suficientes para o gr\u00e1fico \"${title}\".</em></p>`;\n  }\n  const config = {\n    type: \"horizontalBar\",\n    data: {\n      labels: items.map((i) => String(i[labelKey]).slice(0, 20)),\n      datasets: [\n        {\n          label: xLabel || title,\n          data: items.map((i) => i[valueKey] ?? 0),\n          backgroundColor: items.map((i) => (i[valueKey] >= 0 ? color : COLOR_DECLINE)),\n        },\n      ],\n    },\n    options: {\n      title: { display: true, text: title, fontSize: 16 },\n      legend: { display: false },\n      scales: {\n        xAxes: [{ scaleLabel: { display: !!xLabel, labelString: xLabel } }],\n      },\n    },\n  };\n  const height = Math.max(220, items.length * 42 + 60);\n  const url = await createChartUrl.call(this, config, 600, height);\n  if (!url) {\n    return `<p><em>Gr\u00e1fico \"${title}\" temporariamente indispon\u00edvel.</em></p>`;\n  }\n  return `<img src=\"${url}\" alt=\"${title}\" style=\"max-width:100%;border-radius:8px;display:block;margin:0 auto;\" />`;\n}\n\nasync function scatterChartImg(title, points, xLabel, yLabel) {\n  if (!points || points.length === 0) {\n    return `<p><em>Sem dados suficientes para o gr\u00e1fico \"${title}\".</em></p>`;\n  }\n  const config = {\n    type: \"scatter\",\n    data: {\n      datasets: [\n        {\n          label: title,\n          data: points.map((p) => ({ x: p.x, y: p.y })),\n          backgroundColor: COLOR_ACCENT,\n          pointRadius: 6,\n        },\n      ],\n    },\n    options: {\n      title: { display: true, text: title, fontSize: 16 },\n      legend: { display: false },\n      scales: {\n        xAxes: [{ scaleLabel: { display: true, labelString: xLabel } }],\n        yAxes: [{ scaleLabel: { display: true, labelString: yLabel } }],\n      },\n    },\n  };\n  const url = await createChartUrl.call(this, config, 520, 380);\n  const imgHtml = url\n    ? `<img src=\"${url}\" alt=\"${title}\" style=\"max-width:100%;border-radius:8px;display:block;margin:0 auto;\" />`\n    : `<p><em>Gr\u00e1fico \"${title}\" temporariamente indispon\u00edvel.</em></p>`;\n\n  // QuickChart (config simples acima) n\u00e3o rotula cada ponto com o nome do\n  // pa\u00eds - por isso, inclu\u00edmos uma legenda em tabela HTML logo abaixo, para\n  // n\u00e3o perder essa informa\u00e7\u00e3o mesmo se o gr\u00e1fico n\u00e3o rotular os pontos.\n  const legendRows = points\n    .map((p) => `<tr><td>${p.label}</td><td>${p.x}</td><td>${p.y}</td></tr>`)\n    .join(\"\");\n  const legendTable = `\n    <table style=\"width:100%; font-size:12px; margin-top:8px; border-collapse:collapse;\">\n      <tr style=\"background:#F1F8F2;\"><th style=\"text-align:left;padding:4px 8px;\">Pa\u00eds</th><th style=\"text-align:left;padding:4px 8px;\">${xLabel}</th><th style=\"text-align:left;padding:4px 8px;\">${yLabel}</th></tr>\n      ${legendRows}\n    </table>`;\n\n  return imgHtml + legendTable;\n}\n\n// ---- 2) Conversor simples de Markdown para HTML ----\nfunction markdownToHtml(md) {\n  const lines = md.split(\"\\n\");\n  let html = \"\";\n  let inList = false;\n\n  for (const rawLine of lines) {\n    const line = rawLine.trim();\n\n    if (line === \"\") {\n      if (inList) {\n        html += \"</ul>\\n\";\n        inList = false;\n      }\n      continue;\n    }\n\n    const headerMatch = line.match(/^(#{1,3})\\s+(.*)/);\n    if (headerMatch) {\n      if (inList) {\n        html += \"</ul>\\n\";\n        inList = false;\n      }\n      const level = headerMatch[1].length + 1;\n      html += `<h${level}>${inlineFormat(headerMatch[2])}</h${level}>\\n`;\n      continue;\n    }\n\n    const listMatch = line.match(/^[-*]\\s+(.*)/);\n    if (listMatch) {\n      if (!inList) {\n        html += \"<ul>\\n\";\n        inList = true;\n      }\n      html += `<li>${inlineFormat(listMatch[1])}</li>\\n`;\n      continue;\n    }\n\n    if (line === \"---\") {\n      if (inList) {\n        html += \"</ul>\\n\";\n        inList = false;\n      }\n      html += \"<hr/>\\n\";\n      continue;\n    }\n\n    if (inList) {\n      html += \"</ul>\\n\";\n      inList = false;\n    }\n    html += `<p>${inlineFormat(line)}</p>\\n`;\n  }\n  if (inList) html += \"</ul>\\n\";\n  return html;\n}\n\nfunction inlineFormat(text) {\n  return text.replace(/\\*\\*(.+?)\\*\\*/g, \"<strong>$1</strong>\").replace(/\\*(.+?)\\*/g, \"<em>$1</em>\");\n}\n\n// ---- 3) Cart\u00f5es de KPI ----\nfunction kpiCardsHtml(summary) {\n  const period = summary.period_covered;\n  const nCountries = summary.countries_analyzed.length;\n  const topGrower = summary.top_growth && summary.top_growth[0];\n  const topDecliner = summary.top_decline && summary.top_decline[0];\n  const topInvestor = summary.top_investment_pct_gdp && summary.top_investment_pct_gdp[0];\n\n  const card = (label, value, sub, color) => `\n    <div class=\"kpi-card\">\n      <div class=\"kpi-label\">${label}</div>\n      <div class=\"kpi-value\" style=\"color:${color}\">${value}</div>\n      <div class=\"kpi-sub\">${sub}</div>\n    </div>`;\n\n  let html = card(\"Per\u00edodo analisado\", `${period.min_year}\u2013${period.max_year}`, `${nCountries} pa\u00edses`, \"#333\");\n  if (topGrower) {\n    html += card(\"Maior crescimento\", `+${topGrower.pct_change}%`, `${topGrower.country_name} (${topGrower.indicator_code})`, COLOR_GROWTH);\n  }\n  if (topDecliner) {\n    html += card(\"Maior queda\", `${topDecliner.pct_change}%`, `${topDecliner.country_name} (${topDecliner.indicator_code})`, COLOR_DECLINE);\n  }\n  if (topInvestor) {\n    html += card(\"Maior investimento (% PIB)\", `${topInvestor.mean_value}%`, topInvestor.country_name, COLOR_GOLD);\n  }\n  return `<div class=\"kpi-grid\">${html}</div>`;\n}\n\n// ---- 4) Junta dois rankings pelo country_code (para o gr\u00e1fico de dispers\u00e3o) ----\nfunction joinRankings(summary, indicatorX, indicatorY) {\n  const rankX = {};\n  (summary.rankings_by_indicator[indicatorX] || []).forEach((r) => (rankX[r.country_code] = r));\n  const rankY = {};\n  (summary.rankings_by_indicator[indicatorY] || []).forEach((r) => (rankY[r.country_code] = r));\n\n  const commonCodes = Object.keys(rankX).filter((c) => rankY[c]);\n  return commonCodes.map((c) => ({\n    label: rankX[c].country_name,\n    x: rankX[c].value,\n    y: rankY[c].value,\n  }));\n}\n\n// ---- 5) Montagem do documento final ----\nconst reportItem = $input.first().json; // { fileName, content } vindo do n\u00f3 anterior\nconst summary = $(\"Consultar World Bank + Analisar\").item.json.summary;\n\nconst kpiHtml = kpiCardsHtml(summary);\n\nconst growthChart = await barChartImg.call(\n  this,\n  \"Maior crescimento no per\u00edodo (%)\",\n  summary.top_growth.slice(0, 8),\n  \"country_name\",\n  \"pct_change\",\n  COLOR_GROWTH,\n  \"% de varia\u00e7\u00e3o\"\n);\nconst declineChart = await barChartImg.call(\n  this,\n  \"Maior queda no per\u00edodo (%)\",\n  summary.top_decline.slice(0, 8),\n  \"country_name\",\n  \"pct_change\",\n  COLOR_DECLINE,\n  \"% de varia\u00e7\u00e3o\"\n);\nconst investmentChart = await barChartImg.call(\n  this,\n  \"Maior investimento m\u00e9dio (% do PIB)\",\n  summary.top_investment_pct_gdp.slice(0, 8),\n  \"country_name\",\n  \"mean_value\",\n  COLOR_GOLD,\n  \"% do PIB\"\n);\n\nconst absoluteIndicator = summary.rankings_by_indicator[\"SE.TER.ENRR\"]\n  ? \"SE.TER.ENRR\"\n  : Object.keys(summary.rankings_by_indicator)[0];\nconst absoluteChart = absoluteIndicator\n  ? await barChartImg.call(\n      this,\n      `Melhores indicadores absolutos \u2014 ${absoluteIndicator}`,\n      summary.rankings_by_indicator[absoluteIndicator].slice(0, 8),\n      \"country_name\",\n      \"value\",\n      COLOR_ACCENT,\n      \"Valor do indicador\"\n    )\n  : \"\";\n\nlet scatterChart = \"\";\nif (summary.rankings_by_indicator[\"SE.XPD.TOTL.GD.ZS\"] && summary.rankings_by_indicator[\"SE.TER.ENRR\"]) {\n  const points = joinRankings(summary, \"SE.XPD.TOTL.GD.ZS\", \"SE.TER.ENRR\");\n  scatterChart = await scatterChartImg.call(\n    this,\n    \"Investimento (% PIB) vs. Matr\u00edcula ensino superior\",\n    points,\n    \"Investimento m\u00e9dio (% do PIB)\",\n    \"Matr\u00edcula ensino superior (%)\"\n  );\n}\n\nconst analysisHtml = markdownToHtml(reportItem.content);\n\nconst now = new Date();\nconst generatedAt = now.toLocaleString(\"pt-BR\");\n\nconst chartCard = (html) => (html ? `<div class=\"chart-card\">${html}</div>` : \"\");\n\nconst fullHtml = `<!DOCTYPE html>\n<html lang=\"pt-BR\">\n<head>\n<meta charset=\"UTF-8\" />\n<title>Relat\u00f3rio Executivo - Indicadores Educacionais</title>\n<style>\n  body { font-family: 'Segoe UI', Arial, Helvetica, sans-serif; color: #222; max-width: 960px; margin: 0 auto; padding: 28px; line-height: 1.6; background: #fff; }\n  .header { background: linear-gradient(135deg, #1B5E20, #2E7D32); color: white; padding: 26px 30px; border-radius: 12px; margin-bottom: 26px; }\n  .header h1 { margin: 0 0 6px 0; font-size: 24px; }\n  .header .subtitle { opacity: 0.9; font-size: 13px; }\n  .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 28px; }\n  .kpi-card { background: #F8F9FA; border: 1px solid #EEE; border-radius: 10px; padding: 14px; text-align: center; }\n  .kpi-label { font-size: 11px; color: #777; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 5px; }\n  .kpi-value { font-size: 21px; font-weight: 700; }\n  .kpi-sub { font-size: 11.5px; color: #999; margin-top: 3px; }\n  .charts-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 16px; margin-bottom: 12px; }\n  .chart-card { background: #FAFAFA; border: 1px solid #EEE; border-radius: 10px; padding: 12px; }\n  h2 { font-size: 19px; color: #1B5E20; margin-top: 30px; border-bottom: 2px solid #E8F5E9; padding-bottom: 6px; }\n  h3 { font-size: 15px; color: #2E7D32; margin-top: 20px; }\n  hr { border: none; border-top: 1px solid #DDD; margin: 22px 0; }\n  .meta { color: #666; font-size: 12.5px; margin-top: 22px; padding-top: 14px; border-top: 1px solid #EEE; }\n  ul { padding-left: 20px; }\n  li { margin-bottom: 6px; }\n  table td, table th { border: 1px solid #DDD; }\n</style>\n</head>\n<body>\n  <div class=\"header\">\n    <h1>\ud83d\udcca Relat\u00f3rio Executivo \u2014 Indicadores Educacionais</h1>\n    <div class=\"subtitle\">Agente Inteligente de Monitoramento Educacional \u00b7 World Bank Education Statistics</div>\n  </div>\n\n  ${kpiHtml}\n\n  <h2>Vis\u00e3o geral em gr\u00e1ficos</h2>\n  <div class=\"charts-grid\">\n    ${chartCard(growthChart)}\n    ${chartCard(declineChart)}\n    ${chartCard(investmentChart)}\n    ${chartCard(absoluteChart)}\n  </div>\n  ${scatterChart ? `<div class=\"charts-grid\">${chartCard(scatterChart)}</div>` : \"\"}\n\n  ${analysisHtml}\n\n  <div class=\"meta\">\n    Gerado automaticamente via n8n Cloud + API do Claude em ${generatedAt}.<br/>\n    Gr\u00e1ficos renderizados via QuickChart.io \u2014 se algum n\u00e3o aparecer, seu cliente de\n    e-mail pode estar bloqueando imagens remotas por padr\u00e3o (procure \"mostrar imagens\").\n  </div>\n</body>\n</html>`;\n\nconst fileName = reportItem.fileName.replace(/\\.md$/, \".html\");\n\nreturn [{ json: { fileName, htmlContent: fullHtml } }];\n"
      },
      "id": "build-html-report",
      "name": "Montar Relat\u00f3rio HTML com Gr\u00e1ficos",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        800,
        380
      ],
      "notes": "Gera cart\u00f5es de KPI, 4 gr\u00e1ficos de barra e 1 de dispers\u00e3o como IMAGENS PNG DE VERDADE via QuickChart.io (POST /chart/create, sem necessidade de conta/chave para este volume de uso). Trocamos de SVG para PNG porque a maioria dos clientes de e-mail (Gmail incluso) n\u00e3o renderiza <svg> no corpo do e-mail. Rate limit gratuito do QuickChart: 60 gr\u00e1ficos/min - bem acima do necess\u00e1rio aqui (5 por execu\u00e7\u00e3o)."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// =============================================================================\n// N\u00d3: \"Preparar Anexo do Relat\u00f3rio\" (n8n Code node, \"Run Once for All Items\")\n// =============================================================================\n// O n\u00f3 do Gmail (e o de e-mail em geral) s\u00f3 consegue anexar ARQUIVOS\n// BIN\u00c1RIOS, n\u00e3o texto puro. Este n\u00f3 pega o HTML j\u00e1 pronto (campo\n// htmlContent, vindo do n\u00f3 \"Montar Relat\u00f3rio HTML com Gr\u00e1ficos\") e usa o\n// helper nativo do n8n para transform\u00e1-lo num anexo bin\u00e1rio de verdade,\n// mantendo tamb\u00e9m os campos originais (json) para uso no corpo do e-mail.\n\nconst item = $input.first().json;\n\nconst buffer = Buffer.from(item.htmlContent, \"utf-8\");\nconst binaryData = await this.helpers.prepareBinaryData(buffer, item.fileName, \"text/html\");\n\nreturn [\n  {\n    json: item, // mant\u00e9m fileName e htmlContent dispon\u00edveis para o n\u00f3 do Gmail\n    binary: {\n      report: binaryData, // nome usado no campo \"Input Data Field Name\" do anexo, no Gmail\n    },\n  },\n];\n"
      },
      "id": "prepare-attachment",
      "name": "Preparar Anexo do Relat\u00f3rio",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        380
      ],
      "notes": "Transforma o HTML (texto) em um anexo bin\u00e1rio de verdade, usando o helper nativo this.helpers.prepareBinaryData do n8n. O anexo fica dispon\u00edvel na propriedade bin\u00e1ria 'report' - use esse nome no campo de anexo do seu n\u00f3 de e-mail/Gmail."
    }
  ],
  "connections": {
    "Agendamento Semanal (Segunda 07h)": {
      "main": [
        [
          {
            "node": "Consultar World Bank + Analisar",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Executar Manualmente (teste)": {
      "main": [
        [
          {
            "node": "Consultar World Bank + Analisar",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Consultar World Bank + Analisar": {
      "main": [
        [
          {
            "node": "Montar Prompt para a IA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Montar Prompt para a IA": {
      "main": [
        [
          {
            "node": "Chamar API do Claude (an\u00e1lise executiva)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chamar API do Claude (an\u00e1lise executiva)": {
      "main": [
        [
          {
            "node": "Extrair Relat\u00f3rio da Resposta",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extrair Relat\u00f3rio da Resposta": {
      "main": [
        [
          {
            "node": "Montar Relat\u00f3rio HTML com Gr\u00e1ficos",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Montar Relat\u00f3rio HTML com Gr\u00e1ficos": {
      "main": [
        [
          {
            "node": "Preparar Anexo do Relat\u00f3rio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "description": "Vers\u00e3o 100% n8n Cloud: gatilho -> Code node consulta a API do World Bank e faz toda a an\u00e1lise em JavaScript -> monta prompt -> chama API do Claude -> monta relat\u00f3rio HTML com gr\u00e1ficos SVG -> prepara anexo bin\u00e1rio -> conecte seu pr\u00f3prio n\u00f3 de Gmail/Email a partir daqui."
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Agente de Monitoramento Educacional - 100% n8n Cloud. Uses httpRequest. Scheduled trigger; 8 nodes.

Source: https://github.com/Martbr/acelrai-desafio/blob/ab419b68836573e1cc63cfb3874046a92e515303/n8n/workflow_cloud.json — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Template 14310. Uses httpRequest, stopAndError, slack, gmail. Scheduled trigger; 53 nodes.

HTTP Request, Stop And Error, Slack +1
AI & RAG

🧑🏾 Chief of Staff — System Intelligence & Operations. Uses httpRequest, microsoftOutlook. Scheduled trigger; 52 nodes.

HTTP Request, Microsoft Outlook
AI & RAG

Master Agent - Orchestrator. Uses httpRequest, telegram, telegramTrigger. Scheduled trigger; 46 nodes.

HTTP Request, Telegram, Telegram Trigger
AI & RAG

Reputation Engine — Content Research Agent. Uses httpRequest. Scheduled trigger; 45 nodes.

HTTP Request
AI & RAG

Master Agent - Orchestrator. Uses httpRequest, telegram, telegramTrigger. Scheduled trigger; 44 nodes.

HTTP Request, Telegram, Telegram Trigger