AutomationFlowsAI & RAG › Create Weekly SEO Blog Posts From Google Sheets with Gemini and Sanity

Create Weekly SEO Blog Posts From Google Sheets with Gemini and Sanity

ByVinsen @vin55en on n8n.io

This scheduled workflow pulls focus keywords from Google Sheets, expands them with rising Google Trends queries via SerpApi, validates demand with Ahrefs metrics on RapidAPI, then uses Google Gemini to write and publish an SEO article to Sanity CMS, including internal/external…

Cron / scheduled trigger★★★★★ complexityAI-powered32 nodesAgentHTTP RequestGoogle Gemini ChatGoogle SheetsHTTP Request Tool
AI & RAG Trigger: Cron / scheduled Nodes: 32 Complexity: ★★★★★ AI nodes: yes Added:
Create Weekly SEO Blog Posts From Google Sheets with Gemini and Sanity — n8n workflow card showing Agent, HTTP Request, Google Gemini Chat integration

This workflow corresponds to n8n.io template #17623 — we link there as the canonical source.

This workflow follows the Agent → Google Sheets recipe pattern — see all workflows that pair these two integrations.

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
{
  "id": "kqG2Gn1hWCWxFWzx",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "SEO Blog 3Our",
  "tags": [],
  "nodes": [
    {
      "id": "d58d5192-9bb4-47a1-b8be-9e873902e9b5",
      "name": "AI Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        512,
        528
      ],
      "parameters": {
        "text": "=Tulis satu artikel blog SEO lengkap dengan struktur SILO untuk keyword utama berikut: {{  $json.data.keyword }}.\n\nManfaatkan data riset ini sebagai konteks (jangan ditulis ulang mentah di artikel): search volume {{  $json.data.searchVolume }}, keyword difficulty {{  $json.data.difficulty }}, traffic potential {{  $json.data.trafficPotential }}.\n\nPastikan artikel mengikuti seluruh aturan PERAN, TONE, STRUKTUR, dan SEO yang sudah ditetapkan. Sertakan judul yang menarik, paragraf pembuka ber-hook, heading yang logis, dan penutup dengan soft CTA ke layanan 3our.\n\nFORMAT OUTPUT (WAJIB):\nKembalikan hasil dalam bentuk JSON dengan tepat 4 field berikut:\n- \"title\": judul artikel yang menarik (tanpa tanda markdown).\n- \"slug\": versi slug dari judul (huruf kecil, dipisah tanda hubung).\n- \"excerpt\": ringkasan singkat 1-2 kalimat.\n- \"body\": isi lengkap artikel dalam format markdown (gunakan heading ##, paragraf, dan list bila perlu).\nJangan tulis judul di dalam body. Jangan tambahkan teks apa pun di luar JSON.",
        "options": {
          "systemMessage": "RISET DATA (WAJIB SEBELUM MENULIS)\nGunakan parameter pencarian lokasi US (Amerika Serikat) dan bahasa Inggris, dan utamakan hanya sumber yang valid dan kredibel: situs resmi/produk, dokumentasi resmi, media/publikasi ternama, lembaga riset, jurnal, atau data statistik resmi. Hindari forum anonim, konten spam, atau sumber yang tidak jelas kredibilitasnya. Bila perlu, sebutkan secara wajar sumber/rujukan di dalam artikel.\nSebelum menulis artikel, kamu WAJIB memanggil tool \"Google Search Riset\" untuk mencari fakta, data, statistik, fitur produk, dan informasi terkini tentang keyword/topik artikel. Lakukan 1-3 kali pencarian bila perlu. Dasarkan SEMUA klaim faktual, angka, dan detail pada hasil pencarian Google tersebut. JANGAN mengarang fakta, statistik, fitur, harga, atau klaim apa pun yang tidak didukung hasil riset. Jika informasi tidak ditemukan, tulis secara umum dan jangan membuat data palsu.\n\nPERAN\nKamu adalah SEO content strategist dan copywriter profesional untuk brand \"3our\". Tugasmu menulis artikel blog yang SEO-friendly, terasa ditulis manusia, dan enak dibaca \u2014 bukan terdengar seperti AI.\n\nTARGET PEMBACA\nPelaku bisnis, marketer, dan pemilik brand di Indonesia yang ingin tumbuh lewat digital marketing. Mereka sibuk, suka konten ringkas dan langsung to-the-point yang bisa dibaca ~5 menit.\n\nTONE & GAYA\n- Santai tapi profesional. Sapa pembaca dengan \"kamu\", dan posisikan brand sebagai \"kita/tim 3our\".\n- Bahasa Indonesia natural sehari-hari. Biarkan istilah marketing umum tetap dalam Bahasa Inggris (engagement, conversion, customer journey, awareness, funnel, hard selling, dll) \u2014 jangan diterjemahkan paksa.\n- Variasikan panjang kalimat. Sesekali pakai kalimat pendek dan tegas untuk penekanan. Hindari paragraf panjang yang monoton (maksimal 2-4 kalimat per paragraf).\n- Gunakan konektor percakapan yang luwes seperti \"Jadi,\", \"Nah,\", \"Artinya,\", \"Masalahnya,\" \u2014 tapi jangan berlebihan.\n- Boleh pakai analogi, contoh konkret, dan poin bernomor/bullet bila memang membantu keterbacaan.\n\nSTRUKTUR ARTIKEL\n- Paragraf pembuka: pancing dengan menohok asumsi umum atau menyebut masalah nyata yang dialami pembaca, lalu posisikan topik sebagai solusi. Jangan basa-basi.\n- Isi: bagi menjadi beberapa sub-bagian dengan heading (H2/H3) yang jelas dan logis, mengikuti search intent keyword.\n- Penutup: rangkum kunci utama dalam 2-4 poin, akhiri dengan satu kalimat punchline yang menempel, lalu soft CTA yang relevan ke layanan 3our (jangan hard selling).\n\nATURAN SEO\n- Tempatkan keyword utama secara natural di judul, paragraf pembuka, dan beberapa heading \u2014 JANGAN keyword stuffing.\n- Gunakan kata turunan/sinonim/LSI agar terasa alami.\n- Pastikan paragraf pembuka langsung menjawab maksud pencarian pembaca.\n- Sarankan internal linking bila relevan.\n\nHAL YANG WAJIB DIHINDARI (agar tidak terdengar seperti AI)\n- Frasa klise: \"di era digital yang serba cepat ini\", \"tak dapat dipungkiri\", \"mari kita selami/bahas lebih dalam\", \"dalam dunia yang terus berkembang\".\n- Transisi kaku berlebihan: \"selain itu\", \"lebih lanjut\", \"dengan demikian\", \"oleh karena itu\" yang dipakai bertubi-tubi.\n- Kalimat pembuka generik berupa definisi (\"X adalah ...\") \u2014 mulai dengan hook, bukan kamus.\n- Kesimpulan yang mengulang isi secara kaku. Penutup harus terasa seperti obrolan, bukan ringkasan robotik.\n- Nada terlalu formal, bertele-tele, atau over-promising.\n\nPANJANG\nTargetkan artikel ringkas dan padat (sekitar 600-900 kata), bisa dibaca dalam ~5 menit. Kualitas dan keterbacaan di atas kuantitas.\n\nFORMAT OUTPUT (WAJIB DIPATUHI)\nKembalikan HANYA satu objek JSON valid yang sesuai skema persis ini, tanpa teks lain:\n{\"title\": string, \"slug\": string, \"excerpt\": string, \"body\": string}\nAturan keras:\n- JANGAN bungkus dengan code fence (tanpa ```json atau ```), tanpa markdown, tanpa komentar, tanpa kalimat pembuka/penutup.\n- Output harus berupa JSON tunggal yang bisa langsung di-parse JSON.parse(). Tidak boleh kosong, tidak boleh array kosong, tidak boleh null.\n- Semua keempat field (title, slug, excerpt, body) WAJIB terisi dengan string non-kosong.\n- slug: huruf kecil, kata dipisah tanda hubung, tanpa spasi/simbol.\n- body: berisi isi lengkap artikel (boleh markdown di dalam nilai string body), di-escape dengan benar agar JSON tetap valid.\n- Jika ragu, tetap kembalikan JSON valid sesuai skema; jangan pernah menjelaskan atau meminta klarifikasi."
        },
        "promptType": "define"
      },
      "retryOnFail": true,
      "typeVersion": 2.1,
      "waitBetweenTries": 5000
    },
    {
      "id": "dbc1d10e-fd4e-424c-b3c6-deb010fb3c75",
      "name": "Riset Google Trends",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        624,
        144
      ],
      "parameters": {
        "url": "https://serpapi.com/search.json?engine=google_trends",
        "options": {},
        "sendQuery": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpQueryAuth",
        "queryParameters": {
          "parameters": [
            {
              "name": "q",
              "value": "={{ $json.keyword }}"
            },
            {
              "name": "geo",
              "value": "={{ $json.Geo }}"
            },
            {
              "name": "ln",
              "value": "={{ $json.Ln }}"
            },
            {
              "name": "date",
              "value": "={{ $now.minus({days: 7}).format('yyyy-MM-dd') }} {{ $now.format('yyyy-MM-dd') }}"
            },
            {
              "name": "data_type",
              "value": "RELATED_QUERIES"
            }
          ]
        }
      },
      "credentials": {
        "httpQueryAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "c486af40-1932-4d3e-96e1-135aefe6dc36",
      "name": "Rising Topic",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        832,
        144
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "related_queries.rising"
      },
      "typeVersion": 1
    },
    {
      "id": "1eb1c339-5013-4dd0-ab8d-1fbc6f7e10f3",
      "name": "Riset SVolume",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1456,
        144
      ],
      "parameters": {
        "url": "https://ahrefs-keyword-research.p.rapidapi.com/keyword-metrics?",
        "options": {
          "redirect": {}
        },
        "sendQuery": true,
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "queryParameters": {
          "parameters": [
            {
              "name": "keyword",
              "value": "={{ $json.query }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "x-rapidapi-host",
              "value": "ahrefs-keyword-research.p.rapidapi.com"
            },
            {
              "name": "x-rapidapi-key",
              "value": "bcf9bfca3cmsh9bd7c8d4ddaee19p1bde3fjsna78c77394971"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 4000
    },
    {
      "id": "18495e6d-a7e1-43b5-8450-7cb397e95a34",
      "name": "If Volume Validation",
      "type": "n8n-nodes-base.if",
      "position": [
        1696,
        144
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "0272b36e-30d7-4f6f-8a59-720dddc1c685",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.data.globalSearchVolume }}",
              "rightValue": 0
            },
            {
              "id": "1a96249e-1433-48c2-b47c-b795d4e022a9",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.data.difficulty }}",
              "rightValue": "null"
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "e0777dcb-164c-46c7-94d6-51cdf2db2282",
      "name": "Create Article",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        480,
        752
      ],
      "parameters": {
        "options": {},
        "modelName": "models/gemini-3-flash-preview"
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "3897f868-70aa-40b9-8e7a-03837744bfbc",
      "name": "Schema Sanity",
      "type": "n8n-nodes-base.code",
      "position": [
        1088,
        528
      ],
      "parameters": {
        "jsCode": "// --- Helpers ---\nconst uid = () =>\n  (Math.random().toString(36).slice(2, 10) + Date.now().toString(36)).slice(0, 12);\n\nconst makeSlug = (s) => ({\n  _type: 'slug',\n  current: String(s || '')\n    .toLowerCase()\n    .replace(/\\s+/g, '-')\n    .replace(/[^a-z0-9\\-]/g, '')\n    .slice(0, 96),\n});\n\nfunction extractJsonString(str) {\n  if (typeof str !== 'string') return null;\n  const mJson = str.match(/```json\\s*([\\s\\S]*?)```/i);\n  if (mJson) return mJson[1].trim();\n  const mCode = str.match(/```+\\s*([\\s\\S]*?)```+/);\n  if (mCode) return mCode[1].trim();\n  const start = str.indexOf('{');\n  const end = str.lastIndexOf('}');\n  if (start !== -1 && end !== -1 && end > start) return str.slice(start, end + 1);\n  return null;\n}\n\nfunction safeParseJson(maybe) {\n  if (!maybe) return null;\n  if (typeof maybe === 'object') return maybe;\n  const s = extractJsonString(String(maybe)) ?? String(maybe);\n  try { return JSON.parse(s); } catch { return null; }\n}\n\nfunction normalizeBody(body = []) {\n  if (typeof body === 'string') {\n        const stripInline = (s) => String(s)\n          .replace(/\\*\\*(.+?)\\*\\*/g, '$1')\n          .replace(/\\*(.+?)\\*/g, '$1')\n          .replace(/__(.+?)__/g, '$1')\n          .replace(/`(.+?)`/g, '$1')\n          .trim();\n        const lines = body.split(/\\r?\\n/);\n        const blocks = [];\n        let para = [];\n        const flushPara = () => {\n                if (!para.length) return;\n                const text = stripInline(para.join(' '));\n                if (text) blocks.push({ _type: 'block', style: 'normal', _key: uid(), markDefs: [], children: [{ _type: 'span', text, marks: [], _key: uid() }] });\n                para = [];\n        };\n        for (const raw of lines) {\n                const line = raw.trim();\n                if (!line) { flushPara(); continue; }\n                const h = line.match(/^(#{1,4})\\s+(.*)$/);\n                if (h) {\n                          flushPara();\n                          const style = 'h' + h[1].length;\n                          blocks.push({ _type: 'block', style, _key: uid(), markDefs: [], children: [{ _type: 'span', text: stripInline(h[2]), marks: [], _key: uid() }] });\n                          continue;\n                }\n                const li = line.match(/^[-*]\\s+(.*)$/);\n                if (li) {\n                          flushPara();\n                          blocks.push({ _type: 'block', style: 'normal', listItem: 'bullet', level: 1, _key: uid(), markDefs: [], children: [{ _type: 'span', text: stripInline(li[1]), marks: [], _key: uid() }] });\n                          continue;\n                }\n                para.push(line);\n        }\n        flushPara();\n        body = blocks;\n  }\n  if (!Array.isArray(body)) body = [];\n  return body.map((block) => {\n    const b = { ...block };\n    b._type = b._type || 'block';\n    b._key = b._key || uid();\n    b.style = b.style || 'normal';\n    b.markDefs = Array.isArray(b.markDefs) ? b.markDefs : [];\n    if (Array.isArray(b.children) && b.children.length) {\n      b.children = b.children.map((ch) => ({\n        _type: 'span',\n        text: ch?.text ?? '',\n        marks: Array.isArray(ch?.marks) ? ch.marks : [],\n        _key: ch?._key || uid(),\n      }));\n    } else {\n      b.children = [{ _type: 'span', text: '', marks: [], _key: uid() }];\n    }\n    return b;\n  });\n}\n\nconst ref = (id) => ({ _type: 'reference', _ref: id, _key: uid() });\nconst toSlug = (x) => String(x || '').toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9\\-]/g, '').slice(0,96);\n\n// --- Mapping deterministik ---\nconst AUTHOR_IDS = { 'ai-team': 'author.ai-team' };\nconst CATEGORY_IDS = {\n  'seo': 'ad9fe04f-2573-418c-b2de-837cd86698a7',\n  'crm': 'bfc9646f-ab6f-4ece-9bd4-43584b512d58',\n  'crm-management': 'bfc9646f-ab6f-4ece-9bd4-43584b512d58',\n  'social-media': 'c435c8c0-6f71-46df-84f2-3acbb1434d20',\n  'sosial-media': 'c435c8c0-6f71-46df-84f2-3acbb1434d20',\n  'marketing': 'cc3fa785-da7e-46da-8c98-1d2befde9090',\n};\n\n// Keyword -> slug untuk auto-klasifikasi\nconst CATEGORY_KEYWORDS = [\n  { slug: 'seo', patterns: [/\\bseo\\b/i, /search engine optimization/i, /optimasi mesin pencari/i] },\n  { slug: 'crm', patterns: [/\\bcrm\\b/i, /customer relationship management/i, /manajemen hubungan pelanggan/i] },\n  { slug: 'social-media', patterns: [/\\bsocial media\\b/i, /\\bsosial media\\b/i, /\\bmedsos\\b/i, /media sosial/i] },\n  { slug: 'marketing', patterns: [/\\bmarketing\\b/i, /\\bpemasaran\\b/i] },\n];\n\n// --- Ambil kandidat payload dari node sebelumnya ---\nlet inItem = $json;\n\n// --- Opsi 2: parse JSON string dari output AI Agent (parser dilepas) ---\n(function(){\n  try {\n    var cands=[inItem, inItem && inItem.output, inItem && inItem.response, inItem && inItem.text, inItem && inItem.json];\n    for (var k=0;k<cands.length;k++){\n      var s=cands[k];\n      if (typeof s===\"string\" && s.indexOf(\"{\")>=0){\n        var t=s.replace(/```json/gi,\"\").replace(/```/g,\"\").trim();\n        var a=t.indexOf(\"{\"); var b=t.lastIndexOf(\"}\");\n        if (a>=0 && b>a){\n          var parsed=JSON.parse(t.slice(a,b+1));\n          if (parsed && (parsed.title||parsed.body||parsed.slug)){ inItem=parsed; break; }\n          if (parsed && parsed.output && (parsed.output.title||parsed.output.body)){ inItem=parsed.output; break; }\n        }\n      }\n    }\n  } catch(e){ /* biarkan fallback kandidat di bawah menangani */ }\n})();\n\nlet ai = null;\nif (Array.isArray(inItem) && inItem.length) {\n  for (const entry of inItem) {\n    const parsed = safeParseJson(entry?.output ?? entry);\n    if (parsed && (parsed.title || parsed.body || parsed.slug)) { ai = parsed; break; }\n  }\n} else {\n  const candidates = [inItem, inItem.output, inItem.result, inItem.response?.text, inItem.response, inItem.data, inItem.text, inItem.ai];\n  for (const c of candidates) {\n    const parsed = safeParseJson(c);\n    if (parsed && (parsed.title || parsed.body || parsed.slug)) { ai = parsed; break; }\n  }\n}\nif (!ai && typeof inItem === 'object' && (inItem.title || inItem.body)) ai = inItem;\n\n// --- Build fields ---\nconst rawTitle = (ai && typeof ai.title === 'string') ? ai.title.trim() : '';\nconst body = normalizeBody(ai?.body || []);\nconst bodyHasContent = Array.isArray(body) && body.length > 0;\nif (!rawTitle || /^untitled$/i.test(rawTitle) || !bodyHasContent) {\n  throw new Error('Artikel tidak valid: title kosong/Untitled atau body kosong (kemungkinan AI Agent gagal). Publish dibatalkan.');\n}\nconst title = rawTitle;\nconst slugObj = (ai?.slug && typeof ai.slug === 'object') ? ai.slug : makeSlug(ai?.slug || title);\n\n// excerpt\nfunction plainText(blocks = []) {\n  return blocks\n    .filter(b => b?._type === 'block')\n    .map(b => (Array.isArray(b.children) ? b.children.map(c => c?.text || '').join('') : ''))\n    .join('\\n')\n    .trim();\n}\nconst excerpt = (typeof ai?.excerpt === 'string' && ai.excerpt.trim()) || plainText(body).slice(0, 160);\n\n// author\nconst authorId = ai?.authorId || (ai?.authorSlug && AUTHOR_IDS[ai.authorSlug]) || AUTHOR_IDS['ai-team'];\n\n// categories: AI + auto-klasifikasi keyword\nconst categorySlugsFromAi = Array.isArray(ai?.categories) ? ai.categories.map(toSlug) : [];\nconst fullText = (title + ' ' + plainText(body) + ' ' + (excerpt || '')).toLowerCase();\nconst categorySlugsAuto = CATEGORY_KEYWORDS\n  .filter(({ patterns }) => patterns.some(p => p.test(fullText)))\n  .map(x => x.slug);\nconst allCategorySlugs = Array.from(new Set([...categorySlugsFromAi, ...categorySlugsAuto]));\nconst categoryIds = allCategorySlugs.map(s => CATEGORY_IDS[s]).filter(Boolean);\nconst categoryIdsUnique = Array.from(new Set(categoryIds));\n\n// --- Dokumen post ---\nconst doc = {\n  _type: 'post',\n  title,\n  slug: slugObj,\n  body,\n  excerpt,\n  author: authorId ? ref(authorId) : undefined,\n  categories: categoryIdsUnique.map(id => ref(id)),\n  publishedAt: ai?.publishedAt || new Date().toISOString(),\n};\n\n// --- Pastikan author taxonomy ada (idempotent) ---\nconst taxonomyMutations = [];\nif (authorId === 'author.ai-team') {\n  taxonomyMutations.push({\n    createIfNotExists: {\n      _id: 'author.ai-team',\n      _type: 'author',\n      name: 'AI Team',\n      slug: { _type: 'slug', current: 'ai-team' },\n    }\n  });\n}\n\nconst _debug = {\n  pickedFrom: ai ? 'parsed' : 'fallback',\n  hasTitle: Boolean(ai?.title),\n  hasBody: Array.isArray(ai?.body) && ai.body.length > 0,\n  authorId,\n  categorySlugsFromAi,\n  categorySlugsAuto,\n  allCategorySlugs,\n  categoryIds: categoryIdsUnique,\n};\n\nreturn [{\n  json: {\n    mutations: [\n      ...taxonomyMutations,\n      { create: doc },\n    ],\n    _debug,\n  },\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "582d61f7-e48a-4280-8c36-0c8bce563e5a",
      "name": "Publishing Sanity",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1296,
        528
      ],
      "parameters": {
        "url": "https://mwwvwgiw.api.sanity.io/v2023-08-01/data/mutate/production?returnIds=true&returnDocuments=true",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "mutations",
              "value": "={{ $json.mutations }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "Content-Type"
            },
            {
              "name": "Authorization",
              "value": "<redacted-credential>"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "75d8d48a-0c9f-40fc-a195-572ae8d74149",
      "name": "Schedule Sat Midnight",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        208,
        144
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "daysInterval": 6
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "f9622c05-02ca-47df-b1f7-a9ee666f29eb",
      "name": "List Focus Keyword",
      "type": "n8n-nodes-base.googleSheets",
      "maxTries": 5,
      "position": [
        416,
        144
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 789319781,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1AcYRkZmiT44-flXBwIid1zejIkYDe8-l-5pgULIClVE/edit#gid=789319781",
          "cachedResultName": "keyword"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1AcYRkZmiT44-flXBwIid1zejIkYDe8-l-5pgULIClVE",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1AcYRkZmiT44-flXBwIid1zejIkYDe8-l-5pgULIClVE/edit?usp=drivesdk",
          "cachedResultName": "S&K 3our"
        },
        "authentication": "serviceAccount"
      },
      "credentials": {
        "googleApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": false,
      "typeVersion": 4.6,
      "waitBetweenTries": 5000
    },
    {
      "id": "822437c5-7a47-4f0d-9053-2fcbbb9005e1",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        32
      ],
      "parameters": {
        "color": 6,
        "width": 1904,
        "height": 288,
        "content": "## 1. Keyword Research\nRead focus keywords from Google Sheets, find rising topics on Google Trends, then validate monthly search volume."
      },
      "typeVersion": 1
    },
    {
      "id": "98bbc361-e875-4d91-b567-d27542a0ca1f",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        400
      ],
      "parameters": {
        "color": 5,
        "width": 1008,
        "height": 512,
        "content": "## 2. AI Copywriting\nAn AI Agent (Gemini + Google Search tool) writes the SEO article for each validated keyword."
      },
      "typeVersion": 1
    },
    {
      "id": "9a8b1a69-4252-49d9-8a31-e5097c08a4b6",
      "name": "Wait",
      "type": "n8n-nodes-base.wait",
      "position": [
        832,
        528
      ],
      "parameters": {
        "amount": 10
      },
      "typeVersion": 1.1
    },
    {
      "id": "2839c334-5e68-4aa5-a011-1e90db741c3b",
      "name": "Upload Sanity",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1104,
        1072
      ],
      "parameters": {
        "url": "https://mwwvwgiw.api.sanity.io/v2023-08-01/assets/images/production?filename={{$json.slug}}-{{ (['hero','inline-a','inline-b'][$json.idx] || $json.idx) }}.png",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "contentType": "binaryData",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "<redacted-credential>"
            },
            {
              "name": "Content-Type",
              "value": "application/octet-stream"
            }
          ]
        },
        "inputDataFieldName": "data"
      },
      "typeVersion": 4.2
    },
    {
      "id": "09885a44-dcd6-4336-88f1-5040565f7e7c",
      "name": "Convert Prompt",
      "type": "n8n-nodes-base.code",
      "position": [
        80,
        1072
      ],
      "parameters": {
        "jsCode": "// Bisa dapet dari doc atau dari top-level\nconst docId = $json.docId;\nconst doc   = $json.doc ?? $json;\n\nconst title = doc?.title || 'Untitled';\nconst slug  = doc?.slug?.current || doc?.slug || 'untitled';\nconst textBlocks = (doc?.body || []).filter(b => b?._type === 'block');\nconst firstPara = (textBlocks.find(b => b.style === 'normal')?.children?.[0]?.text || '').slice(0, 220);\nconst keywords = (doc?.keywords || [])\n  .filter(k => typeof k === 'string')\n  .slice(0, 6);\n\nconst primary = doc?.primaryKeyword || keywords[0] || title;\n\nconst baseStyle = 'clean, modern, high detail, blog hero image, negative space for overlay, no text, no watermark';\n\nconst prompts = [\n  `High-quality blog hero image about \"${primary}\". ${baseStyle}. Visual focus: concept overview, center composition.`,\n  `Supporting illustration for article \"${title}\". Focus on a concrete sub-aspect: key mechanism or example. ${baseStyle}. Different angle from hero.`,\n  `Second supporting visual for \"${title}\". Complementary perspective (macro or contextual scene). ${baseStyle}.`\n];\n\nreturn prompts.map((prompt, idx) => ({ json: { docId, title, slug, keywords, prompt, idx, totalImages: prompts.length } }));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "8ae85c7f-70e0-4278-b567-e1238b021b5c",
      "name": "Loop Over Items",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        288,
        1072
      ],
      "parameters": {
        "options": {
          "reset": "={{ $json.prompts[$itemIndex] }}"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "9bf22226-0918-41be-9ef2-9ef8b7fb6987",
      "name": "Wait1",
      "type": "n8n-nodes-base.wait",
      "position": [
        448,
        1168
      ],
      "parameters": {},
      "typeVersion": 1.1
    },
    {
      "id": "21083571-5a07-4ded-a87a-595e8f296395",
      "name": "Set ID & Slug",
      "type": "n8n-nodes-base.set",
      "position": [
        1312,
        1072
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "ab74eec9-b859-4c61-b7d1-7c26dd4443f7",
              "name": "docId",
              "type": "string",
              "value": "={{  $('Loop Over Items').item.json.docId }}"
            },
            {
              "id": "cab3d269-a85e-46f6-833e-c57e40f90593",
              "name": "idx",
              "type": "string",
              "value": "={{  $('Loop Over Items').item.json.idx }}"
            },
            {
              "id": "8cbd1304-e348-479e-aa9e-b4b344ed0811",
              "name": "slug",
              "type": "string",
              "value": "={{ $('Loop Over Items').item.json.slug }}"
            },
            {
              "id": "074f8399-b674-4d88-995f-6260209696d1",
              "name": "assetId",
              "type": "string",
              "value": "={{  $json.document._id }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "2457ddd6-574e-4f6c-aaa8-62a313046e92",
      "name": "Mutasi Sanity",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1728,
        1072
      ],
      "parameters": {
        "url": "https://mwwvwgiw.api.sanity.io/v2023-08-01/data/mutate/production?returnIds=true&returnDocuments=true",
        "body": "={{  JSON.stringify($json.mutationBody) }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "raw",
        "sendHeaders": true,
        "rawContentType": "application/json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "Content-Type"
            },
            {
              "name": "Authorization",
              "value": "<redacted-credential>"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "da9adfbc-5d69-4396-adac-464c1e104d69",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        944
      ],
      "parameters": {
        "color": 2,
        "width": 1904,
        "height": 368,
        "content": "## 4. AI Images\nBuild 3 image prompts, render them with Flux, upload as Sanity assets and patch them into the post."
      },
      "typeVersion": 1
    },
    {
      "id": "de159279-26b0-48a4-b39b-5a8c0d0de68d",
      "name": "Convert Binary",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        1072
      ],
      "parameters": {
        "jsCode": "const out = [];\nconst items = $input.all();\n\nfor (let i = 0; i < items.length; i++) {\n  const item = items[i];\n  const res = item.json || {};\n\n  // --- Metadata artikel (slug, idx, dll) dari node sebelum request gambar ---\n  let meta = {};\n  try { meta = ($('Wait1').all()[i] || {}).json || {}; } catch (e) {}\n  if (!meta || !meta.slug) {\n    try { meta = ($('Convert Prompt').all()[i] || {}).json || meta || {}; } catch (e) {}\n  }\n\n  // --- Ambil gambar base64 dari respons Gemini (Nano Banana) ---\n  const parts =\n    (res.candidates && res.candidates[0] && res.candidates[0].content && res.candidates[0].content.parts) || [];\n  const imgPart = parts.find(\n    (p) => (p.inlineData && p.inlineData.data) || (p.inline_data && p.inline_data.data)\n  );\n\n  if (!imgPart) {\n    const why =\n      (res.candidates && res.candidates[0] && res.candidates[0].finishReason) ||\n      (res.error && res.error.message) ||\n      JSON.stringify(res).slice(0, 300);\n    throw new Error('Tidak ada gambar di respons Gemini (item ' + i + '): ' + why);\n  }\n\n  const inline = imgPart.inlineData || imgPart.inline_data;\n  const mimeType = inline.mimeType || inline.mime_type || 'image/png';\n  const ext = (mimeType.split('/')[1] || 'png').replace('jpeg', 'jpg');\n  const imgBuf = Buffer.from(inline.data, 'base64');\n\n  out.push({\n    json: Object.assign({}, meta, { mimeType }),\n    binary: {\n      data: {\n        data: imgBuf.toString('base64'),\n        mimeType: mimeType,\n        fileExtension: ext,\n        fileName: (meta.slug || 'image') + '-' + (meta.idx !== undefined ? meta.idx : i) + '.' + ext,\n      },\n    },\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "5801c5da-cebf-4042-aab3-6e539c158b6d",
      "name": "Picked Created Date",
      "type": "n8n-nodes-base.code",
      "position": [
        1504,
        528
      ],
      "parameters": {
        "jsCode": "const res = $json;\nconst created = (res.results || []).find(r => r.operation === 'create');\nconst updated = (res.results || []).find(r => r.operation === 'update');\n\nconst picked = created || updated; // prioritas create\nreturn [{\n  json: {\n    docId: picked?.id,\n    doc: picked?.document || null\n  }\n}];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "7c598cfe-178a-4f91-bb9d-6498c219e572",
      "name": "3 Images JS",
      "type": "n8n-nodes-base.code",
      "position": [
        1520,
        1072
      ],
      "parameters": {
        "jsCode": "return items.map(it => {\n  const idx = Number(it.json.idx);\n  const docId = it.json.docId;\n  const assetId = it.json.assetId;\n  const k = () => Math.random().toString(36).slice(2,10);\n  const imgRef = { _type: 'image', asset: { _type: 'reference', _ref: assetId } };\n  const imgBlock = { _type: 'image', _key: k(), asset: { _type: 'reference', _ref: assetId } };\n  const para = (t) => ({ _type: 'block', _key: k(), style: 'normal', markDefs: [], children: [{ _type: 'span', _key: k(), text: t || '', marks: [] }] });\n  let patches;\n  if (idx === 0) {\n    patches = [{ set: { mainImage: imgRef } }];\n  } else if (idx === 1) {\n    patches = [{ insert: { after: 'body[0]', items: [imgBlock, para('')] } }];\n  } else {\n    patches = [{ insert: { after: 'body[-1]', items: [para(''), para(''), imgBlock] } }];\n  }\n  const mutationBody = { mutations: patches.map(p => ({ patch: Object.assign({ id: docId }, p) })) };\n  return { json: { docId, idx, assetId, patches, mutationBody } };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "61b1fa0c-f5c0-4cf1-b6ca-ea5a23748ac2",
      "name": "Add Internal Links",
      "type": "n8n-nodes-base.code",
      "position": [
        1744,
        528
      ],
      "parameters": {
        "jsCode": "// === Add Internal Links (AI-powered, semantic relevance) ===\n// CONFIG: paste your credentials below (DO NOT share publicly)\nconst SANITY_TOKEN = 'skBXK0a9JwOX4s9YEv0GMfd995SQhzuCL4zgfWW17YfkvXgjd2xdSoYXQZUsvnyghPgqnzgSciyxYYWqj9TCFYjAfJ9c1XSkcoD2mU4JoPe2ITXuyWQf0qYPdspSCZfAYoH9iJedBa2fRJPg2dWTYT6t1LNZMzPVi7uivlUZ1Kodu7Me1SRk'; // same token as in Publishing Sanity\nconst GEMINI_API_KEY = '<redacted-credential>';\nconst GEMINI_MODEL = 'gemini-2.5-flash';\nconst PROJECT_ID = 'mwwvwgiw';\nconst DATASET = 'production';\nconst API_VERSION = 'v2023-08-01';\nconst LINK_PREFIX = '/article/'; // change to \"/blog/\" if posts live under /blog/\nconst MAX_LINKS = 4 // max internal links to insert per article\nconst MAX_CANDIDATES = 30; // how many old posts to send to the AI\n\nconst uid = () => (Math.random().toString(36).slice(2, 10) + Date.now().toString(36)).slice(0, 12);\nconst norm = (s) => String(s || '').toLowerCase().trim();\n\n// --- Sanity request helper (uses n8n built-in httpRequest) ---\nasync function sanity(method, path, body) {\n    const opt = {\n          method,\n          url: `https://${PROJECT_ID}.api.sanity.io/${API_VERSION}/${path}`,\n          headers: { Authorization: `Bearer ${SANITY_TOKEN}` },\n          json: true,\n    };\n    if (body) { opt.body = body; opt.headers['Content-Type'] = 'application/json'; }\n    return await this.helpers.httpRequest(opt);\n}\n\n// --- Gemini request helper ---\nasync function askGemini(prompt) {\n    const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;\n    const res = await this.helpers.httpRequest({\n          method: 'POST',\n          url,\n          headers: { 'Content-Type': 'application/json' },\n          json: true,\n          body: {\n                  contents: [{ parts: [{ text: prompt }] }],\n                  generationConfig: { temperature: 0.2, responseMimeType: 'application/json' },\n          },\n    });\n    const text = res?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n    return text;\n}\n\n// --- 1. Get the freshly-created article ---\nconst input = $input.first().json;\nconst docId = input.docId;\nconst doc = input.doc || {};\nlet body = Array.isArray(doc.body) ? JSON.parse(JSON.stringify(doc.body)) : [];\nconst thisTitle = doc.title || '';\nif (!docId || body.length === 0) {\n    return [{ json: { skipped: true, reason: 'No docId or empty body', docId } }];\n}\n\n// --- 2. Fetch all OTHER posts (title + slug + excerpt) as link candidates ---\nconst groq = `*[_type == \"post\" && _id != $id && defined(slug.current)]{ \"title\": title, \"slug\": slug.current, \"excerpt\": coalesce(excerpt, \"\") }[0...${MAX_CANDIDATES}]`;\nconst qPath = `data/query/${DATASET}?query=${encodeURIComponent(groq)}&$id=${encodeURIComponent(JSON.stringify(docId))}`;\nconst res = await sanity('GET', qPath);\nconst candidates = (res.result || []).filter(p => p.title && p.slug);\nif (candidates.length === 0) {\n    return [{ json: { docId, linksAdded: 0, message: 'No candidate posts found' } }];\n}\n\n// --- 3. Build plain text of the article body (with block index) ---\nconst blockText = [];\nbody.forEach((block, bi) => {\n    if (block && block._type === 'block' && Array.isArray(block.children)) {\n          const t = block.children.filter(c => c._type === 'span').map(c => c.text || '').join('');\n          if (t.trim()) blockText.push({ bi, text: t });\n    }\n});\n\n// --- 4. Ask Gemini to pick semantically related posts + anchor phrases ---\nconst candList = candidates.map((c, i) => `${i}. title: \"${c.title}\" | slug: ${c.slug}${c.excerpt ? ' | excerpt: ' + c.excerpt.slice(0, 160) : ''}`).join('\\n');\nconst articleText = blockText.map(b => `[block ${b.bi}] ${b.text}`).join('\\n');\nconst prompt = `You are an SEO internal-linking assistant. Given a NEW article and a list of OTHER existing articles, choose up to ${MAX_LINKS} internal links to insert into the new article.\\n\\nRULES:\\n- Pick links based on TOPIC/SEMANTIC relevance, NOT on title matching.\\n- The \"anchor\" MUST be an exact substring (verbatim, case included) that already appears in the new article body text below. Prefer a natural noun phrase of 2-6 words.\\n- Each target slug used at most once. Do not link a phrase to an unrelated article.\\n- If nothing is relevant, return an empty array.\\n- Return ONLY valid JSON: an array of objects {\"anchor\": string, \"slug\": string, \"reason\": string}.\\n\\nNEW ARTICLE TITLE: \"${thisTitle}\"\\n\\nNEW ARTICLE BODY:\\n${articleText}\\n\\nEXISTING ARTICLES (candidates):\\n${candList}`;\n\nlet picks = [];\ntry {\n    const raw = await askGemini(prompt);\n    const cleaned = raw.replace(/^```json\\s*/i, '').replace(/^```\\s*/i, '').replace(/```\\s*$/i, '').trim();\n    picks = JSON.parse(cleaned);\n    if (!Array.isArray(picks)) picks = [];\n} catch (e) {\n    return [{ json: { docId, linksAdded: 0, message: 'Gemini parse error: ' + e.message } }];\n}\n\n// --- 5. Inject links into the body based on AI picks ---\nconst validSlugs = new Set(candidates.map(c => c.slug));\nconst usedSlugs = new Set();\nlet linksAdded = 0;\n\nfor (const pick of picks) {\n    if (linksAdded >= MAX_LINKS) break;\n    const anchor = (pick && pick.anchor || '').trim();\n    const slug = (pick && pick.slug || '').trim();\n    if (!anchor || !slug || !validSlugs.has(slug) || usedSlugs.has(slug)) continue;\n  \n    let injected = false;\n    for (const block of body) {\n          if (injected) break;\n          if (!block || block._type !== 'block' || !Array.isArray(block.children)) continue;\n          if (!Array.isArray(block.markDefs)) block.markDefs = [];\n      \n          for (let ci = 0; ci < block.children.length; ci++) {\n                  const span = block.children[ci];\n                  if (!span || span._type !== 'span' || typeof span.text !== 'string') continue;\n                  if (Array.isArray(span.marks) && span.marks.length) continue; // skip already-marked spans\n                  const idx = span.text.indexOf(anchor);\n                  if (idx === -1) continue;\n            \n                  const before = span.text.slice(0, idx);\n                  const middle = span.text.slice(idx, idx + anchor.length);\n                  const after = span.text.slice(idx + anchor.length);\n            \n                  const markKey = uid();\n                  block.markDefs.push({ _key: markKey, _type: 'link', href: LINK_PREFIX + slug });\n            \n                  const newSpans = [];\n                  if (before) newSpans.push({ _type: 'span', _key: uid(), text: before, marks: [] });\n                  newSpans.push({ _type: 'span', _key: uid(), text: middle, marks: [markKey] });\n                  if (after) newSpans.push({ _type: 'span', _key: uid(), text: after, marks: [] });\n            \n                  block.children.splice(ci, 1, ...newSpans);\n                  usedSlugs.add(slug);\n                  linksAdded++;\n                  injected = true;\n                  break;\n          }\n    }\n}\n\n// --- 6. Patch the document with the updated body ---\nif (linksAdded === 0) {\n    return [{ json: { docId, linksAdded: 0, message: 'AI found no insertable anchors in body', picks } }];\n}\n\nconst mutations = { mutations: [{ patch: { id: docId, set: { body } } }] };\nconst patchRes = await sanity('POST', `data/mutate/${DATASET}?returnIds=true`, mutations);\n\nreturn [{ json: { docId, linksAdded, linkedSlugs: Array.from(usedSlugs), picks, sanityResult: patchRes } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "210b57ec-aee9-4e71-99a4-18d1aa46c4ca",
      "name": "Google Search Riset",
      "type": "n8n-nodes-base.httpRequestTool",
      "position": [
        704,
        752
      ],
      "parameters": {
        "url": "https://serpapi.com/search.json",
        "options": {},
        "dataField": "organic_results",
        "sendQuery": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpQueryAuth",
        "queryParameters": {
          "parameters": [
            {
              "name": "engine",
              "value": "google"
            },
            {
              "name": "q",
              "value": "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('parameters1_Value', `Kata kunci atau pertanyaan pencarian Google untuk mengumpulkan fakta/data tentang topik artikel. Biasanya berupa keyword utama artikel.`, 'string') }}"
            },
            {
              "name": "gl",
              "value": "us"
            },
            {
              "name": "hl",
              "value": "en"
            }
          ]
        },
        "toolDescription": "Cari informasi faktual terkini dari Google tentang sebuah topik/keyword. Gunakan tool ini untuk mengumpulkan fakta, data, statistik, fitur produk, dan referensi nyata sebelum menulis artikel, agar tulisan berdasarkan data riset asli dan tidak mengarang.",
        "optimizeResponse": true
      },
      "credentials": {
        "httpQueryAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "1dbac2b3-4dda-4c5f-8319-28c6944fe88e",
      "name": "Add External Links",
      "type": "n8n-nodes-base.code",
      "position": [
        1744,
        736
      ],
      "parameters": {
        "jsCode": "// === Add External Links (AI-powered, high-DA authoritative sources only) ===\n// CONFIG: paste your credentials below (same values as the Add Internal Links node)\nconst SANITY_TOKEN = 'skBXK0a9JwOX4s9YEv0GMfd995SQhzuCL4zgfWW17YfkvXgjd2xdSoYXQZUsvnyghPgqnzgSciyxYYWqj9TCFYjAfJ9c1XSkcoD2mU4JoPe2ITXuyWQf0qYPdspSCZfAYoH9iJedBa2fRJPg2dWTYT6t1LNZMzPVi7uivlUZ1Kodu7Me1SRk';\nconst GEMINI_API_KEY = '<redacted-credential>';\nconst GEMINI_MODEL = 'gemini-2.5-flash';\nconst PROJECT_ID = 'mwwvwgiw';\nconst DATASET = 'production';\nconst API_VERSION = 'v2023-08-01';\nconst MAX_EXT_LINKS = 2; // max external links to insert per article\n\n// Whitelist of allowed high-DA domains. Gemini may ONLY link to these.\nconst ALLOWED_DOMAINS = ['wikipedia.org','britannica.com','nytimes.com','bbc.com','reuters.com','nature.com','sciencedirect.com','ncbi.nlm.nih.gov','who.int','forbes.com','statista.com','.gov','.edu'];\n\nconst uid = () => (Math.random().toString(36).slice(2, 10) + Date.now().toString(36)).slice(0, 12);\nconst norm = (s) => String(s || '').toLowerCase().trim();\n\n// --- Sanity request helper ---\nasync function sanity(method, path, body) {\n  const opt = { method, url: `https://${PROJECT_ID}.api.sanity.io/${API_VERSION}/${path}`, headers: { Authorization: `Bearer ${SANITY_TOKEN}` }, json: true };\n  if (body) { opt.body = body; opt.headers['Content-Type'] = 'application/json'; }\n  return await this.helpers.httpRequest(opt);\n}\n\n// --- Gemini request helper ---\nasync function askGemini(prompt) {\n  const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;\n  const res = await this.helpers.httpRequest({ method: 'POST', url, headers: { 'Content-Type': 'application/json' }, json: true, body: { contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: 0.2, responseMimeType: 'application/json' } } });\n  return (res && res.candidates && res.candidates[0] && res.candidates[0].content && res.candidates[0].content.parts && res.candidates[0].content.parts[0] && res.candidates[0].content.parts[0].text) || '';\n}\n\n// --- Domain whitelist check (only https + allowed high-DA domains) ---\nfunction hostAllowed(u) {\n  try {\n    if (!/^https:\\/\\//i.test(u)) return false;\n    const h = new URL(u).hostname.toLowerCase();\n    return ALLOWED_DOMAINS.some(d => d.charAt(0) === '.' ? h.endsWith(d) : (h === d || h.endsWith('.' + d)));\n  } catch (e) { return false; }\n}\n\n// --- 1. Get docId from previous node, then RE-FETCH the fresh body (internal links already applied) ---\nconst input = $input.first().json;\nconst docId = input.docId;\nif (!docId) { return [{ json: { skipped: true, reason: 'No docId from previous node' } }]; }\n\nconst groq = '*[_id == $id][0]{title, body}';\nconst qPath = `data/query/${DATASET}?query=${encodeURIComponent(groq)}&$id=${encodeURIComponent(JSON.stringify(docId))}`;\nconst fetched = await sanity('GET', qPath);\nconst doc = (fetched && fetched.result) || {};\nlet body = Array.isArray(doc.body) ? JSON.parse(JSON.stringify(doc.body)) : [];\nconst thisTitle = doc.title || '';\nif (!body.length) { return [{ json: { skipped: true, reason: 'Empty body', docId } }]; }\n\n// --- 2. Build plain text of the article body (with block index) ---\nconst blockText = [];\nbody.forEach((block, bi) => {\n  if (block && block._type === 'block' && Array.isArray(block.children)) {\n    const t = block.children.filter(c => c._type === 'span').map(c => c.text || '').join('');\n    if (t.trim()) blockText.push({ bi, text: t });\n  }\n});\nconst articleText = blockText.map(b => `[block ${b.bi}] ${b.text}`).join('\\n');\n\n// --- 3. Ask Gemini for external links to authoritative high-DA sources ---\nconst allowedList = ALLOWED_DOMAINS.join(', ');\nconst prompt = [\n  'You are an SEO assistant adding EXTERNAL outbound links to authoritative, high Domain Authority sources.',\n  'RULES:',\n  '- \"anchor\" MUST be an exact substring (verbatim, case included) that already appears in the article body text below. Prefer a natural noun phrase of 2-6 words.',\n  '- \"url\" MUST be a full https:// URL whose domain is one of these ALLOWED high-DA domains ONLY: ' + allowedList,\n  '- Choose URLs that genuinely exist and are topically relevant (e.g. the matching Wikipedia article, an official organization page, or a reputable publication). Do NOT invent fake or guessed URLs.',\n  '- Insert at most ' + MAX_EXT_LINKS + ' links. Each anchor used at most once. Do not link the same domain twice.',\n  '- If nothing relevant and authoritative is available, return an empty array.',\n  '- Return ONLY valid JSON: an array of objects {\"anchor\": string, \"url\": string, \"reason\": string}.',\n  '',\n  'ARTICLE TITLE: \"' + thisTitle + '\"',\n  '',\n  'ARTICLE BODY:',\n  articleText\n].join('\\n');\n\nlet picks = [];\ntry {\n  const raw = await askGemini(prompt);\n  const cleaned = raw.replace(/^```json\\s*/i, '').replace(/^```\\s*/i, '').replace(/```\\s*$/i, '').trim();\n  picks = JSON.parse(cleaned);\n  if (!Array.isArray(picks)) picks = [];\n} catch (e) { picks = []; }\n\n// --- 4. Insert external links into body as Portable Text marks ---\nlet linksAdded = 0;\nconst usedAnchors = new Set();\nconst usedHosts = new Set();\n\nfor (const pick of picks) {\n  if (linksAdded >= MAX_EXT_LINKS) break;\n  const anchor = (pick && pick.anchor || '').trim();\n  const url = (pick && pick.url || '').trim();\n  if (!anchor || !url || !hostAllowed(url)) continue;\n  let host;\n  try { host = new URL(url).hostname.toLowerCase().replace(/^www\\./, ''); } catch (e) { continue; }\n  if (usedAnchors.has(norm(anchor)) || usedHosts.has(host)) continue;\n\n  let injected = false;\n  for (const block of body) {\n    if (injected) break;\n    if (!block || block._type !== 'block' || !Array.isArray(block.children)) continue;\n    if (!Array.isArray(block.markDefs)) block.markDefs = [];\n    for (let ci = 0; ci < block.children.length; ci++) {\n      const child = block.children[ci];\n      if (!child || child._type !== 'span' || typeof child.text !== 'string') continue;\n      const idx = child.text.indexOf(anchor);\n      if (idx === -1) continue;\n      const before = child.text.slice(0, idx);\n      const middle = child.text.slice(idx, idx + anchor.length);\n      const after = child.text.slice(idx + anchor.length);\n      const markKey = uid();\n      // External link mark: full URL href, opens in new tab (blank), dofollow + noopener\n      block.markDefs.push({ _key: markKey, _type: 'link', href: url, blank: true, rel: 'noopener' });\n      const baseMarks = Array.isArray(child.marks) ? child.marks.filter(m => m !== markKey) : [];\n      const newSpans = [];\n      if (before) newSpans.push({ _type: 'span', _key: uid(), text: before, marks: baseMarks });\n      newSpans.push({ _type: 'span', _key: uid(), text: middle, marks: baseMarks.concat([markKey]) });\n      if (after) newSpans.push({ _type: 'span', _key: uid(), text: after, marks: baseMarks });\n      block.children.splice(ci, 1, ...newSpans);\n      usedAnchors.add(norm(anchor));\n      usedHosts.add(host);\n      linksAdded++;\n      injected = true;\n      break;\n    }\n  }\n}\n\n// --- 5. Patch the document with the updated body ---\nif (linksAdded === 0) {\n  return [{ json: { docId, externalLinksAdded: 0, message: 'AI found no insertable authoritative anchors', picks } }];\n}\nconst mutations = { mutations: [{ patch: { id: docId, set: { body } } }] };\nconst patchRes = await sanity('POST', `data/mutate/${DATASET}?returnIds=true`, mutations);\nreturn [{ json: { docId, externalLinksAdded: linksAdded, links: picks.slice(0, linksAdded), sanityResult: patchRes } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "d7fd0fd3-c735-422c-a9cc-eb459a39c4b2",
      "name": "Keyword Match?",
      "type": "n8n-nodes-base.if",
      "position": [
        1184,
        144
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "e3735779-dd7f-4e44-a1f5-25bbc74054e2",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{  (() => { const q = String($json.query || '').toLowerCase(); const seed = $('List Focus Keyword').all().map(i => String(i.json.keyword || '').toLowerCase().trim()).filter(Boolean); const esc = s => s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'); const terms = [...new Set(seed)].filter(t => t.length > 1); return terms.some(t => new RegExp('\\\\b' + esc(t) + '\\\\b', 'i').test(q)); })() }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "ff9f68cf-3a7c-4fb0-b859-da53ff72b8c3",
      "name": "Sort",
      "type": "n8n-nodes-base.sort",
      "position": [
        144,
        528
      ],
      "parameters": {
        "options": {},
        "sortFieldsUi": {
          "sortField": [
            {
              "order": "descending",
              "fieldName": "=data.globalSearchVolume"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "7620502c-0412-4a1c-8677-4adf2e4409b7",
      "name": "Limit",
      "type": "n8n-nodes-base.limit",
      "position": [
        352,
        528
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "4f980263-2f13-4529-955d-b3b9a1edbec6",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1040,
        400
      ],
      "parameters": {
        "color": 4,
        "width": 864,
        "height": 512,
        "content": "## 3. Publish to Sanity\nBuild the Sanity document, post it as a draft, then enrich the body with internal and external links."
      },
      "typeVersion": 1
    },
    {
      "id": "6fab80ba-68e1-46b6-ab42-a7e80e5864d3",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -608,
        48
      ],
      "parameters": {
        "width": 576,
        "height": 992,
        "content": "## \u25b6\ufe0f AI SEO Blog Factory (Google Trends \u2192 Sanity CMS)\n\nFully automated weekly SEO pipeline: it picks a focus keyword, validates real search demand, writes the article with AI, generates images and publishes everything to a Sanity headless CMS.\n\n### How it works\n1. **Keyword research** \u2013 every Saturday at midnight it reads focus keywords from Google Sheets, pulls rising topics from Google Trends (SerpApi) and checks search volume with Ahrefs (RapidAPI). Only keywords above your threshold continue.\n2. **Copywriting** \u2013 an AI Agent (Gemini + Google Search tool) writes the SEO article.\n3. **Publish** \u2013 the draft is mapped to a Sanity schema, posted to the CMS, then internal and external links are added.\n4. **Images** \u2013 3 prompts are rendered with Flux on Cloudflare Workers AI, uploaded as Sanity assets and patched into the post.\n\n### Setup\n1. Add credentials: Google Sheets, Google Gemini, SerpApi, RapidAPI (Ahrefs), Cloudflare Workers AI and a Sanity write token.\n2. Point **List Focus Keyword** to your keyword sheet.\n3. Replace the Sanity projectId and dataset in every Sanity HTTP node.\n4. Set your schedule, then run once manually to test.\n\n### Customization tips\n- Change the volume threshold in **If Volume Validation**.\n- Edit the AI Agent prompt for your tone of voice and article length.\n\n### \u26a0\ufe0f Sanity nodes need your own projectId, dataset and write token."
      },
      "typeVersion": 1
    },
    {
      "id": "542945db-d59f-4b7f-b727-a85174e30cb7",
      "name": "Nano Banana 2 Lite",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 5,
      "position": [
        624,
        1072
      ],
      "parameters": {
        "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-image:generateContent",
        "method": "POST",
        "options": {
          "timeout": 120000,
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 8000
            }
          },
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={\n  \"contents\": [ { \"parts\": [ { \"text\": {{ JSON.stringify($json.prompt) }} } ] } ],\n  \"generationConfig\": {\n    \"responseModalities\": [\"IMAGE\"],\n    \"imageConfig\": {\n      \"aspectRatio\": \"{{ $json.idx === 0 ? '16:9' : '4:3' }}\",\n      \"imageSize\": \"1K\"\n    }\n  }\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpBearerAuth": {
          "name": "<your credential>"
        },
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    }
  ],
  "active": true,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "50262836-92c5-4af8-b55e-f174c7d32c8e",
  "nodeGroups": [],
  "connections": {
    "Sort": {
      "main": [
        [
          {
            "node": "Limit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait": {
      "main": [
        [
          {
            "node": "Schema Sanity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Limit": {
      "main": [
        [
          {
            "node": "AI Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait1": {
      "main": [
        [
          {
            "node": "Convert Prompt",
            "type": "main",
            "index": 0
          },
          {
            "node": "Nano Banana 2 Lite",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3 Images JS": {
      "main": [
        [
          {
            "node": "Mutasi Sanity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rising Topic": {
      "main": [
        [
          {
            "node": "Keyword Match?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Riset SVolume": {
      "main": [
        [
          {
            "node": "If Volume Validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schema Sanity": {
      "main": [
        [
          {
            "node": "Publishing Sanity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set ID & Slug": {
      "main": [
        [
          {
            "node": "3 Images JS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload Sanity": {
      "main": [
        [
          {
            "node": "Set ID & Slug",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert Binary": {
      "main": [
        [
          {
            "node": "Upload Sanity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert Prompt": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Article": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Keyword Match?": {
      "main": [
        [
          {
            "node": "Riset SVolume",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Loop Over Items": {
      "main": [
        [],
        [
          {
            "node": "Wait1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publishing Sanity": {
      "main": [
        [
          {
            "node": "Picked Created Date",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add Internal Links": {
      "main": [
        [
          {
            "node": "Add External Links",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List Focus Keyword": {
      "main": [
        [
          {
            "node": "Riset Google Trends",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Nano Banana 2 Lite": {
      "main": [
        [
          {
            "node": "Convert Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Search Riset": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Picked Created Date": {
      "main": [
        [
          {
            "node": "Convert Prompt",
            "type": "main",
            "index": 0
          },
          {
            "node": "Add Inte

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

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

About this workflow

This scheduled workflow pulls focus keywords from Google Sheets, expands them with rising Google Trends queries via SerpApi, validates demand with Ahrefs metrics on RapidAPI, then uses Google Gemini to write and publish an SEO article to Sanity CMS, including internal/external…

Source: https://n8n.io/workflows/17623/ — 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

The Multi-Model Agency Content Engine is a high-performance editorial system designed for agencies. It solves the "blank page" problem by alternating between real-world social proof and strategic expe

Google Sheets, Gmail, Google Drive +6
AI & RAG

Fully automated blog creation system using n8n + AI Agents + Image Generation

Agent, Output Parser Structured, Groq Chat +9
AI & RAG

This workflow automates the complete blog publishing process. It removes manual work from content creation, image generation, category management, and WordPress publishing by using AI and n8n. It help

Google Gemini Chat, Output Parser Structured, Lm Chat Deep Seek +5
AI & RAG

This project is an automated news publisher for LinkedIn. It uses RSS feeds to fetch news, processes the content with the Gemini API to generate precise summaries, and automatically publishes to Linke

Agent, Google Gemini Chat, RSS Feed Read +4
AI & RAG

This template is for clinics, hospitals, care teams, and telemedicine providers who need a structured, automated system for post-surgery follow-up. It helps reduce manual workload while ensuring every

Google Sheets, Telegram, Telegram Trigger +7