{
  "name": "Enroutia · Cluster SEO keywords",
  "nodes": [
    {
      "parameters": {},
      "name": "Run",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "keywords",
              "name": "keywords",
              "value": "cookieless analytics\ngoogle analytics alternative\ngdpr analytics tool\nweb analytics without cookies\nmatomo vs plausible\nprivacy friendly analytics\nanalytics for ecommerce\nconversion tracking without consent banner",
              "type": "string"
            },
            {
              "id": "language",
              "name": "language",
              "value": "English",
              "type": "string"
            },
            {
              "id": "client",
              "name": "client",
              "value": "",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "name": "Keywords",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// One item per keyword: the embeddings node turns each into a vector.\nconst { keywords, language, client } = $json;\nreturn [...new Set(String(keywords).split('\\n').map((k) => k.trim().toLowerCase()).filter(Boolean))]\n  .map((keyword) => ({ json: { keyword, language, client } }));\n"
      },
      "name": "One item per keyword",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "resource": "embedding",
        "embeddingsModel": "embed",
        "text": "={{ $json.keyword }}",
        "options": {
          "clientTag": "={{ $json.client }}"
        }
      },
      "name": "Embed keywords",
      "type": "n8n-nodes-enroutia.enroutia",
      "typeVersion": 1,
      "position": [
        660,
        0
      ],
      "credentials": {
        "enroutiaApi": {
          "name": "Enroutia account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Groups keywords whose vectors point the same way (cosine similarity at or above\n// THRESHOLD). Greedy and single pass: fine for a few thousand keywords. Raise the\n// threshold for tighter clusters, lower it for broader ones.\nconst THRESHOLD = 0.82;\nconst keywords = $('One item per keyword').all().map((item) => item.json);\nconst vectors = $input.all().map((item) => item.json.data[0].embedding);\n\nconst norm = (v) => Math.sqrt(v.reduce((s, x) => s + x * x, 0));\nconst unit = vectors.map((v) => { const n = norm(v) || 1; return v.map((x) => x / n); });\nconst dot = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0);\n\nconst clusters = [];\nunit.forEach((vector, i) => {\n  let best = null, bestScore = -1;\n  for (const cluster of clusters) {\n    const score = dot(vector, cluster.centroid);\n    if (score > bestScore) { best = cluster; bestScore = score; }\n  }\n  if (best && bestScore >= THRESHOLD) {\n    best.members.push(i);\n    const n = best.members.length;\n    best.centroid = best.centroid.map((x, d) => (x * (n - 1) + vector[d]) / n);\n  } else {\n    clusters.push({ centroid: vector, members: [i] });\n  }\n});\n\nconst { language, client } = keywords[0] || {};\nreturn clusters\n  .sort((a, b) => b.members.length - a.members.length)\n  .map((cluster) => ({ json: {\n    keywords: cluster.members.map((i) => keywords[i].keyword),\n    size: cluster.members.length,\n    language,\n    client,\n  } }));\n"
      },
      "name": "Cluster by meaning",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "resource": "chat",
        "operation": "message",
        "model": "=enroutia/keywords",
        "prompt": "=Keywords in this cluster:\n{{ $json.keywords.join('\\n') }}",
        "options": {
          "systemMessage": "=You are an SEO strategist. Name the topic these keywords share and their dominant search intent. Return a JSON object {\"name\": <3 to 6 words in {{ $json.language }}>, \"intent\": <informational|commercial|transactional|navigational>, \"pillar_keyword\": <the one keyword a pillar page should target>}.",
          "jsonOutput": true,
          "temperature": 0,
          "maxTokens": 200,
          "clientTag": "={{ $json.client }}"
        }
      },
      "name": "Name clusters",
      "type": "n8n-nodes-enroutia.enroutia",
      "typeVersion": 1,
      "position": [
        1100,
        0
      ],
      "credentials": {
        "enroutiaApi": {
          "name": "Enroutia account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Each cluster with its name, intent and pillar keyword, ready for a sheet or a brief.\nconst clusters = $('Cluster by meaning').all();\nreturn $input.all().map((item, i) => {\n  const cluster = clusters[i].json;\n  const named = JSON.parse(item.json.text);\n  return { json: {\n    name: named.name,\n    intent: named.intent,\n    pillar_keyword: named.pillar_keyword,\n    size: cluster.size,\n    keywords: cluster.keywords.join(', '),\n  } };\n});\n"
      },
      "name": "One row per cluster",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        0
      ]
    }
  ],
  "connections": {
    "Run": {
      "main": [
        [
          {
            "node": "Keywords",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keywords": {
      "main": [
        [
          {
            "node": "One item per keyword",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One item per keyword": {
      "main": [
        [
          {
            "node": "Embed keywords",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Embed keywords": {
      "main": [
        [
          {
            "node": "Cluster by meaning",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cluster by meaning": {
      "main": [
        [
          {
            "node": "Name clusters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Name clusters": {
      "main": [
        [
          {
            "node": "One row per cluster",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
